diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0b679a..4943927 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -567,6 +567,39 @@ jobs: echo '```' } >> "$GITHUB_STEP_SUMMARY" + # The HARDWARE configuration, which until now nothing built. Every job + # above uses sdkconfig.qemu, which sets CONFIG_KILN_PLANT_SIM and + # compiles out the WiFi, the HTTP server and the whole hardware path in + # main.cpp. So the configuration that will actually ship was guarded by + # no build at all, and when it was first tried it did not compile: two + # -Wformat-truncation errors in code no CI job had ever seen. + # + # It is a build and not a test: nothing here can run it. What it buys is + # that the half of the firmware aimed at real hardware cannot rot + # unnoticed between now and the first board. + - name: Build the hardware configuration + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: ${{ env.IDF_VERSION }} + target: esp32s3 + path: firmware/controller + command: | + idf.py -B build-hw -D SDKCONFIG=build-hw/sdkconfig -D SDKCONFIG_DEFAULTS="sdkconfig.defaults" build + idf.py -B build-hw size > build-hw/size.txt + + - name: Summarise the hardware image size + if: always() + run: | + { + echo '### Hardware image size' + echo + echo 'The configuration with WiFi, the HTTP server and the hardware' + echo 'adapters, which the simulated build compiles out.' + echo '```' + head -20 firmware/controller/build-hw/size.txt || echo 'build failed' + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@v4 with: name: firmware-esp32s3-simulated diff --git a/.gitignore b/.gitignore index 0999428..8b3ab31 100644 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,10 @@ coverage/ sdkconfig sdkconfig.old sdkconfig.hw +# Fetched by the component manager and reproducible from the lockfile, so it +# is not committed; dependencies.lock IS committed, because UR-CON-04 makes it +# the record of what a release was built from. managed_components/ -dependencies.lock *.o *.d diff --git a/README.md b/README.md index e3e48cb..a0d53dd 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,9 @@ An open-source PID controller for electric ceramic and glass kilns, built on the **ESP32-S3** with **ESP-IDF**. - **Simple local display**: a 128×64 OLED showing current and target temperature at a glance, plus state, segment progress and rate of rise. -- **Web interface**: served by the device itself. Live dashboard, charts of the logged data, power and energy, and the firing programs as stored. **Observation only**: nothing reachable over the network can start a firing, heat the kiln, change its configuration or author a program. Every one of those is done at the kiln. +- **Web interface**: served by the device itself. Live dashboard, charts of the logged data, power and energy, and the firing programs the firmware carries. **Observation only**: nothing reachable over the network can start a firing, heat the kiln or change its configuration. Those are done at the kiln. - **PID with automatic tuning**: relay (Åström–Hägglund) autotune on the real kiln; no manual gain hunting. +- **Firing profiles are compiled in**: the device ships with its curves and nothing edits them, not over the network and not at the kiln. That removes every authoring path and the attack surface each one carries, and it has a real cost: a curve of your own means building a firmware that carries it. - **Safety first**: thermal runaway, thermocouple failure, shorted-SSR, over-temperature and door-interlock detection, with a safety supervisor that has sole authority over a heat-enable line that decays unless actively refreshed. - **Current monitoring**: a current transformer turns relay and element failures from slow thermal inferences into fast electrical facts, with the thermal rules retained as an independent backstop. - **Self-contained**: no SD card, no external database, no account, no filesystem. Logs live in a circular partition on internal flash and programs in a fixed-slot one, each built so a power cut cannot tear a record; web assets are embedded in the firmware. The device makes **one** outbound connection, a daily check for a firmware update that carries nothing about the device and can be turned off at the display. @@ -60,7 +61,8 @@ all green plain and under AddressSanitizer and UBSan. `clang-tidy` is clean on host and target with no suppressions baseline, MC/DC over the supervisor's trip logic is 100 % against an 80 % floor, the `esp32s3` image builds with zero warnings at 246 kB (88 % of the OTA slot free), and QEMU boots that image and -fires it. +fires it. The hardware configuration, which that one compiles out, builds at +908 kB with the browser interface gzipped into it and 56 % of the slot free. The controller is C++20; the supervisor is C++17, which is the standard MISRA C++:2023 is written against. What is done, what is not, and what blocks @@ -221,7 +223,7 @@ three-phase kiln, so it is out of scope rather than partially supported. | Output | Zero-cross SSR in series with a safety contactor | | Supply | **Single phase only.** One current transformer on the heater conductor | | Door interlock | Optional normally-closed switch, stops the heater immediately when the door opens | -| Connectivity | WiFi station only. **No access point:** the network is selected and its passphrase typed at the display, so setting it up needs somebody at the kiln. mDNS (`kiln.local`) is specified and not implemented, so the device is reached by IP, which the display shows | +| Connectivity | WiFi station only. **No access point:** the network is selected and its passphrase typed at the display, so setting it up needs somebody at the kiln. Reachable at `kiln.local` over mDNS, and by IP, which the display shows | Details and rationale are in [requirements §6](docs/03_software_req.sdoc). diff --git a/docs/01_user_req.sdoc b/docs/01_user_req.sdoc index b959c38..e2d72e0 100644 --- a/docs/01_user_req.sdoc +++ b/docs/01_user_req.sdoc @@ -748,7 +748,15 @@ Logged data and programs shall live on internal flash. **No SD card** shall be r [CONSTRAINT] UID: UR-CON-04 STATEMENT: >>> -The project shall be licensed **GPL-3.0-or-later**, and every third-party component shall carry a licence compatible with distribution under GPL-3.0. Web assets shall be vendored, not fetched at build time from an unpinned source. +The project shall be licensed **GPL-3.0-or-later**, and every third-party component shall carry a licence compatible with distribution under GPL-3.0. + +Third-party code may be fetched at build time only through the ESP-IDF component manager, and only under all three of the following conditions, so that what a given release was built from stays recoverable from the repository alone: + +1. the version is pinned exactly (`==`), never to a range or to the newest available; +2. the resolved `dependencies.lock`, which carries the content hash, is committed; +3. the component appears in the generated SBOM with its licence. + +Web assets shall be vendored rather than fetched, at any version: they are reachable from the network at run time and are not covered by the component manager's integrity check. <<< [CONSTRAINT] diff --git a/docs/02_system_req.sdoc b/docs/02_system_req.sdoc index add42ac..72bc0a4 100644 --- a/docs/02_system_req.sdoc +++ b/docs/02_system_req.sdoc @@ -132,7 +132,14 @@ VERIFICATION: I [REQUIREMENT] UID: SYS-HW-02 STATEMENT: >>> -The chamber and enclosure thermocouple front-ends shall be MAX31856 devices on a shared SPI bus with individual chip selects. +Both thermocouple front ends shall be MAX31856 devices, and they shall be on **separate SPI buses belonging to different microcontrollers**: + +- the **chamber** front end on the supervisor's bus ([LINK: SYS-HW-26]), read by the supervisor and by nothing else; +- the **enclosure** front end on the controller's bus, read by the controller. + +**Amended 2026-10-10 with the supervisor (SWA-22).** This said "a shared SPI bus with individual chip selects", which was true when one microcontroller read both. A shared bus is now exactly what must not happen: a supervisor whose chamber reading can be disturbed by the controller's bus traffic, or whose chip select can be driven by a controller that has crashed with a pin latched, is not independent of it, and independence is the whole of what the second part buys. + +One **common cause** survives any bus arrangement, and it is the chamber couple itself: both microcontrollers derive chamber temperature from the same junction, so the independence this requirement buys is independence against software and silicon, not against the sensor. It is recorded in `safety.md` section 6 and in section 9 of the supervisor's design rather than claimed away here. <<< PRIORITY: M VERIFICATION: I @@ -268,7 +275,18 @@ VERIFICATION: I [REQUIREMENT] UID: SYS-HW-24 STATEMENT: >>> -The thermocouple front ends' **`FAULT` outputs shall interrupt the contactor coil in hardware**, each through its own series switching element, so that a reported sensor fault removes the heater without the firmware taking part. Each `FAULT` net shall retain its own test point. The limits of this interlock shall be documented: the outputs are open-drain, so an **unpowered or absent** front end leaves the path closed, and the MAX31856 detects an open circuit only once its fault mask has been configured. The hardware interlock therefore covers faults the device actively reports, and [LINK: SWR-SAF-04] in firmware remains the cover for a dead or unconfigured front end. +**Superseded 2026-10-10 by [LINK: SYS-HW-26] and [LINK: SYS-SAF-25]. Rewritten rather than deleted, because the property it reached for is now delivered differently.** + +It required the thermocouple front ends' `FAULT` outputs to interrupt the contactor coil in hardware, each through its own series switching element, so that a reported sensor fault removed the heater with no firmware taking part. What that was buying is a sensor fault that stops the kiln without trusting the controller's firmware, and it bought it with an interlock whose limits had to be documented at length: the outputs are open-drain, so an unpowered or absent front end leaves the path closed, and a MAX31856 reports an open circuit only once its fault mask is configured, which is firmware work. The chain covered faults the device actively reported and nothing else, so [LINK: SWR-SAF-04] remained the real cover for a dead or unconfigured front end. + +The supervisor delivers the same property and more of it. It reads the chamber front end itself, decides on a persistent fault, and holds its own series element in the coil; a front end that is dead, absent or unconfigured is a front end that does not answer the supervisor either, and that is a trip rather than a documented limit. The discrete chain is therefore not built. + +What is **kept** from this requirement: + +- each `FAULT` net retains its own test point, which costs nothing and is how the front ends are diagnosed on a bench; +- the enclosure channel has no hardware interlock and never did; [LINK: SWR-SAF-11] in the controller's firmware is its cover, and that channel is deliberately outside the supervisor's remit so that an enclosure fault can never cause the supervisor's trip. + +The lid's series contact of [LINK: SYS-HW-21] is **unchanged** and stays in the coil. It is the one element in the design that depends on no firmware at all. <<< PRIORITY: M VERIFICATION: I, T @@ -281,10 +299,51 @@ Where a door or lid interlock is fitted, its switch shall be **normally closed** PRIORITY: S VERIFICATION: I, T +[REQUIREMENT] +UID: SYS-HW-26 +STATEMENT: >>> +**The independent safety supervisor shall be a separate microcontroller** with: + +- **its own clock and its own watchdog**, neither derived from nor serviced by the controller; +- **its own series interrupting element in the contactor coil path**, in addition to the elements of [LINK: SYS-SAF-03] and the lid contact of [LINK: SYS-HW-21]; +- **its own chamber thermocouple front end** on its own SPI bus ([LINK: SYS-HW-02]); +- **its own programming and debug interface**, brought out to test points, so that its firmware can be reviewed, built and flashed without the controller taking part. + +The permit output shall be **active high**, and the series element shall have an **external pull-down**: every GPIO is high impedance between reset and the first instruction, so without one there is a window at every supervisor reset in which the element's state is whatever the board leaks to. + +It can refuse heat on its own and it cannot command heat on its own: holding one element of a series path is permission, not control. That asymmetry is the whole design, and it is why the supervisor needs no safety case for its own failure to permit -- a supervisor that fails off is a kiln that does not fire. +<<< +PRIORITY: M +VERIFICATION: I, T + +[REQUIREMENT] +UID: SYS-HW-27 +STATEMENT: >>> +**The link between the controller and the supervisor shall be point-to-point serial, one direction, and report only.** The supervisor transmits; it shall not act on anything received, and the controller shall have no electrical path able to change what the supervisor does. + +No message shall exist that can raise the supervisor's trip threshold, lower its sensitivity, clear its latch, or suppress its reports. The threshold is compiled in ([LINK: SYS-SAF-25]) and the latch is cleared locally ([LINK: SYS-HW-28]). + +The reason is the attack surface and the failure surface at once: a controller that can be compromised over the network, or that can simply crash with a transmitter stuck, must not be able to reach the part whose job is to disbelieve it. A link that only carries information out cannot carry instruction in. +<<< +PRIORITY: M +VERIFICATION: I, T + +[REQUIREMENT] +UID: SYS-HW-28 +STATEMENT: >>> +**The supervisor's latch shall be cleared by a local action at the panel**, by a button wired to the supervisor and to nothing else, or by a power cycle. + +The button shall be edge triggered and shall require a deliberate hold, and a line stuck low shall never arm it, so that a short circuit fails towards the latch holding rather than towards it clearing. + +The panel shall not imply that there is one acknowledgement. There are two and they are not the same: this one clears the supervisor, and the controller's own latched fault ([LINK: SWR-SAF-17]) is acknowledged at the display. An operator cannot see the difference, so the labelling has to make it. +<<< +PRIORITY: M +VERIFICATION: I, T + [REQUIREMENT] UID: SYS-HW-25 STATEMENT: >>> -The contactor coil's freewheel diode shall be connected **across the coil itself**, on the kiln side of every series interrupting element of [LINK: SYS-HW-21] and [LINK: SYS-HW-24], so that opening any of them leaves the coil current a path to decay through. A freewheel path referenced to the raw supply instead would put the inductive transient across the opening contacts, eroding the lid switch and stressing the sense divider. +The contactor coil's freewheel diode shall be connected **across the coil itself**, on the kiln side of every series interrupting element of [LINK: SYS-HW-21] and [LINK: SYS-HW-26], so that opening any of them leaves the coil current a path to decay through. A freewheel path referenced to the raw supply instead would put the inductive transient across the opening contacts, eroding the lid switch and stressing the sense divider. <<< PRIORITY: M VERIFICATION: I @@ -341,6 +400,39 @@ The heating output stage shall consist of an SSR for modulation **in series with <<< VERIFICATION: I +[REQUIREMENT] +UID: SYS-SAF-25 +STATEMENT: >>> +**An independent supervisor shall de-energise the heater above a hard-coded absolute chamber temperature, and on a persistent chamber thermocouple fault, independently of the controller.** + +The threshold shall be **compiled into the supervisor's firmware** and shall not be reachable by configuration, by the link, or by any other means short of reflashing that part. A ceiling an operator can raise is a ceiling, not a backstop: [LINK: SWR-SAF-23]'s configurable maximum is the operator's limit, and this is the one that exists for when the controller's own rules, or the controller itself, have stopped working. The two values are stated together wherever either appears, because limits that can be edited apart eventually cross. + +It supersedes the hardware fault chain of [LINK: SYS-HW-24], and it is a stronger claim than that chain could make: a front end that is dead, absent or unconfigured does not answer the supervisor, and not answering is a trip. +<<< +VERIFICATION: A, T + +[REQUIREMENT] +UID: SYS-SAF-26 +STATEMENT: >>> +**The supervisor's trip shall latch**, and shall be clearable only by a power cycle or the local action of [LINK: SYS-HW-28] -- never by a command over the link ([LINK: SYS-HW-27]). + +A latch that the controller could clear is not a latch: the controller is the part that may have failed, and the condition that tripped the supervisor is one the controller either did not see or did not act on. Somebody has to go and look at the kiln. + +The latch is **not required to survive a power cycle**, and that is deliberate rather than an omission. The supervisor holds no non-volatile state at all, so it cannot be left in a state that a reflash or a flat battery changes, and the system-level obligation to remember a fault across a power loss is met by the controller's persisted fault ([LINK: SWR-SAF-17]). Section 3 of the supervisor's design states what that leaves: a power cycle clears the supervisor's own memory of why it tripped, and the controller's record is what the operator is shown. +<<< +VERIFICATION: A, T + +[REQUIREMENT] +UID: SYS-SAF-27 +STATEMENT: >>> +**Chamber temperature shall be acquired by the supervisor and reported at not less than 4 Hz, and a stale report shall be treated by the controller as a sensor fault.** + +The controller has no other source of chamber temperature, so the report rate is the controller's sample rate and silence on the link is indistinguishable from a dead front end -- which is why it is treated as one rather than as a communications problem to retry. + +**The supervisor's own trip shall not depend on the link.** It shall act within one acquisition period of the condition arising, whether or not anything is listening. The link exists so that the controller can display a temperature and say why the kiln stopped; the protection works with the link unplugged. +<<< +VERIFICATION: A, T + [REQUIREMENT] UID: SYS-SAF-24 STATEMENT: >>> diff --git a/docs/03_software_req.sdoc b/docs/03_software_req.sdoc index 29bb745..0cda039 100644 --- a/docs/03_software_req.sdoc +++ b/docs/03_software_req.sdoc @@ -413,7 +413,15 @@ TITLE: 3.1 Temperature acquisition (FR-ACQ) [REQUIREMENT] UID: SWR-ACQ-01 STATEMENT: >>> -The system shall measure chamber temperature with a MAX31856 thermocouple front-end over SPI. +Chamber temperature shall be measured with a MAX31856 thermocouple front end over SPI **by the independent supervisor**, and reported to the controller over the link of [LINK: SYS-HW-27] at not less than 4 Hz. The **enclosure** channel's MAX31856 remains the controller's own, on the controller's bus. + +**Amended 2026-10-10 with the supervisor (SWA-22).** This said "the system shall measure", which was accurate when one microcontroller read both front ends. It no longer is, and the difference matters to every requirement downstream: the controller does not own the chamber front end, cannot configure it ([LINK: SWR-ACQ-02]), and has no access to it other than a frame arriving on a serial line. + +What that makes the controller's obligation here: + +- **A report is a sample.** The link's rate is the controller's sample rate; there is nothing behind it to poll faster. +- **Silence is a sensor fault, not a comms problem.** A link that has gone quiet, or that is repeating a sequence number, presents to the controller exactly as a front end that has stopped answering, and is handled by [LINK: SWR-SAF-04]'s grace and latch rather than retried. That is tested end to end: the kiln is heating, the link stops, the fault latches and the contactor opens. +- **A reading is not trusted further than its source.** The supervisor's own fault bits and its trip reason come up the same link and are displayed, never acted on as permission. <<< PRIORITY: M VERIFICATION: T @@ -787,7 +795,19 @@ VERIFICATION: T [REQUIREMENT] UID: SWR-PRG-04 STATEMENT: >>> -The system shall store at least **20** programs in non-volatile storage, persisting across power loss and firmware update. +**Withdrawn 2026-10-10.** This required at least 20 programs in non-volatile storage, persisting across power loss and firmware update. + +It was the last requirement standing on the authoring path that [LINK: SWR-PRG-07], [LINK: SWR-PRG-08] and [LINK: SWR-PRG-10] were withdrawn with. With nothing on the device able to create or edit a program, the storage held a copy of the three examples that arrive in the image ([LINK: SWR-PRG-09]) and nothing else: persistence of something already persistent, in a medium that wears out, behind a seeding path that could fail on a first boot and a store that could fail to mount. + +So the programs are read from the firmware. What that *removes* is worth stating, because this is a deletion that makes the device better rather than smaller: + +- A device whose flash will not mount now still has its programs. It used to serve `503` for the list of programs it was carrying in its own image. +- A factory reset ([LINK: SWR-CFG-09]) no longer costs the operator their programs. The partition holds run history, and losing history is what a factory reset is for. +- The first boot has nothing to get wrong. Seeding was idempotent and tested, and it was still a write to flash on every power-on of a brand-new device. + +"Persisting across a firmware update" is now trivially true in the only sense left: the programs are part of what the update installs. A user who wants a different curve edits `kiln_core/profile` and builds, which is the cost [LINK: SWR-PRG-09] states plainly. + +The 20-slot figure is gone with the requirement. `kiln_core/fileslots` still carries 64 regions and the run index still uses 20 of them ([LINK: SWR-LOG-09]), so the partition is now a third used rather than two thirds. That is recorded in architecture 10.6 and deliberately not acted on: shrinking the partition is a flash-layout change that breaks every device already flashed, to recover space nothing needs. <<< PRIORITY: M VERIFICATION: T @@ -795,7 +815,9 @@ VERIFICATION: T [REQUIREMENT] UID: SWR-PRG-05 STATEMENT: >>> -The system shall validate a program on save and on start, rejecting it with a specific reason if: no segments; any target above the configured maximum temperature; any field out of range; total predicted duration above 168 h. +The system shall validate a program on start, rejecting it with a specific reason if: no segments; any target above the configured maximum temperature; any field out of range; total predicted duration above 168 h. + +**Still required after [LINK: SWR-PRG-04]'s withdrawal, and the reason is the configured maximum.** The programs are compiled in and therefore trusted as *data*, but `safety.max_temp_c` is set by the operator and can be lowered below a program's peak; validation on start is what refuses that firing rather than running it into a ceiling. A test asserts that every compiled-in example validates against the default maximum, because an example that cannot start is one an operator discovers by selecting it. <<< PRIORITY: M VERIFICATION: T @@ -811,7 +833,11 @@ VERIFICATION: T [REQUIREMENT] UID: SWR-PRG-07 STATEMENT: >>> -The system shall allow creating, reading, updating, renaming, duplicating and deleting programs through the web interface. +**Withdrawn 2026-10-09: programs are pre-compiled and are not edited anywhere.** This required creating, reading, updating, renaming, duplicating and deleting programs through the web interface. + +Half of it was already gone: [LINK: SWR-WEB-26] made the interface read-only, so nothing over the network could create or change a program. The rest goes with the decision that the set of firing profiles is **fixed at build time** ([LINK: SWR-PRG-09]), which answers `OQ-09` by removing the question rather than by choosing one of the editors it offered. + +What that buys, and it is the reason to prefer it: there is no authoring path to secure, no file format to validate, no import to get wrong, and no editor to build on a rotary encoder. What it costs is stated plainly in [LINK: SWR-PRG-09]: a user who wants a curve of their own builds one into the firmware. <<< PRIORITY: M VERIFICATION: T @@ -819,7 +845,9 @@ VERIFICATION: T [REQUIREMENT] UID: SWR-PRG-08 STATEMENT: >>> -The system shall export and import a program as a documented, human-readable JSON file. +**Withdrawn 2026-10-09 with [LINK: SWR-PRG-07].** This required exporting and importing a program as a documented JSON file. + +Import is authoring by another name, and the set of profiles is fixed at build time. Export has no purpose on its own: what a user would export is already in the source, under version control, readable. <<< PRIORITY: M VERIFICATION: T @@ -827,7 +855,11 @@ VERIFICATION: T [REQUIREMENT] UID: SWR-PRG-09 STATEMENT: >>> -The system shall ship with at least **3** example programs (e.g. bisque cone 06, glaze cone 6, glass fusing) marked read-only. +The system shall ship with at least **3** example programs (e.g. bisque cone 06, glaze cone 6, glass fusing), compiled into the firmware and read-only. + +**These are the only firing programs the device has**, as of the 2026-10-09 decision that withdrew [LINK: SWR-PRG-07], [LINK: SWR-PRG-08] and [LINK: SWR-PRG-10]. A user who wants a different curve adds it here and builds, which is a real cost and is the honest consequence of having no authoring path at all: it puts a compiler between a potter and a firing schedule. + +The trade was taken deliberately. Every authoring route costs something this design is not willing to spend: over the network reopens a write path that [LINK: SWR-WEB-26] closed, at the display means editing five segments of target, rate and dwell on a rotary encoder, and an import file is a parser accepting operator-supplied data on the component that commands a heater. <<< PRIORITY: S VERIFICATION: I @@ -835,7 +867,9 @@ VERIFICATION: I [REQUIREMENT] UID: SWR-PRG-10 STATEMENT: >>> -The system shall permit editing the *remaining* segments of a running program; edits to the current or already-completed segments shall be rejected. +**Withdrawn 2026-10-09 with [LINK: SWR-PRG-07].** This required editing the remaining segments of a running program while rejecting edits to the current or completed ones. + +It was the most delicate requirement in this section, because it asks the run controller to accept a change to the thing it is executing, and it is now moot: there is no editor and no segment to change. [LINK: SWR-RUN-07]'s abort remains the way to stop a firing that is doing the wrong thing. <<< PRIORITY: S VERIFICATION: T @@ -897,7 +931,9 @@ VERIFICATION: T [REQUIREMENT] UID: SWR-RUN-06 STATEMENT: >>> -On program completion the system shall disable heating, sound the alarm for a configurable duration (0–600 s, default 30 s), and enter Complete. +On program completion the system shall disable heating, sound the alarm for a configurable duration (0–600 s, default 4 s), and enter Complete. + +**Amended 2026-10-09.** The default was 30 s, which with the completion pattern of [LINK: SWR-SAF-20] is fifteen repetitions of it. A firing that has just finished correctly is good news, and a notification is what it wants rather than half a minute of chirping, so the default is now one burst's worth. The range is unchanged: an operator who wants the kiln to keep saying so until somebody comes can still set it, and 0 silences the completion annunciation without touching the fault one. <<< PRIORITY: M VERIFICATION: T @@ -1456,7 +1492,7 @@ VERIFICATION: T [REQUIREMENT] UID: SWR-CFG-02 STATEMENT: >>> -The configuration shall include at minimum: **safety**: maximum chamber temperature, maximum enclosure temperature, runaway detection parameters, power-loss recovery policy; **control**: Kp, Ki, Kd, loop period, PWM window, minimum on/off time, maximum duty, hold-back band, dwell tolerance; **sensing**: TC types, line filter frequency, filter time constant, calibration offset and gain for both channels; **tuning**: amplitude, hysteresis, rule, timeout; **current**: CT ratio and output type, nominal heater current, mains voltage, fail-on and fail-off thresholds and settle times, deviation bands, over-current limit, relay life limits, monitoring enable; **logging**: sample interval; **HMI**: units, dim timeout, alarm duration; **network**: WiFi credentials, hostname, NTP server, timezone, there being no mode to choose and no access point since [LINK: SWR-NET-11]; **security**: none, since [LINK: SWR-WEB-26] left nothing reachable over the network to authenticate. +The configuration shall include at minimum: **safety**: maximum chamber temperature, maximum enclosure temperature, runaway detection parameters, power-loss recovery policy; **control**: Kp, Ki, Kd, loop period, PWM window, minimum on/off time, maximum duty, hold-back band, dwell tolerance; **sensing**: the *enclosure* TC type, line filter frequency, filter time constant, calibration offset and gain for both channels (there is no chamber TC type: [LINK: SWR-ACQ-02] fixes it at K and schema 2 removed the item, because a setting that is offered and ignored is worse than one that is absent); **tuning**: amplitude, hysteresis, rule, timeout; **current**: CT ratio and output type, nominal heater current, mains voltage, fail-on and fail-off thresholds and settle times, deviation bands, over-current limit, relay life limits, monitoring enable; **logging**: sample interval; **HMI**: units, dim timeout, alarm duration; **network**: WiFi credentials, hostname, NTP server, timezone, there being no mode to choose and no access point since [LINK: SWR-NET-11]; **security**: none, since [LINK: SWR-WEB-26] left nothing reachable over the network to authenticate. <<< PRIORITY: M VERIFICATION: I @@ -1643,6 +1679,10 @@ What the entry has to provide, each because its absence makes a network unjoinab - **An open network shall not ask for a passphrase at all.** The credentials shall be **persisted before the join is attempted**, so that a device which joins successfully and then loses power comes back to the same network rather than to a display nobody is standing at. + +**Added 2026-10-09.** The display shall also show **the outcome of the last attempt**, distinguishing a join in progress from one the radio refused, and shall show the refusal's reason code. "Not connected" is not an answer an operator can act on, and since this screen is the only route in, acting on it is the whole job: a mistyped passphrase, an SSID out of range and a radio that never came up are three different remedies and were previously one message. The reason code is shown as the number the driver reports, to be read out to somebody who can look it up, rather than translated into a sentence that guesses which of them it was. + +A knob is a poor keyboard, so entry shall **resume at the character the previous press selected** rather than returning to the start of the set. <<< PRIORITY: M VERIFICATION: T @@ -2174,6 +2214,13 @@ VERIFICATION: I UID: SWR-NFR-23 STATEMENT: >>> Operator-facing text shall be held in a **single resource location**, indexed by language, with no text embedded at its point of use. **English and German** shall both be provided, selected by the `hmi.language` configuration item and offered on the local display and in the web interface. A language with no entry for a string shall fall back to English rather than showing nothing. Diagnostic output, the event log and requirement identifiers shall remain English regardless of the setting, so that a fault quoted in a report reads the same whoever filed it. + +**Clarified 2026-10-09, after "no text embedded at its point of use" was found to be half true.** Faults and warnings were in a table from the start; the display's own frame -- screen titles, the menu, the two answers to a confirmation, the footers that say what the knob does -- was string literals at their draw sites, so a device set to German announced its faults in German inside an English frame. There are now two tables, `kiln_core/faults` for what has gone wrong and `kiln_hmi/strings` for the display's own words, and no operator-facing literal remains at a draw site. + +Two constraints on the German, both from the panel rather than from the language, and both asserted rather than reviewed: + +- **The 5x7 font carries the 95 printable ASCII glyphs and nothing else**, so German text is transliterated (AE, OE, UE, SS). A byte outside 0x20 to 0x7E reaches the panel as a blank, which nobody would see until the device was in a workshop. +- **21 characters is the display's width** at scale 1, past which the framebuffer clips silently. German runs about 15 per cent longer than English, so this is the bound the translation is written against rather than something to discover on hardware. <<< PRIORITY: M VERIFICATION: T @@ -2538,8 +2585,15 @@ VERIFICATION: I, T UID: SWR-SAF-20 STATEMENT: >>> The on-board buzzer ([LINK: SYS-HW-09]) shall sound on any fault and on program completion, and the two shall be audibly distinguishable by their pattern. + +**Amended 2026-10-09 to say what the two patterns are**, because "distinguishable" was left to the implementation and the implementation was in a timer callback no test could reach: + +- **Completion** is three 90 ms chirps, then five seconds of silence. Brief and unmistakably a notification. +- **A fault** is six 60 ms chirps at twice that rate, a 400 ms gap, and again without end until the fault is acknowledged. Twice the chirp rate, a third of the cycle sounding against a twentieth, and a cycle five times shorter: the two differ in rhythm, in rate and in duty, so neither a noisy workshop nor a closed door collapses one into the other. + +The patterns are a table in `kiln_core/alarmptn`, and the distinguishability is asserted as a comparison between them rather than described, which is why the verification method changes from Demonstration to Test. A buzzer that cannot be commanded, including a pattern value that is not one of the three, is silent: the failure direction is off, because an operator's only remedy for a buzzer stuck on is the mains switch. <<< -VERIFICATION: D +VERIFICATION: T [REQUIREMENT] UID: SWR-SAF-21 @@ -2892,7 +2946,13 @@ STATUS: Resolved [QUESTION] UID: OQ-09 STATEMENT: >>> -Where are firing programs authored, now that [LINK: SWR-WEB-26] has made the web interface read-only? A local editor on a rotary encoder and a 128x64 display is possible but unpleasant; importing from a file on first boot, or shipping only the examples of [LINK: SWR-PRG-09], are the alternatives. Until this is answered a user cannot create a curve of their own, which is a real functional gap rather than a nicety. +**Resolved 2026-10-09: nowhere. The profiles are pre-compiled and nothing edits them.** The question was where firing programs are authored now that [LINK: SWR-WEB-26] has made the web interface read-only, with a local editor, a file import, or shipping only the examples as the candidates. + +The answer is the third, taken as a decision rather than as a default. [LINK: SWR-PRG-09]'s examples are the device's whole repertoire; [LINK: SWR-PRG-07], [LINK: SWR-PRG-08] and [LINK: SWR-PRG-10] are withdrawn. + +**The gap this leaves is real and is not being minimised**: a potter who wants a curve of their own needs a toolchain and a flash, which for most of them means somebody else does it. That is a genuine cost of the read-only decision and it should be stated in the user documentation rather than discovered. + +What it buys is that there is no authoring path at all: nothing over the network to secure, no file format to validate, no parser accepting operator data on the processor that commands a heater, and no editor to design for a rotary encoder. If the cost proves too high in use, the cheapest reversal is the file import of [LINK: SWR-PRG-08] at service time, which needs physical access and so leaves the security argument intact. <<< [QUESTION] diff --git a/docs/04_software_arch.sdoc b/docs/04_software_arch.sdoc index 54861c1..db6de8a 100644 --- a/docs/04_software_arch.sdoc +++ b/docs/04_software_arch.sdoc @@ -109,6 +109,13 @@ RELATIONS: UID: SWA-04 STATEMENT: >>> **The safety supervisor is a separate, higher-priority task that owns the heat-enable output.** The control path can only *request* heat; safety alone grants it. + +**There are now two supervisors and this is the one made of software.** Since [LINK: SWA-22] the kiln has an independent supervisor on its own microcontroller holding its own series element in the coil, and the distinction has to be kept straight in every sentence that says "the supervisor": + +- **this** one is a task inside the controller's firmware. It protects against bugs, deadlocks and overruns in control, HMI, web and networking, and it enforces every configurable rule: the operator's maximum, runaway detection, the thermal and current rules; +- **that** one is a second part. It protects against the failure of this entire processor, and enforces one rule that nothing here can change. + +Neither replaces the other, and the reason is what each can be wrong about. A task cannot protect against the processor it runs on; a part with no configuration, no network and 3 488 bytes of program cannot run a firing schedule. The configurable rules have to live where the configuration is, which is here, and the backstop has to live where nothing can reach it, which is there. <<< RATIONALE: >>> [LINK: SYS-SAF-01], [LINK: SYS-SAF-02], [LINK: SWR-SAF-13]. A bug, deadlock or overrun anywhere in control, HMI, web or networking cannot produce heat. @@ -131,6 +138,10 @@ RELATIONS: UID: SWA-05 STATEMENT: >>> **Heat enable is a software-generated square wave into a hardware charge pump**, not a static level. + +**It remains the controller's path to the coil and is no longer the only path.** Since [LINK: SWA-22] the supervisor holds a second series element with its own output, so the coil needs the charge pump *and* the supervisor's permit. The charge pump still does what it was put there for -- a hung safety task stops toggling and the contactor drops in about a second with no code involved -- and that property is now one of two, not the whole of the argument. + +The supervisor's element is **a static level with an external pull-down** rather than a second charge pump ([LINK: SYS-HW-26]), and that asymmetry is deliberate. A charge pump protects against a processor that has stopped executing while leaving a pin latched high, which is the failure a 240 MHz processor running WiFi, a web server and a filesystem actually has. The supervisor executes 3 488 bytes in a loop with a windowed watchdog cross-checked against an independent oscillator; what protects against *it* stopping is that stopping removes the permit. A second charge pump would add a timing requirement to the part whose value is having almost none. <<< RATIONALE: >>> [LINK: SYS-SAF-02], [LINK: SYS-HW-07], and gives [LINK: SWR-SAF-14] for free: a hung safety task stops toggling and the contactor drops in ~1 s with no code involved. diff --git a/docs/architecture.md b/docs/architecture.md index 3b1598e..f3dacc5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,7 +40,7 @@ as a decision in [§3](#3-key-decisions). | 3 | **Control and safety must meet hard deadlines regardless of load.** | `SWR-NFR-01`–`SWR-NFR-04` | Core affinity split, priority ordering, no blocking calls on the control path, message passing instead of shared locks. | | 4 | **No SD card, no external database, no external host.** | `UR-CON-03`, `SWR-WEB-02` | Log store as a raw circular flash partition; web assets embedded and gzipped in the firmware image; charting rendered client-side from the device's own API. | | 5 | **Fits an 8 MB / no-PSRAM ESP32-S3 and runs for a week.** | `SWR-NFR-10`–`SWR-NFR-14` | Fixed-size records, static allocation in the core, bounded HTTP buffers, flash write pattern designed against an endurance budget. | -| 6 | **GPL-3.0-or-later, self-contained, vendored dependencies.** | `UR-CON-04` | No build-time network fetch, third-party licences audited and recorded. | +| 6 | **GPL-3.0-or-later, self-contained, dependencies pinned.** | `UR-CON-04` | Web assets vendored. One managed component (`espressif/mdns`), pinned exactly with the content hash in a committed lockfile; third-party licences audited and recorded in the SBOM. | ## 3. Key decisions @@ -141,7 +141,7 @@ double is a compile-time-checked substitution. |---|---|---| | `port_tc` | `configure(type, filter_hz)`, `read(→ temp_c, cj_c, fault_bits)` | MAX31856; simulator; fault-injecting stub | | `port_heat` | `set_duty(permille)`, `enable_refresh()`, `force_off()` | GPIO + 10 ms timer; simulator; recording spy | -| `port_alarm` | `set(pattern)` | GPIO; spy | +| `port_alarm` | `set(pattern)` | GPIO and a one-shot timer, stepping the rhythm table of `kiln_core/alarmptn` (§10.7); spy | | `port_display` | `blit(framebuffer)`, `set_contrast()`, `present()` | SSD1306 over I²C; in-memory framebuffer for golden-image tests | | `port_input` | `poll(→ events)` | PCNT + GPIO; scripted event source | | `port_current` | `start_burst(window_phase)`, `read_burst(→ samples, n)`, `present()` | ADC continuous-mode DMA burst; simulator; scripted sample source | @@ -152,7 +152,7 @@ double is a compile-time-checked substitution. | `port_system` | `reset_cause()`, `fw_info()`, `stats()`, `wdt_subscribe/feed()` | `esp_system` + task WDT; stub | | `port_update` | `check()`, `fetch()`, `begin/write/finish()`, `confirm_running()`, `rollback()` | `esp_https_ota` for the pull path of §13.5, `esp_ota_ops` for the slots; stub | | `port_kvstore` | `get/set/erase(namespace, key, blob)` | NVS; in-memory fake | -| `port_filestore` | `list/read/write/delete(path)` | `kiln_core/fileslots` over `port_flash` (`SWA-21`); RAM fake on host | +| `port_filestore` | `read/write_atomic/remove(path)`, `usage` | `kiln_core/fileslots` over `port_flash` (`SWA-21`); RAM fake on host | | `port_net` | `status()`, `scan_begin/busy/results()`, `connect()` | WiFi station, scanning and joining (§5.4), plus SNTP; stub. **No access point**: network setup is on the display | ### 5.3 Application (`kiln_app`) @@ -167,7 +167,6 @@ double is a compile-time-checked substitution. | `telemetry` | Maintains the current published snapshot for queries, lock-free for readers. | | `logger_task` | Drains the log queue to `port_logstore`; the only component that blocks on flash. | | `settings` | Configuration load, validate, migrate, apply, persist; marks reboot-required items. | -| `program_store` | Program CRUD over `port_filestore`, atomic replace, seeding of read-only examples. | | `run_index` | Run record persistence and the 20-run retention policy. | ### 5.4 Presentation @@ -658,8 +657,9 @@ decimated series equal the extrema of the full series. ### 10.6 The file store -`kilnfs` holds programs and run records as a fixed array rather than a -filesystem (`SWA-21`). The partition divides into equal **regions of two erase +`kilnfs` holds run records as a fixed array rather than a filesystem +(`SWA-21`). It held programs too until `SWR-PRG-04` was withdrawn; the store +below is unchanged, and what changed is that only one caller writes to it. The partition divides into equal **regions of two erase sectors**, 64 regions in 512 kB. One file occupies one region and alternates between its two sectors, so the copy being replaced is never the copy being erased. @@ -689,17 +689,94 @@ nothing can disagree with the data. The loser is left in place rather than erased, because it is the next write's target and erasing it at boot would spend an erase cycle on every power-up. -Capacity is 40 of 64 regions for the 20 programs of `SWR-PRG-04` and the 20 run -records of `SWR-LOG-09`. Endurance is generous for the same reason the log's is: -a program is written when a user saves it and a run record once per firing, so a -region sees single-digit erases per year against a 100 000 cycle rating. - -Three properties carry the weight and each is a host test, driven through a -flash fake that enforces NOR semantics and can cut power part-way through a -write: a file survives a remount, a cut before the commit leaves the previous -copy, and a cut inside the commit header leaves the previous copy. The same -fake drives `program_store` and `run_index` over the real store in -`test_stores_on_flash`, which is the combination that runs on the board. +A name longer than 63 bytes is **refused**, not truncated: `KILN_PATH_MAX` is 64 +including the terminator and the stored name field is exactly that, so a longer +path returns `KILN_ERR_INVALID_ARG`. Both callers produce five-character slot +paths, so nothing generates one today; refusing is the answer because storing a +file under a name the caller did not ask for is worse than failing. + +Capacity is 20 of 64 regions, for the run records of `SWR-LOG-09` and nothing +else. It was 40, half of them programs, until `SWR-PRG-04` was withdrawn and the +firing profiles moved into the image: the partition is now a third used rather +than two thirds. That is recorded and deliberately not acted on, because +shrinking it is a flash-layout change that breaks every device already flashed, +to recover space nothing needs. Endurance is generous for the same reason the +log's is: a run record is written once per firing, so a region sees single-digit +erases per year against a 100 000 cycle rating. + +**Wear is handled at the write rather than at the read.** A NOR sector at the +end of its life does not report an error: it takes the write, returns success, +and holds something else. Every write is therefore read back and re-validated +through the same path a mount uses, and only then does the in-RAM index point at +it. A copy that does not verify retires its region, and the write is +re-attempted in a free region at a higher sequence number, up to three regions. +The caller gets success, because the file is stored; what changed is where. + +Retirement needs no bad-block table. Once the migrated copy has verified the old +region is erased, best effort: if that succeeds the stale copy is gone, which is +what stops a later delete from being undone at the next mount, and the region +returns to the pool at the next boot, which is the right answer for a sector +that can still be erased. If the erase fails, which is what a sector that is +genuinely finished does, the stale copy stays at a *lower* sequence number, two +regions claim one name, and mount resolves it the only way it can be true: the +lower sequence number is the write that failed, its region is retired again, and +the higher one is the file. A power cut between the verify and the erase leaves +the same state and is resolved the same way. The count of retired regions is +logged at boot, and is the only warning anybody gets that the part is going. + +What the store does **not** do is spread wear. A region is chosen by index and +keeps its file for as long as it works, because the endurance arithmetic above +has four orders of magnitude of headroom and levelling would be mechanism bought +for nothing. Retirement is in because its failure mode is losing a run record, +which is cheap to prevent and annoying to explain. + +**What a mount costs**, measured in `test_fileslots` rather than estimated: the +medium this firmware produces, 20 run records of 554 bytes, costs **488 reads +and 31 888 bytes** of CRC. A partition full of +maximum-size files, both copies valid, costs **8 192 reads and 524 288 bytes**, +which is the whole partition. Both are paid once, at start-up, and the +expectation is tens of milliseconds rather than a budget item; if a board ever +says otherwise, the only thing mount needs eagerly is which copy of each region +wins, and the payload CRC could be deferred to the read. The numbers are +asserted so that making mount lazy cannot happen quietly. + +Several properties carry the weight and each is a host test, driven through a +flash fake that enforces NOR semantics, can cut power part-way through a write, +and can model a worn sector that accepts a write and keeps zeros: a file +survives a remount, a cut before the commit leaves the previous copy, a cut +inside the commit header leaves the previous copy, a sector that lies about a +write retires its region and the file is still readable, a region that cannot be +erased stays retired across a reboot, and a part with nowhere left to write says +so instead of losing the previous content. The same fake drives `run_index` +over the real store in `test_stores_on_flash`, which is the combination that +runs on the board. + +### 10.7 What the buzzer sounds like + +The buzzer is a 5 V active part that makes its own tone (`SYS-HW-09`), so the +only thing the firmware chooses is the rhythm, and `SWR-SAF-20` asks for two +annunciations an operator can tell apart without looking. + +| | Pattern | Cycle | Sounding | +|---|---|---|---| +| Completion | three 90 ms chirps, then silence | 5 s | 270 ms, 5 % | +| Fault | six 60 ms chirps, 400 ms gap, without end | 1.06 s | 360 ms, 34 % | + +They differ in rhythm, in rate and in duty, which is what "distinguishable" +has to mean for a part with one tone and no volume control. Completion is a +notification: a firing that took nine hours has finished correctly, and +`hmi.alarm_duration_s` defaults to 4 s so the operator hears the burst once. +A fault is meant to be unpleasant and to carry through a wall, and it continues +until the fault is acknowledged, because a fault that stopped announcing itself +is a fault nobody heard. + +The table and the stepper are in `kiln_core/alarmptn`, with no clock and no +hardware, so the rhythms are host-tested and the comparison between them is +asserted rather than described (`SWA-01`, `SWA-02`). The adapter sets a pin and +arms a one-shot timer for the interval it is handed; it was previously the whole +pattern, inside an `esp_timer` callback no test could reach. A pattern value +that is not one of the three is silent: the failure direction is off, because +an operator's only remedy for a buzzer stuck on is the mains switch. ## 11. Configuration @@ -730,9 +807,8 @@ set (`SWR-WEB-23`). | `GET` | `/api/info` | Version, git revision, build time, target, uptime, gains + provenance | `SWR-UPD-06` | | `GET` `PUT` | `/api/config` | Read / atomically write configuration | `SWR-CFG-03` | | `POST` | `/api/config/defaults` | Restore defaults | `SWR-CFG-06` | -| `GET` `POST` | `/api/programs` | List / create | `SWR-PRG-07` | -| `GET` `PUT` `DELETE` | `/api/programs/{id}` | Read / update / delete | `SWR-PRG-07` | -| `POST` | `/api/programs/{id}/copy` | Duplicate | `SWR-PRG-07` | +| `GET` | `/api/programs` | List the programs compiled into the firmware | `SWR-PRG-09` | +| `GET` | `/api/programs/{id}` | Read one; an id past the end is `404` | `SWR-PRG-09` | | `GET` | `/api/programs/{id}/preview` | Predicted setpoint curve, duration, end time | `SWR-PRG-06` | | `POST` | `/api/run` | Start `{program_id}` | `SWR-RUN-02` | | `POST` | `/api/run/pause` `resume` `abort` `ack` | Run control and segment acknowledgement | `SWR-RUN-03/04`, `SWR-PRG-03` | @@ -803,15 +879,65 @@ The SSE connection drives every live value; on disconnect the UI marks values stale and reconnects with backoff rather than showing old data as current (`SWR-WEB-25`). +**How the stream is served, and why not from the handler.** `esp_http_server` +runs one task and serialises handlers on it, so a handler that looped pushing a +frame a second would hold that task for as long as a browser tab stayed open: +the other sockets would never be served again, and the kiln would appear to +hang the moment somebody left a dashboard open and then tried to load the log. + +So `/api/events` does not stream from its handler. It hands the socket to the +server's async machinery (`httpd_req_async_handler_begin`), parks the request in +a two-slot table and returns, which releases the server task immediately. One +task at priority 3 -- below the server's 4, far below control and safety -- +pushes to every parked subscriber once a second, with an SSE comment frame every +fifteen pushes to keep an idle proxy or a phone's radio from dropping a stream +that is working. + +Two subscribers, not four: a stream holds its socket indefinitely, so letting +every socket become one would leave nothing for the page, the log or an update +check. Two is a dashboard on a phone and one on a laptop. A third is refused +with `503` and `Retry-After` rather than queued, because a client that is told +to come back does and one left hanging does not. The socket count is six rather +than four for the same reason: `SWR-WEB-21` counts *clients*, and a client with +a dashboard open holds one socket parked on the stream and needs another to +fetch anything else. + +The framing is in `kiln_web/api` rather than in the transport, because it is the +part of SSE that can be got wrong and the transport cannot be host-tested. A +frame that does not fit its buffer is not written at all, and a payload +containing a newline is refused: a newline inside a data field is SSE's own way +of writing a multi-line payload, so half a frame or a stray newline does not +fail visibly, it moves the frame boundary into the middle of a JSON document and +every frame after it is misread. The development harness in +`firmware/controller/host/webhost` uses the same framing function, so the format +the browser is tested against is the format the device sends. + ### 12.4 Asset budget -| Asset | Budget (gzipped) | -|---|---| -| `index.html` | 4 kB | -| `app.css` | 6 kB | -| `app.js` (views, SSE, API client) | 24 kB | -| `chart.js` | 10 kB | -| Font | 0, system font stack only | +| Asset | Budget (gzipped) | Measured 2026-10-09 | +|---|---|---| +| `index.html` | 4 kB | 2.2 kB | +| `app.css` | 6 kB | 3.6 kB | +| `app.js` (views, SSE, API client) | 24 kB | 8.6 kB | +| `chart.js` | 10 kB | 4.2 kB | +| Font | 0, system font stack only | 0 | +| **Total** | **44 kB** | **18.6 kB** | + +Measured for the first time when the assets were embedded, and they pass with +room. The four are gzipped at build time by `kiln_web`'s CMakeLists and linked +in as binary data, so the hardware image carries them at 908 kB, 56 % of the +OTA slot still free. + +They are served **still compressed**, with `Content-Encoding: gzip`: every +browser that can run this interface can inflate it, and inflating on the device +would cost a window buffer per request on the processor that is also running a +kiln. + +The simulated image is **unchanged at 246 kB**, and that is the linker rather +than a special case. `CONFIG_KILN_PLANT_SIM` compiles out the call that starts +the server, nothing then references the asset table, and `--gc-sections` drops +the blobs with it. The configuration that cannot serve the interface does not +carry it. | **Total** | **≤ 48 kB** embedded in the image (`SWA-11`) | ## 13. Cross-cutting concerns @@ -912,8 +1038,8 @@ Five properties of this that are decisions rather than details: is enforced in `kiln_app`, where the state machine lives, not in the port. 5. **Rollback is cancelled late** (`SWR-UPD-15`). The first boot of a new image stays pending-verify until the supervisor link, both thermocouples, the - configuration, the stored programs and the display have all proved themselves - in the running system. `confirm_running()` in start-up code would make + configuration and the display have all proved themselves in the running + system. `confirm_running()` in start-up code would make `SWR-UPD-02` decoration. #### The manifest @@ -1141,7 +1267,7 @@ Requirement-level traceability is maintained mechanically by `tools/trace` | `FR-CUR` | `core/current`, `hal/adc_ct`, `current` task | Unit, integration, driver, HIL | | `FR-CTL` | `core/pid`, `core/setpoint`, `core/window`, `control` task, `heat_window` | Unit, integration, driver (timing) | | `FR-TUN` | `core/autotune` | Unit, integration across plant sets | -| `FR-PRG` | `core/profile`, `app/program_store` | Unit, API | +| `FR-PRG` | `core/profile` (the programs are compiled in) | Unit, API | | `FR-RUN` | `app/run_controller`, `core/runstate` | Unit, integration (incl. power loss) | | `FR-HMI` | `kiln_hmi`, `hal/ssd1306`, `hal/encoder` | Golden image, driver, demonstration | | `FR-WEB` | `kiln_web`, `web/` | API, browser demonstration | @@ -1181,7 +1307,7 @@ Requirement-level traceability is maintained mechanically by `tools/trace` | **M3, Control** | `pid`, `window`, `setpoint`, `profile`, `control` task, heat output. | A program runs closed-loop against the simulator to `SWR-NFR-05`. | | **M4, Safety** | `core/safety`, safety task, charge-pump enable, latching, watchdogs. | Every rule in [§8.2](#82-rule-table) has a passing automated test; HIL confirms contactor release. | | **M4b, Current** | `core/current`, CT adapter, `SWR-SAF-25`–`SWR-SAF-30`, weld discrimination. | Every current rule has a passing automated test; HIL confirms `SWR-SAF-27` against an emulated welded contactor. | -| **M5, Persist** | Log ring, run index, programs, configuration, power-loss recovery. | `FR-LOG`, `FR-CFG`, `SWR-RUN-08` pass; endurance analysis confirmed by measurement. | +| **M5, Persist** | Log ring, run index, configuration, power-loss recovery. | `FR-LOG`, `FR-CFG`, `SWR-RUN-08` pass; endurance analysis confirmed by measurement. | | **M6, Web** | HTTP server, REST API, SSE, dashboard, chart, program editor, settings, OTA. | `FR-WEB`, `FR-UPD` pass; API suite green. | | **M7, Tune** | `autotune` and its UI. | `FR-TUN` passes across the simulator's plant parameter sweep. | | **M8, Commission** | Soak test, timing report, documentation, real kiln firing. | `SWR-NFR-10`, `SWR-TST-18` reports published; a real firing completed and logged. | diff --git a/docs/safety.md b/docs/safety.md index a02a253..c65a220 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -81,26 +81,41 @@ flowchart TB KILN["Kiln body, elements,
lid interlock, wiring"] end subgraph IN["Inside the boundary, Safe Kiln Controller"] - MCU["ESP32-S3 firmware:
control + safety supervisor"] - CP["Charge pump → contactor coil"] + MCU["ESP32-S3 firmware:
control + safety task"] + SUPV["STM32G031 supervisor:
backstop + permit (SWA-22)"] + CP["Charge pump → coil element"] CON["Safety contactor"] SSR["Zero-cross SSR"] - TC["2 × MAX31856
chamber + enclosure"] + TCC["MAX31856, chamber
on the supervisor's bus"] + TCE["MAX31856, enclosure
on the controller's bus"] CT["Current transformer"] BUZ["Buzzer"] end SUP --> CUT --> CON --> SSR --> KILN MCU --> CP --> CON + SUPV -->|"permit, a second
series element"| CON MCU --> SSR - TC --> MCU + TCC --> SUPV + TCC -.->|"one junction,
the common cause"| TCE + SUPV -->|"report only, 4 Hz"| MCU + TCE --> MCU CT --> MCU MCU --> BUZ --> OP OP -.->|"acknowledge, abort,
isolate at supply"| MCU + OP -.->|"clear the latch
(SYS-HW-28)"| SUPV ``` -**Inside the boundary.** The firmware, the two switching devices and their -drive circuits, the two thermocouple front ends, the current transformer and -its conditioning, and the annunciation. +The dashed line between the two front ends is not a signal. It is there because +the diagram would otherwise suggest two independent temperature channels: both +MAX31856 read **one chamber junction**, which is the common cause §6 records and +the one thing the second part does not remove. + +**Inside the boundary.** Both firmwares, the switching devices and their drive +circuits, the two thermocouple front ends, the current transformer and its +conditioning, and the annunciation. The supervisor is inside the boundary and +is **not** a substitute for the independent cutout outside it: it shares the +chamber couple, the 3V3 rail and the enclosure, and `SYS-HW-13` stays mandatory +for exactly those reasons. **Outside the boundary, but relied upon.** The supply protection, the independent cutout, the kiln's own construction and the operator. Each is the @@ -152,14 +167,16 @@ software being correct. Nothing below is a substitute for anything above it. ```mermaid flowchart TB L1["**L1: Control**
setpoint clamped to the configured max,
configured max clamped to 1350 °C (SWR-SAF-23)"] - L2["**L2: Safety supervisor**
18 rules, 100 ms cycle, sole heat authority (SWA-04)
thermal channel + current channel"] + L2["**L2: Safety supervisor (a task)**
18 rules, 100 ms cycle, sole heat authority (SWA-04)
thermal channel + current channel"] + L2S["**L2S: Independent supervisor (a part)**
second MCU, own clock, watchdog, front end
1350 °C backstop, own series element (SWA-22)"] L3["**L3: Heat authority hardware**
charge pump: a square wave, not a level (SWA-05)
contactor in series with the SSR (SYS-SAF-03)"] L4["**L4: Reset to safe**
task + RTC watchdog, brownout detector,
output pull-downs (SYS-HW-08), latched fault in NVS"] L5["**L5: Independent hardware cutout**
own sensor, own contacts, outside this project (SYS-HW-13)"] L6["**L6: Installation and operator**
supply protection, competent installation,
attendance (SYS-ASM-04, SYS-ASM-06)"] - L1 --> L2 --> L3 --> L4 --> L5 --> L6 + L1 --> L2 --> L2S --> L3 --> L4 --> L5 --> L6 style L1 fill:#e8f0fe,stroke:#4a76c4 style L2 fill:#e8f0fe,stroke:#4a76c4 + style L2S fill:#fef0e0,stroke:#c48a4a style L3 fill:#fef0e0,stroke:#c48a4a style L4 fill:#fef0e0,stroke:#c48a4a style L5 fill:#e9f6ea,stroke:#4aa052 @@ -200,6 +217,61 @@ of [§6](#6-independence): gated to the commanded output state (`SWA-17`) so that the measurement is a statement about the *relay*, not an average over a mostly-off window. +### 5.2a L2S, The independent supervisor + +**It is lettered rather than numbered, and that is deliberate.** The layer +numbers are cited from `safety.sdoc`'s `LAYERS` fields, from the firmware and +from the hardware design; renumbering L3 to L4 and so on to open a gap would +break every one of those citations to no benefit, which is the same reasoning +the system requirements give for their own numbering. + +A second microcontroller, an STM32G031K8T6, with its own clock, its own +windowed watchdog cross-checked against an independent oscillator, its own +MAX31856 on its own SPI bus, and **one element of the contactor coil** +(`SYS-HW-26`). It reads the chamber couple, and above a backstop compiled into +its firmware, or on a chamber thermocouple fault that persists, it opens its +element. The whole of it is 3 488 bytes of program memory across four source +files, which is a design constraint and not an aspiration: it is small enough +to be read in one sitting by somebody assessing it. + +What it protects against is **the failure of the entire controller**, which is +the one thing no layer above it could cover. L2 is a task on the processor that +may itself be the problem: a controller that stops with the charge-pump pin +toggling, or that fires on a reading it has mis-scaled, is a single failure that +L1 and L2 are both downstream of. Until this layer existed, the honest answer to +that was the operator's own hardware cutout at L5. + +What it deliberately **does not** do, each because adding it would cost more +than it buys: + +- **It runs no rules of its own beyond two.** Runaway detection, the current + rules, the enclosure channel, hold-back, the operator's configurable maximum: + all of those stay at L2, where the configuration is. A backstop with + configuration is not a backstop. +- **It takes no instruction.** The link is one direction and report only + (`SYS-HW-27`). No message can raise its threshold, lower its sensitivity, + clear its latch or silence it, because the part that might be compromised or + crashed is the part at the other end. +- **It cannot command heat.** Holding one element of a series path is + permission, not control: it can refuse heat alone and cannot grant it alone. + So it needs no safety case for failing to permit -- a supervisor that fails + off is a kiln that does not fire. +- **It keeps no non-volatile state.** Its latch does not survive a power cycle, + by design, and the system-level obligation to remember a fault across a power + loss is met by the controller's persisted fault (`SWR-SAF-17`). Section 3 of + [`safety-supervisor.md`](safety-supervisor.md) states the gap that leaves. + +Its latch is cleared by a button at the panel wired to it and to nothing else +(`SYS-HW-28`). There are therefore **two acknowledgements** in this system and +they are not the same: this one, and the controller's own latched fault at the +display. An operator cannot see the difference, so the panel has to make it. + +**No performance level and no SIL is claimed for it**, and §9 of this document +is not improved on by its existence. What is claimed is narrower and checkable: +a documented language subset, the diagnostics of EN IEC 60730-1 Annex H listed +in its unit design document, MC/DC over its trip logic, and an architecture that +keeps the safety function on a part the network cannot reach. + ### 5.3 L3, The heat authority hardware This is the layer that does not depend on the firmware being correct, only on @@ -219,30 +291,37 @@ failing to run. > circuit as safety-critical, and the HIL suite verifies contactor release by > halting the safety task. -**Four independent things now break the coil, three of them without firmware.** +**Three independent things break the coil, and they do not share a decider.** The coil circuit is a series chain, and any element opening drops the contactor: -| Element | Opens when | Firmware involved? | +| Element | Opens when | What has to be working | |---|---|---| -| Lid switch contacts (`SYS-HW-21`) | the door opens | **No.** Mechanical, in the coil circuit. | -| `Q6`, gated by the chamber `FAULT` output (`SYS-HW-24`) | the chamber front end reports a fault | No, once configured. See the limit below. | -| `Q5`, gated by the enclosure `FAULT` output (`SYS-HW-24`) | the enclosure front end reports a fault | As above. | -| `Q3`, gated by the charge pump (`SWA-05`) | the supervisor stops toggling | Only in that it must keep running. | +| Lid switch contacts (`SYS-HW-21`) | the door opens | **Nothing.** A mechanical contact in the coil circuit. | +| The supervisor's permit (`SYS-HW-26`) | the chamber passes the compiled-in backstop, or the chamber front end stops answering, or the supervisor itself stops | The supervisor's own firmware, on its own clock, on a part the network cannot reach. | +| `Q3`, gated by the charge pump (`SWA-05`) | the controller's safety task stops toggling | Only that the controller is still executing; not that it is correct. | -The freewheel diode sits **across the coil**, on the kiln side of all four +The freewheel diode sits **across the coil**, on the kiln side of all three (`SYS-HW-25`), so opening any of them leaves the coil current somewhere to decay and does not put the transient across the opening contacts. -The limit of the two `FAULT` elements is worth stating rather than discovering: -the outputs are open-drain, so an **unpowered or absent** front end leaves its -transistor on and the path closed, and the MAX31856 detects an open circuit -only after its fault mask has been configured. They interrupt on faults the -device actively *reports*. A dead or unconfigured front end is covered by -`SWR-SAF-04` in firmware (fault 5, front-end communication failure), not here. The -lid switch carries no such caveat: it is a contact in the circuit. +**It was four, two of them gated by the front ends' `FAULT` outputs +(`SYS-HW-24`), and that chain is superseded** by the row above it. The limit of +those two elements had to be stated rather than discovered: the outputs are +open-drain, so an unpowered or absent front end left its transistor on and the +path closed, and a MAX31856 reports an open circuit only after its fault mask +has been configured, which is firmware work. They interrupted on faults the +device actively *reported*, and a dead or unconfigured front end was covered by +`SWR-SAF-04` in firmware rather than by the hardware. + +The supervisor has neither caveat, because it is not gated by a pin: a front end +that is dead, absent or unconfigured is a front end that does not answer the +supervisor, and not answering is a trip. The enclosure channel loses its +hardware element and keeps `SWR-SAF-11` in firmware, deliberately: it is outside +the supervisor's remit so that a failed enclosure probe can never stop a firing +mid-glaze, which is how protections come to be disabled (`HZ-10`). In series with the charge-pump-driven contactor sits the SSR, modulating under -the supervisor's duty command (`SYS-SAF-03`). Two devices, two drive circuits, two +the controller's duty command (`SYS-SAF-03`). Two devices, two drive circuits, two failure modes. All heater and contactor control outputs carry external pull-downs to the de-energised state and avoid strapping pins and pins that glitch during reset (`SYS-HW-08`), so the safe state holds through the window before @@ -286,12 +365,22 @@ section states where the claim is genuine and where it is not. | Pair | Independent? | Shared element, what defeats both | |---|---|---| | L1 control vs. L2 thermal rules | **No** | The same chamber thermocouple. A plausible-but-wrong reading (HZ-03) misleads both. This is the entire reason `SWR-SAF-04`–`SWR-SAF-06` exist: they do not measure temperature, they interrogate the *measurement*, fault bits, polarity, and whether the number moves at all. | -| L2 thermal rules vs. L2 current rules | **Yes, physically** | Different sensor (CT vs. thermocouple), different quantity (amps vs. degrees), different front end, different failure modes. They share the supervisor task and the MCU. | +| **L2S supervisor vs. the controller (L1–L4)** | **Yes** | Separate silicon, separate firmware in a separate language subset, separate clock, separate watchdog, separate thermocouple front end on a separate bus, separate series element in the coil, separate programming interface. **The only thing shared is the 3V3 rail**, and a failure of that rail removes heat by construction, since every element in the coil path fails towards de-energised. This is the row the second part was added to be able to write, and it is the only "yes" in this table that covers the failure of a whole processor. | +| **L2S supervisor vs. the controller, on the chamber couple** | **No** | **One thermocouple feeds both microcontrollers.** Each has its own MAX31856, its own bus and its own linearisation, so a front-end failure is independent; the *junction* is not. A couple that reads plausibly low in a way both parts believe defeats the thermal channel at both L2 and L2S simultaneously. This is the common cause the architecture does not remove, and the reason the row below is load-bearing rather than defence in depth. It is argued at length in §9 of [`safety-supervisor.md`](safety-supervisor.md), including the two-couple option that was declined and why. | +| L2 thermal rules vs. L2 current rules | **Yes, physically, and now load-bearing** | Different sensor (CT vs. thermocouple), different quantity (amps vs. degrees), different front end, different failure modes. They share the supervisor task and the MCU. **Promoted from defence in depth 2026-10-10**: with one chamber couple feeding both microcontrollers, the CT is the only physically independent detection channel left in the system. A kiln whose current monitoring is disabled or whose transformer was never fitted (`SWR-CUR-12`) has, for the shared-couple failure mode, exactly one channel and the operator's L5 cutout. That is a decision an installer can make; it should be made knowing this. | | L2 vs. L3 | **Yes, for the failure class that matters** | L3 does not depend on L2 being *correct*, only on it *running*. A logic error in a rule does not stop the toggle; a hang, crash or overrun stops it immediately. The converse is also true: L3 cannot detect anything, so a subtly wrong rule is L2's problem alone. | | L3 SSR vs. L3 contactor | **Yes** | Separate devices on separate outputs. A shorted SSR leaves the contactor able to interrupt; this is what `SWR-SAF-27`'s weld discrimination tests, and what makes "SSR shorted" a recoverable fault and "contactor welded" not. | -| L2+L3 vs. L4 watchdogs | **Partly** | Same MCU and same power rail. A supply fault defeats all three, and is then safe by construction, since every one of them fails towards de-energised. | +| L2+L3 vs. L4 watchdogs | **Partly** | Same MCU and same power rail. A supply fault defeats all three, and is then safe by construction, since every one of them fails towards de-energised. The supervisor's watchdog at L2S is not in this row: it is on the other part. | | L1–L4 vs. L5 cutout | **Yes, fully** | Nothing is shared: separate sensor, separate contacts, separate failure modes. This is why L5 is mandatory and why no amount of software may be offered as a substitute. | +**What the second part changed in this section**, stated plainly because it is +the point of the exercise: the table used to have no row in which the failure of +a whole microcontroller was covered by anything inside this project. It has one +now. What it still has no row for is the chamber couple, and adding the second +part made that *more* exposed rather than less, because it is now the only +shared element between two otherwise independent channels. Both things are true +at once and neither is a reason to leave the other unsaid. + **Defence in depth, stated as a rule rather than an aspiration.** The current rules are the *primary* detection of a failed relay, contactor or element, because they are fast and unambiguous. The thermal rules are retained diff --git a/docs/safety.sdoc b/docs/safety.sdoc index 61c4d85..94968b3 100644 --- a/docs/safety.sdoc +++ b/docs/safety.sdoc @@ -389,9 +389,15 @@ UID: SG-01 STATEMENT: >>> No single failure of software, MCU, sensor or switching device shall result in heating; every such failure shall result in heating being removed. <<< -LAYERS: L2, L3, L5 +LAYERS: L2, L2S, L3, L5 COMMENT: >>> Realised by [LINK: SYS-SAF-01] and by the whole of [`safety.md` §5](safety.md#5-the-protection-layers), which is why only the one requirement is named here. + +**Strengthened 2026-10-10 by the independent supervisor ([LINK: SYS-SAF-25]), and in one respect it has to be read more carefully than before.** + +"No single failure of **MCU**" was the weakest word in this goal while there was one microcontroller. A processor that stops with the charge-pump pin toggling, or that fires on a reading it has mis-scaled, is a single failure of the MCU that the MCU itself was being asked to catch; the honest position was that layers L2 and L3 are mostly *software on the part that may have failed*, with the operator's own hardware cutout ([LINK: SYS-HW-13]) behind them. That is now a second part, with its own clock, watchdog, front end and series element, and a failure of either processor removes heat. + +What a careful reader must not conclude is that the sensor clause is equally covered. **One thermocouple feeds both microcontrollers**, so a failure of that junction is a single failure that both channels see identically, and it is the common cause this architecture does not remove. It is detected rather than tolerated: an open, shorted or out-of-range couple is a fault to both parts, and the current rules of `SG-05` are the only physically independent detection channel left. Section 6 of `safety.md` records it as load-bearing for exactly that reason. <<< RELATIONS: - TYPE: Parent @@ -436,9 +442,22 @@ RELATIONS: [SAFETY_GOAL] UID: SG-03 STATEMENT: >>> -There shall be two series interrupting devices under independent control, so that one failing closed does not remove the ability to interrupt. +There shall be **at least two** series interrupting devices under independent control, so that one failing closed does not remove the ability to interrupt. +<<< +LAYERS: L2S, L3 +COMMENT: >>> +**Amended 2026-10-10: "two" became "at least two", and "independent control" became true.** + +The original pair is the SSR and the contactor of [LINK: SYS-SAF-03], each with its own output. They are two devices in series, which defeats one failing closed, but both outputs came from one microcontroller: a processor that failed with both pins asserted left both devices conducting, so "independent control" meant independent *drivers* rather than independent *deciders*. + +The coil path now has three elements that can open it, and they do not share a decider: + +- the controller's charge pump ([LINK: SWA-05]), which needs its safety task to keep toggling; +- the supervisor's permit ([LINK: SYS-HW-26]), on a different part with its own clock and watchdog, which needs the chamber below a compiled-in ceiling and the front end answering; +- the lid's series contact ([LINK: SYS-HW-21]), which depends on no firmware at all. + +Any one of the three opens the coil. The first two are now genuinely under independent control, which is what this goal was reaching for and could not previously claim. <<< -LAYERS: L3 RELATIONS: - TYPE: Parent VALUE: HZ-01 @@ -452,6 +471,9 @@ RELATIONS: - TYPE: Child VALUE: SYS-HW-07 ROLE: Realised by +- TYPE: Child + VALUE: SYS-HW-26 + ROLE: Realised by [SAFETY_GOAL] UID: SG-04 diff --git a/docs/security.sdoc b/docs/security.sdoc index 38eb1ea..f4273ec 100644 --- a/docs/security.sdoc +++ b/docs/security.sdoc @@ -366,7 +366,7 @@ CAPABILITY: >>> Code execution in the image. <<< REALISTIC: >>> -Low: no package manager at build time, assets vendored ([LINK: UR-CON-04]), dependencies are ESP-IDF itself. +Low: dependencies are ESP-IDF itself plus one pinned managed component (`espressif/mdns`, Apache-2.0), with the content hash in a committed `dependencies.lock`; web assets are vendored, not fetched. [LINK: UR-CON-04] holds the conditions. A compromise of the registry therefore has to produce the hash that is already in the repository, and the upstream of the pinned version remains a path in. <<< [[/SECTION]] @@ -677,7 +677,7 @@ STATEMENT: >>> **mDNS and network reconnaissance.** <<< VIA: >>> -`kiln.local` advertised on the LAN ([LINK: SWR-NET-05]). +`kiln.local` advertised on the LAN ([LINK: SWR-NET-04]). <<< CONSEQUENCE: >>> Makes the device trivially discoverable; a precondition for the rest rather than a threat alone. @@ -685,7 +685,7 @@ Makes the device trivially discoverable; a precondition for the rest rather than SAFETY_RELEVANT: No STATUS: Active COMMENT: >>> -**Has no security goal, and never had one.** Discoverability is accepted: the device is meant to be found by its operator, and `SWR-NET-05` asks for exactly that. Recorded so the acceptance is explicit rather than an omission. +**Has no security goal, and never had one.** Discoverability is accepted: the device is meant to be found by its operator, and `SWR-NET-04` asks for exactly that. Recorded so the acceptance is explicit rather than an omission. Live since 2026-10-09, when mDNS was implemented; the link used to name the withdrawn `SWR-NET-05`, which was the access point's passphrase rule. <<< RELATIONS: - TYPE: Parent diff --git a/firmware/controller/.cache/clangd/index/api.cpp.9028AD392AB399C7.idx b/firmware/controller/.cache/clangd/index/api.cpp.9028AD392AB399C7.idx new file mode 100644 index 0000000..00bd768 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/api.cpp.9028AD392AB399C7.idx differ diff --git a/firmware/controller/.cache/clangd/index/api.h.7C57622043E2F1F4.idx b/firmware/controller/.cache/clangd/index/api.h.7C57622043E2F1F4.idx new file mode 100644 index 0000000..297e960 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/api.h.7C57622043E2F1F4.idx differ diff --git a/firmware/controller/.cache/clangd/index/api_log.cpp.ECD6E8B698B80465.idx b/firmware/controller/.cache/clangd/index/api_log.cpp.ECD6E8B698B80465.idx new file mode 100644 index 0000000..8371390 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/api_log.cpp.ECD6E8B698B80465.idx differ diff --git a/firmware/controller/.cache/clangd/index/app.cpp.429EE0B06C87FF78.idx b/firmware/controller/.cache/clangd/index/app.cpp.429EE0B06C87FF78.idx new file mode 100644 index 0000000..4bc9907 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/app.cpp.429EE0B06C87FF78.idx differ diff --git a/firmware/controller/.cache/clangd/index/app.h.66207A1D80971935.idx b/firmware/controller/.cache/clangd/index/app.h.66207A1D80971935.idx new file mode 100644 index 0000000..e52db0d Binary files /dev/null and b/firmware/controller/.cache/clangd/index/app.h.66207A1D80971935.idx differ diff --git a/firmware/controller/.cache/clangd/index/autotune.cpp.5E644C8450ADBC62.idx b/firmware/controller/.cache/clangd/index/autotune.cpp.5E644C8450ADBC62.idx new file mode 100644 index 0000000..07a5b68 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/autotune.cpp.5E644C8450ADBC62.idx differ diff --git a/firmware/controller/.cache/clangd/index/autotune.h.ABA8346B3BC72970.idx b/firmware/controller/.cache/clangd/index/autotune.h.ABA8346B3BC72970.idx new file mode 100644 index 0000000..51258b6 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/autotune.h.ABA8346B3BC72970.idx differ diff --git a/firmware/controller/.cache/clangd/index/board_pins.h.003CC979DEBB4325.idx b/firmware/controller/.cache/clangd/index/board_pins.h.003CC979DEBB4325.idx new file mode 100644 index 0000000..4dc86e6 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/board_pins.h.003CC979DEBB4325.idx differ diff --git a/firmware/controller/.cache/clangd/index/configmodel.cpp.38BC0F6BEB86FCF3.idx b/firmware/controller/.cache/clangd/index/configmodel.cpp.38BC0F6BEB86FCF3.idx new file mode 100644 index 0000000..e4d7826 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/configmodel.cpp.38BC0F6BEB86FCF3.idx differ diff --git a/firmware/controller/.cache/clangd/index/configmodel.h.74606DBA908BC1F1.idx b/firmware/controller/.cache/clangd/index/configmodel.h.74606DBA908BC1F1.idx new file mode 100644 index 0000000..89853f8 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/configmodel.h.74606DBA908BC1F1.idx differ diff --git a/firmware/controller/.cache/clangd/index/current.cpp.AD2AE87B3A3BCB6E.idx b/firmware/controller/.cache/clangd/index/current.cpp.AD2AE87B3A3BCB6E.idx new file mode 100644 index 0000000..2492490 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/current.cpp.AD2AE87B3A3BCB6E.idx differ diff --git a/firmware/controller/.cache/clangd/index/current.h.620B7FB1FF26858B.idx b/firmware/controller/.cache/clangd/index/current.h.620B7FB1FF26858B.idx new file mode 100644 index 0000000..24d9f70 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/current.h.620B7FB1FF26858B.idx differ diff --git a/firmware/controller/.cache/clangd/index/draw.cpp.CE59529FCD350067.idx b/firmware/controller/.cache/clangd/index/draw.cpp.CE59529FCD350067.idx new file mode 100644 index 0000000..5fa4894 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/draw.cpp.CE59529FCD350067.idx differ diff --git a/firmware/controller/.cache/clangd/index/draw.h.824614D74BA33394.idx b/firmware/controller/.cache/clangd/index/draw.h.824614D74BA33394.idx new file mode 100644 index 0000000..1c46c86 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/draw.h.824614D74BA33394.idx differ diff --git a/firmware/controller/.cache/clangd/index/err.cpp.AF9C8548C8887FF6.idx b/firmware/controller/.cache/clangd/index/err.cpp.AF9C8548C8887FF6.idx new file mode 100644 index 0000000..3d153a3 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/err.cpp.AF9C8548C8887FF6.idx differ diff --git a/firmware/controller/.cache/clangd/index/err.h.21E50DB0E8019E3F.idx b/firmware/controller/.cache/clangd/index/err.h.21E50DB0E8019E3F.idx new file mode 100644 index 0000000..ff26206 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/err.h.21E50DB0E8019E3F.idx differ diff --git a/firmware/controller/.cache/clangd/index/faults.cpp.D3BCC87829A3272E.idx b/firmware/controller/.cache/clangd/index/faults.cpp.D3BCC87829A3272E.idx new file mode 100644 index 0000000..c904d43 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/faults.cpp.D3BCC87829A3272E.idx differ diff --git a/firmware/controller/.cache/clangd/index/faults.h.3B90034CCCD79A17.idx b/firmware/controller/.cache/clangd/index/faults.h.3B90034CCCD79A17.idx new file mode 100644 index 0000000..e8375a9 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/faults.h.3B90034CCCD79A17.idx differ diff --git a/firmware/controller/.cache/clangd/index/fileslots.cpp.B4E990AA46522936.idx b/firmware/controller/.cache/clangd/index/fileslots.cpp.B4E990AA46522936.idx new file mode 100644 index 0000000..ba1d8c3 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/fileslots.cpp.B4E990AA46522936.idx differ diff --git a/firmware/controller/.cache/clangd/index/fileslots.h.7D454C4314D7D133.idx b/firmware/controller/.cache/clangd/index/fileslots.h.7D454C4314D7D133.idx new file mode 100644 index 0000000..e1358da Binary files /dev/null and b/firmware/controller/.cache/clangd/index/fileslots.h.7D454C4314D7D133.idx differ diff --git a/firmware/controller/.cache/clangd/index/hal_clock.cpp.3444033FF17237DA.idx b/firmware/controller/.cache/clangd/index/hal_clock.cpp.3444033FF17237DA.idx new file mode 100644 index 0000000..effb14c Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hal_clock.cpp.3444033FF17237DA.idx differ diff --git a/firmware/controller/.cache/clangd/index/hal_counters.cpp.BBCB2E13707BCF16.idx b/firmware/controller/.cache/clangd/index/hal_counters.cpp.BBCB2E13707BCF16.idx new file mode 100644 index 0000000..01c3fdf Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hal_counters.cpp.BBCB2E13707BCF16.idx differ diff --git a/firmware/controller/.cache/clangd/index/hal_current.cpp.3D3F77BE12D7401F.idx b/firmware/controller/.cache/clangd/index/hal_current.cpp.3D3F77BE12D7401F.idx new file mode 100644 index 0000000..6f6bce6 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hal_current.cpp.3D3F77BE12D7401F.idx differ diff --git a/firmware/controller/.cache/clangd/index/hal_display.cpp.B0F7B92DB11BD7D8.idx b/firmware/controller/.cache/clangd/index/hal_display.cpp.B0F7B92DB11BD7D8.idx new file mode 100644 index 0000000..a1d8eca Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hal_display.cpp.B0F7B92DB11BD7D8.idx differ diff --git a/firmware/controller/.cache/clangd/index/hal_esp32s3.h.F7C571DF8DED891E.idx b/firmware/controller/.cache/clangd/index/hal_esp32s3.h.F7C571DF8DED891E.idx new file mode 100644 index 0000000..40ba229 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hal_esp32s3.h.F7C571DF8DED891E.idx differ diff --git a/firmware/controller/.cache/clangd/index/hal_flash.cpp.BD735EDE9CCE05A3.idx b/firmware/controller/.cache/clangd/index/hal_flash.cpp.BD735EDE9CCE05A3.idx new file mode 100644 index 0000000..6e94a27 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hal_flash.cpp.BD735EDE9CCE05A3.idx differ diff --git a/firmware/controller/.cache/clangd/index/hal_heat.cpp.B12AB124127403BA.idx b/firmware/controller/.cache/clangd/index/hal_heat.cpp.B12AB124127403BA.idx new file mode 100644 index 0000000..052d262 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hal_heat.cpp.B12AB124127403BA.idx differ diff --git a/firmware/controller/.cache/clangd/index/hal_input.cpp.10B8CEC564533626.idx b/firmware/controller/.cache/clangd/index/hal_input.cpp.10B8CEC564533626.idx new file mode 100644 index 0000000..a3b5c6e Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hal_input.cpp.10B8CEC564533626.idx differ diff --git a/firmware/controller/.cache/clangd/index/hal_io.cpp.EC5F8CD87CBB0D9D.idx b/firmware/controller/.cache/clangd/index/hal_io.cpp.EC5F8CD87CBB0D9D.idx new file mode 100644 index 0000000..133dd6d Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hal_io.cpp.EC5F8CD87CBB0D9D.idx differ diff --git a/firmware/controller/.cache/clangd/index/hal_kvstore.cpp.B7DF5B21F17B1AEA.idx b/firmware/controller/.cache/clangd/index/hal_kvstore.cpp.B7DF5B21F17B1AEA.idx new file mode 100644 index 0000000..7184f8d Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hal_kvstore.cpp.B7DF5B21F17B1AEA.idx differ diff --git a/firmware/controller/.cache/clangd/index/hal_net.cpp.5928048DFBE34E30.idx b/firmware/controller/.cache/clangd/index/hal_net.cpp.5928048DFBE34E30.idx new file mode 100644 index 0000000..a9301cb Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hal_net.cpp.5928048DFBE34E30.idx differ diff --git a/firmware/controller/.cache/clangd/index/hal_prod.cpp.E2093DEBAC13EC5B.idx b/firmware/controller/.cache/clangd/index/hal_prod.cpp.E2093DEBAC13EC5B.idx new file mode 100644 index 0000000..27b6fdf Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hal_prod.cpp.E2093DEBAC13EC5B.idx differ diff --git a/firmware/controller/.cache/clangd/index/hal_suplink.cpp.F952145BD4011FA3.idx b/firmware/controller/.cache/clangd/index/hal_suplink.cpp.F952145BD4011FA3.idx new file mode 100644 index 0000000..28ea474 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hal_suplink.cpp.F952145BD4011FA3.idx differ diff --git a/firmware/controller/.cache/clangd/index/hal_system.cpp.051C49FACAE9C053.idx b/firmware/controller/.cache/clangd/index/hal_system.cpp.051C49FACAE9C053.idx new file mode 100644 index 0000000..c544f0e Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hal_system.cpp.051C49FACAE9C053.idx differ diff --git a/firmware/controller/.cache/clangd/index/hal_tc.cpp.A936EE8D2D953264.idx b/firmware/controller/.cache/clangd/index/hal_tc.cpp.A936EE8D2D953264.idx new file mode 100644 index 0000000..7ba7fef Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hal_tc.cpp.A936EE8D2D953264.idx differ diff --git a/firmware/controller/.cache/clangd/index/hmi.cpp.4C133F4B3BACFE6A.idx b/firmware/controller/.cache/clangd/index/hmi.cpp.4C133F4B3BACFE6A.idx new file mode 100644 index 0000000..7c4b365 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hmi.cpp.4C133F4B3BACFE6A.idx differ diff --git a/firmware/controller/.cache/clangd/index/hmi.h.55E5381D963422F3.idx b/firmware/controller/.cache/clangd/index/hmi.h.55E5381D963422F3.idx new file mode 100644 index 0000000..d50daf0 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/hmi.h.55E5381D963422F3.idx differ diff --git a/firmware/controller/.cache/clangd/index/httpd.cpp.3949980EE3DBFFF2.idx b/firmware/controller/.cache/clangd/index/httpd.cpp.3949980EE3DBFFF2.idx new file mode 100644 index 0000000..2a2774f Binary files /dev/null and b/firmware/controller/.cache/clangd/index/httpd.cpp.3949980EE3DBFFF2.idx differ diff --git a/firmware/controller/.cache/clangd/index/httpd.h.AE03CFFCC5598233.idx b/firmware/controller/.cache/clangd/index/httpd.h.AE03CFFCC5598233.idx new file mode 100644 index 0000000..8551bb4 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/httpd.h.AE03CFFCC5598233.idx differ diff --git a/firmware/controller/.cache/clangd/index/json.cpp.71CA25D08E6E4912.idx b/firmware/controller/.cache/clangd/index/json.cpp.71CA25D08E6E4912.idx new file mode 100644 index 0000000..e50c335 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/json.cpp.71CA25D08E6E4912.idx differ diff --git a/firmware/controller/.cache/clangd/index/json.h.A5CC97710F05390F.idx b/firmware/controller/.cache/clangd/index/json.h.A5CC97710F05390F.idx new file mode 100644 index 0000000..8c63c3c Binary files /dev/null and b/firmware/controller/.cache/clangd/index/json.h.A5CC97710F05390F.idx differ diff --git a/firmware/controller/.cache/clangd/index/logrec.cpp.C765F885F08DE1A6.idx b/firmware/controller/.cache/clangd/index/logrec.cpp.C765F885F08DE1A6.idx new file mode 100644 index 0000000..10f97f9 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/logrec.cpp.C765F885F08DE1A6.idx differ diff --git a/firmware/controller/.cache/clangd/index/logrec.h.44464D377E7E4D85.idx b/firmware/controller/.cache/clangd/index/logrec.h.44464D377E7E4D85.idx new file mode 100644 index 0000000..22d8942 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/logrec.h.44464D377E7E4D85.idx differ diff --git a/firmware/controller/.cache/clangd/index/logring.cpp.D4136777A6514E28.idx b/firmware/controller/.cache/clangd/index/logring.cpp.D4136777A6514E28.idx new file mode 100644 index 0000000..da9d54d Binary files /dev/null and b/firmware/controller/.cache/clangd/index/logring.cpp.D4136777A6514E28.idx differ diff --git a/firmware/controller/.cache/clangd/index/logring.h.906F81554E8AE5F1.idx b/firmware/controller/.cache/clangd/index/logring.h.906F81554E8AE5F1.idx new file mode 100644 index 0000000..2075ab8 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/logring.h.906F81554E8AE5F1.idx differ diff --git a/firmware/controller/.cache/clangd/index/main.cpp.1D658C8212876481.idx b/firmware/controller/.cache/clangd/index/main.cpp.1D658C8212876481.idx new file mode 100644 index 0000000..1264f4d Binary files /dev/null and b/firmware/controller/.cache/clangd/index/main.cpp.1D658C8212876481.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns.h.C76CB2B1CB5F31EF.idx b/firmware/controller/.cache/clangd/index/mdns.h.C76CB2B1CB5F31EF.idx new file mode 100644 index 0000000..757c34c Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns.h.C76CB2B1CB5F31EF.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_browser.c.C25D87CE840B727B.idx b/firmware/controller/.cache/clangd/index/mdns_browser.c.C25D87CE840B727B.idx new file mode 100644 index 0000000..7c931f9 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_browser.c.C25D87CE840B727B.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_browser.h.BA6CAACB78CB669F.idx b/firmware/controller/.cache/clangd/index/mdns_browser.h.BA6CAACB78CB669F.idx new file mode 100644 index 0000000..99aa91c Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_browser.h.BA6CAACB78CB669F.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_cache.c.294254DF303FA182.idx b/firmware/controller/.cache/clangd/index/mdns_cache.c.294254DF303FA182.idx new file mode 100644 index 0000000..77b2dc7 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_cache.c.294254DF303FA182.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_cache.h.EADAE4363ADEBE57.idx b/firmware/controller/.cache/clangd/index/mdns_cache.h.EADAE4363ADEBE57.idx new file mode 100644 index 0000000..dd7f83a Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_cache.h.EADAE4363ADEBE57.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_console.c.0C11AABAE1148BC3.idx b/firmware/controller/.cache/clangd/index/mdns_console.c.0C11AABAE1148BC3.idx new file mode 100644 index 0000000..4fe3f52 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_console.c.0C11AABAE1148BC3.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_debug.c.70AFC7F558EBD732.idx b/firmware/controller/.cache/clangd/index/mdns_debug.c.70AFC7F558EBD732.idx new file mode 100644 index 0000000..f7a0d8c Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_debug.c.70AFC7F558EBD732.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_debug.h.190DA7C4B218495A.idx b/firmware/controller/.cache/clangd/index/mdns_debug.h.190DA7C4B218495A.idx new file mode 100644 index 0000000..aa3ab35 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_debug.h.190DA7C4B218495A.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_mem_caps.c.F3CF0DFBFC63F33E.idx b/firmware/controller/.cache/clangd/index/mdns_mem_caps.c.F3CF0DFBFC63F33E.idx new file mode 100644 index 0000000..6b730b3 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_mem_caps.c.F3CF0DFBFC63F33E.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_mem_caps.h.3FECC6AB3E87B33A.idx b/firmware/controller/.cache/clangd/index/mdns_mem_caps.h.3FECC6AB3E87B33A.idx new file mode 100644 index 0000000..8e1fac2 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_mem_caps.h.3FECC6AB3E87B33A.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_netif.c.505B78302CC0E92E.idx b/firmware/controller/.cache/clangd/index/mdns_netif.c.505B78302CC0E92E.idx new file mode 100644 index 0000000..7ae6d4e Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_netif.c.505B78302CC0E92E.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_netif.h.D0D872E6E68AF30A.idx b/firmware/controller/.cache/clangd/index/mdns_netif.h.D0D872E6E68AF30A.idx new file mode 100644 index 0000000..1ac1ee4 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_netif.h.D0D872E6E68AF30A.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_networking.h.FFFA02FFE676A743.idx b/firmware/controller/.cache/clangd/index/mdns_networking.h.FFFA02FFE676A743.idx new file mode 100644 index 0000000..5f18013 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_networking.h.FFFA02FFE676A743.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_networking_lwip.c.BC1C86DD66B32A39.idx b/firmware/controller/.cache/clangd/index/mdns_networking_lwip.c.BC1C86DD66B32A39.idx new file mode 100644 index 0000000..aae2b04 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_networking_lwip.c.BC1C86DD66B32A39.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_pcb.c.A1C59CE9F92A2EB9.idx b/firmware/controller/.cache/clangd/index/mdns_pcb.c.A1C59CE9F92A2EB9.idx new file mode 100644 index 0000000..5037f75 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_pcb.c.A1C59CE9F92A2EB9.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_pcb.h.65809FE99D54C152.idx b/firmware/controller/.cache/clangd/index/mdns_pcb.h.65809FE99D54C152.idx new file mode 100644 index 0000000..f7cb866 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_pcb.h.65809FE99D54C152.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_private.h.E6C7E8B4460B3C26.idx b/firmware/controller/.cache/clangd/index/mdns_private.h.E6C7E8B4460B3C26.idx new file mode 100644 index 0000000..c352cff Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_private.h.E6C7E8B4460B3C26.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_querier.c.1B1086BFD1CC4A05.idx b/firmware/controller/.cache/clangd/index/mdns_querier.c.1B1086BFD1CC4A05.idx new file mode 100644 index 0000000..805d7ac Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_querier.c.1B1086BFD1CC4A05.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_querier.h.8D9747B017BBA1BD.idx b/firmware/controller/.cache/clangd/index/mdns_querier.h.8D9747B017BBA1BD.idx new file mode 100644 index 0000000..a868a72 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_querier.h.8D9747B017BBA1BD.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_receive.c.48FB52E1C144A520.idx b/firmware/controller/.cache/clangd/index/mdns_receive.c.48FB52E1C144A520.idx new file mode 100644 index 0000000..de83c73 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_receive.c.48FB52E1C144A520.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_receive.h.DE63D8312E409545.idx b/firmware/controller/.cache/clangd/index/mdns_receive.h.DE63D8312E409545.idx new file mode 100644 index 0000000..cfd7ab0 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_receive.h.DE63D8312E409545.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_responder.c.400ADCEFAE8349E5.idx b/firmware/controller/.cache/clangd/index/mdns_responder.c.400ADCEFAE8349E5.idx new file mode 100644 index 0000000..38e4d96 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_responder.c.400ADCEFAE8349E5.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_responder.h.C2F515428F349B06.idx b/firmware/controller/.cache/clangd/index/mdns_responder.h.C2F515428F349B06.idx new file mode 100644 index 0000000..39aaba5 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_responder.h.C2F515428F349B06.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_send.c.70C72B8F58248918.idx b/firmware/controller/.cache/clangd/index/mdns_send.c.70C72B8F58248918.idx new file mode 100644 index 0000000..d0d9c66 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_send.c.70C72B8F58248918.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_send.h.4A1D72CBC50AEB40.idx b/firmware/controller/.cache/clangd/index/mdns_send.h.4A1D72CBC50AEB40.idx new file mode 100644 index 0000000..50fd395 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_send.h.4A1D72CBC50AEB40.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_service.c.92D5EB5771567036.idx b/firmware/controller/.cache/clangd/index/mdns_service.c.92D5EB5771567036.idx new file mode 100644 index 0000000..6a09750 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_service.c.92D5EB5771567036.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_service.h.7C851492F5E0D8AB.idx b/firmware/controller/.cache/clangd/index/mdns_service.h.7C851492F5E0D8AB.idx new file mode 100644 index 0000000..a80370a Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_service.h.7C851492F5E0D8AB.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_utils.c.F534F6547BABC748.idx b/firmware/controller/.cache/clangd/index/mdns_utils.c.F534F6547BABC748.idx new file mode 100644 index 0000000..400eeb4 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_utils.c.F534F6547BABC748.idx differ diff --git a/firmware/controller/.cache/clangd/index/mdns_utils.h.82A7215ABE807ABD.idx b/firmware/controller/.cache/clangd/index/mdns_utils.h.82A7215ABE807ABD.idx new file mode 100644 index 0000000..e558474 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/mdns_utils.h.82A7215ABE807ABD.idx differ diff --git a/firmware/controller/.cache/clangd/index/pid.cpp.7A2C4FF97B438BF3.idx b/firmware/controller/.cache/clangd/index/pid.cpp.7A2C4FF97B438BF3.idx new file mode 100644 index 0000000..e9266d3 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/pid.cpp.7A2C4FF97B438BF3.idx differ diff --git a/firmware/controller/.cache/clangd/index/pid.h.21295E2653B28799.idx b/firmware/controller/.cache/clangd/index/pid.h.21295E2653B28799.idx new file mode 100644 index 0000000..fabcf17 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/pid.h.21295E2653B28799.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_alarm.h.398A6B39659208EF.idx b/firmware/controller/.cache/clangd/index/port_alarm.h.398A6B39659208EF.idx new file mode 100644 index 0000000..62626c5 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_alarm.h.398A6B39659208EF.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_clock.h.DB1153E770FCA061.idx b/firmware/controller/.cache/clangd/index/port_clock.h.DB1153E770FCA061.idx new file mode 100644 index 0000000..10b6faf Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_clock.h.DB1153E770FCA061.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_counters.h.C8FD845045631D2B.idx b/firmware/controller/.cache/clangd/index/port_counters.h.C8FD845045631D2B.idx new file mode 100644 index 0000000..205cf91 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_counters.h.C8FD845045631D2B.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_current.h.DA521570B5762B1B.idx b/firmware/controller/.cache/clangd/index/port_current.h.DA521570B5762B1B.idx new file mode 100644 index 0000000..e78d09f Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_current.h.DA521570B5762B1B.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_display.h.0D9E490EBFE7255B.idx b/firmware/controller/.cache/clangd/index/port_display.h.0D9E490EBFE7255B.idx new file mode 100644 index 0000000..e62f85f Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_display.h.0D9E490EBFE7255B.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_door.h.21BF44FA16DF7DC2.idx b/firmware/controller/.cache/clangd/index/port_door.h.21BF44FA16DF7DC2.idx new file mode 100644 index 0000000..959067e Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_door.h.21BF44FA16DF7DC2.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_filestore.h.2CDC67F6137FFA29.idx b/firmware/controller/.cache/clangd/index/port_filestore.h.2CDC67F6137FFA29.idx new file mode 100644 index 0000000..c5dd780 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_filestore.h.2CDC67F6137FFA29.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_flash.h.32C19661C6CD7505.idx b/firmware/controller/.cache/clangd/index/port_flash.h.32C19661C6CD7505.idx new file mode 100644 index 0000000..0a026a7 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_flash.h.32C19661C6CD7505.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_heat.h.E8AB1E4F6E4B178D.idx b/firmware/controller/.cache/clangd/index/port_heat.h.E8AB1E4F6E4B178D.idx new file mode 100644 index 0000000..7004742 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_heat.h.E8AB1E4F6E4B178D.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_input.h.09A927FB6F93BEF1.idx b/firmware/controller/.cache/clangd/index/port_input.h.09A927FB6F93BEF1.idx new file mode 100644 index 0000000..9722408 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_input.h.09A927FB6F93BEF1.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_kvstore.h.5EAB128A42CCE10A.idx b/firmware/controller/.cache/clangd/index/port_kvstore.h.5EAB128A42CCE10A.idx new file mode 100644 index 0000000..4c3229e Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_kvstore.h.5EAB128A42CCE10A.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_logstore.h.4AAE0293EEF7E78B.idx b/firmware/controller/.cache/clangd/index/port_logstore.h.4AAE0293EEF7E78B.idx new file mode 100644 index 0000000..6cb2284 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_logstore.h.4AAE0293EEF7E78B.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_net.h.92B924E7C7938803.idx b/firmware/controller/.cache/clangd/index/port_net.h.92B924E7C7938803.idx new file mode 100644 index 0000000..221cf64 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_net.h.92B924E7C7938803.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_supervisor.h.5FDAC2A543564004.idx b/firmware/controller/.cache/clangd/index/port_supervisor.h.5FDAC2A543564004.idx new file mode 100644 index 0000000..79f0c82 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_supervisor.h.5FDAC2A543564004.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_system.h.AE99EF9BA20AE698.idx b/firmware/controller/.cache/clangd/index/port_system.h.AE99EF9BA20AE698.idx new file mode 100644 index 0000000..f9ec602 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_system.h.AE99EF9BA20AE698.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_tc.h.77F0CC7425708CC7.idx b/firmware/controller/.cache/clangd/index/port_tc.h.77F0CC7425708CC7.idx new file mode 100644 index 0000000..4920f02 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_tc.h.77F0CC7425708CC7.idx differ diff --git a/firmware/controller/.cache/clangd/index/port_update.h.07DD0CA770131E6B.idx b/firmware/controller/.cache/clangd/index/port_update.h.07DD0CA770131E6B.idx new file mode 100644 index 0000000..7ef25e0 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/port_update.h.07DD0CA770131E6B.idx differ diff --git a/firmware/controller/.cache/clangd/index/profile.cpp.B87D6AA6848F66A1.idx b/firmware/controller/.cache/clangd/index/profile.cpp.B87D6AA6848F66A1.idx new file mode 100644 index 0000000..65ad9e1 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/profile.cpp.B87D6AA6848F66A1.idx differ diff --git a/firmware/controller/.cache/clangd/index/profile.h.130A26E1FEB83C21.idx b/firmware/controller/.cache/clangd/index/profile.h.130A26E1FEB83C21.idx new file mode 100644 index 0000000..5e611cd Binary files /dev/null and b/firmware/controller/.cache/clangd/index/profile.h.130A26E1FEB83C21.idx differ diff --git a/firmware/controller/.cache/clangd/index/program_store.cpp.BC4CCB967AEDF9AE.idx b/firmware/controller/.cache/clangd/index/program_store.cpp.BC4CCB967AEDF9AE.idx new file mode 100644 index 0000000..ed2a750 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/program_store.cpp.BC4CCB967AEDF9AE.idx differ diff --git a/firmware/controller/.cache/clangd/index/program_store.h.2EDFE6D17879C6F7.idx b/firmware/controller/.cache/clangd/index/program_store.h.2EDFE6D17879C6F7.idx new file mode 100644 index 0000000..1551a23 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/program_store.h.2EDFE6D17879C6F7.idx differ diff --git a/firmware/controller/.cache/clangd/index/run_index.cpp.D330C06CDA62E5DC.idx b/firmware/controller/.cache/clangd/index/run_index.cpp.D330C06CDA62E5DC.idx new file mode 100644 index 0000000..4f5f2b5 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/run_index.cpp.D330C06CDA62E5DC.idx differ diff --git a/firmware/controller/.cache/clangd/index/run_index.h.394D0BEB2FC87A2E.idx b/firmware/controller/.cache/clangd/index/run_index.h.394D0BEB2FC87A2E.idx new file mode 100644 index 0000000..1274bd9 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/run_index.h.394D0BEB2FC87A2E.idx differ diff --git a/firmware/controller/.cache/clangd/index/runstate.cpp.AADA49FF2FC36235.idx b/firmware/controller/.cache/clangd/index/runstate.cpp.AADA49FF2FC36235.idx new file mode 100644 index 0000000..f1c15b6 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/runstate.cpp.AADA49FF2FC36235.idx differ diff --git a/firmware/controller/.cache/clangd/index/runstate.h.AA7C2D3C583492F7.idx b/firmware/controller/.cache/clangd/index/runstate.h.AA7C2D3C583492F7.idx new file mode 100644 index 0000000..18e026a Binary files /dev/null and b/firmware/controller/.cache/clangd/index/runstate.h.AA7C2D3C583492F7.idx differ diff --git a/firmware/controller/.cache/clangd/index/safety.cpp.627B6D98AF3BA28C.idx b/firmware/controller/.cache/clangd/index/safety.cpp.627B6D98AF3BA28C.idx new file mode 100644 index 0000000..3f6a3cb Binary files /dev/null and b/firmware/controller/.cache/clangd/index/safety.cpp.627B6D98AF3BA28C.idx differ diff --git a/firmware/controller/.cache/clangd/index/safety.h.0E0EE6BC58EF2778.idx b/firmware/controller/.cache/clangd/index/safety.h.0E0EE6BC58EF2778.idx new file mode 100644 index 0000000..f10ff47 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/safety.h.0E0EE6BC58EF2778.idx differ diff --git a/firmware/controller/.cache/clangd/index/setpoint.cpp.B979F998BBBBEE03.idx b/firmware/controller/.cache/clangd/index/setpoint.cpp.B979F998BBBBEE03.idx new file mode 100644 index 0000000..b6165e4 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/setpoint.cpp.B979F998BBBBEE03.idx differ diff --git a/firmware/controller/.cache/clangd/index/setpoint.h.ADEF0AAE1F9E133E.idx b/firmware/controller/.cache/clangd/index/setpoint.h.ADEF0AAE1F9E133E.idx new file mode 100644 index 0000000..1d81ee3 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/setpoint.h.ADEF0AAE1F9E133E.idx differ diff --git a/firmware/controller/.cache/clangd/index/settings.cpp.FE374ADAC354D9D8.idx b/firmware/controller/.cache/clangd/index/settings.cpp.FE374ADAC354D9D8.idx new file mode 100644 index 0000000..d77bfbd Binary files /dev/null and b/firmware/controller/.cache/clangd/index/settings.cpp.FE374ADAC354D9D8.idx differ diff --git a/firmware/controller/.cache/clangd/index/settings.h.BC2BAF94023B13A2.idx b/firmware/controller/.cache/clangd/index/settings.h.BC2BAF94023B13A2.idx new file mode 100644 index 0000000..3e686da Binary files /dev/null and b/firmware/controller/.cache/clangd/index/settings.h.BC2BAF94023B13A2.idx differ diff --git a/firmware/controller/.cache/clangd/index/sim.cpp.E87AB3FC8C65FB87.idx b/firmware/controller/.cache/clangd/index/sim.cpp.E87AB3FC8C65FB87.idx new file mode 100644 index 0000000..eee4d7a Binary files /dev/null and b/firmware/controller/.cache/clangd/index/sim.cpp.E87AB3FC8C65FB87.idx differ diff --git a/firmware/controller/.cache/clangd/index/sim.h.D849C2DDB57F058C.idx b/firmware/controller/.cache/clangd/index/sim.h.D849C2DDB57F058C.idx new file mode 100644 index 0000000..6ac6541 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/sim.h.D849C2DDB57F058C.idx differ diff --git a/firmware/controller/.cache/clangd/index/suplink.cpp.994C7AA985D1F7EC.idx b/firmware/controller/.cache/clangd/index/suplink.cpp.994C7AA985D1F7EC.idx new file mode 100644 index 0000000..ac8f7d5 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/suplink.cpp.994C7AA985D1F7EC.idx differ diff --git a/firmware/controller/.cache/clangd/index/suplink.h.DAAAB212E792F5BB.idx b/firmware/controller/.cache/clangd/index/suplink.h.DAAAB212E792F5BB.idx new file mode 100644 index 0000000..a2522a4 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/suplink.h.DAAAB212E792F5BB.idx differ diff --git a/firmware/controller/.cache/clangd/index/tempfilt.cpp.4AF84C324F2390E5.idx b/firmware/controller/.cache/clangd/index/tempfilt.cpp.4AF84C324F2390E5.idx new file mode 100644 index 0000000..fedba20 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/tempfilt.cpp.4AF84C324F2390E5.idx differ diff --git a/firmware/controller/.cache/clangd/index/tempfilt.h.2BED8E89BF6BCFA8.idx b/firmware/controller/.cache/clangd/index/tempfilt.h.2BED8E89BF6BCFA8.idx new file mode 100644 index 0000000..1c2d653 Binary files /dev/null and b/firmware/controller/.cache/clangd/index/tempfilt.h.2BED8E89BF6BCFA8.idx differ diff --git a/firmware/controller/.cache/clangd/index/types.h.AB46E6CB91327A24.idx b/firmware/controller/.cache/clangd/index/types.h.AB46E6CB91327A24.idx new file mode 100644 index 0000000..e431e4b Binary files /dev/null and b/firmware/controller/.cache/clangd/index/types.h.AB46E6CB91327A24.idx differ diff --git a/firmware/controller/.cache/clangd/index/window.cpp.AA9D9AA70187704C.idx b/firmware/controller/.cache/clangd/index/window.cpp.AA9D9AA70187704C.idx new file mode 100644 index 0000000..3ccd8ae Binary files /dev/null and b/firmware/controller/.cache/clangd/index/window.cpp.AA9D9AA70187704C.idx differ diff --git a/firmware/controller/.cache/clangd/index/window.h.5D39ED538CF8C810.idx b/firmware/controller/.cache/clangd/index/window.h.5D39ED538CF8C810.idx new file mode 100644 index 0000000..99251fe Binary files /dev/null and b/firmware/controller/.cache/clangd/index/window.h.5D39ED538CF8C810.idx differ diff --git a/firmware/controller/components/kiln_app/CMakeLists.txt b/firmware/controller/components/kiln_app/CMakeLists.txt index fe8674e..2feda20 100644 --- a/firmware/controller/components/kiln_app/CMakeLists.txt +++ b/firmware/controller/components/kiln_app/CMakeLists.txt @@ -3,7 +3,6 @@ set(KILN_APP_SRCS src/app.cpp - src/program_store.cpp src/run_index.cpp src/settings.cpp ) diff --git a/firmware/controller/components/kiln_app/include/kiln_app/program_store.h b/firmware/controller/components/kiln_app/include/kiln_app/program_store.h deleted file mode 100644 index 9446e11..0000000 --- a/firmware/controller/components/kiln_app/include/kiln_app/program_store.h +++ /dev/null @@ -1,47 +0,0 @@ -/* SPDX-FileCopyrightText: 2026 Bitcrush Testing - * SPDX-License-Identifier: GPL-3.0-or-later - * - * Stored firing programs -- SWR-PRG-04, SWR-PRG-07, SWR-PRG-08, SWR-PRG-09, - * SWA-10 (programs in LittleFS: they are user files that want names, import and - * export, and atomic replace). - * - * Addressed by slot rather than by a filename derived from the name. Names - * arrive from the network (SWR-NFR-19), and turning untrusted UTF-8 into a path is a - * directory-traversal bug waiting to be written; a fixed set of slots has no - * such surface, and the name lives inside the file where it is data rather than - * structure. Twenty slots is SWR-PRG-04's limit, so a lookup by name is a scan - * of twenty short reads -- nothing worth an index that could disagree. - */ -#ifndef KILN_APP_PROGRAM_STORE_H -#define KILN_APP_PROGRAM_STORE_H - -#include "kiln/err.h" -#include "kiln/types.h" -#include "kiln_ports/port_filestore.h" - -#define KILN_PROGRAM_SLOTS KILN_MAX_PROGRAMS /* SWR-PRG-04 */ - -/* Seed the built-in read-only examples if they are not present (SWR-PRG-09). - * Idempotent, so it can run on every boot. */ -kiln_err_t kiln_program_store_seed(const kiln_port_filestore_t *fs); - -/* Save under its own name, replacing an existing program of that name. - * - * Validates first (SWR-PRG-05), refuses to overwrite a read-only example - * (SWR-PRG-09), and returns KILN_ERR_NO_SPACE when all slots are taken. */ -kiln_err_t kiln_program_store_save(const kiln_port_filestore_t *fs, - const kiln_program_t *p, float max_temp_c); - -kiln_err_t kiln_program_store_load(const kiln_port_filestore_t *fs, - const char *name, kiln_program_t *out); - -/* Refuses a read-only example (SWR-PRG-09). */ -kiln_err_t kiln_program_store_delete(const kiln_port_filestore_t *fs, const char *name); - -/* Read a slot directly, for enumeration. KILN_ERR_NOT_FOUND for an empty slot. */ -kiln_err_t kiln_program_store_get_slot(const kiln_port_filestore_t *fs, - uint8_t slot, kiln_program_t *out); - -uint8_t kiln_program_store_count(const kiln_port_filestore_t *fs); - -#endif diff --git a/firmware/controller/components/kiln_app/src/app.cpp b/firmware/controller/components/kiln_app/src/app.cpp index 5ac98a5..ea4e6e0 100644 --- a/firmware/controller/components/kiln_app/src/app.cpp +++ b/firmware/controller/components/kiln_app/src/app.cpp @@ -3,7 +3,6 @@ #include #include "kiln_app/app.h" -#include "kiln_app/program_store.h" #include "kiln_app/run_index.h" #include "kiln_app/settings.h" @@ -163,8 +162,15 @@ kiln_err_t kiln_app_init(kiln_app_t *app, const kiln_app_ports_t *ports, (void)app->ports.counters->load(app->ports.counters->ctx, &app->counters); } if (app->ports.tc->configure != nullptr) { - (void)app->ports.tc->configure(app->ports.tc->ctx, - (kiln_tc_type_t)app->cfg.tc_type, + /* SWR-ACQ-02: type K, from here and not from the configuration, which + * no longer carries a chamber type at all. On the product this call + * is refused anyway -- the supervisor owns that front end and + * kiln_suplink returns KILN_ERR_UNSUPPORTED -- and the refusal is the + * point: a caller that still thinks it owns the front end finds out. + * On a host or simulated build, where the chamber port is a fake that + * accepts it, type K is what the supervisor would have been + * linearising for. */ + (void)app->ports.tc->configure(app->ports.tc->ctx, KILN_TC_TYPE_K, (uint8_t)app->cfg.line_filter_hz); } if ((app->ports.case_tc != nullptr) && (app->ports.case_tc->configure != nullptr)) { @@ -856,10 +862,10 @@ kiln_err_t kiln_app_boot(kiln_app_t *app, kiln_reset_cause_t cause, float outage } } - /* SWR-PRG-09: the examples, idempotently. */ + /* The file store now holds run records and nothing else: SWR-PRG-09's + * examples are compiled into the image and read from there, so there is + * nothing to seed and no copy of them on the medium to go stale. */ if (app->ports.filestore != nullptr) { - (void)kiln_program_store_seed(app->ports.filestore); - /* Run numbering continues across a reboot rather than restarting and * colliding with records already on disk. */ app->next_run_id = kiln_run_index_next_run_id(app->ports.filestore); diff --git a/firmware/controller/components/kiln_app/src/program_store.cpp b/firmware/controller/components/kiln_app/src/program_store.cpp deleted file mode 100644 index e69ad20..0000000 --- a/firmware/controller/components/kiln_app/src/program_store.cpp +++ /dev/null @@ -1,267 +0,0 @@ -/* SPDX-FileCopyrightText: 2026 Bitcrush Testing - * SPDX-License-Identifier: GPL-3.0-or-later */ - -#include -#include -#include "kiln_app/program_store.h" -#include "kiln_core/logrec.h" /* kiln_crc16 */ -#include "kiln_core/profile.h" - -constexpr uint32_t PROG_MAGIC = 0x47525021U; /* "!PRG" */ -#define PROG_BLOB_BYTES (8u + sizeof(kiln_program_t) + 2u) - -namespace { - -void slot_path(uint8_t slot, char out[KILN_PATH_MAX]) -{ - /* slot is a uint8_t, so the longest result is "/p/255": 6 characters into - * KILN_PATH_MAX (64). Truncation is unreachable, which is the handling - * SWR-NFR-17 asks for here -- stated rather than checked at runtime. */ - (void)snprintf(out, KILN_PATH_MAX, "/p/%02u", (unsigned)slot); -} - -kiln_err_t encode(const kiln_program_t *p, uint8_t *out, size_t cap, size_t *len) -{ - if (cap < PROG_BLOB_BYTES) { - return KILN_ERR_NO_SPACE; - } - - memset(out, 0, PROG_BLOB_BYTES); - out[0] = (uint8_t)PROG_MAGIC; - out[1] = (uint8_t)(PROG_MAGIC >> 8u); - out[2] = (uint8_t)(PROG_MAGIC >> 16u); - out[3] = (uint8_t)(PROG_MAGIC >> 24u); - out[4] = (uint8_t)KILN_PROGRAM_SCHEMA_VERSION; - out[5] = (uint8_t)(sizeof(kiln_program_t)); - out[6] = (uint8_t)(sizeof(kiln_program_t) >> 8u); - out[7] = 0; - memcpy(&out[8], p, sizeof(*p)); - - const uint16_t crc = kiln_crc16(out, PROG_BLOB_BYTES - 2); - out[PROG_BLOB_BYTES - 2] = (uint8_t)crc; - out[PROG_BLOB_BYTES - 1] = (uint8_t)(crc >> 8u); - - *len = PROG_BLOB_BYTES; - return KILN_OK; -} - -kiln_err_t decode(const uint8_t *in, size_t len, kiln_program_t *out) -{ - if (len != PROG_BLOB_BYTES) { - return KILN_ERR_CORRUPT; - } - - const uint32_t magic = (uint32_t)in[0] | ((uint32_t)in[1] << 8u) | - ((uint32_t)in[2] << 16u) | ((uint32_t)in[3] << 24u); - if (magic != PROG_MAGIC) { - return KILN_ERR_CORRUPT; - } - - const uint16_t crc = (uint16_t)((uint32_t)in[PROG_BLOB_BYTES - 2] | - ((uint32_t)in[PROG_BLOB_BYTES - 1] << 8u)); - if (kiln_crc16(in, PROG_BLOB_BYTES - 2) != crc) { - return KILN_ERR_CORRUPT; - } - - memcpy(out, &in[8], sizeof(*out)); - - /* SWR-NFR-19: this came off storage, so it is untrusted in exactly the way a - * network body is. Force termination before anything reads the strings. */ - kiln_profile_terminate_strings(out); - return KILN_OK; -} - -kiln_err_t read_slot(const kiln_port_filestore_t *fs, uint8_t slot, - kiln_program_t *out) -{ - char path[KILN_PATH_MAX]; - slot_path(slot, path); - - uint8_t blob[PROG_BLOB_BYTES]; - size_t len = 0; - const kiln_err_t e = fs->read(fs->ctx, path, blob, sizeof(blob), &len); - if (e != KILN_OK) { - return e; - } - return decode(blob, len, out); -} - -kiln_err_t write_slot(const kiln_port_filestore_t *fs, uint8_t slot, - const kiln_program_t *p) -{ - uint8_t blob[PROG_BLOB_BYTES]; - size_t len = 0; - const kiln_err_t e = encode(p, blob, sizeof(blob), &len); - if (e != KILN_OK) { - return e; - } - - char path[KILN_PATH_MAX]; - slot_path(slot, path); - /* Atomic by rename, so a power cut leaves the previous program rather than - * half of the new one. */ - return fs->write_atomic(fs->ctx, path, blob, len); -} - -/* Slot holding `name`, or KILN_PROGRAM_SLOTS if there is none. */ -uint8_t find_by_name(const kiln_port_filestore_t *fs, const char *name, - bool *readonly_out) -{ - for (uint8_t slot = 0; slot < KILN_PROGRAM_SLOTS; slot++) { - kiln_program_t p; - if (read_slot(fs, slot, &p) != KILN_OK) { - continue; - } - if (strncmp(p.name, name, KILN_PROGRAM_NAME_LEN) == 0) { - if (readonly_out != nullptr) { - *readonly_out = (p.flags & KILN_PROG_FLAG_READONLY) != 0; - } - return slot; - } - } - return KILN_PROGRAM_SLOTS; -} - -uint8_t first_free_slot(const kiln_port_filestore_t *fs) -{ - for (uint8_t slot = 0; slot < KILN_PROGRAM_SLOTS; slot++) { - kiln_program_t p; - if (read_slot(fs, slot, &p) != KILN_OK) { - return slot; - } - } - return KILN_PROGRAM_SLOTS; -} - -} // namespace - -kiln_err_t kiln_program_store_seed(const kiln_port_filestore_t *fs) -{ - if ((fs == nullptr) || (fs->read == nullptr) || (fs->write_atomic == nullptr)) { - return KILN_ERR_INVALID_ARG; - } - - const uint8_t n = kiln_profile_example_count(); - for (uint8_t i = 0; i < n; i++) { - kiln_program_t p; - kiln_err_t e = kiln_profile_example(i, &p); - if (e != KILN_OK) { - return e; - } - - /* Idempotent: an example the operator has not deleted is left exactly - * as it is, so seeding on every boot costs one read per example. */ - bool ro = false; - if (find_by_name(fs, p.name, &ro) != KILN_PROGRAM_SLOTS) { - continue; - } - - const uint8_t slot = first_free_slot(fs); - if (slot >= KILN_PROGRAM_SLOTS) { - return KILN_ERR_NO_SPACE; - } - - e = write_slot(fs, slot, &p); - if (e != KILN_OK) { - return e; - } - } - return KILN_OK; -} - -kiln_err_t kiln_program_store_save(const kiln_port_filestore_t *fs, - const kiln_program_t *p, float max_temp_c) -{ - if ((fs == nullptr) || (fs->read == nullptr) || (fs->write_atomic == nullptr) || - (p == nullptr)) { - return KILN_ERR_INVALID_ARG; - } - - /* SWR-PRG-05 before anything reaches storage. */ - const kiln_prog_validation_t v = kiln_profile_validate(p, max_temp_c); - if (v.code != KILN_PROG_OK) { - return KILN_ERR_RANGE; - } - - bool ro = false; - uint8_t slot = find_by_name(fs, p->name, &ro); - if (slot < KILN_PROGRAM_SLOTS) { - /* SWR-PRG-09: the built-in examples are read-only. Saving over one would - * leave the operator with no way back to a known-good program. */ - if (ro) { - return KILN_ERR_STATE; - } - } else { - slot = first_free_slot(fs); - if (slot >= KILN_PROGRAM_SLOTS) { - return KILN_ERR_NO_SPACE; - } - } - return write_slot(fs, slot, p); -} - -kiln_err_t kiln_program_store_load(const kiln_port_filestore_t *fs, - const char *name, kiln_program_t *out) -{ - if ((fs == nullptr) || (fs->read == nullptr) || (name == nullptr) || (out == nullptr)) { - return KILN_ERR_INVALID_ARG; - } - - for (uint8_t slot = 0; slot < KILN_PROGRAM_SLOTS; slot++) { - kiln_program_t p; - if (read_slot(fs, slot, &p) != KILN_OK) { - continue; - } - if (strncmp(p.name, name, KILN_PROGRAM_NAME_LEN) == 0) { - *out = p; - return KILN_OK; - } - } - return KILN_ERR_NOT_FOUND; -} - -kiln_err_t kiln_program_store_delete(const kiln_port_filestore_t *fs, const char *name) -{ - if ((fs == nullptr) || (fs->read == nullptr) || (fs->remove == nullptr) || (name == nullptr)) { - return KILN_ERR_INVALID_ARG; - } - - bool ro = false; - const uint8_t slot = find_by_name(fs, name, &ro); - if (slot >= KILN_PROGRAM_SLOTS) { - return KILN_ERR_NOT_FOUND; - } - if (ro) { - return KILN_ERR_STATE; /* SWR-PRG-09 */ - } - - char path[KILN_PATH_MAX]; - slot_path(slot, path); - return fs->remove(fs->ctx, path); -} - -kiln_err_t kiln_program_store_get_slot(const kiln_port_filestore_t *fs, - uint8_t slot, kiln_program_t *out) -{ - if ((fs == nullptr) || (fs->read == nullptr) || (out == nullptr) || - slot >= KILN_PROGRAM_SLOTS) { - return KILN_ERR_INVALID_ARG; - } - return read_slot(fs, slot, out); -} - -uint8_t kiln_program_store_count(const kiln_port_filestore_t *fs) -{ - if ((fs == nullptr) || (fs->read == nullptr)) { - return 0; - } - - uint8_t n = 0; - for (uint8_t slot = 0; slot < KILN_PROGRAM_SLOTS; slot++) { - kiln_program_t p; - if (read_slot(fs, slot, &p) == KILN_OK) { - n++; - } - } - return n; -} diff --git a/firmware/controller/components/kiln_app/src/run_index.cpp b/firmware/controller/components/kiln_app/src/run_index.cpp index 4b05060..83e110f 100644 --- a/firmware/controller/components/kiln_app/src/run_index.cpp +++ b/firmware/controller/components/kiln_app/src/run_index.cpp @@ -1,5 +1,22 @@ /* SPDX-FileCopyrightText: 2026 Bitcrush Testing - * SPDX-License-Identifier: GPL-3.0-or-later */ + * SPDX-License-Identifier: GPL-3.0-or-later + * + * The 20 most recent firings, one record a slot, oldest overwritten first + * (SWR-LOG-09). + * + * Every operation here walks all 20 slots: append reads them to find the oldest, + * and mark_truncated and baseline read them to find the one they want. That is + * not as expensive as it looks -- the file store answers a read from its in-RAM + * index and a 554 byte CRC, with one region write at the end of an append -- and + * the walk is now the only reason this file reads at all. + * + * It is also the one place where the store being a fixed array rather than a + * directory costs something: with a name to look up rather than a slot to scan, + * each of the three would be one access instead of twenty. Left as it is + * because twenty accesses once per firing is not a cost worth a mechanism, and + * recorded here because it is the natural thing to simplify if this file is ever + * revisited for another reason. + */ #include #include diff --git a/firmware/controller/components/kiln_core/CMakeLists.txt b/firmware/controller/components/kiln_core/CMakeLists.txt index ecd0d56..b1c97e4 100644 --- a/firmware/controller/components/kiln_core/CMakeLists.txt +++ b/firmware/controller/components/kiln_core/CMakeLists.txt @@ -6,6 +6,7 @@ # core may not include a single IDF header -- tools/layercheck enforces that. set(KILN_CORE_SRCS + src/alarmptn.cpp src/autotune.cpp src/configmodel.cpp src/current.cpp diff --git a/firmware/controller/components/kiln_core/include/kiln_core/alarmptn.h b/firmware/controller/components/kiln_core/include/kiln_core/alarmptn.h new file mode 100644 index 0000000..86e6844 --- /dev/null +++ b/firmware/controller/components/kiln_core/include/kiln_core/alarmptn.h @@ -0,0 +1,81 @@ +/* SPDX-FileCopyrightText: 2026 Bitcrush Testing + * SPDX-License-Identifier: GPL-3.0-or-later + * + * What the buzzer sounds like -- SWR-SAF-20, SWR-RUN-06, SYS-HW-09. + * + * The buzzer is a 5 V active part that makes its own tone, so the only thing + * this device chooses is the rhythm. SWR-SAF-20 asks for two annunciations + * that an operator can tell apart without looking, and a rhythm is the whole of + * how that is done. + * + * KILN_ALARM_COMPLETE three short chirps, then quiet for five seconds. + * The firing is finished: that is good news about + * something that took nine hours, and it wants a + * notification rather than an alarm. Brief by + * construction -- the burst is over in half a second, + * and the operator hears it once unless + * `hmi.alarm_duration_s` is set long enough to let the + * burst come round again. + * + * KILN_ALARM_FAULT six fast chirps, a short gap, and again, without + * end. Something has gone wrong with a kiln at + * 1 200 degC and the heat is already off; this one is + * meant to be unpleasant, to carry through a wall, and + * to keep going until somebody acknowledges it. Twice + * the chirp rate and ten times the duty of the + * completion burst, so the two are not confusable even + * through a door. + * + * --------------------------------------------------------------------------- + * WHY THE RHYTHM IS IN THE CORE AND NOT IN THE ADAPTER + * --------------------------------------------------------------------------- + * It was in the adapter, inside an esp_timer callback, where no host test can + * reach it: the one thing SWR-SAF-20 actually requires -- that the two are + * audibly distinguishable -- was asserted by nobody, and "the fault pattern + * continues until acknowledged" was a comment rather than a property. Here it + * is a table and a step function with no clock, no allocation and no hardware + * (SWA-01, SWA-02), the adapter does nothing but set a pin and arm a timer for + * the interval it is handed, and the rhythms are host-tested like everything + * else that matters. + */ +#ifndef KILN_CORE_ALARMPTN_H +#define KILN_CORE_ALARMPTN_H + +#include "kiln/types.h" +#include "kiln_ports/port_alarm.h" + +/* Where a pattern has got to. One step is one interval at one level. */ +typedef struct { + kiln_alarm_pattern_t pattern; + uint8_t step; +} kiln_alarm_seq_t; + +/* The next interval: hold the buzzer at `on` for `hold_ms`, then call again. + * `hold_ms == 0` means the pattern has finished and nothing further is due, + * which for KILN_ALARM_OFF is immediate and for KILN_ALARM_FAULT never + * happens. */ +typedef struct { + bool on; + uint32_t hold_ms; +} kiln_alarm_step_t; + +/* Start a pattern from its first interval. Re-starting the pattern that is + * already running is the caller's decision to make, not this one's: see the + * note in kiln_hal_alarm_init's adapter about why re-asserting a pattern must + * not restart its beat. */ +void kiln_alarm_seq_begin(kiln_alarm_seq_t *s, kiln_alarm_pattern_t pattern); + +/* Advance by one interval. Deterministic, total, and safe on a null sequence + * or an out-of-range pattern, both of which answer "silent, nothing due" -- + * a buzzer that cannot be commanded must be quiet rather than stuck on. */ +kiln_alarm_step_t kiln_alarm_seq_next(kiln_alarm_seq_t *s); + +/* The total length of one cycle of a pattern, for the tests and for anything + * sizing a timeout against it. Zero for KILN_ALARM_OFF. */ +uint32_t kiln_alarm_cycle_ms(kiln_alarm_pattern_t pattern); + +/* How long a pattern sounds within one cycle, which is what makes two patterns + * distinguishable at a distance rather than only on paper. */ +uint32_t kiln_alarm_on_ms(kiln_alarm_pattern_t pattern); + +#endif /* KILN_CORE_ALARMPTN_H */ diff --git a/firmware/controller/components/kiln_core/include/kiln_core/configmodel.h b/firmware/controller/components/kiln_core/include/kiln_core/configmodel.h index 12fad39..3eb252b 100644 --- a/firmware/controller/components/kiln_core/include/kiln_core/configmodel.h +++ b/firmware/controller/components/kiln_core/include/kiln_core/configmodel.h @@ -21,7 +21,12 @@ #include "kiln/err.h" #include "kiln/types.h" -constexpr uint16_t KILN_CFG_SCHEMA_VERSION = 1; +/* 2: `sense.tc_type` removed (SWR-ACQ-02, tasklist R9). A removal in the + * middle of the struct shifts every field after it, so a version 1 blob cannot + * be read as a version 2 one; kiln_config_decode carries the splice that + * migrates it, and SWR-CFG-05's "older: migrate" is met rather than answered + * with defaults. */ +constexpr uint16_t KILN_CFG_SCHEMA_VERSION = 2; constexpr size_t KILN_CFG_STR_LEN = 33; /* WiFi SSID is 32 + NUL */ constexpr size_t KILN_CFG_PASS_LEN = 65; @@ -108,7 +113,14 @@ typedef struct { float dwell_tol_c; /* sensing */ - uint8_t tc_type; /* kiln_tc_type_t */ + /* There is no chamber tc_type, and that is SWR-ACQ-02: the chamber front + * end belongs to the independent supervisor, which linearises for type K + * and whose backstop means nothing if this side can change that. Removed + * from the schema at version 2 rather than left as an item nothing could + * act on -- a setting that is offered and ignored is worse than one that + * is absent, because an operator can set it and believe it. The + * enclosure channel's type stays configurable; that channel is not in the + * supervisor's remit. */ uint8_t case_tc_type; uint16_t line_filter_hz; float filter_tau_s; diff --git a/firmware/controller/components/kiln_core/include/kiln_core/fileslots.h b/firmware/controller/components/kiln_core/include/kiln_core/fileslots.h index 52e42c6..53c949e 100644 --- a/firmware/controller/components/kiln_core/include/kiln_core/fileslots.h +++ b/firmware/controller/components/kiln_core/include/kiln_core/fileslots.h @@ -9,9 +9,9 @@ * not control. The two callers above it -- program_store and run_index -- do * not need one either. Both address a fixed array of numbered slots, `/p/00` * to `/p/19` and `/r/00` upwards, never an arbitrary name, and between them - * they call read, write_atomic, remove and usage. `list` and `exists` are in - * the port but have no caller. A filesystem would be a general mechanism paid - * for in failure modes and bought for a fixed-size array. + * they call read, write_atomic, remove and usage, which is the whole port. A + * filesystem would be a general mechanism paid for in failure modes and bought + * for a fixed-size array. * * The medium is divided into equal regions of two erase sectors. One file * lives in one region and alternates between its two sectors, so the copy being @@ -35,6 +35,50 @@ * at which a reader sees a torn file, which is the property SWR-RUN-08 needs and * the one the host tests cut power to prove. * + * --------------------------------------------------------------------------- + * WEAR, AND WHAT THE STORE DOES ABOUT IT + * --------------------------------------------------------------------------- + * A NOR sector at the end of its life does not report an error. It takes the + * write, returns success, and holds something other than what it was given. + * An implementation that trusts the return code therefore discovers the loss + * at the next read, which on this device is after a firing rather than during + * the write it could still have redirected. + * + * So every write is proved before it is believed: the copy is read back and + * re-validated through the same path a mount uses, and only then does the + * in-RAM index point at it. A copy that does not verify retires its region -- + * the region is excluded from further allocation for this power cycle -- and + * the write is re-attempted in a free region at a higher seq. The caller gets + * KILN_OK, because the file is stored; what changed is where. + * + * Retirement needs no bad-block table, because the medium carries the evidence + * on its own. Once the migrated copy has verified, the retired region is + * erased, best effort: + * + * - if the erase succeeds the stale copy is gone, which is what keeps a later + * remove from being undone at the next mount by an old copy of the file + * that nobody asked to keep. The region returns to the pool at the next + * boot and will be retired again the next time it fails, which for a sector + * that can still be erased is the right answer rather than a life sentence + * passed on one bad write; + * - if the erase fails, which is what a sector that is actually gone does, + * the stale copy stays at a LOWER seq than the migrated one. Two regions + * then claim one name, and mount resolves it the only way it can be true: + * the lower seq is the write that failed, so its region is retired again + * and the higher seq is the file. Retirement survives the power cycle + * exactly in the case that deserves it. + * + * A power cut between the verify and the erase leaves both copies on the + * medium, which is the same state and is resolved the same way. + * + * What this does NOT do is spread wear. A region is chosen by index and keeps + * the file for as long as it works; at 40 regions of two sectors, written + * single-digit times a year against a 100 000 cycle rating, the endurance + * arithmetic of architecture 10.4 has four orders of magnitude of headroom and + * levelling would be mechanism bought for nothing. Retirement is in because + * its failure mode is losing a run record, which is cheap to prevent and + * annoying to explain. + * * Pure logic over kiln_port_flash: no clock, no allocation, all state in the * caller's struct (SWA-02, SWA-03). */ @@ -53,6 +97,14 @@ constexpr size_t KILN_FILESLOT_REGIONS_MAX = 64u; /* Byte offsets above. Payload capacity is sector_bytes - KILN_FILESLOT_HDR. */ constexpr uint32_t KILN_FILESLOT_HDR = 76u; +/* How many regions one write may be attempted in before it gives up and + * returns the medium's error. Three rather than "all of them": each attempt + * spends an erase cycle and a write on a part that is already misbehaving, and + * a store that cannot place a file in three separate regions has a problem that + * a fourth attempt does not fix. One is too few, because the first retirement + * is exactly the case this exists for. */ +constexpr unsigned KILN_FILESLOT_WRITE_TRIES = 3u; + typedef struct { char name[KILN_PATH_MAX]; uint32_t seq; /* of the live copy */ @@ -70,15 +122,32 @@ typedef struct { uint16_t payload_max; bool mounted; kiln_fileslot_entry_t entry[KILN_FILESLOT_REGIONS_MAX]; + /* Retired: a region whose write did not verify, or which mount found + * holding the losing copy of a name. Never allocated again until a + * format. One byte a region rather than a bitmap, because 64 bytes of RAM + * is not worth the shift-and-mask at every allocation. */ + bool retired[KILN_FILESLOT_REGIONS_MAX]; } kiln_fileslots_t; /* Scan every region and build the index. A region whose two copies are both * invalid is free; where both are valid the higher seq wins and the loser is * left alone, because it is the next write's target and erasing it here would - * spend an erase cycle on every boot. */ + * spend an erase cycle on every boot. + * + * Two REGIONS holding the same name is the trace a retirement leaves, and is + * resolved here: higher seq is the file, and the other region is retired. See + * the wear note above. + * + * Mount reads and CRCs every copy, which is 768 reads and 47 kB on the medium + * this firmware produces and 8 192 reads and 512 kB on a full one of + * maximum-size files; both are measured in test_fileslots.cpp. That is the + * cost of knowing at start-up what is on the medium, and it is paid once. */ kiln_err_t kiln_fileslots_mount(kiln_fileslots_t *fs, const kiln_port_flash_t *flash); -/* Erase every region. FR-CFG-12's factory reset. */ +/* Erase every region and un-retire all of them. FR-CFG-12's factory reset: a + * part that failed a verify may have been failing for a reason that a full + * erase clears, and refusing to try again after an explicit factory reset would + * leave the operator with no way to find out. */ kiln_err_t kiln_fileslots_format(kiln_fileslots_t *fs); /* Present the mounted store as the port the application holds. */ @@ -87,4 +156,9 @@ void kiln_fileslots_bind(kiln_fileslots_t *fs, kiln_port_filestore_t *out); /* How many regions hold a file, for the log line at boot and the tests. */ uint16_t kiln_fileslots_used_regions(const kiln_fileslots_t *fs); +/* How many regions have been retired. Logged at boot: a store that is quietly + * losing regions is a part on its way out, and the number is the only warning + * anybody gets. */ +uint16_t kiln_fileslots_retired_regions(const kiln_fileslots_t *fs); + #endif /* KILN_CORE_FILESLOTS_H */ diff --git a/firmware/controller/components/kiln_core/src/alarmptn.cpp b/firmware/controller/components/kiln_core/src/alarmptn.cpp new file mode 100644 index 0000000..12257ce --- /dev/null +++ b/firmware/controller/components/kiln_core/src/alarmptn.cpp @@ -0,0 +1,124 @@ +/* SPDX-FileCopyrightText: 2026 Bitcrush Testing + * SPDX-License-Identifier: GPL-3.0-or-later + * + * The buzzer's two rhythms. Why they are here and not in the adapter is in + * alarmptn.h. + */ + +#include "kiln_core/alarmptn.h" + +namespace { + +/* One interval of a pattern. Written as a table rather than as a state + * machine because the whole content of SWR-SAF-20 is these numbers, and a table + * can be read against an oscilloscope by somebody who does not read C++. */ +typedef struct { + bool on; + uint16_t ms; +} interval_t; + +/* Three chirps and five seconds of quiet. The chirps are 90 ms, which is long + * enough to be a note rather than a click and short enough that three of them + * plus their gaps are over in half a second. */ +const interval_t k_complete[] = { + { true, 90 }, { false, 90 }, + { true, 90 }, { false, 90 }, + { true, 90 }, { false, 4550 }, +}; + +/* Six chirps at twice that rate, a 400 ms gap, and round again. 60 ms on and + * 60 ms off is about as insistent as an active buzzer gets before the bursts + * run together into one tone and stop sounding urgent. */ +const interval_t k_fault[] = { + { true, 60 }, { false, 60 }, + { true, 60 }, { false, 60 }, + { true, 60 }, { false, 60 }, + { true, 60 }, { false, 60 }, + { true, 60 }, { false, 60 }, + { true, 60 }, { false, 400 }, +}; + +const interval_t *table_for(kiln_alarm_pattern_t p, uint8_t *count) +{ + switch (p) { + case KILN_ALARM_COMPLETE: + *count = (uint8_t)(sizeof(k_complete) / sizeof(k_complete[0])); + return k_complete; + case KILN_ALARM_FAULT: + *count = (uint8_t)(sizeof(k_fault) / sizeof(k_fault[0])); + return k_fault; + case KILN_ALARM_OFF: + default: + /* Including a pattern value that is not one of these: an enum carries + * whatever was written through it, and silence is the answer that + * cannot hurt. */ + *count = 0; + return nullptr; + } +} + +} // namespace + +void kiln_alarm_seq_begin(kiln_alarm_seq_t *s, kiln_alarm_pattern_t pattern) +{ + if (s == nullptr) { + return; + } + s->pattern = pattern; + s->step = 0; +} + +kiln_alarm_step_t kiln_alarm_seq_next(kiln_alarm_seq_t *s) +{ + kiln_alarm_step_t out = { false, 0u }; + if (s == nullptr) { + return out; + } + + uint8_t count = 0; + const interval_t *tab = table_for(s->pattern, &count); + if ((tab == nullptr) || (count == 0u)) { + return out; + } + + if (s->step >= count) { + /* Past the end. KILN_ALARM_FAULT cycles, because a fault that stopped + * announcing itself after six seconds would be a fault nobody heard; + * KILN_ALARM_COMPLETE also cycles, and is kept brief by the caller + * stopping it -- the application turns the alarm off after + * hmi.alarm_duration_s, whose default is one burst's worth, so the + * normal case is that the operator hears it once. Both are expressed + * the same way here, and the difference is entirely the five seconds of + * quiet in the completion table. */ + s->step = 0; + } + + out.on = tab[s->step].on; + out.hold_ms = tab[s->step].ms; + s->step = (uint8_t)(s->step + 1u); + return out; +} + +uint32_t kiln_alarm_cycle_ms(kiln_alarm_pattern_t pattern) +{ + uint8_t count = 0; + const interval_t *tab = table_for(pattern, &count); + uint32_t total = 0; + for (uint8_t i = 0; i < count; i++) { + total += tab[i].ms; + } + return total; +} + +uint32_t kiln_alarm_on_ms(kiln_alarm_pattern_t pattern) +{ + uint8_t count = 0; + const interval_t *tab = table_for(pattern, &count); + uint32_t total = 0; + for (uint8_t i = 0; i < count; i++) { + if (tab[i].on) { + total += tab[i].ms; + } + } + return total; +} diff --git a/firmware/controller/components/kiln_core/src/configmodel.cpp b/firmware/controller/components/kiln_core/src/configmodel.cpp index b8594b9..d1526d6 100644 --- a/firmware/controller/components/kiln_core/src/configmodel.cpp +++ b/firmware/controller/components/kiln_core/src/configmodel.cpp @@ -1,6 +1,7 @@ /* SPDX-FileCopyrightText: 2026 Bitcrush Testing * SPDX-License-Identifier: GPL-3.0-or-later */ +#include #include #include "kiln_core/configmodel.h" #include "kiln_core/logrec.h" /* kiln_crc16 */ @@ -68,7 +69,6 @@ const kiln_cfg_item_t k_items[] = { NUM("control.dwell_tol_c", "degC", "SWR-CTL-12", KILN_CFG_T_FLOAT, dwell_tol_c, 0.5, 50, 5, 0), /* sensing */ - ENUMI("sense.tc_type", "SWR-ACQ-02", tc_type, k_tc_names, KILN_TC_TYPE_COUNT, KILN_TC_TYPE_K, SAFE | BOOTR), ENUMI("sense.case_tc_type", "SWR-ACQ-02", case_tc_type, k_tc_names, KILN_TC_TYPE_COUNT, KILN_TC_TYPE_K, SAFE | BOOTR), NUM("sense.line_filter_hz", "Hz", "SWR-ACQ-06", KILN_CFG_T_U16, line_filter_hz, 50, 60, 50, LOCKED), NUM("sense.filter_tau_s", "s", "SWR-ACQ-07", KILN_CFG_T_FLOAT, filter_tau_s, 0, 30, 2, 0), @@ -118,7 +118,7 @@ const kiln_cfg_item_t k_items[] = { ENUMI("hmi.units", "SWR-HMI-13", units, k_units_names, KILN_UNITS_COUNT, KILN_UNITS_C, 0), ENUMI("hmi.language", "SWR-NFR-23", language, k_lang_names, KILN_LANG_COUNT, KILN_LANG_EN, 0), NUM("hmi.dim_timeout_s", "s", "SWR-HMI-12", KILN_CFG_T_U16, dim_timeout_s, 0, 3600, 60, 0), - NUM("hmi.alarm_duration_s", "s", "SWR-RUN-06", KILN_CFG_T_U16, alarm_duration_s, 0, 600, 30, 0), + NUM("hmi.alarm_duration_s", "s", "SWR-RUN-06", KILN_CFG_T_U16, alarm_duration_s, 0, 600, 4, 0), /* network */ /* Set at the display (SWR-NET-12) rather than typed into a configuration @@ -511,9 +511,39 @@ kiln_err_t kiln_config_decode(const void *blob, size_t len, kiln_config_t *cfg) /* Equal or older: take what the stored payload holds, leave the rest at the * defaults already in place. That is exactly "migrate, filling new items - * with defaults". */ - const size_t take = payload < sizeof(kiln_config_t) ? payload : sizeof(kiln_config_t); - memcpy(cfg, &p[CFG_HDR_BYTES], take); + * with defaults". + * + * Version 1 needs more than that, because schema 2 REMOVED a field rather + * than adding one. `sense.tc_type` was one byte immediately before + * `case_tc_type` (SWR-ACQ-02 took it away), so every byte after that hole + * sits one lower in the version 2 struct and a straight copy would land + * the whole sensing, control and network block one byte out of place: + * `line_filter_hz` would read as half of itself plus half a calibration + * offset, and the CRC would have passed on the way in. The splice is + * explicit for that reason, and is what keeps an operator's configuration + * across the update instead of resetting it to defaults. */ + const uint8_t *stored = &p[CFG_HDR_BYTES]; + if (ver == 1u) { + const size_t hole = offsetof(kiln_config_t, case_tc_type); + const size_t head = (payload < hole) ? payload : hole; + memcpy(cfg, stored, head); + if (payload > (hole + 1u)) { + size_t tail = payload - hole - 1u; + if (tail > (sizeof(kiln_config_t) - hole)) { + tail = sizeof(kiln_config_t) - hole; + } + /* Byte arithmetic on the struct, for the reason field_of above + * gives: offsetof yields a byte offset and the address has to be + * walked as bytes. + * NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) */ + memcpy(reinterpret_cast(cfg) + hole, stored + hole + 1u, tail); + } + } + else { + const size_t take = payload < sizeof(kiln_config_t) ? payload + : sizeof(kiln_config_t); + memcpy(cfg, stored, take); + } cfg->schema_version = KILN_CFG_SCHEMA_VERSION; /* A blob that passed its CRC can still hold a value outside a range this diff --git a/firmware/controller/components/kiln_core/src/fileslots.cpp b/firmware/controller/components/kiln_core/src/fileslots.cpp index 32357ee..f3e2f9a 100644 --- a/firmware/controller/components/kiln_core/src/fileslots.cpp +++ b/firmware/controller/components/kiln_core/src/fileslots.cpp @@ -126,7 +126,7 @@ uint16_t find_region(const kiln_fileslots_t *fs, const char *name) uint16_t free_region(const kiln_fileslots_t *fs) { for (uint16_t r = 0; r < fs->region_count; r++) { - if (!fs->entry[r].used) { + if (!fs->entry[r].used && !fs->retired[r]) { return r; } } @@ -182,6 +182,29 @@ kiln_err_t kiln_fileslots_mount(kiln_fileslots_t *fs, const kiln_port_flash_t *f } } + /* Two regions claiming one name is the trace of a retirement (see the wear + * note in the header). find_region returns the first match, so leaving + * both would serve whichever happens to sit at the lower index, which half + * the time is the copy a failed write left behind. The higher seq is the + * file; the other region is the one that failed and is retired again. */ + for (uint16_t r = 0; r < fs->region_count; r++) { + if (!fs->entry[r].used) { + continue; + } + for (uint16_t q = (uint16_t)(r + 1u); q < fs->region_count; q++) { + if (!fs->entry[q].used || + (strcmp(fs->entry[q].name, fs->entry[r].name) != 0)) { + continue; + } + const uint16_t loser = (fs->entry[q].seq > fs->entry[r].seq) ? r : q; + fs->retired[loser] = true; + fs->entry[loser] = kiln_fileslot_entry_t{}; + if (loser == r) { + break; /* r holds nothing now; nothing else can match it */ + } + } + } + fs->mounted = true; return KILN_OK; } @@ -198,10 +221,25 @@ kiln_err_t kiln_fileslots_format(kiln_fileslots_t *fs) return e; } fs->entry[r] = kiln_fileslot_entry_t{}; + fs->retired[r] = false; } return KILN_OK; } +uint16_t kiln_fileslots_retired_regions(const kiln_fileslots_t *fs) +{ + if ((fs == nullptr) || !fs->mounted) { + return 0u; + } + uint16_t n = 0; + for (uint16_t r = 0; r < fs->region_count; r++) { + if (fs->retired[r]) { + n++; + } + } + return n; +} + uint16_t kiln_fileslots_used_regions(const kiln_fileslots_t *fs) { if ((fs == nullptr) || !fs->mounted) { @@ -263,29 +301,22 @@ kiln_err_t fs_read(void *ctx, const char *path, void *out, size_t cap, size_t *l return KILN_OK; } -kiln_err_t fs_write_atomic(void *ctx, const char *path, const void *data, size_t len) +/* Write one copy and prove it. + * + * Everything physical about a write is in here, so that the retry above it has + * one thing to call and one outcome to interpret: the copy is there and + * verified, or this region did not take it. + * + * The proof is read_copy, which is the same validation a mount performs. What + * is accepted here is therefore exactly what a reboot would accept, and a + * sector that took the write and kept something else -- which is how NOR fails, + * without an error -- is caught now, while there is still another region to put + * the file in. */ +kiln_err_t write_copy(kiln_fileslots_t *fs, uint16_t region, uint8_t copy, + const uint8_t *namebuf, const void *data, size_t len, + uint32_t seq, uint16_t *crc_out) { - kiln_fileslots_t *fs = static_cast(ctx); - if ((fs == nullptr) || !fs->mounted || !name_ok(path) || - ((data == nullptr) && (len > 0u))) { - return KILN_ERR_INVALID_ARG; - } - if (len > fs->payload_max) { - return KILN_ERR_RANGE; - } - - uint16_t r = find_region(fs, path); - const bool fresh = (r == fs->region_count); - if (fresh) { - r = free_region(fs); - if (r == fs->region_count) { - return KILN_ERR_NO_SPACE; - } - } - - const uint8_t target = fresh ? 0u : (uint8_t)(1u - fs->entry[r].copy); - const uint32_t seq = fresh ? 1u : (fs->entry[r].seq + 1u); - const uint32_t base = copy_off(fs, r, target); + const uint32_t base = copy_off(fs, region, copy); kiln_err_t e = fs->flash->erase(fs->flash->ctx, base, fs->sector_bytes); if (e != KILN_OK) { @@ -294,11 +325,7 @@ kiln_err_t fs_write_atomic(void *ctx, const char *path, const void *data, size_t /* The name field is written in full so that its 64 bytes are what the CRC * covers on both sides, whatever the caller's string length. */ - uint8_t namebuf[KILN_PATH_MAX]; - memset(namebuf, 0, sizeof(namebuf)); - memcpy(namebuf, path, strnlen(path, KILN_PATH_MAX - 1u)); - - e = fs->flash->write(fs->flash->ctx, base + OFF_NAME, namebuf, sizeof(namebuf)); + e = fs->flash->write(fs->flash->ctx, base + OFF_NAME, namebuf, KILN_PATH_MAX); if (e != KILN_OK) { return e; } @@ -322,7 +349,7 @@ kiln_err_t fs_write_atomic(void *ctx, const char *path, const void *data, size_t } } - uint16_t crc = kiln_crc16_update(KILN_CRC16_INIT, namebuf, sizeof(namebuf)); + uint16_t crc = kiln_crc16_update(KILN_CRC16_INIT, namebuf, KILN_PATH_MAX); crc = kiln_crc16_update(crc, static_cast(data), len); /* The commit. Until these twelve bytes land the copy above does not exist, @@ -337,16 +364,108 @@ kiln_err_t fs_write_atomic(void *ctx, const char *path, const void *data, size_t return e; } - kiln_fileslot_entry_t *en = &fs->entry[r]; - memcpy(en->name, namebuf, sizeof(namebuf)); - en->seq = seq; - en->len = (uint16_t)len; - en->crc = crc; - en->copy = target; - en->used = true; + kiln_fileslot_entry_t check = {}; + if (!read_copy(fs, region, copy, &check)) { + return KILN_ERR_CORRUPT; + } + if ((check.seq != seq) || (check.len != (uint16_t)len) || (check.crc != crc) || + (memcmp(check.name, namebuf, KILN_PATH_MAX) != 0)) { + return KILN_ERR_CORRUPT; + } + + *crc_out = crc; return KILN_OK; } +kiln_err_t fs_write_atomic(void *ctx, const char *path, const void *data, size_t len) +{ + kiln_fileslots_t *fs = static_cast(ctx); + if ((fs == nullptr) || !fs->mounted || !name_ok(path) || + ((data == nullptr) && (len > 0u))) { + return KILN_ERR_INVALID_ARG; + } + if (len > fs->payload_max) { + return KILN_ERR_RANGE; + } + + uint8_t namebuf[KILN_PATH_MAX]; + memset(namebuf, 0, sizeof(namebuf)); + memcpy(namebuf, path, strnlen(path, KILN_PATH_MAX - 1u)); + + /* Where the file lives now, if it lives anywhere. Kept separate from the + * region being written, because after a retirement they differ and the old + * one must stay readable until the new one has verified. */ + const uint16_t home = find_region(fs, path); + + uint16_t r = home; + uint8_t target = 0u; + uint32_t seq = 1u; + if (home == fs->region_count) { + r = free_region(fs); + if (r == fs->region_count) { + return KILN_ERR_NO_SPACE; + } + } + else { + target = (uint8_t)(1u - fs->entry[home].copy); + seq = fs->entry[home].seq + 1u; + } + + for (unsigned attempt = 1u; ; attempt++) { + uint16_t crc = 0u; + const kiln_err_t e = + write_copy(fs, r, target, namebuf, data, len, seq, &crc); + + if (e == KILN_OK) { + if ((home != fs->region_count) && (r != home)) { + /* The file has moved. Erase what the retired region holds, + * best effort and only now that the new copy has verified: + * + * - it succeeds, and the stale copy is gone, which is what + * stops a later remove from being undone at the next mount + * by a copy of the file nobody asked to keep; + * - it fails, which is what a dead sector does, and the stale + * copy stays at a lower seq. That is the trace mount reads + * to retire this region again, so retirement outlives the + * power cycle exactly when the medium is genuinely gone. + * + * A power cut between the verify above and this erase leaves + * both copies on the medium, which is the same case and is + * resolved the same way. */ + (void)fs->flash->erase(fs->flash->ctx, + copy_off(fs, home, 0), fs->region_bytes); + fs->entry[home] = kiln_fileslot_entry_t{}; + } + kiln_fileslot_entry_t *en = &fs->entry[r]; + memcpy(en->name, namebuf, sizeof(namebuf)); + en->seq = seq; + en->len = (uint16_t)len; + en->crc = crc; + en->copy = target; + en->used = true; + return KILN_OK; + } + + /* This region did not take the file. It is out of the pool for the + * rest of the power cycle; whether it is out for good is settled at the + * next mount, by whether the erase above could reach it. */ + fs->retired[r] = true; + if (attempt >= KILN_FILESLOT_WRITE_TRIES) { + return e; + } + const uint16_t next = free_region(fs); + if (next == fs->region_count) { + return e; /* nowhere left to put it: the caller has to know */ + } + + r = next; + target = 0u; + /* Above anything the retired region can be holding, so that a mount + * which finds both copies of the name resolves in favour of this one. */ + seq = seq + 1u; + } +} + kiln_err_t fs_remove(void *ctx, const char *path) { kiln_fileslots_t *fs = static_cast(ctx); @@ -366,39 +485,6 @@ kiln_err_t fs_remove(void *ctx, const char *path) return KILN_OK; } -kiln_err_t fs_exists(void *ctx, const char *path) -{ - const kiln_fileslots_t *fs = static_cast(ctx); - if ((fs == nullptr) || !fs->mounted || !name_ok(path)) { - return KILN_ERR_INVALID_ARG; - } - return (find_region(fs, path) == fs->region_count) ? KILN_ERR_NOT_FOUND : KILN_OK; -} - -kiln_err_t fs_list(void *ctx, const char *dir, - bool (*fn)(void *user, const char *name, size_t size), - void *user) -{ - const kiln_fileslots_t *fs = static_cast(ctx); - if ((fs == nullptr) || !fs->mounted || (fn == nullptr)) { - return KILN_ERR_INVALID_ARG; - } - const size_t pre = (dir != nullptr) ? strnlen(dir, KILN_PATH_MAX) : 0u; - for (uint16_t r = 0; r < fs->region_count; r++) { - const kiln_fileslot_entry_t *en = &fs->entry[r]; - if (!en->used) { - continue; - } - if ((pre > 0u) && (strncmp(en->name, dir, pre) != 0)) { - continue; - } - if (!fn(user, en->name, en->len)) { - break; - } - } - return KILN_OK; -} - kiln_err_t fs_usage(void *ctx, size_t *total, size_t *used) { const kiln_fileslots_t *fs = static_cast(ctx); @@ -432,7 +518,5 @@ void kiln_fileslots_bind(kiln_fileslots_t *fs, kiln_port_filestore_t *out) out->read = fs_read; out->write_atomic = fs_write_atomic; out->remove = fs_remove; - out->exists = fs_exists; - out->list = fs_list; out->usage = fs_usage; } diff --git a/firmware/controller/components/kiln_hal_esp32s3/idf_component.yml b/firmware/controller/components/kiln_hal_esp32s3/idf_component.yml new file mode 100644 index 0000000..9edd7c1 --- /dev/null +++ b/firmware/controller/components/kiln_hal_esp32s3/idf_component.yml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2026 Bitcrush Testing +# SPDX-License-Identifier: GPL-3.0-or-later +# +# The one managed dependency this project has (UR-CON-04). +# +# mDNS left the ESP-IDF tree for the component registry, and SWR-NET-04 asks for +# kiln.local, so the choice was between vendoring several thousand lines of +# somebody else's protocol implementation, dropping the requirement, or taking +# the dependency under conditions that make it auditable. UR-CON-04 states +# those conditions and this file meets them: +# +# PINNED TO AN EXACT VERSION, not a range. "~1.14" would let a build pick up +# code nobody reviewed, which is the whole thing the constraint exists to +# prevent; a release has to be reproducible from its tag years later. +# +# dependencies.lock IS COMMITTED, so the resolved version and its checksum +# are in the repository rather than in whatever the registry served that +# afternoon. +# +# IT APPEARS IN THE SBOM automatically, by its registry name and its OWN +# version rather than the ESP-IDF version it was built against, because the +# version is the only thing an advisory can be matched against. tools/sbom.py +# reads the components the build actually linked and scans each one's SPDX +# tags, so this one is listed as espressif/mdns 1.14.0, Apache-2.0 (checked: +# compatible with distribution under GPL-3.0), with the registry content hash +# in its description, and nobody maintains a list. +# +# managed_components/ stays out of git: it is a cache, the lockfile is the +# record, and committing both would be two sources of truth for one fact. +dependencies: + espressif/mdns: "==1.14.0" diff --git a/firmware/controller/components/kiln_hal_esp32s3/src/hal_io.cpp b/firmware/controller/components/kiln_hal_esp32s3/src/hal_io.cpp index 9108a42..a1afe3a 100644 --- a/firmware/controller/components/kiln_hal_esp32s3/src/hal_io.cpp +++ b/firmware/controller/components/kiln_hal_esp32s3/src/hal_io.cpp @@ -11,6 +11,7 @@ #include "esp_log.h" #include "esp_timer.h" +#include "kiln_core/alarmptn.h" #include "kiln_hal/board_pins.h" #include "kiln_hal/hal_esp32s3.h" @@ -82,12 +83,8 @@ namespace { /* --- buzzer (SYS-HW-09, SWR-SAF-20) ---------------------------------------------- */ /* - * SWR-SAF-20 wants fault and completion audibly distinguishable, so this is a - * pattern and not a level. A 5 V active buzzer makes its own tone (SYS-HW-09), so - * all that is needed is gating it on and off: - * - * fault 200 ms on, 200 ms off urgent, and continues until acknowledged - * complete 200 ms on, 1800 ms off a periodic chirp, not an alarm + * The rhythms are in kiln_core/alarmptn, where they are host-tested; this is + * the half that cannot be: a pin and a one-shot timer. * * Driven by esp_timer rather than by the control task, because the pattern must * keep sounding while the operator is doing something else, and because a fault @@ -95,8 +92,7 @@ namespace { */ typedef struct { esp_timer_handle_t timer; - kiln_alarm_pattern_t pattern; - bool on; + kiln_alarm_seq_t seq; } alarm_t; alarm_t s_alarm; @@ -104,19 +100,15 @@ alarm_t s_alarm; void alarm_tick(void *arg) { alarm_t *a = static_cast(arg); - if (a->pattern == KILN_ALARM_OFF) { - a->on = false; - gpio_set_level((gpio_num_t)KILN_PIN_ALARM, 0); - return; - } - - a->on = !a->on; - gpio_set_level((gpio_num_t)KILN_PIN_ALARM, a->on ? 1 : 0); + const kiln_alarm_step_t s = kiln_alarm_seq_next(&a->seq); - const uint64_t next_us = - a->on ? 200u * 1000u - : ((a->pattern == KILN_ALARM_FAULT) ? 200u * 1000u : 1800u * 1000u); - (void)esp_timer_start_once(a->timer, next_us); + gpio_set_level((gpio_num_t)KILN_PIN_ALARM, s.on ? 1 : 0); + if (s.hold_ms > 0u) { + (void)esp_timer_start_once(a->timer, (uint64_t)s.hold_ms * 1000u); + } + /* hold_ms == 0 means nothing further is due, and the level set above is + * already the silent one. No timer is armed, so the buzzer stays quiet + * until something sets a pattern. */ } void alarm_set(void *ctx, kiln_alarm_pattern_t pattern) @@ -125,19 +117,13 @@ void alarm_set(void *ctx, kiln_alarm_pattern_t pattern) if (a == nullptr) { return; } - if (a->pattern == pattern) { + if (a->seq.pattern == pattern) { return; /* re-asserting must not restart the beat */ } - a->pattern = pattern; (void)esp_timer_stop(a->timer); + kiln_alarm_seq_begin(&a->seq, pattern); - if (pattern == KILN_ALARM_OFF) { - a->on = false; - gpio_set_level((gpio_num_t)KILN_PIN_ALARM, 0); - return; - } - /* Start loud: the first edge is immediate, not one period away. */ - a->on = false; + /* Start loud: the first interval is applied now, not one period away. */ alarm_tick(a); } @@ -164,8 +150,7 @@ void kiln_hal_alarm_init(kiln_port_alarm_t *out) args.name = "alarm"; (void)esp_timer_create(&args, &s_alarm.timer); - s_alarm.pattern = KILN_ALARM_OFF; - s_alarm.on = false; + kiln_alarm_seq_begin(&s_alarm.seq, KILN_ALARM_OFF); out->ctx = &s_alarm; out->set = alarm_set; diff --git a/firmware/controller/components/kiln_hal_esp32s3/src/hal_net.cpp b/firmware/controller/components/kiln_hal_esp32s3/src/hal_net.cpp index 3fca617..dd154d5 100644 --- a/firmware/controller/components/kiln_hal_esp32s3/src/hal_net.cpp +++ b/firmware/controller/components/kiln_hal_esp32s3/src/hal_net.cpp @@ -1,8 +1,7 @@ /* SPDX-FileCopyrightText: 2026 Bitcrush Testing * SPDX-License-Identifier: GPL-3.0-or-later * - * WiFi station, scanning, joining and SNTP (SWR-NET-01..SWR-NET-12, - * except SWR-NET-04: see the note on mDNS below). + * WiFi station, scanning, joining, mDNS and SNTP: SWR-NET-01 to SWR-NET-12. * * --------------------------------------------------------------------------- * SWR-NET-07 is the requirement that shapes this file @@ -24,6 +23,8 @@ #include #include +#include "mdns.h" + #include "esp_event.h" #include "esp_log.h" #include "esp_mac.h" @@ -183,6 +184,11 @@ kiln_err_t net_connect(void *ctx, const char *ssid, const char *pass) s_net.sta_configured = true; s_net.retry_ms = RETRY_MIN_MS; s_net.state = KILN_NET_CONNECTING; + /* A fresh attempt, so the previous attempt's reason stops being reported: + * the display distinguishes "joining" from "it failed, and here is why" by + * exactly this field, and a stale code would show the new attempt as + * already failed. */ + s_net.last_reason = 0u; (void)copy_checked(s_net.ssid, sizeof(s_net.ssid), ssid, "ssid"); ESP_LOGI(TAG, "joining '%s' as asked at the display", ssid); return (esp_wifi_connect() == ESP_OK) ? KILN_OK : KILN_ERR_IO; @@ -285,6 +291,7 @@ kiln_err_t net_status(void *ctx, kiln_net_status_t *out) (void)snprintf(out->hostname, sizeof(out->hostname), "%s", n->hostname); out->disconnect_count = n->disconnects; out->time_synced = n->time_synced; + out->last_reason = n->last_reason; out->uptime_s = (n->state == KILN_NET_STA_CONNECTED && n->up_since_us > 0) ? (uint32_t)((esp_timer_get_time() - n->up_since_us) / 1000000) : 0u; @@ -380,17 +387,31 @@ kiln_err_t kiln_hal_net_init(const kiln_config_t *cfg, kiln_port_net_t *out) return KILN_ERR_IO; } - /* SWR-NET-04 wants kiln.local, and it is **not implemented here**. + /* SWR-NET-04: kiln.local. * - * mDNS left the ESP-IDF tree for the component manager, and UR-CON-04 forbids - * a build-time fetch from an unpinned source -- the same constraint that - * has LittleFS waiting to be vendored (tasklist E7). Adding a managed - * dependency to get a convenience feature would be the wrong trade against - * a constraint the project applies to everything else, so the device is - * reachable by IP until mDNS is vendored deliberately. + * The project's one managed dependency, pinned exactly and locked, on the + * terms UR-CON-04 now sets out; see this component's idf_component.yml for + * why that was the trade rather than vendoring a protocol implementation + * or dropping the requirement. * - * The address is on the network screen (SWR-HMI-07), which is where an - * operator standing at the kiln would look for it anyway. */ + * Best effort, like everything else in this file. An operator who cannot + * resolve the name still has the IP on the network screen (SWR-HMI-07), + * and SWR-NET-07 means none of this can touch a firing either way. */ + if (mdns_init() == ESP_OK) { + (void)mdns_hostname_set(s_net.hostname); + (void)mdns_instance_name_set("Safe Kiln Controller"); + /* Advertised so that a browser or a phone finds the interface by + * looking rather than by being told an address. It is the read-only + * interface of SWR-WEB-26: advertising it exposes nothing that an + * HTTP GET to the same port does not. */ + (void)mdns_service_add(NULL, "_http", "_tcp", 80, NULL, 0); + ESP_LOGI(TAG, "mdns: %s.local", s_net.hostname); + } + else { + /* Not a failure of anything the kiln needs. The address is on the + * display and the API answers on it. */ + ESP_LOGW(TAG, "mdns did not start; reach the device by IP"); + } /* SWR-NET-06. Best effort by design: no route to the internet is normal in * a workshop, and warning 105 says so rather than anything failing. */ diff --git a/firmware/controller/components/kiln_hmi/CMakeLists.txt b/firmware/controller/components/kiln_hmi/CMakeLists.txt index 3c9f42d..a35a5b3 100644 --- a/firmware/controller/components/kiln_hmi/CMakeLists.txt +++ b/firmware/controller/components/kiln_hmi/CMakeLists.txt @@ -9,6 +9,7 @@ set(KILN_HMI_SRCS src/draw.cpp src/hmi.cpp + src/strings.cpp ) if(ESP_PLATFORM) diff --git a/firmware/controller/components/kiln_hmi/include/kiln_hmi/hmi.h b/firmware/controller/components/kiln_hmi/include/kiln_hmi/hmi.h index 6781948..35168bf 100644 --- a/firmware/controller/components/kiln_hmi/include/kiln_hmi/hmi.h +++ b/firmware/controller/components/kiln_hmi/include/kiln_hmi/hmi.h @@ -102,6 +102,11 @@ typedef struct { * between "no networks here" and "ask again in a moment", which is the * whole of what an operator needs during the two seconds a scan takes. */ bool net_scanning; + /* SWR-NET-12: the radio's reason for the last disconnect or refused join, + * straight from kiln_net_status_t. With the display the only way + * credentials reach the device, "it did not work" is not a useful answer, + * and a reason code is something an operator can read out over a phone. */ + uint8_t net_last_reason; uint8_t net_count; char net_list_ssid[KILN_HMI_MAX_NETWORKS][KILN_HMI_SSID_LEN]; int8_t net_list_rssi[KILN_HMI_MAX_NETWORKS]; diff --git a/firmware/controller/components/kiln_hmi/include/kiln_hmi/strings.h b/firmware/controller/components/kiln_hmi/include/kiln_hmi/strings.h new file mode 100644 index 0000000..f24f4c2 --- /dev/null +++ b/firmware/controller/components/kiln_hmi/include/kiln_hmi/strings.h @@ -0,0 +1,114 @@ +/* SPDX-FileCopyrightText: 2026 Bitcrush Testing + * SPDX-License-Identifier: GPL-3.0-or-later + * + * The display's own words -- SWR-NFR-23, SWR-HMI-15. + * + * `kiln_core/faults` already holds every fault and warning in both languages, + * which is the prose an operator reads when something has gone wrong. This is + * the other half: the screen titles, the menu, the two answers to a + * confirmation and the footers that say what the knob does. They were English + * string literals at their draw sites, so a device configured for German + * announced its faults in German inside an English frame. + * + * --------------------------------------------------------------------------- + * WHY THE GERMAN IS SPELLED LIKE THAT + * --------------------------------------------------------------------------- + * The 5x7 font in draw.cpp carries the 95 printable ASCII glyphs and nothing + * else, so there are no umlauts and no sharp s to draw with. Every German + * string here is therefore transliterated -- AE OE UE SS -- which is what + * `kiln_core/faults` has always done and is a convention German readers are + * used to from equipment displays. A test asserts it, because a single + * umlaut typed into this table would reach the panel as a blank or a + * question mark, and nobody would see it until the device was in a workshop. + * + * The strings are also bounded: 21 characters is the display's width at + * scale 1 (128 pixels, 6 per glyph), and a label that overruns it is silently + * clipped by the framebuffer. German runs about 15 per cent longer than + * English, so this is the bound the translation has to be written against + * rather than a thing to check afterwards -- the test holds every line-length + * string to it. + */ +#ifndef KILN_HMI_STRINGS_H +#define KILN_HMI_STRINGS_H + +#include "kiln/types.h" + +/* One identifier per piece of text. An enum rather than keys, so that adding a + * screen without translating it does not compile. */ +typedef enum { + /* Screen titles */ + KILN_HMI_STR_MENU = 0, + KILN_HMI_STR_PROGRAMS, + KILN_HMI_STR_NETWORK, + KILN_HMI_STR_NETWORKS, + KILN_HMI_STR_PASSPHRASE, + KILN_HMI_STR_DIAGNOSTICS, + KILN_HMI_STR_INFO, + KILN_HMI_STR_FAULT, + KILN_HMI_STR_SUPERVISOR, + + /* The main screen's badges. Four or five characters: these sit beside one + * another on one row and are read at a glance from across a workshop. */ + KILN_HMI_STR_HEAT, + KILN_HMI_STR_HOLD, + KILN_HMI_STR_ACK, + + /* Menu */ + KILN_HMI_STR_START_PROGRAM, + KILN_HMI_STR_PAUSE, + KILN_HMI_STR_RESUME, + KILN_HMI_STR_PAUSE_RESUME, + KILN_HMI_STR_ABORT, + KILN_HMI_STR_NONE_STORED, + + /* Confirmation */ + KILN_HMI_STR_START_FIRING_Q, + KILN_HMI_STR_ABORT_FIRING_Q, + KILN_HMI_STR_YES, + KILN_HMI_STR_NO, + KILN_HMI_STR_TURN_THEN_PRESS, + + /* Network */ + KILN_HMI_STR_CONNECTED, + KILN_HMI_STR_NOT_CONNECTED, + KILN_HMI_STR_PRESS_SETUP_WIFI, + KILN_HMI_STR_SCANNING, + KILN_HMI_STR_NONE_FOUND, + KILN_HMI_STR_HOLD_BACK, + KILN_HMI_STR_NEEDS_KEY, + KILN_HMI_STR_OF, + KILN_HMI_STR_JOIN_FAILED, + KILN_HMI_STR_JOINING, + + /* Passphrase entry */ + KILN_HMI_STR_DELETE, + KILN_HMI_STR_CONNECT, + KILN_HMI_STR_EIGHT_OR_MORE, + KILN_HMI_STR_CHARS, + KILN_HMI_STR_HOLD_CANCEL, + + /* Diagnostics and info. Column labels, padded to a common width at the + * draw site rather than here, so the padding is not part of the + * translation. */ + KILN_HMI_STR_CASE, + KILN_HMI_STR_AMPS, + KILN_HMI_STR_POWER, + KILN_HMI_STR_USED, + KILN_HMI_STR_DUTY, + KILN_HMI_STR_UPTIME, + KILN_HMI_STR_TUNED, + KILN_HMI_STR_UNTUNED, + + KILN_HMI_STR_COUNT, +} kiln_hmi_str_id_t; + +/* The string, in the configured language. A language the table does not carry + * and an identifier out of range both fall back to English rather than to an + * empty screen, which is what types.h promises for kiln_lang_t. */ +const char *kiln_hmi_str(kiln_hmi_str_id_t id, kiln_lang_t lang); + +/* The display's width in characters at scale 1, which is the bound every line + * of text here is written against. */ +constexpr unsigned KILN_HMI_COLS = 21u; + +#endif /* KILN_HMI_STRINGS_H */ diff --git a/firmware/controller/components/kiln_hmi/src/hmi.cpp b/firmware/controller/components/kiln_hmi/src/hmi.cpp index 982ad0c..f65698a 100644 --- a/firmware/controller/components/kiln_hmi/src/hmi.cpp +++ b/firmware/controller/components/kiln_hmi/src/hmi.cpp @@ -10,6 +10,7 @@ #include "kiln_core/faults.h" #include "kiln_hmi/hmi.h" +#include "kiln_hmi/strings.h" /* --- small helpers ------------------------------------------------------ */ @@ -67,6 +68,14 @@ void fmt_hms(char *buf, size_t n, uint32_t s) else { (void)snprintf(buf, n, "%um%02u", (unsigned)m, (unsigned)(s % 60u)); } } +/* The one place the language is read, so a draw site is a lookup rather than a + * conditional. SWR-NFR-23 is a presentation concern: nothing downstream of + * here knows what language the panel is in. */ +const char *S(const kiln_hmi_view_t *v, kiln_hmi_str_id_t id) +{ + return kiln_hmi_str(id, v->language); +} + const char *state_text(const kiln_hmi_view_t *v) { /* SWR-NFR-23 / SWR-HMI-15: the one table, in the configured language. */ @@ -123,9 +132,9 @@ void draw_main(kiln_hmi_t *h, const kiln_hmi_view_t *v) /* Duty, and the two states worth seeing at a glance. */ (void)snprintf(buf, sizeof(buf), "%u%%", (unsigned)(v->snap.duty_permille / 10u)); kiln_fb_text(&h->fb, 0, 40, buf, 1, true); - if (v->snap.heat_authorised) { kiln_fb_text(&h->fb, 30, 40, "HEAT", 1, true); } - if (v->snap.holdback_active) { kiln_fb_text(&h->fb, 62, 40, "HOLD", 1, true); } - if (v->awaiting_ack) { kiln_fb_text(&h->fb, 94, 40, "ACK?", 1, true); } + if (v->snap.heat_authorised) { kiln_fb_text(&h->fb, 30, 40, S(v, KILN_HMI_STR_HEAT), 1, true); } + if (v->snap.holdback_active) { kiln_fb_text(&h->fb, 62, 40, S(v, KILN_HMI_STR_HOLD), 1, true); } + if (v->awaiting_ack) { kiln_fb_text(&h->fb, 94, 40, S(v, KILN_HMI_STR_ACK), 1, true); } /* SWR-HMI-04's progress indication: segment position through the program. */ uint8_t pct = 0; @@ -158,9 +167,10 @@ void draw_fault(kiln_hmi_t *h, const kiln_hmi_view_t *v) char buf[40]; if (sup_blame) { - (void)snprintf(buf, sizeof(buf), "SUPERVISOR"); + (void)snprintf(buf, sizeof(buf), "%s", S(v, KILN_HMI_STR_SUPERVISOR)); } else { - (void)snprintf(buf, sizeof(buf), "FAULT %u", (unsigned)v->fault); + (void)snprintf(buf, sizeof(buf), "%s %u", S(v, KILN_HMI_STR_FAULT), + (unsigned)v->fault); } kiln_fb_text(&h->fb, 2, 2, buf, 1, false); kiln_fb_text_right(&h->fb, KILN_DISPLAY_W - 2, 2, @@ -200,12 +210,14 @@ const char *menu_label(uint8_t i, const kiln_hmi_view_t *v) const bool running = (v->snap.state == (uint8_t)KILN_STATE_RUNNING); const bool paused = (v->snap.state == (uint8_t)KILN_STATE_PAUSED); switch (i) { - case 0: return "Start program"; - case 1: return running ? "Pause" : (paused ? "Resume" : "Pause/Resume"); - case 2: return "Abort"; - case 3: return "Network"; - case 4: return "Diagnostics"; - case 5: return "Info"; + case 0: return S(v, KILN_HMI_STR_START_PROGRAM); + case 1: return running ? S(v, KILN_HMI_STR_PAUSE) + : (paused ? S(v, KILN_HMI_STR_RESUME) + : S(v, KILN_HMI_STR_PAUSE_RESUME)); + case 2: return S(v, KILN_HMI_STR_ABORT); + case 3: return S(v, KILN_HMI_STR_NETWORK); + case 4: return S(v, KILN_HMI_STR_DIAGNOSTICS); + case 5: return S(v, KILN_HMI_STR_INFO); default: return ""; } } @@ -250,7 +262,9 @@ const char *program_label(uint8_t i, const kiln_hmi_view_t *v) void draw_confirm(kiln_hmi_t *h, const kiln_hmi_view_t *v) { /* SWR-HMI-11: starting and aborting both require this step. */ - const char *what = (h->pending == KILN_HMI_ACT_START) ? "Start firing?" : "Abort firing?"; + const char *what = (h->pending == KILN_HMI_ACT_START) + ? S(v, KILN_HMI_STR_START_FIRING_Q) + : S(v, KILN_HMI_STR_ABORT_FIRING_Q); kiln_fb_text(&h->fb, 0, 4, what, 1, true); if (h->pending == KILN_HMI_ACT_START && h->pending_program < v->program_count) { @@ -258,29 +272,58 @@ void draw_confirm(kiln_hmi_t *h, const kiln_hmi_view_t *v) } if (h->confirm_yes) { - kiln_fb_text_inv(&h->fb, 14, 40, "YES", 1); - kiln_fb_text(&h->fb, 80, 40, "NO", 1, true); + kiln_fb_text_inv(&h->fb, 14, 40, S(v, KILN_HMI_STR_YES), 1); + kiln_fb_text(&h->fb, 80, 40, S(v, KILN_HMI_STR_NO), 1, true); } else { - kiln_fb_text(&h->fb, 14, 40, "YES", 1, true); - kiln_fb_text_inv(&h->fb, 80, 40, "NO", 1); + kiln_fb_text(&h->fb, 14, 40, S(v, KILN_HMI_STR_YES), 1, true); + kiln_fb_text_inv(&h->fb, 80, 40, S(v, KILN_HMI_STR_NO), 1); } - kiln_fb_text(&h->fb, 0, 54, "turn to choose, press", 1, true); + kiln_fb_text(&h->fb, 0, 54, S(v, KILN_HMI_STR_TURN_THEN_PRESS), 1, true); } void draw_network(kiln_hmi_t *h, const kiln_hmi_view_t *v) { /* SWR-HMI-07: where the web interface is, which is the question an operator * standing at the kiln actually has. */ - kiln_fb_text(&h->fb, 0, 0, "NETWORK", 1, true); + kiln_fb_text(&h->fb, 0, 0, S(v, KILN_HMI_STR_NETWORK), 1, true); kiln_fb_hline(&h->fb, 0, 9, KILN_DISPLAY_W, true); - kiln_fb_text(&h->fb, 0, 13, v->net_up ? "connected" : "not connected", 1, true); + + /* SWR-NET-12: what became of the last attempt. + * + * "not connected" on its own is the answer an operator cannot act on, and + * with the display the only route in -- there is no access point -- acting + * on it is the whole job. So a join in progress says so, and a join that + * the radio refused says so with the radio's reason code: 15 is a failed + * four-way handshake, which is a wrong passphrase in almost every case, + * 201 is "no AP of that name answered", and 2 and 4 are the AP dropping + * the association. The number is there to be read out over a phone to + * somebody who can look it up, which beats a sentence that guesses. */ + const char *first = v->net_up ? S(v, KILN_HMI_STR_CONNECTED) + : S(v, KILN_HMI_STR_NOT_CONNECTED); + kiln_fb_text(&h->fb, 0, 13, first, 1, true); kiln_fb_text(&h->fb, 0, 23, v->net_up ? v->net_ssid : "", 1, true); - kiln_fb_text(&h->fb, 0, 33, v->hostname, 1, true); + + if (!v->net_up && v->net_last_reason != 0u) { + char why[32]; + (void)snprintf(why, sizeof(why), "%s (%u)", S(v, KILN_HMI_STR_JOIN_FAILED), + (unsigned)v->net_last_reason); + kiln_fb_text(&h->fb, 0, 33, why, 1, true); + } + else if (!v->net_up) { + /* No reason recorded: either nothing has been attempted yet, or an + * attempt is in flight. Both are "ask again in a moment" rather than + * a failure, and saying so is what stops an operator abandoning a join + * that was going to work. */ + kiln_fb_text(&h->fb, 0, 33, S(v, KILN_HMI_STR_JOINING), 1, true); + } + else { + kiln_fb_text(&h->fb, 0, 33, v->hostname, 1, true); + } kiln_fb_text(&h->fb, 0, 43, v->ip, 1, true); /* SWR-NET-11: this is the only route into WiFi setup, and it says so. The * device raises no access point, so if this screen cannot be reached the * network cannot be configured at all. */ - kiln_fb_text(&h->fb, 0, 54, "press: set up wifi", 1, true); + kiln_fb_text(&h->fb, 0, 54, S(v, KILN_HMI_STR_PRESS_SETUP_WIFI), 1, true); } /* SWR-NET-11. Four rows at a time, strongest first, with a bar for signal and @@ -289,13 +332,14 @@ void draw_network(kiln_hmi_t *h, const kiln_hmi_view_t *v) * the operator can see that it was cut. */ void draw_networks(kiln_hmi_t *h, const kiln_hmi_view_t *v) { - kiln_fb_text(&h->fb, 0, 0, "WIFI NETWORKS", 1, true); + kiln_fb_text(&h->fb, 0, 0, S(v, KILN_HMI_STR_NETWORKS), 1, true); kiln_fb_hline(&h->fb, 0, 9, KILN_DISPLAY_W, true); if (v->net_count == 0u) { kiln_fb_text(&h->fb, 0, 24, - v->net_scanning ? "scanning..." : "none found", 1, true); - kiln_fb_text(&h->fb, 0, 54, "hold: back", 1, true); + v->net_scanning ? S(v, KILN_HMI_STR_SCANNING) + : S(v, KILN_HMI_STR_NONE_FOUND), 1, true); + kiln_fb_text(&h->fb, 0, 54, S(v, KILN_HMI_STR_HOLD_BACK), 1, true); return; } @@ -327,8 +371,9 @@ void draw_networks(kiln_hmi_t *h, const kiln_hmi_view_t *v) * drawn on: the compiler is right that %u of an unsigned is ten digits, * and a truncated footer is a worse answer than a wide buffer. */ char foot[40]; - (void)snprintf(foot, sizeof(foot), "%u of %u * needs key", - (unsigned)(h->net_sel + 1u), (unsigned)v->net_count); + (void)snprintf(foot, sizeof(foot), "%u %s %u %s", + (unsigned)(h->net_sel + 1u), S(v, KILN_HMI_STR_OF), + (unsigned)v->net_count, S(v, KILN_HMI_STR_NEEDS_KEY)); kiln_fb_text(&h->fb, 0, 54, foot, 1, true); } @@ -339,7 +384,7 @@ void draw_networks(kiln_hmi_t *h, const kiln_hmi_view_t *v) void draw_passphrase(kiln_hmi_t *h, const kiln_hmi_view_t *v) { (void)v; - kiln_fb_text(&h->fb, 0, 0, "PASSPHRASE", 1, true); + kiln_fb_text(&h->fb, 0, 0, S(v, KILN_HMI_STR_PASSPHRASE), 1, true); kiln_fb_hline(&h->fb, 0, 9, KILN_DISPLAY_W, true); /* The tail, because that is where the cursor is: 20 characters fit and a @@ -352,43 +397,48 @@ void draw_passphrase(kiln_hmi_t *h, const kiln_hmi_view_t *v) char pick[24]; if (h->charset_sel == K_SEL_DEL) { - (void)snprintf(pick, sizeof(pick), "[ delete ]"); + (void)snprintf(pick, sizeof(pick), "%s", S(v, KILN_HMI_STR_DELETE)); } else if (h->charset_sel == K_SEL_OK) { /* The length rule is shown where it is enforced, so "nothing happened * when I pressed" never has to be guessed at. */ (void)snprintf(pick, sizeof(pick), "%s", - (h->pass_len >= 8u) ? "[ connect ]" : "[ 8 or more ]"); + (h->pass_len >= 8u) ? S(v, KILN_HMI_STR_CONNECT) + : S(v, KILN_HMI_STR_EIGHT_OR_MORE)); } else { (void)snprintf(pick, sizeof(pick), " %c", k_charset[h->charset_sel]); } kiln_fb_text(&h->fb, 0, 30, pick, 1, true); char foot[40]; - (void)snprintf(foot, sizeof(foot), "%u chars hold: cancel", - (unsigned)h->pass_len); + (void)snprintf(foot, sizeof(foot), "%u %s %s", (unsigned)h->pass_len, + S(v, KILN_HMI_STR_CHARS), S(v, KILN_HMI_STR_HOLD_CANCEL)); kiln_fb_text(&h->fb, 0, 54, foot, 1, true); } void draw_diag(kiln_hmi_t *h, const kiln_hmi_view_t *v) { char buf[32], t[12]; - kiln_fb_text(&h->fb, 0, 0, "DIAGNOSTICS", 1, true); + kiln_fb_text(&h->fb, 0, 0, S(v, KILN_HMI_STR_DIAGNOSTICS), 1, true); kiln_fb_hline(&h->fb, 0, 9, KILN_DISPLAY_W, true); fmt_temp(t, sizeof(t), v, v->snap.case_c, v->snap.case_valid); - (void)snprintf(buf, sizeof(buf), "case %s%s", t, unit_str(v)); + (void)snprintf(buf, sizeof(buf), "%-5s %s%s", S(v, KILN_HMI_STR_CASE), t, + unit_str(v)); kiln_fb_text(&h->fb, 0, 13, buf, 1, true); - (void)snprintf(buf, sizeof(buf), "amps %.1f", (double)v->snap.current_a); + (void)snprintf(buf, sizeof(buf), "%-5s %.1f", S(v, KILN_HMI_STR_AMPS), + (double)v->snap.current_a); kiln_fb_text(&h->fb, 0, 23, buf, 1, true); /* SWR-CUR-07, in the units a kiln owner thinks in. */ - (void)snprintf(buf, sizeof(buf), "power %.2fkW", v->power_w / 1000.0); + (void)snprintf(buf, sizeof(buf), "%-5s %.2fkW", S(v, KILN_HMI_STR_POWER), + v->power_w / 1000.0); kiln_fb_text(&h->fb, 0, 33, buf, 1, true); - (void)snprintf(buf, sizeof(buf), "used %.2fkWh", v->energy_wh / 1000.0); + (void)snprintf(buf, sizeof(buf), "%-5s %.2fkWh", S(v, KILN_HMI_STR_USED), + v->energy_wh / 1000.0); kiln_fb_text(&h->fb, 0, 43, buf, 1, true); - (void)snprintf(buf, sizeof(buf), "duty %u%%", + (void)snprintf(buf, sizeof(buf), "%-5s %u%%", S(v, KILN_HMI_STR_DUTY), (unsigned)(v->snap.duty_permille / 10u)); kiln_fb_text(&h->fb, 0, 53, buf, 1, true); } @@ -396,12 +446,12 @@ void draw_diag(kiln_hmi_t *h, const kiln_hmi_view_t *v) void draw_info(kiln_hmi_t *h, const kiln_hmi_view_t *v) { char buf[40], up[12]; - kiln_fb_text(&h->fb, 0, 0, "INFO", 1, true); + kiln_fb_text(&h->fb, 0, 0, S(v, KILN_HMI_STR_INFO), 1, true); kiln_fb_hline(&h->fb, 0, 9, KILN_DISPLAY_W, true); kiln_fb_text(&h->fb, 0, 13, v->version, 1, true); fmt_hms(up, sizeof(up), v->uptime_s); - (void)snprintf(buf, sizeof(buf), "up %s", up); + (void)snprintf(buf, sizeof(buf), "%s %s", S(v, KILN_HMI_STR_UPTIME), up); kiln_fb_text(&h->fb, 0, 23, buf, 1, true); (void)snprintf(buf, sizeof(buf), "P%.1f I%.3f", (double)v->kp, (double)v->ki); @@ -411,7 +461,9 @@ void draw_info(kiln_hmi_t *h, const kiln_hmi_view_t *v) /* SWR-TUN-11: factory gains are not gains for *this* kiln, and the screen * that shows them is the right place to say so. */ - kiln_fb_text(&h->fb, 0, 53, v->gains_tuned ? "tuned" : "UNTUNED defaults", 1, true); + kiln_fb_text(&h->fb, 0, 53, + v->gains_tuned ? S(v, KILN_HMI_STR_TUNED) + : S(v, KILN_HMI_STR_UNTUNED), 1, true); } } // namespace @@ -652,15 +704,21 @@ kiln_hmi_action_t kiln_hmi_update(kiln_hmi_t *h, const kiln_hmi_view_t *view, switch (h->screen) { case KILN_HMI_SCREEN_FAULT: draw_fault(h, view); break; case KILN_HMI_SCREEN_MENU: - draw_list(h, "MENU", MENU_ITEMS, h->menu_sel, 0, menu_label, view); + draw_list(h, kiln_hmi_str(KILN_HMI_STR_MENU, view->language), + MENU_ITEMS, h->menu_sel, 0, menu_label, view); break; case KILN_HMI_SCREEN_PROGRAMS: if (view->program_count == 0u) { - kiln_fb_text(&h->fb, 0, 0, "PROGRAMS", 1, true); + kiln_fb_text(&h->fb, 0, 0, + kiln_hmi_str(KILN_HMI_STR_PROGRAMS, view->language), + 1, true); kiln_fb_hline(&h->fb, 0, 9, KILN_DISPLAY_W, true); - kiln_fb_text(&h->fb, 0, 20, "none stored", 1, true); + kiln_fb_text(&h->fb, 0, 20, + kiln_hmi_str(KILN_HMI_STR_NONE_STORED, view->language), + 1, true); } else { - draw_list(h, "PROGRAMS", view->program_count, h->prog_sel, h->prog_top, + draw_list(h, kiln_hmi_str(KILN_HMI_STR_PROGRAMS, view->language), + view->program_count, h->prog_sel, h->prog_top, program_label, view); } break; diff --git a/firmware/controller/components/kiln_hmi/src/strings.cpp b/firmware/controller/components/kiln_hmi/src/strings.cpp new file mode 100644 index 0000000..92d3508 --- /dev/null +++ b/firmware/controller/components/kiln_hmi/src/strings.cpp @@ -0,0 +1,108 @@ +/* SPDX-FileCopyrightText: 2026 Bitcrush Testing + * SPDX-License-Identifier: GPL-3.0-or-later + * + * The display's own words, in both languages. The conventions, the ASCII + * transliteration and the 21 character bound are explained in strings.h. + * + * The German is written for somebody standing at a kiln, which is why it uses + * the abbreviations that appear on industrial panels rather than the full words + * a letter would use: HEIZ, QUIT and STOERUNG are what a German-speaking + * operator already reads on machinery, and a display is not the place to teach + * new vocabulary. Two of them are worth a note: + * + * HOLD -> WARTET. Holdback is the program waiting for the chamber to catch + * up, so "wartet" (waiting) says what is happening. "HALT" would have been + * the literal translation and would have been read as "stopped", which is the + * opposite of a kiln that is still firing. + * + * ACK? -> QUIT? Quittieren is the standard German verb for acknowledging an + * alarm on industrial equipment. It has nothing to do with quitting, and a + * German operator will not read it that way. + * + * J4 still applies: none of this has been reviewed by a native speaker. + */ + +#include "kiln_hmi/strings.h" + +namespace { + +/* [identifier][language]. A plain table so that the whole translation can be + * read in one go, which is how a reviewer can actually check it. */ +const char *const k_str[KILN_HMI_STR_COUNT][KILN_LANG_COUNT] = { + /* Screen titles */ + { "MENU", "MENUE" }, + { "PROGRAMS", "PROGRAMME" }, + { "NETWORK", "NETZWERK" }, + { "WIFI NETWORKS", "WLAN NETZE" }, + { "PASSPHRASE", "PASSWORT" }, + { "DIAGNOSTICS", "DIAGNOSE" }, + { "INFO", "INFO" }, + { "FAULT", "STOERUNG" }, + { "SUPERVISOR", "WAECHTER" }, + + /* Main screen badges */ + { "HEAT", "HEIZ" }, + { "HOLD", "WARTET" }, + { "ACK?", "QUIT?" }, + + /* Menu */ + { "Start program", "Programm starten" }, + { "Pause", "Pause" }, + { "Resume", "Fortsetzen" }, + { "Pause/Resume", "Pause/Fortsetzen" }, + { "Abort", "Abbrechen" }, + { "none stored", "keine gespeichert" }, + + /* Confirmation */ + { "Start firing?", "Brennen starten?" }, + { "Abort firing?", "Brennen abbrechen?" }, + { "YES", "JA" }, + { "NO", "NEIN" }, + { "turn to choose, press", "drehen, dann druecken" }, + + /* Network */ + { "connected", "verbunden" }, + { "not connected", "nicht verbunden" }, + { "press: set up wifi", "druecken: WLAN einr." }, + { "scanning...", "suche..." }, + { "none found", "nichts gefunden" }, + { "hold: back", "halten: zurueck" }, + { "* needs key", "* Schluessel" }, + { "of", "von" }, + { "join failed", "Verb. gescheitert" }, + { "joining...", "verbinde..." }, + + /* Passphrase entry */ + { "[ delete ]", "[ loeschen ]" }, + { "[ connect ]", "[ verbinden ]" }, + { "[ 8 or more ]", "[ min. 8 ]" }, + { "chars", "Zeichen" }, + { "hold: cancel", "halten: Abbruch" }, + + /* Diagnostics and info */ + { "case", "Geh." }, + { "amps", "Strom" }, + { "power", "Leist" }, + { "used", "Verbr" }, + { "duty", "Takt" }, + { "up", "Lauf" }, + { "tuned", "abgestimmt" }, + { "UNTUNED defaults", "NICHT abgestimmt" }, +}; + +} // namespace + +const char *kiln_hmi_str(kiln_hmi_str_id_t id, kiln_lang_t lang) +{ + if ((unsigned)id >= (unsigned)KILN_HMI_STR_COUNT) { + /* Not an identifier this build knows. An empty string rather than a + * guess: a wrong label on a kiln's display is worse than a gap, which + * at least looks like what it is. */ + return ""; + } + if ((unsigned)lang >= (unsigned)KILN_LANG_COUNT) { + lang = KILN_LANG_EN; + } + const char *s = k_str[id][lang]; + return (s != nullptr) ? s : k_str[id][KILN_LANG_EN]; +} diff --git a/firmware/controller/components/kiln_ports/include/kiln_ports/port_filestore.h b/firmware/controller/components/kiln_ports/include/kiln_ports/port_filestore.h index e170e27..142dc3e 100644 --- a/firmware/controller/components/kiln_ports/include/kiln_ports/port_filestore.h +++ b/firmware/controller/components/kiln_ports/include/kiln_ports/port_filestore.h @@ -7,6 +7,16 @@ * gets the atomicity from alternating between two copies rather than from a * rename. There is no filesystem behind this on the target, and nothing above * it needs one: both callers address a fixed set of numbered slots. + * + * There is no `exists` and no `list`, and there were both until the profiles + * became compile-time data. They existed for a local program editor that would + * have enumerated what is on the medium; with programs arriving in the image + * and nothing editing them, nobody was going to call either, and three + * implementations and two tests for an interface with no caller is a liability + * rather than headroom -- it is code that compiles, is covered, and is never + * exercised by the product. `exists` was in any case `read` with the answer + * thrown away, and anything that needs to enumerate a fixed array of numbered + * slots can count to twenty. */ #ifndef KILN_PORT_FILESTORE_H #define KILN_PORT_FILESTORE_H @@ -15,6 +25,11 @@ #include "kiln/err.h" #include "kiln/types.h" +/* Including the terminator, and the stored name field is exactly this, so a + * longer path is REFUSED with KILN_ERR_INVALID_ARG rather than truncated. + * Storing a file under a name the caller did not ask for is the worse of the + * two failures; both callers produce five-character slot paths, so nothing + * generates one today. */ constexpr size_t KILN_PATH_MAX = 64; typedef struct kiln_port_filestore { @@ -22,11 +37,6 @@ typedef struct kiln_port_filestore { kiln_err_t (*read)(void *ctx, const char *path, void *out, size_t cap, size_t *len); kiln_err_t (*write_atomic)(void *ctx, const char *path, const void *data, size_t len); kiln_err_t (*remove)(void *ctx, const char *path); - kiln_err_t (*exists)(void *ctx, const char *path); - /* Enumerate; returning false from fn stops. */ - kiln_err_t (*list)(void *ctx, const char *dir, - bool (*fn)(void *user, const char *name, size_t size), - void *user); kiln_err_t (*usage)(void *ctx, size_t *total, size_t *used); } kiln_port_filestore_t; diff --git a/firmware/controller/components/kiln_ports/include/kiln_ports/port_net.h b/firmware/controller/components/kiln_ports/include/kiln_ports/port_net.h index 31d7cc5..4fb07f1 100644 --- a/firmware/controller/components/kiln_ports/include/kiln_ports/port_net.h +++ b/firmware/controller/components/kiln_ports/include/kiln_ports/port_net.h @@ -40,6 +40,20 @@ typedef struct { uint32_t disconnect_count; uint32_t uptime_s; bool time_synced; + /* SWR-NET-12. The radio's own reason for the last disconnect or refused + * join -- IEEE 802.11 reason codes as the driver reports them, plus the + * vendor range above 200 that carries "no AP found" and "handshake + * timeout", which are the two an operator at the display will actually + * meet. Zero means none has been recorded, which is also what a device + * that has never tried to associate says. + * + * Carried because the display had nothing to show when a join failed: it + * returned to the network screen saying "not connected", leaving the + * operator to guess between a typed passphrase that was wrong, an SSID + * that is out of range, and a radio that never came up. With the setup at + * the display the only route in (there is no access point), guessing is the + * whole cost of getting it wrong. */ + uint8_t last_reason; } kiln_net_status_t; /* The most networks a scan will report. Sixteen rather than "all of them": diff --git a/firmware/controller/components/kiln_sim/src/sim.cpp b/firmware/controller/components/kiln_sim/src/sim.cpp index b881d85..f2d74a5 100644 --- a/firmware/controller/components/kiln_sim/src/sim.cpp +++ b/firmware/controller/components/kiln_sim/src/sim.cpp @@ -706,39 +706,6 @@ kiln_err_t sfs_remove(void *ctx, const char *path) return KILN_OK; } -kiln_err_t sfs_exists(void *ctx, const char *path) -{ - kiln_sim_fs_t *fs = static_cast(ctx); - if ((fs == nullptr) || (path == nullptr)) { - return KILN_ERR_INVALID_ARG; - } - return sfs_find(fs, path) >= 0 ? KILN_OK : KILN_ERR_NOT_FOUND; -} - -kiln_err_t sfs_list(void *ctx, const char *dir, - bool (*fn)(void *user, const char *name, size_t size), - void *user) -{ - kiln_sim_fs_t *fs = static_cast(ctx); - if ((fs == nullptr) || (dir == nullptr) || (fn == nullptr)) { - return KILN_ERR_INVALID_ARG; - } - - const size_t dlen = strlen(dir); - for (size_t i = 0; i < KILN_SIM_FS_FILES; i++) { - if (!fs->files[i].used) { - continue; - } - if (strncmp(fs->files[i].path, dir, dlen) != 0) { - continue; - } - if (!fn(user, fs->files[i].path, fs->files[i].len)) { - break; - } - } - return KILN_OK; -} - kiln_err_t sfs_usage(void *ctx, size_t *total, size_t *used) { const kiln_sim_fs_t *fs = static_cast(ctx); @@ -773,8 +740,6 @@ void kiln_sim_fs_bind(kiln_sim_fs_t *fs, kiln_port_filestore_t *out) out->read = sfs_read; out->write_atomic = sfs_write; out->remove = sfs_remove; - out->exists = sfs_exists; - out->list = sfs_list; out->usage = sfs_usage; } diff --git a/firmware/controller/components/kiln_web/CMakeLists.txt b/firmware/controller/components/kiln_web/CMakeLists.txt index b70800c..35f31fd 100644 --- a/firmware/controller/components/kiln_web/CMakeLists.txt +++ b/firmware/controller/components/kiln_web/CMakeLists.txt @@ -19,6 +19,50 @@ if(ESP_PLATFORM) INCLUDE_DIRS include REQUIRES kiln_app kiln_core kiln_ports esp_http_server ) + + # --- the browser interface, gzipped into the image (SWA-11, SWR-WEB-02) + # + # Gzipped at build time rather than committed compressed: the source of + # truth stays the readable file in web/, and nobody has to remember to + # recompress after an edit. `gzip -9 -n` for the -n: without it the + # header carries the mtime and the image stops being reproducible from + # the same sources, which tools/sbom.py and the release both assume. + # + # Served with Content-Encoding: gzip rather than decompressed on the + # device. Every browser that can run the interface can inflate it, and + # doing it here costs 38 kB of flash and no RAM at all, where inflating + # on demand would cost a window buffer per request on the processor that + # is also running a kiln. + set(KILN_WEB_ASSETS index.html app.css app.js chart.js) + set(KILN_WEB_SRC_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../../web") + set(KILN_WEB_GZ_DIR "${CMAKE_CURRENT_BINARY_DIR}/assets") + file(MAKE_DIRECTORY "${KILN_WEB_GZ_DIR}") + + foreach(asset ${KILN_WEB_ASSETS}) + set(src "${KILN_WEB_SRC_DIR}/${asset}") + set(gz "${KILN_WEB_GZ_DIR}/${asset}.gz") + if(NOT EXISTS "${src}") + message(FATAL_ERROR + "web asset ${src} is missing: the interface would build " + "without it and 404 at run time, which is worse than failing " + "here") + endif() + add_custom_command( + OUTPUT "${gz}" + COMMAND gzip -9 -n -c "${src}" > "${gz}" + DEPENDS "${src}" + COMMENT "gzip ${asset}" + VERBATIM + ) + list(APPEND KILN_WEB_GZ "${gz}") + target_add_binary_data(${COMPONENT_LIB} "${gz}" BINARY) + endforeach() + + # The custom commands are attached to a target of their own so they run + # before the component is linked; target_add_binary_data alone does not + # create that dependency for a generated file. + add_custom_target(kiln_web_assets DEPENDS ${KILN_WEB_GZ}) + add_dependencies(${COMPONENT_LIB} kiln_web_assets) else() add_library(kiln_web STATIC ${KILN_WEB_SRCS}) target_include_directories(kiln_web PUBLIC include) diff --git a/firmware/controller/components/kiln_web/include/kiln_web/api.h b/firmware/controller/components/kiln_web/include/kiln_web/api.h index 170b1ca..f391a86 100644 --- a/firmware/controller/components/kiln_web/include/kiln_web/api.h +++ b/firmware/controller/components/kiln_web/include/kiln_web/api.h @@ -145,4 +145,27 @@ bool kiln_api_needs_auth(const kiln_api_req_t *req); * so the UI has one parser. */ size_t kiln_api_telemetry_event(kiln_api_ctx_t *ctx, char *buf, size_t cap); +/* Wrap a payload as one Server-Sent Events frame: an optional `event:` line, + * then `data:`, then the blank line that ends it. Returns the length written, + * or 0 if it did not fit, in which case nothing is written -- half an SSE frame + * on the wire desynchronises the stream for every frame after it, so the only + * safe partial write is none. + * + * `data` must contain no newline. A newline inside a data field is how SSE + * expresses a multi-line payload, so one arriving unannounced would be read by + * the client as a frame boundary in the middle of a JSON document. The JSON + * this device produces has none; the check is here because the consequence of + * being wrong about that is silent and remote. + * + * Here rather than in the transport because it is the part of SSE that can be + * got wrong, and the transport cannot be host-tested (SWA-01). */ +size_t kiln_api_sse_frame(char *buf, size_t cap, const char *event, + const char *data, size_t data_len); + +/* The comment frame SSE uses as a keepalive. A proxy or a phone's radio will + * drop an idle TCP connection, and a stream that has been pushing once a second + * is only idle because the device stopped; this is what keeps the distinction + * visible rather than papering over it. */ +size_t kiln_api_sse_comment(char *buf, size_t cap, const char *text); + #endif diff --git a/firmware/controller/components/kiln_web/src/api.cpp b/firmware/controller/components/kiln_web/src/api.cpp index 9403d76..4cf58a0 100644 --- a/firmware/controller/components/kiln_web/src/api.cpp +++ b/firmware/controller/components/kiln_web/src/api.cpp @@ -4,7 +4,6 @@ #include #include #include -#include "kiln_app/program_store.h" #include "kiln_app/run_index.h" #include "kiln_core/faults.h" #include "kiln_core/profile.h" @@ -687,22 +686,25 @@ kiln_err_t handle_tune(kiln_api_ctx_t *ctx, const kiln_api_req_t *req, /* --- /api/runs, /api/current, /api/storage, /api/net ------------------- */ -/* SWR-PRG-07: the stored programs, read only. */ +/* SWR-PRG-09: the programs, read only and compiled in. + * + * No storage check any more, and that is the point of the change: these came + * from the file store, which meant a device whose flash would not mount served + * 503 for the list of programs it was carrying in its own image. The + * programs are now exactly as available as the firmware is. */ kiln_err_t handle_program_list(kiln_api_ctx_t *ctx, kiln_api_resp_t *resp) { - if (ctx->filestore == nullptr) { - kiln_api_error(resp, 503, "no_storage", "program storage is unavailable"); - return KILN_ERR_IO; - } kiln_json_t j; resp_begin(resp, &j); kiln_json_obj_open(&j); kiln_json_key(&j, "programs"); kiln_json_arr_open(&j); - const uint8_t n = kiln_program_store_count(ctx->filestore); + /* SWR-PRG-09, and now the whole of it: the programs are compiled into the + * image, so this enumerates the firmware rather than the medium. */ + const uint8_t n = kiln_profile_example_count(); for (uint8_t i = 0; i < n; i++) { kiln_program_t prog; - if (kiln_program_store_get_slot(ctx->filestore, i, &prog) != KILN_OK) { + if (kiln_profile_example(i, &prog) != KILN_OK) { continue; } write_program(&j, &prog, i, ctx->app->cfg.max_temp_c); @@ -712,19 +714,15 @@ kiln_err_t handle_program_list(kiln_api_ctx_t *ctx, kiln_api_resp_t *resp) return resp_end(resp, &j); } -/* One stored program by slot. A missing slot is 404 and not an empty list: - * a client asking for a program that is not there has made a different - * mistake from one asking what programs exist. */ +/* One program by id. An id past the end is 404 and not an empty list: a + * client asking for a program that is not there has made a different mistake + * from one asking what programs exist. */ kiln_err_t handle_program_one(kiln_api_ctx_t *ctx, uint8_t id, kiln_api_resp_t *resp) { - if (ctx->filestore == nullptr) { - kiln_api_error(resp, 503, "no_storage", "program storage is unavailable"); - return KILN_ERR_IO; - } kiln_program_t prog; - if (kiln_program_store_get_slot(ctx->filestore, id, &prog) != KILN_OK) { - kiln_api_error(resp, 404, "not_found", "no program in that slot"); + if (kiln_profile_example(id, &prog) != KILN_OK) { + kiln_api_error(resp, 404, "not_found", "no program with that id"); return KILN_ERR_NOT_FOUND; } kiln_json_t j; @@ -895,7 +893,6 @@ kiln_err_t handle_storage(kiln_api_ctx_t *ctx, kiln_api_resp_t *resp) kiln_json_kv_uint(&j, "total_bytes", total); kiln_json_kv_uint(&j, "used_bytes", used); } - kiln_json_kv_uint(&j, "programs", kiln_program_store_count(ctx->filestore)); kiln_json_kv_uint(&j, "runs", kiln_run_index_count(ctx->filestore)); } else { @@ -903,6 +900,13 @@ kiln_err_t handle_storage(kiln_api_ctx_t *ctx, kiln_api_resp_t *resp) } kiln_json_obj_close(&j); + /* Outside "files", because the programs are no longer files: they are in + * the image, and reporting them as occupants of the medium said the + * opposite of what is true. Kept in this response because "how many + * programs does this device have" is a storage-shaped question even when + * the answer no longer involves storage. */ + kiln_json_kv_uint(&j, "programs", kiln_profile_example_count()); + kiln_json_obj_close(&j); return resp_end(resp, &j); } @@ -956,6 +960,74 @@ size_t kiln_api_telemetry_event(kiln_api_ctx_t *ctx, char *buf, size_t cap) return kiln_json_ok(&j) ? kiln_json_len(&j) : 0; } +/* --- Server-Sent Events framing (SWR-WEB-05) ---------------------------- */ + +size_t kiln_api_sse_frame(char *buf, size_t cap, const char *event, + const char *data, size_t data_len) +{ + if ((buf == nullptr) || (cap == 0u) || (data == nullptr)) { + return 0; + } + for (size_t i = 0; i < data_len; i++) { + if ((data[i] == '\n') || (data[i] == '\r')) { + /* Refused rather than escaped. A newline in a data field is SSE's + * own way of writing a multi-line payload, so passing one through + * would move the frame boundary into the middle of a JSON + * document, and every frame after it would be misread. Nothing + * this device serialises contains one; if that changes, the fix is + * at the serialiser rather than a rewrite here. */ + return 0; + } + } + + const size_t ev_len = (event != nullptr) ? strlen(event) : 0u; + /* "event: " + name + "\n" + "data: " + payload + "\n\n", and the NUL that + * makes the result printable in a log. */ + const size_t needed = (ev_len > 0u ? (7u + ev_len + 1u) : 0u) + + 6u + data_len + 2u + 1u; + if (needed > cap) { + return 0; + } + + size_t n = 0; + if (ev_len > 0u) { + memcpy(&buf[n], "event: ", 7u); n += 7u; + memcpy(&buf[n], event, ev_len); n += ev_len; + buf[n++] = '\n'; + } + memcpy(&buf[n], "data: ", 6u); n += 6u; + memcpy(&buf[n], data, data_len); n += data_len; + buf[n++] = '\n'; + buf[n++] = '\n'; + buf[n] = '\0'; + return n; +} + +size_t kiln_api_sse_comment(char *buf, size_t cap, const char *text) +{ + if ((buf == nullptr) || (cap == 0u)) { + return 0; + } + const char *s = (text != nullptr) ? text : ""; + const size_t len = strlen(s); + for (size_t i = 0; i < len; i++) { + if ((s[i] == '\n') || (s[i] == '\r')) { + return 0; + } + } + if ((2u + len + 2u + 1u) > cap) { + return 0; + } + size_t n = 0; + buf[n++] = ':'; + buf[n++] = ' '; + memcpy(&buf[n], s, len); n += len; + buf[n++] = '\n'; + buf[n++] = '\n'; + buf[n] = '\0'; + return n; +} + /* --- dispatch ---------------------------------------------------------- */ kiln_err_t kiln_api_handle(kiln_api_ctx_t *ctx, const kiln_api_req_t *req, diff --git a/firmware/controller/components/kiln_web/src/httpd.cpp b/firmware/controller/components/kiln_web/src/httpd.cpp index 79b916d..78f5360 100644 --- a/firmware/controller/components/kiln_web/src/httpd.cpp +++ b/firmware/controller/components/kiln_web/src/httpd.cpp @@ -23,6 +23,20 @@ * Neither is load-bearing alone and both are cheap. * * --------------------------------------------------------------------------- + * The interface itself (SWA-11, SWR-WEB-02) + * --------------------------------------------------------------------------- + * The four browser assets are gzipped into the image by this component's + * CMakeLists and served from flash, which is what SWR-WEB-02 means by every + * asset coming from the device: no CDN, no external font, nothing that + * reaches off the box, and the interface works on a network with no route to + * anywhere. + * + * They are served **still compressed**, with Content-Encoding: gzip. Every + * browser that can run this interface can inflate it, and doing it on the + * device would cost a window buffer per request on the processor that is also + * running a kiln. + * + * --------------------------------------------------------------------------- * SWR-NFR-02: this must not delay a control cycle * --------------------------------------------------------------------------- * The server runs on core 0 with the rest of the UI (SWA-15), at a priority @@ -33,6 +47,9 @@ #include "esp_http_server.h" #include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" #include "kiln_web/httpd.h" @@ -54,6 +71,67 @@ static httpd_ctx_t s_http; /* --- helpers ------------------------------------------------------------ */ +/* --- the embedded interface -------------------------------------------- + * + * The symbol names are the ones the build generates for an embedded binary: + * _binary__gz_start and _end. Declared here + * rather than generated into a header because there are four of them and a + * generator for four lines would be the more complicated answer. + */ +#define KILN_ASSET(sym) \ + extern const uint8_t sym##_start[] asm("_" #sym "_start"); \ + extern const uint8_t sym##_end[] asm("_" #sym "_end") + +KILN_ASSET(binary_index_html_gz); +KILN_ASSET(binary_app_css_gz); +KILN_ASSET(binary_app_js_gz); +KILN_ASSET(binary_chart_js_gz); + +typedef struct { + const char *path; + const char *type; + const uint8_t *start; + const uint8_t *end; +} asset_t; + +static const asset_t k_assets[] = { + /* "/" first: it is the one every browser asks for. */ + { "/", "text/html", binary_index_html_gz_start, binary_index_html_gz_end }, + { "/index.html","text/html", binary_index_html_gz_start, binary_index_html_gz_end }, + { "/app.css", "text/css", binary_app_css_gz_start, binary_app_css_gz_end }, + { "/app.js", "text/javascript", binary_app_js_gz_start, binary_app_js_gz_end }, + { "/chart.js", "text/javascript", binary_chart_js_gz_start, binary_chart_js_gz_end }, +}; + +/* The asset for this exact path, or NULL. + * + * An exact match and no path walking of any kind: there is no filesystem + * behind this and no directory to escape from, so ".." is not special, it + * simply matches nothing. */ +static const asset_t *find_asset(const char *path) +{ + for (size_t i = 0; i < sizeof(k_assets) / sizeof(k_assets[0]); i++) { + if (strcmp(path, k_assets[i].path) == 0) { + return &k_assets[i]; + } + } + return NULL; +} + +static esp_err_t send_asset(httpd_req_t *req, const asset_t *a) +{ + const size_t len = (size_t)(a->end - a->start); + (void)httpd_resp_set_type(req, a->type); + (void)httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); + /* The assets change only when the firmware does, and a firmware update + * changes every one of them at once, so a browser holding a stale pair of + * app.js and index.html is the failure to avoid. no-cache means + * revalidate, not "do not store": the 304 costs nothing on a LAN and the + * pair can never be mismatched. */ + (void)httpd_resp_set_hdr(req, "Cache-Control", "no-cache"); + return httpd_resp_send(req, (const char *)a->start, (ssize_t)len); +} + static const char *status_line(int code) { switch (code) { @@ -102,6 +180,195 @@ static bool stream_sink(void *user, const char *data, size_t len) return httpd_resp_send_chunk(req, data, (ssize_t)len) == ESP_OK; } +/* --- the telemetry stream (SWR-WEB-05) ---------------------------------- + * + * SWR-WEB-05 asks for live values at least once a second by push rather than + * by polling, and the browser already asks for it: app.js opens an + * EventSource on /api/events and listens for a `telemetry` event. Until now + * nothing answered, so the interface fell back on nothing and the dashboard + * went stale after four seconds exactly as SWR-WEB-25 says it should. + * + * --------------------------------------------------------------------------- + * WHY A TASK, AND NOT A LOOP IN THE HANDLER + * --------------------------------------------------------------------------- + * esp_http_server runs one task and serialises handlers on it. A handler that + * sat in a loop pushing a frame a second would therefore hold that task for as + * long as the browser tab stayed open, and the other three sockets of + * SWR-WEB-21 would never be served again: the kiln would appear to hang the + * moment somebody left a dashboard open and then tried to load the log. + * + * So the handler does not stream. It hands the socket to the server's async + * machinery (httpd_req_async_handler_begin), parks the request in the table + * below, and returns, which frees the server task immediately. One low + * priority task then pushes to every parked subscriber once a second. + * + * Bounded at two subscribers of the four sockets SWR-WEB-21 allows. A stream + * holds its socket indefinitely, so letting every socket become a stream would + * leave nothing for the page itself, the log or an update check; two is a + * dashboard on a phone and one on a laptop, which is the case this is for. + * A third is refused with 503 and a Retry-After rather than queued, because a + * client that is told to come back does, and one left hanging does not. + */ +#define SSE_MAX_SUBS 2 +#define SSE_PERIOD_MS 1000 +#define SSE_BUF_BYTES 2048 +/* A comment frame every fifteen pushes, which is what keeps an idle proxy or a + * phone's radio from dropping a stream that is working. */ +#define SSE_PING_EVERY 15 + +typedef struct { + httpd_req_t *req; /* the async copy; NULL when the slot is free */ + uint32_t pushes; +} sse_sub_t; + +static sse_sub_t s_subs[SSE_MAX_SUBS]; +static SemaphoreHandle_t s_subs_lock; +static TaskHandle_t s_sse_task; +static char s_sse_buf[SSE_BUF_BYTES]; + +/* Drop a subscriber and give its socket back to the server. Called with the + * lock held, from the pushing task only: the handler never closes a request it + * has already parked, because the task may be writing to it. */ +static void sse_release(sse_sub_t *s) +{ + if (s->req != NULL) { + (void)httpd_req_async_handler_complete(s->req); + s->req = NULL; + } + s->pushes = 0; +} + +/* One frame to one subscriber. Returns false when the socket is gone, which + * is the only way a stream ends: the client closes the tab, the radio drops, + * or the page is reloaded, and none of those is an error worth logging at + * anything above debug. */ +static bool sse_push(sse_sub_t *s, const char *frame, size_t len) +{ + const esp_err_t e = httpd_resp_send_chunk(s->req, frame, (ssize_t)len); + return e == ESP_OK; +} + +static void sse_task(void *arg) +{ + (void)arg; + for (;;) { + vTaskDelay(pdMS_TO_TICKS(SSE_PERIOD_MS)); + + if (xSemaphoreTake(s_subs_lock, pdMS_TO_TICKS(100)) != pdTRUE) { + continue; + } + + bool any = false; + for (int i = 0; i < SSE_MAX_SUBS; i++) { + if (s_subs[i].req != NULL) { any = true; break; } + } + if (!any) { + (void)xSemaphoreGive(s_subs_lock); + continue; + } + + /* Serialised once for every subscriber: the payload is identical, and + * building it per socket would be the same work twice on the processor + * that is also running a kiln. */ + char payload[1024]; + const size_t plen = kiln_api_telemetry_event(s_http.api, payload, + sizeof(payload)); + size_t flen = 0; + if (plen > 0u) { + flen = kiln_api_sse_frame(s_sse_buf, sizeof(s_sse_buf), "telemetry", + payload, plen); + } + if (flen == 0u) { + /* Nothing to say, or it did not fit. A half frame would + * desynchronise the stream for good, so the second is skipped as + * silently as the first and SWR-WEB-25 shows the dashboard going + * stale, which is the truth. */ + ESP_LOGW(TAG, "sse: no frame this second (payload %u bytes)", + (unsigned)plen); + (void)xSemaphoreGive(s_subs_lock); + continue; + } + + for (int i = 0; i < SSE_MAX_SUBS; i++) { + sse_sub_t *s = &s_subs[i]; + if (s->req == NULL) { + continue; + } + if (!sse_push(s, s_sse_buf, flen)) { + ESP_LOGD(TAG, "sse: subscriber %d gone after %u pushes", + i, (unsigned)s->pushes); + sse_release(s); + continue; + } + s->pushes++; + if ((s->pushes % SSE_PING_EVERY) == 0u) { + char ping[32]; + const size_t n = kiln_api_sse_comment(ping, sizeof(ping), "ping"); + if ((n > 0u) && !sse_push(s, ping, n)) { + sse_release(s); + } + } + } + (void)xSemaphoreGive(s_subs_lock); + } +} + +/* Park this request as a subscriber. Returns false when there is no room, in + * which case the caller answers rather than this. */ +static bool sse_subscribe(httpd_req_t *req) +{ + if ((s_subs_lock == NULL) || (s_sse_task == NULL)) { + return false; + } + httpd_req_t *copy = NULL; + if (httpd_req_async_handler_begin(req, ©) != ESP_OK) { + return false; + } + + bool parked = false; + if (xSemaphoreTake(s_subs_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + for (int i = 0; i < SSE_MAX_SUBS; i++) { + if (s_subs[i].req == NULL) { + s_subs[i].req = copy; + s_subs[i].pushes = 0; + parked = true; + break; + } + } + (void)xSemaphoreGive(s_subs_lock); + } + if (!parked) { + (void)httpd_req_async_handler_complete(copy); + } + return parked; +} + +static esp_err_t handle_events(httpd_req_t *req) +{ + (void)httpd_resp_set_type(req, "text/event-stream"); + /* No caching and no buffering anywhere in between: a cached event stream is + * a dashboard showing a firing that finished yesterday. */ + (void)httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + (void)httpd_resp_set_hdr(req, "X-Accel-Buffering", "no"); + (void)httpd_resp_set_hdr(req, "X-Content-Type-Options", "nosniff"); + + if (!sse_subscribe(req)) { + /* Full. Said plainly, with how long to wait: app.js reconnects with + * backoff on any error, so a 503 costs the client a second and a + * silent drop costs it the same with no explanation in the log. */ + (void)httpd_resp_set_status(req, status_line(503)); + (void)httpd_resp_set_type(req, "application/json"); + (void)httpd_resp_set_hdr(req, "Retry-After", "5"); + return httpd_resp_sendstr(req, + "{\"error\":{\"code\":\"too_many_streams\"," + "\"message\":\"the device streams to two clients at a time\"}}"); + } + + /* Parked. The first frame comes from the pushing task within the second, + * and returning here releases the server task for everybody else. */ + return ESP_OK; +} + /* --- the single handler ------------------------------------------------- */ static esp_err_t handle_any(httpd_req_t *req) @@ -123,6 +390,16 @@ static esp_err_t handle_any(httpd_req_t *req) memcpy(uri, req->uri, uri_len + 1u); const char *query = split_query(uri); + /* The interface before the API, and only on an exact match: every path + * that is not one of the four assets falls through to the route table, + * which answers the API routes and produces the 404 for anything else. */ + if (strncmp(uri, "/api/", 5) != 0) { + const asset_t *a = find_asset(uri); + if (a != NULL) { + return send_asset(req, a); + } + } + kiln_api_req_t ar = {}; ar.method = to_kiln_method((int)req->method); ar.path = uri; @@ -140,6 +417,12 @@ static esp_err_t handle_any(httpd_req_t *req) resp.body = h->buf; resp.body_cap = sizeof(h->buf); + /* The telemetry stream, which does not answer here at all: it parks the + * socket and returns, so this task is free for the next request. */ + if ((strcmp(uri, "/api/events") == 0) && (ar.method == KILN_HTTP_GET)) { + return handle_events(req); + } + /* The log is the one route that streams: a 24 h run is far larger than any * buffer worth having, so it is pushed in chunks rather than built. */ if (strncmp(uri, "/api/log", 8) == 0 && ar.method == KILN_HTTP_GET) { @@ -192,9 +475,12 @@ kiln_err_t kiln_httpd_start(kiln_api_ctx_t *api) s_http.api = api; httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); - /* SWR-WEB-21: four concurrent clients, which is what the requirement asks - * for and what architecture 13.4's RAM budget was written against. */ - cfg.max_open_sockets = 4; + /* SWR-WEB-21 asks for four concurrent clients. Six sockets, because a + * client with the dashboard open holds one of them parked on the telemetry + * stream and still needs another to fetch the log or the config: four with + * two of them streaming would be the requirement met on paper and not in a + * workshop. Architecture 13.4's RAM budget carries the extra two. */ + cfg.max_open_sockets = 6; cfg.lru_purge_enable = true; cfg.uri_match_fn = httpd_uri_match_wildcard; cfg.max_uri_handlers = 2; @@ -209,6 +495,22 @@ kiln_err_t kiln_httpd_start(kiln_api_ctx_t *api) return KILN_ERR_IO; } + /* SWR-WEB-05's pushing task. Priority 3, below the server's 4 and well + * below control and safety: a frame that is late is a dashboard that + * updates in 1.1 s, and SWR-NFR-02 does not negotiate. */ + if (s_subs_lock == NULL) { + s_subs_lock = xSemaphoreCreateMutex(); + } + if ((s_subs_lock != NULL) && (s_sse_task == NULL)) { + if (xTaskCreatePinnedToCore(sse_task, "kiln_sse", 3072, NULL, 3, + &s_sse_task, 0) != pdPASS) { + s_sse_task = NULL; + /* The interface still works: app.js falls back to showing the + * dashboard going stale, which is SWR-WEB-25 and is honest. */ + ESP_LOGE(TAG, "sse task would not start; /api/events will answer 503"); + } + } + /* SWR-WEB-26: GET and nothing else is ever registered. A POST does not * reach a handler at all; the server answers 405 on its own, and the * error handler below rewrites that into the API's own read_only body so @@ -222,11 +524,29 @@ kiln_err_t kiln_httpd_start(kiln_api_ctx_t *api) ESP_LOGI(TAG, "listening on port %u, GET only (SWR-WEB-26)", (unsigned)cfg.server_port); + ESP_LOGI(TAG, "interface: %u bytes gzipped across %u assets", + (unsigned)((binary_index_html_gz_end - binary_index_html_gz_start) + + (binary_app_css_gz_end - binary_app_css_gz_start) + + (binary_app_js_gz_end - binary_app_js_gz_start) + + (binary_chart_js_gz_end - binary_chart_js_gz_start)), + (unsigned)(sizeof(k_assets) / sizeof(k_assets[0]))); return KILN_OK; } void kiln_httpd_stop(void) { + /* The task goes first, and its subscribers with it: stopping the server + * under a task that is writing to one of its sockets is a use-after-free + * with a kiln attached. */ + if (s_sse_task != nullptr) { + vTaskDelete(s_sse_task); + s_sse_task = nullptr; + } + if (s_subs_lock != nullptr) { + for (int i = 0; i < SSE_MAX_SUBS; i++) { + sse_release(&s_subs[i]); + } + } if (s_http.server != nullptr) { (void)httpd_stop(s_http.server); s_http.server = nullptr; diff --git a/firmware/controller/dependencies.lock b/firmware/controller/dependencies.lock new file mode 100644 index 0000000..56f1d22 --- /dev/null +++ b/firmware/controller/dependencies.lock @@ -0,0 +1,20 @@ +dependencies: + espressif/mdns: + component_hash: b5b30022c46302eab89c1d2e3b6fb5918a627563a7462f0ee284b22b9a934967 + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.14.0 + idf: + source: + type: idf + version: 6.0.1 +direct_dependencies: +- espressif/mdns +manifest_hash: 3508f391dcb73e00fc839bb5085e60138fbb3972631b238d446d4efed1901376 +target: esp32s3 +version: 3.0.0 diff --git a/firmware/controller/host/kiln_hal_host/include/kiln_hal_host/hal_host.h b/firmware/controller/host/kiln_hal_host/include/kiln_hal_host/hal_host.h index ba5849a..bc207a4 100644 --- a/firmware/controller/host/kiln_hal_host/include/kiln_hal_host/hal_host.h +++ b/firmware/controller/host/kiln_hal_host/include/kiln_hal_host/hal_host.h @@ -31,6 +31,9 @@ constexpr uint32_t KILN_HOST_FLASH_MAX_BYTES = 2u * 1024u * 1024u; +/* No sector selected, for the offset-addressed fault injection below. */ +constexpr uint32_t KILN_HOST_NO_OFFSET = 0xFFFFFFFFu; + typedef struct { uint8_t *data; /* caller-provided, so the size is the test's choice */ uint32_t size_bytes; @@ -41,6 +44,9 @@ typedef struct { uint32_t writes; uint32_t reads; uint32_t bytes_written; + /* Mount streams every copy through the CRC, so what a mount costs is this + * number and not the read count (tasklist Q2). */ + uint32_t bytes_read; /* Fault injection. */ uint32_t fail_write_after; /* 0 = never: writes fail once this many have succeeded */ @@ -51,6 +57,22 @@ typedef struct { * That is exactly a torn record: some bytes landed, the rest did not. */ uint32_t cut_power_at_write; uint32_t cut_bytes; + + /* A worn sector: the write returns KILN_OK and the medium keeps zeros + * instead of the data. The failure mode that no error code reports, and + * the one fileslots' write verification exists for. `garble_write_at` is + * the 1-based write to spoil (0 = none); `garble_every_write` spoils all of + * them, which is a part with nowhere left to write. */ + uint32_t garble_write_at; + bool garble_every_write; + + /* Refuse to erase the sector at this offset, for as long as it is set. + * By offset rather than by a count, because what a wear test needs to say + * is WHICH region is finished, not how many erases have gone by: counting + * them means knowing the order fileslots happens to erase in, and a test + * written against that measures the implementation instead of the medium. + * KILN_HOST_NO_OFFSET means no such sector, and is what init leaves. */ + uint32_t fail_erase_off; } kiln_host_flash_t; /* `storage` must be at least size_bytes and is set to 0xFF (erased). */ diff --git a/firmware/controller/host/kiln_hal_host/src/hal_host.cpp b/firmware/controller/host/kiln_hal_host/src/hal_host.cpp index d43be64..5530844 100644 --- a/firmware/controller/host/kiln_hal_host/src/hal_host.cpp +++ b/firmware/controller/host/kiln_hal_host/src/hal_host.cpp @@ -19,6 +19,9 @@ void kiln_host_flash_init(kiln_host_flash_t *f, uint8_t *storage, f->size_bytes = size_bytes; f->sector_bytes = sector_bytes; f->powered = true; + /* Not zero: zero is the first sector's offset, and a fault injector that is + * armed by default would be found the hard way. */ + f->fail_erase_off = KILN_HOST_NO_OFFSET; memset(storage, 0xFF, size_bytes); } @@ -62,6 +65,7 @@ kiln_err_t hf_read(void *ctx, uint32_t off, void *out, size_t len) memcpy(out, &f->data[off], len); f->reads++; + f->bytes_read += (uint32_t)len; return KILN_OK; } @@ -107,8 +111,15 @@ kiln_err_t hf_write(void *ctx, uint32_t off, const void *data, size_t len) return KILN_ERR_IO; } + /* Wear, as NOR actually presents it: the write is accepted, the status says + * success, and the cell holds something else. Modelled as stuck-at-zero + * because that is the direction a programmed cell fails in and the only one + * a write could produce. Reported as KILN_OK deliberately -- a store that + * only notices the errors it is told about does not notice this one. */ + const bool garble = f->garble_every_write || + ((f->garble_write_at != 0u) && (f->writes == f->garble_write_at)); for (size_t i = 0; i < len; i++) { - f->data[off + i] &= src[i]; + f->data[off + i] &= garble ? 0x00u : src[i]; } f->bytes_written += (uint32_t)len; return KILN_OK; @@ -137,6 +148,13 @@ kiln_err_t hf_erase(void *ctx, uint32_t off, size_t len) if ((f->fail_erase_after != 0u) && f->erases + sectors > f->fail_erase_after) { return KILN_ERR_IO; } + /* A sector that will not erase, which is the other half of how NOR ends: + * the whole call fails, including a multi-sector erase that covers it, and + * nothing in the range is touched. */ + if ((f->fail_erase_off != KILN_HOST_NO_OFFSET) && + (f->fail_erase_off >= off) && (f->fail_erase_off < off + len)) { + return KILN_ERR_IO; + } memset(&f->data[off], 0xFF, len); f->erases += sectors; @@ -397,42 +415,6 @@ kiln_err_t fs_remove(void *ctx, const char *path) return KILN_OK; } -kiln_err_t fs_exists(void *ctx, const char *path) -{ - kiln_host_fs_t *fs = static_cast(ctx); - if ((fs == nullptr) || (path == nullptr)) { - return KILN_ERR_INVALID_ARG; - } - return (fs_find(fs, path) != nullptr) ? KILN_OK : KILN_ERR_NOT_FOUND; -} - -kiln_err_t fs_list(void *ctx, const char *dir, - bool (*fn)(void *user, const char *name, size_t size), - void *user) -{ - kiln_host_fs_t *fs = static_cast(ctx); - if ((fs == nullptr) || (dir == nullptr) || (fn == nullptr)) { - return KILN_ERR_INVALID_ARG; - } - if (!fs->powered) { - return KILN_ERR_IO; - } - - const size_t dlen = strlen(dir); - for (size_t i = 0; i < KILN_HOST_FS_FILES; i++) { - if (!fs->files[i].used) { - continue; - } - if (strncmp(fs->files[i].path, dir, dlen) != 0) { - continue; - } - if (!fn(user, fs->files[i].path, fs->files[i].len)) { - break; - } - } - return KILN_OK; -} - kiln_err_t fs_usage(void *ctx, size_t *total, size_t *used) { const kiln_host_fs_t *fs = static_cast(ctx); @@ -466,8 +448,6 @@ void kiln_host_fs_bind(kiln_host_fs_t *fs, kiln_port_filestore_t *out) out->read = fs_read; out->write_atomic = fs_write_atomic; out->remove = fs_remove; - out->exists = fs_exists; - out->list = fs_list; out->usage = fs_usage; } diff --git a/firmware/controller/host/webhost/webhost.cpp b/firmware/controller/host/webhost/webhost.cpp index 415a74c..7647dc8 100644 --- a/firmware/controller/host/webhost/webhost.cpp +++ b/firmware/controller/host/webhost/webhost.cpp @@ -39,7 +39,6 @@ #include #include "kiln_app/app.h" -#include "kiln_app/program_store.h" #include "kiln_app/run_index.h" #include "kiln_core/faults.h" #include "kiln_core/logring.h" @@ -132,9 +131,12 @@ void device_init(void) } (void)kiln_app_boot(&g_app, KILN_RESET_POWER_ON, -1.0f); - /* The built-in examples of SWR-PRG-09, so the Programs view has something in - * it. Idempotent by design, and the device does this on every boot too. */ - (void)kiln_program_store_seed(&g_fs_port); + /* Nothing to seed: SWR-PRG-09's examples are compiled into the firmware and + * the Programs view enumerates them from there, which is also what the + * device does. + + * The file store below is still bound, because the run records are real + * files and /api/runs reads them. */ g_api.app = &g_app; g_api.filestore = &g_fs_port; @@ -209,6 +211,17 @@ bool send_all(int fd, const char *data, size_t len) bool send_str(int fd, const char *s) { return send_all(fd, s, strlen(s)); } +/* The two lengths that are not a length. CHUNKED is the log stream, which is + * framed by chunk_write below; UNFRAMED is the event stream, which is framed by + * SSE itself and ends when the connection closes. + * + * They were one value, -1, and the event stream therefore announced + * Transfer-Encoding: chunked and then sent raw SSE frames -- a response whose + * body does not match its own framing, which a browser is entitled to reject + * outright and which this harness got away with only because nothing checked. */ +constexpr long BODY_CHUNKED = -1; +constexpr long BODY_UNFRAMED = -2; + void send_headers(int fd, int status, const char *content_type, long content_length, const char *extra) { @@ -233,9 +246,11 @@ void send_headers(int fd, int status, const char *content_type, if (content_length >= 0) { n += snprintf(h + n, sizeof(h) - (size_t)n, "Content-Length: %ld\r\n", content_length); - } else { + } else if (content_length == BODY_CHUNKED) { n += snprintf(h + n, sizeof(h) - (size_t)n, "Transfer-Encoding: chunked\r\n"); } + /* BODY_UNFRAMED: neither header. With Connection: close above, the body + * runs until the socket does, which is what an event stream is. */ if (extra != nullptr) { n += snprintf(h + n, sizeof(h) - (size_t)n, "%s", extra); } @@ -379,7 +394,7 @@ void handle_request(client_t *c) /* SWR-WEB-05: the SSE stream. */ if (strcmp(target, "/api/events") == 0 && m == KILN_HTTP_GET) { - send_headers(c->fd, 200, "text/event-stream", -1, + send_headers(c->fd, 200, "text/event-stream", BODY_UNFRAMED, "X-Accel-Buffering: no\r\n"); c->sse = true; return; @@ -415,7 +430,7 @@ void handle_request(client_t *c) * envelope. The device's transport can do better because * esp_http_server lets it set the status after the handler returns; * this is a harness, and the API suite covers the status codes. */ - send_headers(c->fd, 200, "application/json", -1, NULL); + send_headers(c->fd, 200, "application/json", BODY_CHUNKED, NULL); (void)kiln_api_log_stream(&g_api, &req, chunk_write, &sink, &meta); if (sink.ok && meta.streaming) { (void)send_str(c->fd, "0\r\n\r\n"); @@ -449,9 +464,13 @@ void push_sse(void) return; } + /* The same framing the device uses, rather than this harness's own + * snprintf: two implementations of one wire format is how a harness comes + * to pass while the firmware does not, and this one was the implementation + * the browser had actually been tested against. */ char frame[8600]; - const int fn = snprintf(frame, sizeof(frame), "event: telemetry\ndata: %s\n\n", ev); - if (fn <= 0) { + const size_t fn = kiln_api_sse_frame(frame, sizeof(frame), "telemetry", ev, n); + if (fn == 0u) { return; } @@ -459,7 +478,7 @@ void push_sse(void) if (!g_clients[i].in_use || !g_clients[i].sse) { continue; } - if (!send_all(g_clients[i].fd, frame, (size_t)fn)) { + if (!send_all(g_clients[i].fd, frame, fn)) { close(g_clients[i].fd); g_clients[i].in_use = false; } diff --git a/firmware/controller/main/main.cpp b/firmware/controller/main/main.cpp index 9123972..5711b05 100644 --- a/firmware/controller/main/main.cpp +++ b/firmware/controller/main/main.cpp @@ -456,12 +456,23 @@ void hmi_apply(const kiln_hmi_action_t *a) kiln_hal_heat_rearm(); /* SWR-SAF-18: let the output arm again */ } break; - case KILN_HMI_ACT_START: - /* No program store on hardware yet (tasklist E7), so there is nothing - * to start. The HMI already refuses to offer an empty list; this is - * the belt to that braces. */ - e = KILN_ERR_NOT_FOUND; + case KILN_HMI_ACT_START: { + /* SWR-HMI-10 and SWR-PRG-09. This used to return NOT_FOUND with a + * note that there was no program store on hardware, which made the + * menu's first entry dead on the device that ships: an operator could + * select a program and press, and nothing happened. + * + * With the profiles compiled into the image there is nothing to look + * up and nothing that can fail to mount, so the local input can start + * a firing on its own, which is what SWR-HMI-10 asked for and what a + * kiln with no network needs. */ + kiln_program_t prog; + e = kiln_profile_example(a->program_index, &prog); + if (e == KILN_OK) { + e = kiln_app_start(&s_app, &prog); + } break; + } case KILN_HMI_ACT_WIFI_SCAN: /* SWR-NET-11. The results arrive asynchronously; the view builder @@ -525,7 +536,13 @@ void hmi_build_view(kiln_hmi_view_t *v) if (s_system.fw_info != NULL) { (void)s_system.fw_info(s_system.ctx, &fw); } - (void)snprintf(v->version, sizeof(v->version), "%s", fw.version); + /* An explicit bound rather than letting snprintf cut: kiln_fw_info_t + * carries 32 characters and the view has 24, so a long version string + * loses its build metadata here. Stating the width says that is the + * design; "%s" into a shorter buffer reads as an accident, and GCC says + * so (-Wformat-truncation). */ + (void)snprintf(v->version, sizeof(v->version), "%.*s", + (int)(sizeof(v->version) - 1u), fw.version); v->uptime_s = (uint32_t)(esp_timer_get_time() / 1000000); /* SWR-HMI-07: where the web interface is, which is the question an operator @@ -540,12 +557,31 @@ void hmi_build_view(kiln_hmi_view_t *v) (void)snprintf(v->hostname, sizeof(v->hostname), "%s", ns.hostname); (void)snprintf(v->ip, sizeof(v->ip), "%s", (ns.ip[0] != '\0') ? ns.ip : "no address"); + /* SWR-NET-12: why the last attempt did not take, if it did not. */ + v->net_last_reason = ns.last_reason; } else { v->net_up = false; (void)snprintf(v->hostname, sizeof(v->hostname), "%s", "wifi down"); (void)snprintf(v->ip, sizeof(v->ip), "%s", "-"); } + /* SWR-HMI-10: the programs the menu offers. Read from the image, which is + * also why there are any: the list was never filled on hardware, so the + * programs screen said "none stored" on a device carrying three firing + * profiles in its own flash. */ + const uint8_t progs = kiln_profile_example_count(); + v->program_count = (progs < KILN_HMI_MAX_PROGRAMS) ? progs + : (uint8_t)KILN_HMI_MAX_PROGRAMS; + for (uint8_t i = 0; i < v->program_count; i++) { + kiln_program_t prog; + if (kiln_profile_example(i, &prog) != KILN_OK) { + v->program_name[i][0] = '\0'; + continue; + } + (void)snprintf(v->program_name[i], KILN_HMI_NAME_LEN, "%.*s", + (int)(KILN_HMI_NAME_LEN - 1u), prog.name); + } + /* SWR-NET-11: collect the scan once, when the radio says it has finished. */ if (s_net_port.scan_busy != nullptr) { v->net_scanning = s_net_port.scan_busy(s_net_port.ctx); @@ -557,7 +593,12 @@ void hmi_build_view(kiln_hmi_view_t *v) } v->net_count = s_scan_count; for (uint8_t i = 0; i < s_scan_count && i < KILN_HMI_MAX_NETWORKS; i++) { - (void)snprintf(v->net_list_ssid[i], KILN_HMI_SSID_LEN, "%s", s_scan[i].ssid); + /* Bounded explicitly for the same reason, and here the compiler is + * making a sharper point: it cannot prove the driver NUL-terminated + * the SSID inside its own field, so without a width the copy could + * run on into the rest of the record. */ + (void)snprintf(v->net_list_ssid[i], KILN_HMI_SSID_LEN, "%.*s", + (int)(KILN_HMI_SSID_LEN - 1u), s_scan[i].ssid); v->net_list_rssi[i] = s_scan[i].rssi; v->net_list_secured[i] = s_scan[i].secured; } @@ -682,9 +723,19 @@ extern "C" void app_main(void) if (fe == KILN_OK) { kiln_fileslots_bind(&s_fileslots, &s_fs); ports.filestore = &s_fs; + const unsigned retired = + (unsigned)kiln_fileslots_retired_regions(&s_fileslots); ESP_LOGI(TAG, "file store: %u of %u regions used", (unsigned)kiln_fileslots_used_regions(&s_fileslots), (unsigned)s_fileslots.region_count); + if (retired > 0u) { + /* A region retires when a write did not read back as written, + * which is how NOR wears out. The store carries on in another + * region, so nothing above this notices; this line is the only + * warning anybody gets that the part is on its way out. */ + ESP_LOGW(TAG, "file store: %u region(s) retired after a failed " + "write; the flash is wearing out", retired); + } } else { /* As with the log: a kiln that cannot store programs is still a * kiln that can be watched, so this warns rather than halting. */ diff --git a/firmware/controller/test/host/CMakeLists.txt b/firmware/controller/test/host/CMakeLists.txt index 662c2d2..3b0fd8d 100644 --- a/firmware/controller/test/host/CMakeLists.txt +++ b/firmware/controller/test/host/CMakeLists.txt @@ -72,6 +72,7 @@ function(kiln_add_test name) add_test(NAME ${name} COMMAND ${name}) endfunction() +kiln_add_test(test_alarmptn unit/test_alarmptn.cpp) kiln_add_test(test_pid unit/test_pid.cpp) kiln_add_test(test_window unit/test_window.cpp) kiln_add_test(test_suplink unit/test_suplink.cpp) diff --git a/firmware/controller/test/host/integration/test_api.cpp b/firmware/controller/test/host/integration/test_api.cpp index fcd8d1c..99c840a 100644 --- a/firmware/controller/test/host/integration/test_api.cpp +++ b/firmware/controller/test/host/integration/test_api.cpp @@ -13,7 +13,6 @@ #include #include #include "kiln_check.h" -#include "kiln_app/program_store.h" #include "kiln_app/run_index.h" #include "kiln_core/logring.h" #include "kiln_core/profile.h" @@ -124,8 +123,10 @@ static void rig_run(rig_t *r, double seconds) */ static void local_start(rig_t *r, uint8_t program_id) { + /* From the image, which is where the programs are and where the local HMI + * reads them from too. */ kiln_program_t prog; - CHECK_OK(kiln_program_store_get_slot(&r->fs_port, program_id, &prog)); + CHECK_OK(kiln_profile_example(program_id, &prog)); CHECK_OK(kiln_app_start(&r->app, &prog)); } @@ -1420,3 +1421,153 @@ KILN_TEST(the_api_validates_its_own_arguments) .body_len = 0, .authenticated = true}; CHECK_ERR(kiln_api_handle(&r.api, &nopath, &resp), KILN_ERR_INVALID_ARG); } + +/* =========================================================================== + * THE EVENT STREAM'S FRAMING (SWR-WEB-05, tasklist P2) + * =========================================================================== + * The payload has been tested since it was written; what was missing was + * anything to send it over. The transport cannot be host-tested -- it is + * esp_http_server sockets and a FreeRTOS task -- so the part of SSE that can + * be got wrong lives here instead: the framing. A frame that is half written, + * or that carries a newline into a data field, does not fail visibly. It + * desynchronises the stream for every frame after it, on a browser in a + * workshop, where nobody is reading a log. + */ + +/* + * @relation(SWR-WEB-05, scope=function) + */ +KILN_TEST(swrweb05_a_frame_is_what_the_browser_is_listening_for) +{ + /* app.js opens an EventSource on /api/events and listens for a named + * `telemetry` event, so the frame has to carry the name and end with the + * blank line that completes it. */ + char buf[128]; + const char *data = "{\"kiln_c\":523.5}"; + const size_t n = kiln_api_sse_frame(buf, sizeof(buf), "telemetry", + data, strlen(data)); + CHECK(n > 0); + CHECK_STR_EQ(buf, "event: telemetry\ndata: {\"kiln_c\":523.5}\n\n"); + CHECK_EQ_UINT(n, strlen(buf)); + + /* Without a name it is still a frame, which is what a client using + * onmessage rather than addEventListener receives. */ + const size_t m = kiln_api_sse_frame(buf, sizeof(buf), nullptr, data, strlen(data)); + CHECK_STR_EQ(buf, "data: {\"kiln_c\":523.5}\n\n"); + CHECK_EQ_UINT(m, strlen(buf)); +} + +/* + * @relation(SWR-WEB-05, scope=function) + */ +KILN_TEST(swrweb05_a_frame_that_does_not_fit_is_not_written_at_all) +{ + /* Half an SSE frame is worse than no frame: the client reads the next + * frame's `event:` line as part of this one's data and never recovers, + * because there is no resynchronisation in the protocol other than a blank + * line that has already been consumed. */ + char buf[32]; + memset(buf, 'x', sizeof(buf)); + const char *data = "{\"this\":\"does not fit in thirty-two bytes at all\"}"; + CHECK_EQ_UINT(kiln_api_sse_frame(buf, sizeof(buf), "telemetry", + data, strlen(data)), 0u); + /* Nothing written: not a prefix, not a terminator. */ + for (size_t i = 0; i < sizeof(buf); i++) { CHECK_EQ_INT(buf[i], 'x'); } + + /* Exactly one byte short still writes nothing. */ + const char *small = "{\"a\":1}"; + const size_t need = strlen("data: ") + strlen(small) + 2u + 1u; + char tight[64]; + CHECK_EQ_UINT(kiln_api_sse_frame(tight, need - 1u, nullptr, small, + strlen(small)), 0u); + CHECK_EQ_UINT(kiln_api_sse_frame(tight, need, nullptr, small, + strlen(small)), need - 1u); +} + +/* + * @relation(SWR-WEB-05, scope=function) + */ +KILN_TEST(swrweb05_a_newline_in_the_payload_is_refused_not_passed_on) +{ + /* A newline inside a data field is SSE's own way of writing a multi-line + * payload, so passing one through would move the frame boundary into the + * middle of a JSON document. Nothing this device serialises contains one; + * the check is here because the consequence of being wrong about that is + * silent and remote. */ + char buf[128]; + const char *lf = "{\"a\":1}\n{\"b\":2}"; + CHECK_EQ_UINT(kiln_api_sse_frame(buf, sizeof(buf), "telemetry", lf, strlen(lf)), 0u); + + const char *cr = "{\"a\":1}\r"; + CHECK_EQ_UINT(kiln_api_sse_frame(buf, sizeof(buf), "telemetry", cr, strlen(cr)), 0u); + + /* And the event name is not a way round it either. */ + CHECK_EQ_UINT(kiln_api_sse_comment(buf, sizeof(buf), "two\nlines"), 0u); +} + +/* + * @relation(SWR-WEB-05, scope=function) + */ +KILN_TEST(swrweb05_the_keepalive_is_a_comment_and_carries_no_data) +{ + /* A proxy or a phone's radio drops an idle TCP connection, and a stream + * pushing once a second is only idle because the device stopped. The + * keepalive has to be invisible to the client's event handlers, which in + * SSE means a comment line. */ + char buf[32]; + const size_t n = kiln_api_sse_comment(buf, sizeof(buf), "ping"); + CHECK(n > 0); + CHECK_STR_EQ(buf, ": ping\n\n"); + CHECK_EQ_UINT(n, strlen(buf)); + CHECK_EQ_INT(buf[0], ':'); /* not "data:", so no event fires */ +} + +/* + * @relation(SWR-WEB-05, scope=function) + */ +KILN_TEST(swrweb05_the_real_telemetry_payload_frames_cleanly) +{ + /* The two halves together, against the payload the device actually + * produces rather than a short literal: this is what proves the status + * JSON has no newline in it, which is the assumption the refusal above + * rests on. */ + static rig_t r; + rig_init(&r); + rig_run(&r, 2.0); + + char payload[1024]; + const size_t plen = kiln_api_telemetry_event(&r.api, payload, sizeof(payload)); + CHECK(plen > 0); + + char frame[2048]; + const size_t flen = kiln_api_sse_frame(frame, sizeof(frame), "telemetry", + payload, plen); + CHECK_MSG(flen > 0, "the device's own telemetry does not frame: %u bytes", + (unsigned)plen); + + /* The frame ends the way SSE requires and nowhere else. */ + CHECK_EQ_INT(frame[flen - 1], '\n'); + CHECK_EQ_INT(frame[flen - 2], '\n'); + CHECK_EQ_UINT(flen, strlen(frame)); + + /* And the data line still parses as the status object, so the UI's one + * parser is one parser. */ + const char *p = strstr(frame, "data: "); + CHECK(p != nullptr); + if (p == nullptr) { + /* CHECK records and carries on, so without this the next line + * dereferences the null it just reported and takes the runner with it, + * turning a failed assertion into a crash with no summary. */ + return; + } + p += 6; + kiln_json_tok_t t[RTOKS]; + const int tn = kiln_json_parse(p, strlen(p) - 2u, t, RTOKS); + CHECK(tn > 0); + CHECK(kiln_json_find(p, t, tn, 0, "kiln_c") > 0); + + /* The transport's buffer is 2 kB and the frame has to fit it with the + * event line on top, which is the sizing this test exists to pin. */ + CHECK_MSG(flen < 2048u, "the frame is %u bytes and httpd's SSE buffer is " + "2048", (unsigned)flen); +} diff --git a/firmware/controller/test/host/integration/test_integration.cpp b/firmware/controller/test/host/integration/test_integration.cpp index 542244e..2ddc351 100644 --- a/firmware/controller/test/host/integration/test_integration.cpp +++ b/firmware/controller/test/host/integration/test_integration.cpp @@ -15,6 +15,7 @@ #include "kiln_app/app.h" #include "kiln_core/faults.h" #include "kiln_core/profile.h" +#include "kiln_core/suplink.h" #include "kiln_sim/sim.h" /* --- the harness -------------------------------------------------------- */ @@ -881,3 +882,201 @@ KILN_TEST(a_multi_segment_firing_completes_without_a_spurious_fault) CHECK(r.app.record.peak_c > 750.0f); CHECK(kiln_current_energy_wh(&r.app.cur) > 0.0); } + +/* =========================================================================== + * THE SUPERVISOR LINK, END TO END (SWA-22, tasklist R8) + * =========================================================================== + * The pieces were each tested and the chain was not. test_suplink proves that + * a quiet link and a stale sequence number both become KILN_TC_FAULT_COMMS; + * test_safety_thermal proves that bit becomes KILN_FAULT_TC_COMMS; other tests + * prove a latched fault withholds heat. What R8 actually asks for is the + * property those three add up to, and an assembly of proven parts is not a + * proven assembly: the chamber temperature enters this firmware at exactly one + * place, over a wire, and if silence on that wire does not stop the heater then + * nothing else in the safety argument matters. + * + * So these two run the real app with the real link bound as the chamber port, + * the way main.cpp binds it on the board, and watch the heater. + */ + +namespace { + +/* The chamber port, as the product has it: a decoder fed bytes from a UART. */ +typedef struct { + kiln_suplink_t link; + kiln_port_tc_t tc; +} suplink_rig_t; + +sup_report_t sup_rep(uint8_t seq, float c) +{ + sup_report_t r = {}; + r.version = SUP_VERSION; + r.seq = seq; + r.chamber_dc = (int16_t)lroundf(c * (float)SUP_DC_PER_C); + r.cj_dc = (int16_t)(25 * SUP_DC_PER_C); + r.fault_bits = 0u; + r.flags = (uint8_t)(SUP_FLAG_PERMIT | SUP_FLAG_TC_VALID | SUP_FLAG_SELFTEST_OK); + r.trip_reason = SUP_TRIP_NONE; + return r; +} + +void sup_send(kiln_suplink_t *s, const sup_report_t &r) +{ + uint8_t f[SUP_FRAME_BYTES]; + (void)sup_encode(&r, f, sizeof(f)); + kiln_suplink_feed(s, f, sizeof(f)); +} + +/* One acquisition period's worth of link service, which on the board is the + * UART draining into the decoder and kiln_suplink_tick ageing it. */ +void sup_service(suplink_rig_t *sl, rig_t *r, uint8_t seq, bool speak) +{ + if (speak) { + sup_send(&sl->link, sup_rep(seq, (float)kiln_sim_temperature(&r->sim))); + } + kiln_suplink_tick(&sl->link, (float)ACQUIRE_DT); +} + +} // namespace + +/* + * @relation(SWA-22, scope=function) + */ +KILN_TEST(swa22_silence_on_the_link_takes_the_heat_away) +{ + static rig_t r; + static suplink_rig_t sl; + rig_init(&r, 20.0f); + + memset(&sl, 0, sizeof(sl)); + kiln_suplink_init(&sl.link); + kiln_suplink_bind(&sl.link, &sl.tc); + + /* Rebind the chamber port to the link, which is what the board does: the + * simulated plant still provides the physics, and the temperature reaches + * the core only by way of a frame. */ + kiln_app_ports_t ports = {}; + ports.tc = &sl.tc; + ports.case_tc = &r.ports.case_tc; + ports.heat = &r.ports.heat; + ports.current = &r.ports.current; + ports.counters = &r.ports.counters; + kiln_config_t cfg; + kiln_config_defaults(&cfg); + cfg.kp = 6.0f; cfg.ki = 0.02f; cfg.kd = 30.0f; + cfg.filter_tau_s = 1.0f; + cfg.holdback_band_c = 0.0f; + CHECK_OK(kiln_app_init(&r.app, &ports, &cfg)); + + kiln_program_t prog; + CHECK_OK(kiln_profile_example(0, &prog)); + + /* Let acquisition answer first, which over this port means letting a few + * frames arrive: a start is refused while the chamber has no reading, and + * on this device a reading is a frame. */ + uint8_t seq = 1; + for (int i = 0; i < 200; i++) { + rig_step(&r); + if ((i % 25) == 0) { sup_service(&sl, &r, seq++, true); } + } + CHECK_OK(kiln_app_start(&r.app, &prog)); + + /* A talking link: the kiln heats. Asserted, because a test where the heat + * never came on would pass the interesting half for the wrong reason. */ + for (int i = 0; i < 400; i++) { + rig_step(&r); + if ((i % 25) == 0) { sup_service(&sl, &r, seq++, true); } + } + CHECK_MSG(r.app.heat_authorised, "the kiln never started heating"); + CHECK_EQ_INT(r.app.fault, KILN_FAULT_NONE); + + /* Now the supervisor stops talking: the board is pulled, the wire breaks, + * the other processor hangs. Nothing announces it. */ + bool heat_seen = false; + kiln_fault_t fault = KILN_FAULT_NONE; + for (int i = 0; i < 4000; i++) { + rig_step(&r); + if ((i % 25) == 0) { sup_service(&sl, &r, seq++, false); } + if (r.app.fault != KILN_FAULT_NONE) { fault = r.app.fault; break; } + if (r.app.heat_authorised) { heat_seen = true; } + } + + /* SWR-SAF-04's grace, then a comms fault: not a temperature of zero, not + * the last reading held for ever. */ + CHECK_EQ_INT(fault, KILN_FAULT_TC_COMMS); + CHECK(heat_seen); /* it was heating before */ + CHECK(!r.app.heat_authorised); /* and is not now */ + + /* And the contactor is open, which is the part the kiln cares about. */ + rig_run(&r, 1.0); + CHECK(!kiln_sim_contactor(&r.sim)); +} + +/* + * @relation(SWA-22, scope=function) + */ +KILN_TEST(swa22_a_supervisor_repeating_itself_takes_the_heat_away_too) +{ + /* The failure that looks like health: frames keep arriving, the CRC is + * good, the temperature is plausible, and the sequence number never + * advances -- a supervisor stuck in a loop, or a transmit buffer being + * replayed. A link check that only counted bytes would see a healthy + * channel and let the kiln run on a reading from minutes ago. + */ + static rig_t r; + static suplink_rig_t sl; + rig_init(&r, 20.0f); + + memset(&sl, 0, sizeof(sl)); + kiln_suplink_init(&sl.link); + kiln_suplink_bind(&sl.link, &sl.tc); + + kiln_app_ports_t ports = {}; + ports.tc = &sl.tc; + ports.case_tc = &r.ports.case_tc; + ports.heat = &r.ports.heat; + ports.current = &r.ports.current; + ports.counters = &r.ports.counters; + kiln_config_t cfg; + kiln_config_defaults(&cfg); + cfg.kp = 6.0f; cfg.ki = 0.02f; cfg.kd = 30.0f; + cfg.filter_tau_s = 1.0f; + cfg.holdback_band_c = 0.0f; + CHECK_OK(kiln_app_init(&r.app, &ports, &cfg)); + + kiln_program_t prog; + CHECK_OK(kiln_profile_example(0, &prog)); + uint8_t seq = 1; + for (int i = 0; i < 200; i++) { + rig_step(&r); + if ((i % 25) == 0) { sup_service(&sl, &r, seq++, true); } + } + CHECK_OK(kiln_app_start(&r.app, &prog)); + for (int i = 0; i < 400; i++) { + rig_step(&r); + if ((i % 25) == 0) { sup_service(&sl, &r, seq++, true); } + } + CHECK(r.app.heat_authorised); + + /* Frames keep coming, all of them seq 9, all of them well formed. */ + const uint8_t stuck = 9; + kiln_fault_t fault = KILN_FAULT_NONE; + const uint32_t frames_before = sl.link.frames; + for (int i = 0; i < 4000; i++) { + rig_step(&r); + if ((i % 25) == 0) { + sup_send(&sl.link, sup_rep(stuck, 600.0f)); + kiln_suplink_tick(&sl.link, (float)ACQUIRE_DT); + } + if (r.app.fault != KILN_FAULT_NONE) { fault = r.app.fault; break; } + } + + CHECK_EQ_INT(fault, KILN_FAULT_TC_COMMS); + CHECK(!r.app.heat_authorised); + /* Frames really were arriving throughout: this is not the silence test + * wearing a different hat. */ + CHECK(sl.link.frames > frames_before + 10u); + + rig_run(&r, 1.0); + CHECK(!kiln_sim_contactor(&r.sim)); +} diff --git a/firmware/controller/test/host/integration/test_persistence.cpp b/firmware/controller/test/host/integration/test_persistence.cpp index 13ca417..1bbbf95 100644 --- a/firmware/controller/test/host/integration/test_persistence.cpp +++ b/firmware/controller/test/host/integration/test_persistence.cpp @@ -13,7 +13,6 @@ #include #include "kiln_check.h" #include "kiln_app/app.h" -#include "kiln_app/program_store.h" #include "kiln_app/run_index.h" #include "kiln_app/settings.h" #include "kiln_core/faults.h" @@ -326,18 +325,37 @@ KILN_TEST(swrcfg05_configuration_survives_a_reboot) /* * @relation(SWR-PRG-09, scope=function) */ -KILN_TEST(swrprg09_the_examples_are_present_after_a_first_boot) +KILN_TEST(swrprg09_the_examples_need_no_first_boot_at_all) { + /* This used to check that the first boot seeded the examples onto the + * medium. There is no seeding: they are in the image, so they are present + * on a device whose flash has never been written, whose flash will not + * mount, and immediately rather than after whatever the boot sequence gets + * round to. A first boot is no longer a thing that can go wrong here. + * + * Checked after a boot anyway, because what the test is really about is + * that an operator has programs to start, and the boot is how a device + * arrives at that state. */ static medium_t m; static boot_t b; medium_init(&m); CHECK_OK(boot(&b, &m, KILN_RESET_POWER_ON, -1.0f, 20.0f)); - CHECK_EQ_UINT(kiln_program_store_count(&b.fs_port), kiln_profile_example_count()); + const uint8_t n = kiln_profile_example_count(); + CHECK(n > 0u); - kiln_program_t p; - CHECK_OK(kiln_program_store_load(&b.fs_port, "Glaze cone 6", &p)); - CHECK(p.flags & KILN_PROG_FLAG_READONLY); + bool found_glaze = false; + for (uint8_t i = 0; i < n; i++) { + kiln_program_t p; + CHECK_OK(kiln_profile_example(i, &p)); + CHECK(p.flags & KILN_PROG_FLAG_READONLY); + if (strcmp(p.name, "Glaze cone 6") == 0) { found_glaze = true; } + } + CHECK(found_glaze); + + /* And the medium is untouched by any of it: a first boot no longer writes + * a copy of what the firmware already contains. */ + CHECK_EQ_UINT(m.fs.writes, 0u); } /* --- SWR-SAF-17 across a reboot ---------------------------------------------- */ diff --git a/firmware/controller/test/host/integration/test_stores_on_flash.cpp b/firmware/controller/test/host/integration/test_stores_on_flash.cpp index 18953c1..bd53dfc 100644 --- a/firmware/controller/test/host/integration/test_stores_on_flash.cpp +++ b/firmware/controller/test/host/integration/test_stores_on_flash.cpp @@ -12,7 +12,6 @@ #include #include "kiln_check.h" -#include "kiln_app/program_store.h" #include "kiln_app/run_index.h" #include "kiln_core/fileslots.h" #include "kiln_core/profile.h" @@ -56,72 +55,15 @@ static kiln_program_t named(const char *name, uint16_t top_c) return p; } -/* - * @relation(SWA-10, scope=function) - */ -KILN_TEST(swa10_the_seeded_examples_survive_a_reboot_on_flash) -{ - rig_t *r = &g_rig; - power_up_fresh(r); - - /* This is the call that produced nothing on a real board before the store - * existed, which is why the display offered an empty list (M1). */ - CHECK_OK(kiln_program_store_seed(&r->store)); - const uint8_t seeded = kiln_program_store_count(&r->store); - CHECK(seeded > 0u); - - boot(r); /* reboot */ - CHECK_EQ_UINT(kiln_program_store_count(&r->store), seeded); - - /* Seeding is idempotent (SWR-PRG-09), including across the reboot. */ - CHECK_OK(kiln_program_store_seed(&r->store)); - CHECK_EQ_UINT(kiln_program_store_count(&r->store), seeded); -} - -/* - * @relation(SWR-PRG-04, scope=function) - */ -KILN_TEST(swr_prg_04_a_saved_program_reloads_byte_for_byte_after_a_reboot) -{ - rig_t *r = &g_rig; - power_up_fresh(r); - - const kiln_program_t p = named("bisque", 1000); - CHECK_OK(kiln_program_store_save(&r->store, &p, 1300.0f)); - - boot(r); - - kiln_program_t back = {}; - CHECK_OK(kiln_program_store_load(&r->store, "bisque", &back)); - CHECK_STR_EQ(back.name, "bisque"); - CHECK(memcmp(&back, &p, sizeof(p)) == 0); -} - -/* - * @relation(SWR-PRG-04, scope=function) +/* The three tests that stood here saved programs to this flash, reloaded them + * byte for byte after a reboot, and filled twenty slots before refusing the + * twenty-first. They went with the program store: the profiles are compiled + * into the image now, so a program cannot be lost by a medium it was never on. + * + * What the partition still holds is run records, and every property they + * relied on -- atomicity across a power cut, the ring, the identifier + * sequence, surviving a reboot -- is exercised below over the same flash fake. */ -KILN_TEST(swr_prg_04_the_store_fills_to_its_slot_count_and_then_refuses) -{ - rig_t *r = &g_rig; - power_up_fresh(r); - - char name[KILN_PROGRAM_NAME_LEN]; - unsigned saved = 0; - for (unsigned i = 0; i < KILN_PROGRAM_SLOTS + 4u; i++) { - (void)snprintf(name, sizeof(name), "p%02u", i); - const kiln_program_t p = named(name, 900); - if (kiln_program_store_save(&r->store, &p, 1300.0f) == KILN_OK) { - saved++; - } - } - /* The slot count is the limit, not the region count: 64 regions are there - * to be shared with the run records. */ - CHECK_EQ_UINT(saved, KILN_PROGRAM_SLOTS); - CHECK_EQ_UINT(kiln_program_store_count(&r->store), KILN_PROGRAM_SLOTS); - - boot(r); - CHECK_EQ_UINT(kiln_program_store_count(&r->store), KILN_PROGRAM_SLOTS); -} /* * @relation(SWR-LOG-09, scope=function) @@ -153,19 +95,20 @@ KILN_TEST(swr_log_09_run_records_ring_and_survive_a_reboot_on_flash) } /* - * @relation(SWR-PRG-04, scope=function) + * @relation(SWR-LOG-09, scope=function) */ -KILN_TEST(swr_prg_04_twenty_programs_and_twenty_runs_share_the_partition) +KILN_TEST(swr_log_09_the_run_records_are_now_the_only_thing_on_the_partition) { + /* This test used to fill twenty program slots and twenty run slots and + * check that 40 of 64 regions were in use with headroom to spare. With + * the programs in the image, the partition holds twenty regions of the + * sixty-four: it is now oversized by a factor of three rather than by half, + * which is recorded rather than acted on, because a 512 kB partition that + * is a third used costs nothing and shrinking it is a flash layout change + * that breaks every device already flashed. */ rig_t *r = &g_rig; power_up_fresh(r); - char name[KILN_PROGRAM_NAME_LEN]; - for (unsigned i = 0; i < KILN_PROGRAM_SLOTS; i++) { - (void)snprintf(name, sizeof(name), "p%02u", i); - const kiln_program_t p = named(name, 900); - CHECK_OK(kiln_program_store_save(&r->store, &p, 1300.0f)); - } for (unsigned i = 1; i <= KILN_RUN_SLOTS; i++) { kiln_run_record_t rec = {}; rec.run_id = i; @@ -173,43 +116,46 @@ KILN_TEST(swr_prg_04_twenty_programs_and_twenty_runs_share_the_partition) CHECK_OK(kiln_run_index_append(&r->store, &rec)); } - CHECK_EQ_UINT(kiln_fileslots_used_regions(&r->fs), - KILN_PROGRAM_SLOTS + KILN_RUN_SLOTS); - - /* 40 of 64, so the partition is sized with headroom and not to the inch. */ - CHECK(kiln_fileslots_used_regions(&r->fs) < TEST_REGIONS); + CHECK_EQ_UINT(kiln_fileslots_used_regions(&r->fs), KILN_RUN_SLOTS); + CHECK(kiln_fileslots_used_regions(&r->fs) * 3u <= TEST_REGIONS); boot(r); - CHECK_EQ_UINT(kiln_program_store_count(&r->store), KILN_PROGRAM_SLOTS); CHECK_EQ_UINT(kiln_run_index_count(&r->store), KILN_RUN_SLOTS); } /* * @relation(SWR-RUN-08, scope=function) */ -KILN_TEST(swr_run_08_a_power_cut_while_saving_a_program_keeps_the_old_one) +KILN_TEST(swr_run_08_a_power_cut_while_rewriting_a_run_keeps_the_old_one) { + /* The same property the program save used to prove, over the file that is + * still written at run time: a rewrite of an existing name that loses + * power inside the payload leaves the previous content, not half of each. + * + * run_index rewrites a slot when it marks a run truncated, so this is not + * a hypothetical path. */ rig_t *r = &g_rig; power_up_fresh(r); - const kiln_program_t first = named("glaze", 1200); - CHECK_OK(kiln_program_store_save(&r->store, &first, 1300.0f)); + kiln_run_record_t first = {}; + first.run_id = 7; + first.duration_s = 3600; + first.program = named("glaze", 1200); + CHECK_OK(kiln_run_index_append(&r->store, &first)); - /* Overwrite the same name, and lose power inside the payload. */ - const kiln_program_t second = named("glaze", 1250); + kiln_run_record_t edited = first; + edited.duration_s = 9999; r->flash.cut_power_at_write = r->flash.writes + 2u; r->flash.cut_bytes = 16u; - (void)kiln_program_store_save(&r->store, &second, 1300.0f); + (void)kiln_run_index_append(&r->store, &edited); kiln_host_flash_power_on(&r->flash); boot(r); - /* Neither the old program nor the slot is gone, and the half-written one is - * nowhere: a kiln that loses power mid-save still has its firing program. */ - kiln_program_t back = {}; - CHECK_OK(kiln_program_store_load(&r->store, "glaze", &back)); - CHECK_EQ_UINT(back.segments[0].target_c, 1200u); - CHECK_EQ_UINT(kiln_program_store_count(&r->store), 1u); + kiln_run_record_t back = {}; + CHECK_OK(kiln_run_index_find(&r->store, 7u, &back)); + CHECK_EQ_UINT(back.duration_s, 3600u); + CHECK_EQ_UINT(kiln_run_index_count(&r->store), 1u); } /* @@ -245,25 +191,25 @@ KILN_TEST(swr_run_08_a_power_cut_while_appending_a_run_keeps_the_earlier_runs) CHECK_EQ_UINT(kiln_run_index_next_run_id(&r->store), 4u); } -KILN_TEST(fr_cfg_12_a_format_clears_programs_and_runs_together) +KILN_TEST(fr_cfg_12_a_format_clears_the_run_history) { rig_t *r = &g_rig; power_up_fresh(r); - CHECK_OK(kiln_program_store_seed(&r->store)); kiln_run_record_t rec = {}; rec.run_id = 1; rec.program = named("x", 900); CHECK_OK(kiln_run_index_append(&r->store, &rec)); CHECK_OK(kiln_fileslots_format(&r->fs)); - CHECK_EQ_UINT(kiln_program_store_count(&r->store), 0u); CHECK_EQ_UINT(kiln_run_index_count(&r->store), 0u); boot(r); - CHECK_EQ_UINT(kiln_program_store_count(&r->store), 0u); + CHECK_EQ_UINT(kiln_run_index_count(&r->store), 0u); - /* And seeding brings the examples back, so a reset is recoverable. */ - CHECK_OK(kiln_program_store_seed(&r->store)); - CHECK(kiln_program_store_count(&r->store) > 0u); + /* A factory reset no longer costs the operator their programs, which is + * the other half of compiling them in: the only thing on this partition is + * history, and losing history is what a factory reset is for. The next + * run also starts from 1 again, since the sequence is read from here. */ + CHECK_EQ_UINT(kiln_run_index_next_run_id(&r->store), 1u); } diff --git a/firmware/controller/test/host/unit/test_alarmptn.cpp b/firmware/controller/test/host/unit/test_alarmptn.cpp new file mode 100644 index 0000000..969c030 --- /dev/null +++ b/firmware/controller/test/host/unit/test_alarmptn.cpp @@ -0,0 +1,204 @@ +/* SPDX-FileCopyrightText: 2026 Bitcrush Testing + * SPDX-License-Identifier: GPL-3.0-or-later + * + * The buzzer's two rhythms -- SWR-SAF-20, SWR-RUN-06, SYS-HW-09. + * + * SWR-SAF-20's requirement is that fault and completion are *audibly + * distinguishable*, which is a property of numbers and can therefore be + * asserted rather than described. Until the pattern moved into the core it + * lived in an esp_timer callback where nothing could reach it, and "the two + * sound different" was a comment. + */ + +#include "kiln_check.h" +#include "kiln_core/alarmptn.h" + +namespace { + +/* Walk a pattern for one full cycle and report what was heard. */ +typedef struct { + unsigned chirps; /* on-intervals */ + uint32_t on_ms; + uint32_t total_ms; + uint32_t longest_gap; /* the silence that separates bursts */ + uint32_t shortest_on; +} heard_t; + +heard_t listen(kiln_alarm_pattern_t p, unsigned max_steps) +{ + heard_t h = {0, 0, 0, 0, 0xFFFFFFFFu}; + kiln_alarm_seq_t s; + kiln_alarm_seq_begin(&s, p); + + const uint32_t cycle = kiln_alarm_cycle_ms(p); + for (unsigned i = 0; i < max_steps && h.total_ms < cycle; i++) { + const kiln_alarm_step_t step = kiln_alarm_seq_next(&s); + if (step.hold_ms == 0u) { + break; + } + h.total_ms += step.hold_ms; + if (step.on) { + h.chirps++; + h.on_ms += step.hold_ms; + if (step.hold_ms < h.shortest_on) { h.shortest_on = step.hold_ms; } + } + else if (step.hold_ms > h.longest_gap) { + h.longest_gap = step.hold_ms; + } + } + return h; +} + +} // namespace + +/* + * @relation(SWR-RUN-06, scope=function) + */ +KILN_TEST(swr_run_06_the_finished_firing_is_brief_and_over) +{ + const heard_t h = listen(KILN_ALARM_COMPLETE, 64); + + /* Three chirps, and the whole burst inside half a second: a firing that + * took nine hours is finished, which is a notification and not an alarm. */ + CHECK_EQ_UINT(h.chirps, 3u); + CHECK_EQ_UINT(h.on_ms, 270u); + CHECK(h.on_ms + 180u <= 500u); /* burst length, chirps and gaps */ + + /* And then it gets out of the way for five seconds, which is what makes it + * brief in practice: the application turns the alarm off after + * hmi.alarm_duration_s, and the default is shorter than this gap, so the + * operator hears the burst once. */ + CHECK(h.longest_gap >= 4000u); + CHECK_EQ_UINT(kiln_alarm_cycle_ms(KILN_ALARM_COMPLETE), 5000u); +} + +/* + * @relation(SWR-SAF-20, scope=function) + */ +KILN_TEST(swr_saf_20_a_safety_incident_is_announced_aggressively) +{ + const heard_t h = listen(KILN_ALARM_FAULT, 64); + + /* Six fast chirps and a short gap. Something is wrong with a kiln at + * 1 200 degC: this is meant to be unpleasant and to carry through a wall. */ + CHECK_EQ_UINT(h.chirps, 6u); + CHECK(h.longest_gap <= 500u); + + /* Chirping for a third of the time (360 ms of every 1 060), against a + * twentieth for completion (270 ms of every 5 000). That ratio is what + * "audibly distinguishable" means with an active buzzer that has one tone + * and no volume control. */ + const uint32_t cycle = kiln_alarm_cycle_ms(KILN_ALARM_FAULT); + CHECK_EQ_UINT(cycle, 1060u); + CHECK_EQ_UINT(h.on_ms, 360u); + CHECK(h.on_ms * 3u >= cycle); +} + +/* + * @relation(SWR-SAF-20, scope=function) + */ +KILN_TEST(swr_saf_20_the_two_patterns_cannot_be_confused) +{ + /* The requirement is a comparison, so it is asserted as one rather than as + * two sets of numbers that happen to differ. */ + const uint32_t fault_cycle = kiln_alarm_cycle_ms(KILN_ALARM_FAULT); + const uint32_t complete_cycle = kiln_alarm_cycle_ms(KILN_ALARM_COMPLETE); + + /* Duty: the fault sounds for a third of its cycle, completion for a + * twentieth of a cycle five times as long. */ + const uint32_t fault_duty = kiln_alarm_on_ms(KILN_ALARM_FAULT) * 100u / fault_cycle; + const uint32_t complete_duty = kiln_alarm_on_ms(KILN_ALARM_COMPLETE) * 100u / complete_cycle; + CHECK(fault_duty >= 4u * complete_duty); + + /* Rate: the fault repeats several times a second, completion once every + * few seconds. Both the rhythm and the repetition differ, so neither a + * noisy workshop nor a closed door collapses one into the other. */ + CHECK(complete_cycle >= 4u * fault_cycle); + + /* And the chirps themselves are different lengths. */ + const heard_t f = listen(KILN_ALARM_FAULT, 64); + const heard_t c = listen(KILN_ALARM_COMPLETE, 64); + CHECK(f.shortest_on < c.shortest_on); +} + +/* + * @relation(SWR-SAF-20, scope=function) + */ +KILN_TEST(swr_saf_20_the_fault_pattern_never_stops_on_its_own) +{ + /* A fault is cleared by an acknowledgement, never by the buzzer giving up. + * Ten cycles' worth of steps, and every one of them is still due. */ + kiln_alarm_seq_t s; + kiln_alarm_seq_begin(&s, KILN_ALARM_FAULT); + uint32_t sounded = 0; + for (unsigned i = 0; i < 200u; i++) { + const kiln_alarm_step_t step = kiln_alarm_seq_next(&s); + CHECK(step.hold_ms > 0u); + if (step.on) { sounded += step.hold_ms; } + } + /* Not merely "a step was returned": it was still making noise. */ + CHECK(sounded > 5000u); +} + +/* + * @relation(SWR-SAF-20, scope=function) + */ +KILN_TEST(swr_saf_20_off_is_silent_and_asks_for_nothing_further) +{ + kiln_alarm_seq_t s; + kiln_alarm_seq_begin(&s, KILN_ALARM_OFF); + const kiln_alarm_step_t step = kiln_alarm_seq_next(&s); + CHECK_EQ_INT(step.on, false); + CHECK_EQ_UINT(step.hold_ms, 0u); + CHECK_EQ_UINT(kiln_alarm_cycle_ms(KILN_ALARM_OFF), 0u); + + /* Asked again, it says the same thing: an adapter that keeps calling must + * not eventually be told to sound. */ + for (unsigned i = 0; i < 8u; i++) { + const kiln_alarm_step_t again = kiln_alarm_seq_next(&s); + CHECK_EQ_INT(again.on, false); + CHECK_EQ_UINT(again.hold_ms, 0u); + } +} + +/* + * @relation(SWR-SAF-20, scope=function) + */ +KILN_TEST(swr_saf_20_a_buzzer_that_cannot_be_commanded_stays_quiet) +{ + /* A null sequence, and a pattern value that is not one of the three: both + * answer silence. The buzzer's failure direction is off -- a part that + * cannot be commanded must not be a part that is stuck on, because the + * operator's only remedy for that is the mains switch. */ + const kiln_alarm_step_t none = kiln_alarm_seq_next(nullptr); + CHECK_EQ_INT(none.on, false); + CHECK_EQ_UINT(none.hold_ms, 0u); + kiln_alarm_seq_begin(nullptr, KILN_ALARM_FAULT); /* must not fault */ + + kiln_alarm_seq_t s; + kiln_alarm_seq_begin(&s, (kiln_alarm_pattern_t)99); + const kiln_alarm_step_t bogus = kiln_alarm_seq_next(&s); + CHECK_EQ_INT(bogus.on, false); + CHECK_EQ_UINT(bogus.hold_ms, 0u); +} + +/* + * @relation(SWR-SAF-20, scope=function) + */ +KILN_TEST(swr_saf_20_a_pattern_resumes_rather_than_restarting_mid_cycle) +{ + /* The adapter refuses to re-begin the pattern that is already running, so + * that a status update ten times a second cannot hold the buzzer at its + * first interval forever. What that relies on from here is that stepping + * is the only thing that advances the sequence. */ + kiln_alarm_seq_t s; + kiln_alarm_seq_begin(&s, KILN_ALARM_FAULT); + const kiln_alarm_step_t first = kiln_alarm_seq_next(&s); + const kiln_alarm_step_t second = kiln_alarm_seq_next(&s); + CHECK_EQ_INT(first.on, true); + CHECK_EQ_INT(second.on, false); + + kiln_alarm_seq_begin(&s, KILN_ALARM_FAULT); + const kiln_alarm_step_t again = kiln_alarm_seq_next(&s); + CHECK_EQ_INT(again.on, true); /* begun again, so back to the start */ +} diff --git a/firmware/controller/test/host/unit/test_configmodel.cpp b/firmware/controller/test/host/unit/test_configmodel.cpp index b3e0310..97aeb62 100644 --- a/firmware/controller/test/host/unit/test_configmodel.cpp +++ b/firmware/controller/test/host/unit/test_configmodel.cpp @@ -3,6 +3,7 @@ * kiln_core/configmodel -- SWR-CFG-01..SWR-CFG-08, SWR-NFR-19. */ +#include #include #include "kiln_check.h" #include "kiln_core/configmodel.h" @@ -85,7 +86,7 @@ KILN_TEST(swrcfg02_the_minimum_item_set_is_present) "control.kp", "control.ki", "control.kd", "control.loop_period_ms", "control.window_ms", "control.min_on_ms", "control.min_off_ms", "control.duty_max", "control.holdback_band_c", "control.dwell_tol_c", - "sense.tc_type", "sense.line_filter_hz", "sense.filter_tau_s", + "sense.case_tc_type", "sense.line_filter_hz", "sense.filter_tau_s", "sense.cal_offset_c", "sense.cal_gain", "tune.amplitude", "tune.hysteresis_c", "tune.rule", "tune.timeout_s", "current.ct_a_per_v", "current.nominal_a", "current.mains_v", @@ -102,6 +103,14 @@ KILN_TEST(swrcfg02_the_minimum_item_set_is_present) CHECK_MSG(kiln_config_find(required[i]) != NULL, "SWR-CFG-02 requires an item %s", required[i]); } + + /* And one item SWR-ACQ-02 requires NOT to exist. The chamber front end + * belongs to the supervisor, which linearises for type K; an item that let + * this side change that would make the backstop mean something else, and + * one that was offered and then ignored would be worse, because an + * operator can set it and believe it. */ + CHECK_MSG(kiln_config_find("sense.tc_type") == NULL, + "SWR-ACQ-02: the chamber TC type is configurable again"); } /* @@ -423,3 +432,105 @@ KILN_TEST(encode_refuses_a_buffer_that_is_too_small) CHECK_ERR(kiln_config_encode(&cfg, tiny, sizeof(tiny), NULL), KILN_ERR_NO_SPACE); CHECK_ERR(kiln_config_encode(NULL, tiny, sizeof(tiny), NULL), KILN_ERR_INVALID_ARG); } + +/* =========================================================================== + * SCHEMA 2: A REMOVED FIELD (SWR-CFG-05, SWR-ACQ-02, tasklist R9) + * =========================================================================== + * Schema 1 carried `sense.tc_type` as one byte immediately before + * `case_tc_type`. SWR-ACQ-02 took it away, because the chamber front end + * belongs to the supervisor and an item that let this side change the type + * would change what the supervisor's backstop means. + * + * Every earlier migration added fields, which a prefix copy handles. A removal + * in the middle shifts every byte after the hole, and a prefix copy would land + * the whole sensing, control and network block one byte out of place: the + * stored line filter frequency would read as half of itself plus half a + * calibration offset, and the CRC would have passed on the way in, so nothing + * would report it. The device would come back from an update quietly + * mis-calibrated. + * + * This forges a real version 1 blob and checks the fields on both sides of the + * hole, because that is the only way to know the splice is right. + */ + +/* + * @relation(SWR-CFG-05, scope=function) + */ +KILN_TEST(swrcfg05_a_version_1_blob_survives_the_removed_field) +{ + /* The version 1 layout is the version 2 layout with one extra byte at + * offsetof(case_tc_type). Built by taking a version 2 encoding apart and + * inserting the byte, which is exactly what the old firmware wrote. */ + kiln_config_t in; + kiln_config_defaults(&in); + + /* Distinctive values on both sides of the hole, chosen in range so that a + * clamp cannot hide a misplaced byte. */ + in.max_temp_c = 1150.0f; /* before */ + in.case_tc_type = (uint8_t)KILN_TC_TYPE_N; + in.line_filter_hz = 60u; /* after, and the one a shift ruins */ + in.filter_tau_s = 7.5f; + in.cal_offset_c = -3.25f; + in.cal_gain = 1.002f; + in.log_interval_s = 30u; + in.dim_timeout_s = 45u; + CHECK_OK(kiln_config_validate(&in, nullptr)); + + uint8_t v2[1024]; + size_t v2len = 0; + CHECK_OK(kiln_config_encode(&in, v2, sizeof(v2), &v2len)); + + constexpr size_t HDR = 8u; + const size_t hole = offsetof(kiln_config_t, case_tc_type); + const size_t v1_payload = sizeof(kiln_config_t) + 1u; + + uint8_t v1[1200]; + memset(v1, 0, sizeof(v1)); + /* Header: magic as encoded, version 1, the longer payload. */ + memcpy(v1, v2, HDR); + v1[4] = 1u; v1[5] = 0u; + v1[6] = (uint8_t)(v1_payload & 0xFFu); + v1[7] = (uint8_t)(v1_payload >> 8u); + /* Payload: everything before the hole, the removed byte, then the rest. */ + memcpy(&v1[HDR], &v2[HDR], hole); + v1[HDR + hole] = (uint8_t)KILN_TC_TYPE_S; /* the field that went away */ + memcpy(&v1[HDR + hole + 1u], &v2[HDR + hole], sizeof(kiln_config_t) - hole); + const size_t crc_at = HDR + v1_payload; + const uint16_t crc = kiln_crc16(v1, crc_at); + v1[crc_at] = (uint8_t)crc; + v1[crc_at + 1u] = (uint8_t)(crc >> 8u); + + kiln_config_t out; + kiln_config_defaults(&out); + /* Migrated, so the caller is told to write it back. */ + CHECK_ERR(kiln_config_decode(v1, crc_at + 2u, &out), KILN_ERR_UNSUPPORTED); + CHECK_EQ_UINT(out.schema_version, KILN_CFG_SCHEMA_VERSION); + + /* Before the hole. */ + CHECK_NEAR(out.max_temp_c, 1150.0f, 0.01f); + + /* At the hole: the enclosure type, which in version 1 sat one byte later. */ + CHECK_EQ_UINT(out.case_tc_type, (uint8_t)KILN_TC_TYPE_N); + + /* After the hole, which is where a prefix copy would have gone wrong. */ + CHECK_EQ_UINT(out.line_filter_hz, 60u); + CHECK_NEAR(out.filter_tau_s, 7.5f, 0.001f); + CHECK_NEAR(out.cal_offset_c, -3.25f, 0.001f); + CHECK_NEAR(out.cal_gain, 1.002f, 0.0001f); + CHECK_EQ_UINT(out.log_interval_s, 30u); + CHECK_EQ_UINT(out.dim_timeout_s, 45u); + + /* And the whole thing is still a valid configuration, which is what the + * device is about to run a kiln on. */ + CHECK_OK(kiln_config_validate(&out, nullptr)); + + /* The migration is not a one-way street into nonsense: re-encoded and + * decoded, it is now a clean version 2 blob. */ + uint8_t again[1024]; + size_t again_len = 0; + CHECK_OK(kiln_config_encode(&out, again, sizeof(again), &again_len)); + kiln_config_t back; + CHECK_OK(kiln_config_decode(again, again_len, &back)); + CHECK_EQ_UINT(back.line_filter_hz, 60u); + CHECK_EQ_UINT(back.case_tc_type, (uint8_t)KILN_TC_TYPE_N); +} diff --git a/firmware/controller/test/host/unit/test_fileslots.cpp b/firmware/controller/test/host/unit/test_fileslots.cpp index 3aade3c..8b2dde5 100644 --- a/firmware/controller/test/host/unit/test_fileslots.cpp +++ b/firmware/controller/test/host/unit/test_fileslots.cpp @@ -10,10 +10,14 @@ * a file that was already there, and here the test can actually cut the power. */ +#include #include #include "kiln_check.h" #include "kiln_core/fileslots.h" #include "kiln_hal_host/hal_host.h" +/* For the blob size the one real caller stores, in the mount-cost tests at the + * end of this file: a cost measured against made-up payloads measures nothing. */ +#include "kiln_app/run_index.h" constexpr size_t SECTOR_BYTES = 4096u; /* 20 programs plus 20 run records, plus two spare to prove the store refuses a @@ -280,11 +284,12 @@ KILN_TEST(swa10_remove_frees_the_region_for_another_name) rig_t *r = &g_rig; rig_init(r); + uint8_t buf[16]; CHECK_OK(put(r, "/p/00", "gone", 5)); - CHECK_OK(r->store.exists(r->store.ctx, "/p/00")); + CHECK_OK(get(r, "/p/00", buf, sizeof(buf), nullptr)); CHECK_OK(r->store.remove(r->store.ctx, "/p/00")); - CHECK_ERR(r->store.exists(r->store.ctx, "/p/00"), KILN_ERR_NOT_FOUND); + CHECK_ERR(get(r, "/p/00", buf, sizeof(buf), nullptr), KILN_ERR_NOT_FOUND); CHECK_EQ_UINT(kiln_fileslots_used_regions(&r->fs), 0u); CHECK_ERR(r->store.remove(r->store.ctx, "/p/00"), KILN_ERR_NOT_FOUND); @@ -351,7 +356,7 @@ KILN_TEST(swa10_a_short_buffer_is_refused_rather_than_truncated) /* * @relation(SWA-10, scope=function) */ -KILN_TEST(swa10_usage_and_list_report_what_is_stored) +KILN_TEST(swa10_usage_reports_what_is_stored) { rig_t *r = &g_rig; rig_init(r); @@ -365,17 +370,12 @@ KILN_TEST(swa10_usage_and_list_report_what_is_stored) CHECK_EQ_UINT(used, 10u); CHECK_EQ_UINT(total, (size_t)TEST_REGIONS * r->fs.payload_max); - struct counter { unsigned n; size_t bytes; } c = {0, 0}; - auto const tally = [](void *user, const char *name, size_t size) -> bool { - (void)name; - struct counter *k = static_cast(user); - k->n++; - k->bytes += size; - return true; - }; - CHECK_OK(r->store.list(r->store.ctx, "/p/", tally, &c)); - CHECK_EQ_UINT(c.n, 1u); - CHECK_EQ_UINT(c.bytes, 4u); + /* usage is reported from the in-RAM index, so it has to survive a reboot + * on the strength of the mount alone. */ + rig_remount(r); + used = 0; + CHECK_OK(r->store.usage(r->store.ctx, nullptr, &used)); + CHECK_EQ_UINT(used, 10u); } /* @@ -427,3 +427,348 @@ KILN_TEST(swa10_the_store_validates_its_arguments) KILN_ERR_INVALID_ARG); CHECK_ERR(kiln_fileslots_format(&cold), KILN_ERR_STATE); } + +/* =========================================================================== + * WEAR (tasklist Q1) + * =========================================================================== + * A NOR sector at the end of its life takes the write, reports success, and + * keeps something other than what it was given. Every test above this point + * drives a medium that does what it is told, so the one failure mode the part + * will actually exhibit was the one nothing exercised. + * + * The flash fake grows two knobs for it: garble_write_at spoils one write, + * garble_every_write spoils all of them, and both return KILN_OK, because an + * error code is exactly what this failure does not come with. + */ + +/* The write counter after priming, so a test can spoil the next write rather + * than guess which one it is. */ +static void spoil_next_write(rig_t *r) +{ + r->flash.garble_write_at = r->flash.writes + 1u; +} + +/* + * @relation(SWA-21, scope=function) + */ +KILN_TEST(swa21_a_sector_that_lies_about_a_write_retires_its_region) +{ + rig_t *r = &g_rig; + rig_init(r); + + CHECK_OK(put(r, "/r/00", "first", 6)); + CHECK_EQ_UINT(kiln_fileslots_used_regions(&r->fs), 1u); + CHECK_EQ_UINT(kiln_fileslots_retired_regions(&r->fs), 0u); + + /* The region the file is in is about to stop holding what it is given. */ + spoil_next_write(r); + CHECK_OK(put(r, "/r/00", "second", 7)); + + /* The caller gets KILN_OK because the file IS stored -- somewhere else. */ + uint8_t buf[16]; + size_t n = 0; + CHECK_OK(get(r, "/r/00", buf, sizeof(buf), &n)); + CHECK_STR_EQ(as_str(buf), "second"); + CHECK_EQ_UINT(n, 7u); + + CHECK_EQ_UINT(kiln_fileslots_retired_regions(&r->fs), 1u); + CHECK_EQ_UINT(kiln_fileslots_used_regions(&r->fs), 1u); + + /* And the retired region is not handed out again. */ + r->flash.garble_write_at = 0; + CHECK_OK(put(r, "/r/01", "other", 6)); + CHECK_EQ_UINT(kiln_fileslots_used_regions(&r->fs), 2u); + CHECK_EQ_UINT(kiln_fileslots_retired_regions(&r->fs), 1u); + CHECK_OK(get(r, "/r/00", buf, sizeof(buf), nullptr)); + CHECK_STR_EQ(as_str(buf), "second"); +} + +/* + * @relation(SWA-21, scope=function) + */ +KILN_TEST(swa21_a_migration_leaves_no_stale_copy_to_come_back_later) +{ + rig_t *r = &g_rig; + rig_init(r); + + CHECK_OK(put(r, "/r/00", "first", 6)); + spoil_next_write(r); + CHECK_OK(put(r, "/r/00", "second", 7)); + r->flash.garble_write_at = 0; + + /* The erase of the retired region succeeded, so nothing on the medium + * claims the name twice and the region is back in the pool at the next + * boot: a sector that can still be erased has not earned a life sentence + * from one bad write. */ + rig_remount(r); + CHECK_EQ_UINT(kiln_fileslots_used_regions(&r->fs), 1u); + CHECK_EQ_UINT(kiln_fileslots_retired_regions(&r->fs), 0u); + + uint8_t buf[16]; + CHECK_OK(get(r, "/r/00", buf, sizeof(buf), nullptr)); + CHECK_STR_EQ(as_str(buf), "second"); + + /* The case this erase is for: a removed file must not be resurrected by a + * copy the migration left behind. */ + CHECK_OK(r->store.remove(r->store.ctx, "/r/00")); + rig_remount(r); + CHECK_ERR(get(r, "/r/00", buf, sizeof(buf), nullptr), KILN_ERR_NOT_FOUND); + CHECK_EQ_UINT(kiln_fileslots_used_regions(&r->fs), 0u); +} + +/* + * @relation(SWA-21, scope=function) + */ +KILN_TEST(swa21_a_region_that_cannot_even_be_erased_stays_retired_across_a_reboot) +{ + rig_t *r = &g_rig; + rig_init(r); + + CHECK_OK(put(r, "/r/00", "first", 6)); + + /* Now region 0, where the file lives, is properly gone: it keeps zeros + * instead of data, and it will not erase either, so the copy it holds + * cannot be cleaned up after the migration. */ + spoil_next_write(r); + r->flash.fail_erase_off = 0u; /* region 0, copy 0 */ + CHECK_OK(put(r, "/r/00", "second", 7)); + r->flash.garble_write_at = 0; + r->flash.fail_erase_off = KILN_HOST_NO_OFFSET; + + uint8_t buf[16]; + CHECK_OK(get(r, "/r/00", buf, sizeof(buf), nullptr)); + CHECK_STR_EQ(as_str(buf), "second"); + + /* Two regions now hold /r/00. Mount resolves it by seq and retires the + * loser, which is the region whose write failed -- so retirement outlives + * the power cycle without a bad-block table anywhere. */ + rig_remount(r); + CHECK_EQ_UINT(kiln_fileslots_used_regions(&r->fs), 1u); + CHECK_EQ_UINT(kiln_fileslots_retired_regions(&r->fs), 1u); + CHECK_OK(get(r, "/r/00", buf, sizeof(buf), nullptr)); + CHECK_STR_EQ(as_str(buf), "second"); + + /* Twice, because a resolution that is not stable is not a resolution. */ + rig_remount(r); + CHECK_EQ_UINT(kiln_fileslots_retired_regions(&r->fs), 1u); + CHECK_OK(get(r, "/r/00", buf, sizeof(buf), nullptr)); + CHECK_STR_EQ(as_str(buf), "second"); + + /* An explicit factory reset takes it back: FR-CFG-12 erases everything, and + * refusing to try the region again would leave the operator no way to find + * out whether the part is really finished. */ + CHECK_OK(kiln_fileslots_format(&r->fs)); + CHECK_EQ_UINT(kiln_fileslots_retired_regions(&r->fs), 0u); +} + +/* + * @relation(SWA-21, scope=function) + */ +KILN_TEST(swa21_a_part_with_nowhere_left_to_write_says_so) +{ + rig_t *r = &g_rig; + rig_init(r); + + CHECK_OK(put(r, "/r/00", "first", 6)); + + /* Every write from here keeps zeros. Three regions are tried, which is + * KILN_FILESLOT_WRITE_TRIES, and then the store stops spending erase + * cycles on a part that is not coming back. */ + r->flash.garble_every_write = true; + CHECK_ERR(put(r, "/r/00", "second", 7), KILN_ERR_CORRUPT); + r->flash.garble_every_write = false; + + CHECK_EQ_UINT(kiln_fileslots_retired_regions(&r->fs), 3u); + + /* The previous content is still there: the write that failed was the one + * that did not happen, not the file. */ + uint8_t buf[16]; + CHECK_OK(get(r, "/r/00", buf, sizeof(buf), nullptr)); + CHECK_STR_EQ(as_str(buf), "first"); + CHECK_EQ_UINT(kiln_fileslots_used_regions(&r->fs), 1u); + + rig_remount(r); + CHECK_OK(get(r, "/r/00", buf, sizeof(buf), nullptr)); + CHECK_STR_EQ(as_str(buf), "first"); +} + +/* + * @relation(SWA-21, scope=function) + */ +KILN_TEST(swa21_a_fresh_file_migrates_too_and_the_store_can_fill_up_with_retirements) +{ + rig_t *r = &g_rig; + rig_init(r); + + /* A file that has no home yet meets the same sector. Nothing has to be + * preserved here, which is the easy half, but the region still has to be + * retired or the next attempt goes straight back into it. */ + spoil_next_write(r); + CHECK_OK(put(r, "/p/00", "program", 8)); + r->flash.garble_write_at = 0; + + uint8_t buf[16]; + CHECK_OK(get(r, "/p/00", buf, sizeof(buf), nullptr)); + CHECK_STR_EQ(as_str(buf), "program"); + CHECK_EQ_UINT(kiln_fileslots_used_regions(&r->fs), 1u); + CHECK_EQ_UINT(kiln_fileslots_retired_regions(&r->fs), 1u); + + /* Retired regions are not capacity. Fill what is left and the store + * reports no space rather than retrying a region it has written off. */ + for (size_t i = 1; i < TEST_REGIONS - 1u; i++) { + char path[16]; + (void)snprintf(path, sizeof(path), "/p/%02zu", i); + CHECK_OK(put(r, path, "x", 2)); + } + CHECK_EQ_UINT(kiln_fileslots_used_regions(&r->fs), TEST_REGIONS - 1u); + CHECK_ERR(put(r, "/p/99", "y", 2), KILN_ERR_NO_SPACE); +} + +/* =========================================================================== + * WHAT A MOUNT COSTS (tasklist Q2) + * =========================================================================== + * Mount reads every copy of every region and streams its payload through the + * CRC, so start-up is paid in flash reads and CRC bytes before the controller + * does anything. That cost had been described rather than measured, and the + * description was wrong by 60 %. + * + * These two tests measure it on the medium the partition actually gives us: 64 + * regions, both copies valid, which is the state normal operation leaves behind + * because a region keeps the copy it is replacing until the next write. + * + * The expected numbers are DERIVED here rather than written down, so the tests + * pin the model of the cost instead of a constant somebody would update to + * whatever the code now does. Make mount lazy and they fail, which is the + * point: that is a change to when start-up is paid for, and it should not pass + * unnoticed. + */ +constexpr size_t COST_REGIONS = KILN_FILESLOT_REGIONS_MAX; /* 64 */ +constexpr size_t COST_CRC_CHUNK = 64u; /* read_copy's streaming buffer */ + +typedef struct { + uint8_t storage[COST_REGIONS * 2u * SECTOR_BYTES]; + kiln_host_flash_t flash; + kiln_port_flash_t port; + kiln_fileslots_t fs; + kiln_port_filestore_t store; +} cost_rig_t; + +static cost_rig_t g_cost; + +/* Reads and bytes that validating one committed copy of `len` payload bytes + * costs: the 76 byte header, then the payload in 64 byte chunks. */ +static void copy_cost(size_t len, uint32_t *reads, uint32_t *bytes) +{ + *reads = 1u + (uint32_t)((len + COST_CRC_CHUNK - 1u) / COST_CRC_CHUNK); + *bytes = KILN_FILESLOT_HDR + (uint32_t)len; +} + +/* Fill every region, writing each file twice so that both copies are valid -- + * the state a running kiln leaves the medium in. */ +static void cost_fill(cost_rig_t *r, size_t payload) +{ + memset(r, 0, sizeof(*r)); + kiln_host_flash_init(&r->flash, r->storage, sizeof(r->storage), SECTOR_BYTES); + kiln_host_flash_bind(&r->flash, &r->port); + CHECK_OK(kiln_fileslots_mount(&r->fs, &r->port)); + kiln_fileslots_bind(&r->fs, &r->store); + CHECK_EQ_UINT(r->fs.region_count, COST_REGIONS); + + static uint8_t blob[4096]; + memset(blob, 0xA5, sizeof(blob)); + for (size_t i = 0; i < COST_REGIONS; i++) { + char path[16]; + (void)snprintf(path, sizeof(path), "/x/%02zu", i); + CHECK_OK(r->store.write_atomic(r->store.ctx, path, blob, payload)); + CHECK_OK(r->store.write_atomic(r->store.ctx, path, blob, payload)); + } + CHECK_EQ_UINT(kiln_fileslots_used_regions(&r->fs), COST_REGIONS); +} + +/* + * @relation(SWA-21, scope=function) + */ +KILN_TEST(swa21_a_full_mount_costs_what_the_model_says) +{ + /* The largest payload a 4 096 byte sector can hold once the 76 byte header + * is taken out, rounded down to a word: (4096 - 76) & ~3. */ + const size_t payload = 4020u; + + cost_rig_t *r = &g_cost; + cost_fill(r, payload); + CHECK_EQ_UINT(r->fs.payload_max, payload); + + r->flash.reads = 0; + r->flash.bytes_read = 0; + CHECK_OK(kiln_fileslots_mount(&r->fs, &r->port)); + + uint32_t per_reads = 0; + uint32_t per_bytes = 0; + copy_cost(payload, &per_reads, &per_bytes); + + /* Two valid copies per region, both validated in full. */ + CHECK_EQ_UINT(r->flash.reads, 2u * COST_REGIONS * per_reads); + CHECK_EQ_UINT(r->flash.bytes_read, 2u * COST_REGIONS * per_bytes); + + /* The absolute figures, so a reader of this suite does not have to do the + * arithmetic: 8 192 reads and 524 288 bytes, which is the whole partition. + * That is the number the architecture's start-up budget has to carry, and + * it is 1.6 times the 320 kB the task list had guessed at. */ + CHECK_EQ_UINT(r->flash.reads, 8192u); + CHECK_EQ_UINT(r->flash.bytes_read, 524288u); +} + +/* + * @relation(SWA-21, scope=function) + */ +KILN_TEST(swa21_the_mount_this_product_actually_pays_for) +{ + /* What the one real caller stores: 20 run records at the size run_index's + * own serialiser produces. The worst case above is what the partition + * permits; this is what the firmware puts in it. + * + * It used to be 20 programs as well, which is why the medium here is half + * what it was: the programs are compiled into the image and the partition + * holds history and nothing else. */ + constexpr size_t RUN_BLOB = 8u + sizeof(kiln_run_record_t) + 2u; + + cost_rig_t *r = &g_cost; + memset(r, 0, sizeof(*r)); + kiln_host_flash_init(&r->flash, r->storage, sizeof(r->storage), SECTOR_BYTES); + kiln_host_flash_bind(&r->flash, &r->port); + CHECK_OK(kiln_fileslots_mount(&r->fs, &r->port)); + kiln_fileslots_bind(&r->fs, &r->store); + + static uint8_t blob[4096]; + memset(blob, 0x5A, sizeof(blob)); + for (size_t i = 0; i < 20u; i++) { + char path[16]; + (void)snprintf(path, sizeof(path), "/r/%02zu", i); + CHECK_OK(r->store.write_atomic(r->store.ctx, path, blob, RUN_BLOB)); + CHECK_OK(r->store.write_atomic(r->store.ctx, path, blob, RUN_BLOB)); + } + + r->flash.reads = 0; + r->flash.bytes_read = 0; + CHECK_OK(kiln_fileslots_mount(&r->fs, &r->port)); + + uint32_t rr = 0, rb = 0; + copy_cost(RUN_BLOB, &rr, &rb); + + /* 20 regions hold a file; the other 44 cost one failed header read each. */ + const uint32_t want_reads = 2u * 20u * rr + (uint32_t)(COST_REGIONS - 20u) * 2u; + const uint32_t want_bytes = 2u * 20u * rb + + (uint32_t)(COST_REGIONS - 20u) * 2u * KILN_FILESLOT_HDR; + CHECK_EQ_UINT(r->flash.reads, want_reads); + CHECK_EQ_UINT(r->flash.bytes_read, want_bytes); + printf(" mount: %u reads, %u bytes (run blob %zu)\n", + r->flash.reads, r->flash.bytes_read, RUN_BLOB); + + /* Measured, against 8 192 reads and 524 288 bytes for a medium full of + * maximum-size files: a small fraction of the worst case, which is the + * reason the worst case is a bound rather than a budget. The exact numbers + * are asserted so that making mount lazy, or putting something else on + * this partition, shows up here rather than on a board. */ + CHECK_EQ_UINT(r->flash.reads, 488u); + CHECK_EQ_UINT(r->flash.bytes_read, 31888u); +} diff --git a/firmware/controller/test/host/unit/test_hmi.cpp b/firmware/controller/test/host/unit/test_hmi.cpp index 16f95aa..d7eb920 100644 --- a/firmware/controller/test/host/unit/test_hmi.cpp +++ b/firmware/controller/test/host/unit/test_hmi.cpp @@ -18,6 +18,7 @@ #include "kiln_check.h" #include "kiln_core/faults.h" #include "kiln_hmi/hmi.h" +#include "kiln_hmi/strings.h" static kiln_hmi_view_t base_view(void) { @@ -745,3 +746,221 @@ KILN_TEST(swrnet11_the_list_scrolls_and_keeps_the_selection_visible) (void)feed(&h, &v, KILN_INPUT_CW); } } + +/* =========================================================================== + * THE DISPLAY'S OWN WORDS (SWR-NFR-23, tasklist O4 and J2) + * =========================================================================== + * kiln_core/faults has always carried faults and warnings in both languages, + * so a device set to German announced its faults in German inside an English + * frame: every screen title, the whole menu, both answers to a confirmation + * and every footer were string literals at their draw sites. + * + * The table these check is the one a reviewer reads (J4 is still open: no + * native speaker has been through it). What is asserted here is what a + * reviewer cannot see by reading -- that nothing is missing, that nothing + * overruns the panel, and that nothing contains a character the font cannot + * draw. + */ + +/* + * @relation(SWR-NFR-23, scope=function) + */ +KILN_TEST(swrnfr23_every_string_exists_in_both_languages) +{ + for (unsigned id = 0; id < (unsigned)KILN_HMI_STR_COUNT; id++) { + for (unsigned lang = 0; lang < (unsigned)KILN_LANG_COUNT; lang++) { + const char *s = kiln_hmi_str((kiln_hmi_str_id_t)id, (kiln_lang_t)lang); + CHECK_MSG(s != nullptr && s[0] != '\0', + "string %u is missing in language %u", id, lang); + } + } +} + +/* + * @relation(SWR-NFR-23, scope=function) + */ +KILN_TEST(swrnfr23_the_german_uses_only_glyphs_the_font_has) +{ + /* draw.cpp's 5x7 font is the 95 printable ASCII glyphs and nothing else: + * no umlauts, no sharp s. So the German is transliterated -- AE OE UE SS, + * as kiln_core/faults has always done -- and a single umlaut typed into + * the table would reach the panel as a blank nobody sees until the device + * is in a workshop. */ + for (unsigned id = 0; id < (unsigned)KILN_HMI_STR_COUNT; id++) { + for (unsigned lang = 0; lang < (unsigned)KILN_LANG_COUNT; lang++) { + const char *s = kiln_hmi_str((kiln_hmi_str_id_t)id, (kiln_lang_t)lang); + for (const char *p = s; *p != '\0'; p++) { + const unsigned char c = (unsigned char)*p; + CHECK_MSG(c >= 0x20u && c <= 0x7Eu, + "string %u language %u has byte 0x%02X, which the font " + "cannot draw", id, lang, (unsigned)c); + } + } + } +} + +/* + * @relation(SWR-NFR-23, scope=function) + */ +KILN_TEST(swrnfr23_no_label_overruns_the_panel) +{ + /* 21 characters at scale 1, and the framebuffer clips silently past that. + * German runs about 15 per cent longer than English, so this is the bound + * the translation has to be written against rather than something to + * discover on hardware. */ + for (unsigned id = 0; id < (unsigned)KILN_HMI_STR_COUNT; id++) { + for (unsigned lang = 0; lang < (unsigned)KILN_LANG_COUNT; lang++) { + const char *s = kiln_hmi_str((kiln_hmi_str_id_t)id, (kiln_lang_t)lang); + CHECK_MSG(strlen(s) <= KILN_HMI_COLS, + "string %u language %u is %u characters, over the %u the " + "panel has", id, lang, (unsigned)strlen(s), + (unsigned)KILN_HMI_COLS); + } + } +} + +/* + * @relation(SWR-NFR-23, scope=function) + */ +KILN_TEST(swrnfr23_an_unknown_language_falls_back_rather_than_blanking) +{ + /* types.h promises that a language the build does not carry shows English + * rather than an empty screen. A blank menu on a kiln is worse than an + * English one. */ + const char *en = kiln_hmi_str(KILN_HMI_STR_ABORT, KILN_LANG_EN); + CHECK_STR_EQ(kiln_hmi_str(KILN_HMI_STR_ABORT, (kiln_lang_t)47), en); + + /* An identifier out of range is a programming error rather than a + * configuration one, and answers with nothing: a wrong label on a kiln is + * worse than a gap, which at least looks like what it is. */ + CHECK_STR_EQ(kiln_hmi_str((kiln_hmi_str_id_t)KILN_HMI_STR_COUNT, KILN_LANG_EN), ""); +} + +/* + * @relation(SWR-NFR-23, scope=function) + */ +KILN_TEST(swrnfr23_the_screens_themselves_change_language) +{ + /* The table existing is not the point; the screens using it is. Each of + * these is a screen that carried English literals until the table landed, + * and the pixel count moving is what proves the draw site was changed. */ + kiln_hmi_t h; kiln_hmi_init(&h, 0); + kiln_hmi_view_t v = base_view(); + + /* The menu, which is the first screen an operator meets. */ + v.language = KILN_LANG_EN; + (void)feed(&h, &v, KILN_INPUT_PRESS); + CHECK_EQ_INT(h.screen, KILN_HMI_SCREEN_MENU); + const int menu_en = ink(&h, 0, 0, 128, 64); + v.language = KILN_LANG_DE; + (void)feed(&h, &v, KILN_INPUT_NONE); + CHECK_MSG(ink(&h, 0, 0, 128, 64) != menu_en, "the menu did not change"); + + /* The confirmation, where the two answers are the whole screen. */ + v.language = KILN_LANG_EN; + (void)feed(&h, &v, KILN_INPUT_NONE); + (void)feed(&h, &v, KILN_INPUT_PRESS); /* Start program */ + (void)feed(&h, &v, KILN_INPUT_PRESS); /* pick the first */ + CHECK_EQ_INT(h.screen, KILN_HMI_SCREEN_CONFIRM); + const int confirm_en = ink(&h, 0, 0, 128, 64); + v.language = KILN_LANG_DE; + (void)feed(&h, &v, KILN_INPUT_NONE); + CHECK_MSG(ink(&h, 0, 0, 128, 64) != confirm_en, + "the confirmation did not change"); +} + +/* + * @relation(SWR-NET-12, scope=function) + */ +KILN_TEST(swrnet12_a_failed_join_says_so_with_the_radios_reason) +{ + /* Until now the display returned to the network screen saying "not + * connected", leaving the operator to guess between a mistyped + * passphrase, an SSID out of range and a radio that never came up. With + * the display the only route in -- there is no access point -- guessing is + * the entire cost of getting it wrong. */ + kiln_hmi_t h; kiln_hmi_init(&h, 0); + kiln_hmi_view_t v = base_view(); + v.net_up = false; + + (void)feed(&h, &v, KILN_INPUT_PRESS); + for (int i = 0; i < 3; i++) { (void)feed(&h, &v, KILN_INPUT_CW); } + (void)feed(&h, &v, KILN_INPUT_PRESS); + CHECK_EQ_INT(h.screen, KILN_HMI_SCREEN_NETWORK); + + /* Nothing attempted yet: the third row says an attempt is in flight + * rather than that it failed. */ + v.net_last_reason = 0; + (void)feed(&h, &v, KILN_INPUT_NONE); + const int joining = ink(&h, 0, 32, 128, 10); + CHECK(joining > 0); + + /* Reason 15 is a failed four-way handshake, which is a wrong passphrase in + * almost every case. The row has to change, and visibly. */ + v.net_last_reason = 15; + (void)feed(&h, &v, KILN_INPUT_NONE); + const int failed = ink(&h, 0, 32, 128, 10); + CHECK_MSG(failed != joining, + "the network screen shows the same thing whether a join is in " + "progress or has failed"); + + /* The code itself is on the screen: it is there to be read out to somebody + * who can look it up, so a different code must render differently. */ + v.net_last_reason = 201; /* no AP of that name answered */ + (void)feed(&h, &v, KILN_INPUT_NONE); + CHECK_MSG(ink(&h, 0, 32, 128, 10) != failed, + "the reason code is not rendered, only the fact of the failure"); + + /* And once it is up, the row goes back to being the hostname. */ + v.net_up = true; + (void)feed(&h, &v, KILN_INPUT_NONE); + CHECK(ink(&h, 0, 32, 128, 10) > 0); +} + +/* + * @relation(SWR-NET-12, scope=function) + */ +KILN_TEST(swrnet12_the_knob_stays_where_the_last_character_left_it) +{ + /* A rotary encoder is a poor keyboard and 95 characters is a long way + * round, so the one cheap improvement is not sending the knob back to 'a' + * after every press: passphrases repeat characters and cluster inside a + * region of the set, so resuming where the last press landed is usually + * several turns saved and never more. + * + * This is a property of the entry screen that nothing asserted, and it is + * the sort of thing a later edit silently reverses by adding one + * "sensible" reset. + */ + kiln_hmi_t h; kiln_hmi_init(&h, 0); + kiln_hmi_view_t v = base_view(); + v.net_count = 1; + v.net_list_secured[0] = true; + (void)snprintf(v.net_list_ssid[0], KILN_HMI_SSID_LEN, "workshop"); + + (void)feed(&h, &v, KILN_INPUT_PRESS); + for (int i = 0; i < 3; i++) { (void)feed(&h, &v, KILN_INPUT_CW); } + (void)feed(&h, &v, KILN_INPUT_PRESS); /* network screen */ + (void)feed(&h, &v, KILN_INPUT_PRESS); /* the scan list */ + CHECK_EQ_INT(h.screen, KILN_HMI_SCREEN_NETWORKS); + (void)feed(&h, &v, KILN_INPUT_PRESS); /* secured, so type */ + CHECK_EQ_INT(h.screen, KILN_HMI_SCREEN_PASSPHRASE); + + /* Entry starts at the first character, which is right for the first one. */ + CHECK_EQ_UINT(h.charset_sel, 0u); + + for (int i = 0; i < 7; i++) { (void)feed(&h, &v, KILN_INPUT_CW); } + const uint8_t landed = h.charset_sel; + CHECK(landed == 7u); + (void)feed(&h, &v, KILN_INPUT_PRESS); + CHECK_EQ_UINT(h.pass_len, 1u); + CHECK_MSG(h.charset_sel == landed, + "the knob went back to the start after a press, which costs the " + "operator the whole way round again for a repeated character"); + + /* And again, so the second press is not a special case either. */ + (void)feed(&h, &v, KILN_INPUT_PRESS); + CHECK_EQ_UINT(h.pass_len, 2u); + CHECK_EQ_UINT(h.charset_sel, landed); + CHECK_EQ_INT(h.pass[0], h.pass[1]); /* the same character twice */ +} diff --git a/firmware/controller/test/host/unit/test_stores.cpp b/firmware/controller/test/host/unit/test_stores.cpp index 3753e8a..0bbd83f 100644 --- a/firmware/controller/test/host/unit/test_stores.cpp +++ b/firmware/controller/test/host/unit/test_stores.cpp @@ -7,7 +7,6 @@ #include #include "kiln_check.h" -#include "kiln_app/program_store.h" #include "kiln_app/run_index.h" #include "kiln_app/settings.h" #include "kiln_core/profile.h" @@ -175,159 +174,69 @@ static kiln_program_t named(const char *name, uint16_t target) return p; } -/* - * @relation(SWR-PRG-09, scope=function) +/* --- the programs, which are now firmware (SWR-PRG-09) ------------------ + * + * Six tests used to live here, over a store that saved programs to flash, + * replaced them by name, refused to overwrite the read-only examples, filled + * twenty slots and then said no, and re-validated what it read back because a + * stored program is untrusted input. + * + * All of it is gone, with the store. SWR-PRG-07, SWR-PRG-08 and SWR-PRG-10 are + * withdrawn -- nothing authors a program on this device -- and the three + * profiles that exist arrive in the image, so persisting them was keeping a + * copy of something the firmware already contained, in a medium that can wear + * out, behind a seeding path that could fail. + * + * What survives is the property that actually matters, and it is now checkable + * without a medium at all: the programs the firmware carries are valid + * programs. A profile compiled in wrong is a firing that fails at the moment + * somebody starts it. */ -KILN_TEST(swrprg09_the_examples_are_seeded_once_and_are_read_only) -{ - kiln_host_fs_t fs; - kiln_port_filestore_t port; - kiln_host_fs_init(&fs); - kiln_host_fs_bind(&fs, &port); - - CHECK_OK(kiln_program_store_seed(&port)); - const uint8_t n = kiln_profile_example_count(); - CHECK_EQ_UINT(kiln_program_store_count(&port), n); - - const uint32_t writes = fs.writes; - /* Idempotent, so it can run on every boot without rewriting flash. */ - CHECK_OK(kiln_program_store_seed(&port)); - CHECK_EQ_UINT(fs.writes, writes); - CHECK_EQ_UINT(kiln_program_store_count(&port), n); - - kiln_program_t example; - CHECK_OK(kiln_profile_example(0, &example)); - - /* SWR-PRG-09: neither overwritable nor deletable, so the operator always has - * a known-good program to fall back to. */ - kiln_program_t edited = example; - edited.segments[0].target_c = 500; - CHECK_ERR(kiln_program_store_save(&port, &edited, 1280.0f), KILN_ERR_STATE); - CHECK_ERR(kiln_program_store_delete(&port, example.name), KILN_ERR_STATE); -} - -KILN_TEST(swrprg_programs_round_trip_and_replace_by_name) -{ - kiln_host_fs_t fs; - kiln_port_filestore_t port; - kiln_host_fs_init(&fs); - kiln_host_fs_bind(&fs, &port); - - const kiln_program_t p = named("my firing", 900); - CHECK_OK(kiln_program_store_save(&port, &p, 1280.0f)); - - kiln_program_t out; - CHECK_OK(kiln_program_store_load(&port, "my firing", &out)); - CHECK_STR_EQ(out.name, "my firing"); - CHECK_EQ_UINT(out.segments[0].target_c, 900u); - - /* Saving the same name replaces rather than accumulating. */ - const kiln_program_t edited = named("my firing", 1000); - CHECK_OK(kiln_program_store_save(&port, &edited, 1280.0f)); - CHECK_EQ_UINT(kiln_program_store_count(&port), 1u); - CHECK_OK(kiln_program_store_load(&port, "my firing", &out)); - CHECK_EQ_UINT(out.segments[0].target_c, 1000u); - - CHECK_OK(kiln_program_store_delete(&port, "my firing")); - CHECK_ERR(kiln_program_store_load(&port, "my firing", &out), KILN_ERR_NOT_FOUND); - CHECK_ERR(kiln_program_store_delete(&port, "my firing"), KILN_ERR_NOT_FOUND); -} /* - * @relation(SWR-PRG-05, scope=function) - */ -KILN_TEST(swrprg05_an_invalid_program_never_reaches_storage) -{ - kiln_host_fs_t fs; - kiln_port_filestore_t port; - kiln_host_fs_init(&fs); - kiln_host_fs_bind(&fs, &port); - - kiln_program_t bad = named("too hot", 1340); - CHECK_ERR(kiln_program_store_save(&port, &bad, 1280.0f), KILN_ERR_RANGE); - CHECK_EQ_UINT(kiln_program_store_count(&port), 0u); - - bad = named("", 900); - CHECK_ERR(kiln_program_store_save(&port, &bad, 1280.0f), KILN_ERR_RANGE); - CHECK_EQ_UINT(fs.writes, 0u); -} - -/* - * @relation(SWR-PRG-04, scope=function) + * @relation(SWR-PRG-09, scope=function) */ -KILN_TEST(swrprg04_the_store_holds_twenty_programs_and_then_says_no) +KILN_TEST(swrprg09_every_compiled_in_program_is_a_valid_program) { - kiln_host_fs_t fs; - kiln_port_filestore_t port; - kiln_host_fs_init(&fs); - kiln_host_fs_bind(&fs, &port); - - char name[KILN_PROGRAM_NAME_LEN]; - for (unsigned i = 0; i < KILN_PROGRAM_SLOTS; i++) { - (void)snprintf(name, sizeof(name), "program %u", i); - const kiln_program_t p = named(name, 900); - CHECK_OK(kiln_program_store_save(&port, &p, 1280.0f)); + const uint8_t n = kiln_profile_example_count(); + CHECK(n > 0u); + CHECK(n <= KILN_MAX_PROGRAMS); + + for (uint8_t i = 0; i < n; i++) { + kiln_program_t p; + CHECK_OK(kiln_profile_example(i, &p)); + + /* Named, terminated, and not empty: the name is what the menu and the + * API show, and the run record keeps a copy of it. */ + CHECK(p.name[0] != '\0'); + CHECK(strnlen(p.name, KILN_PROGRAM_NAME_LEN) < KILN_PROGRAM_NAME_LEN); + + /* Validates against the default ceiling of SWR-SAF-01. An example that + * does not is one an operator cannot start, which they would discover + * by selecting it. */ + CHECK_MSG(kiln_profile_validate(&p, 1280.0f).code == KILN_PROG_OK, + "example %u (%s) does not validate", i, p.name); + CHECK(p.segment_count > 0u); + CHECK(kiln_profile_peak_c(&p) > 0.0f); } - CHECK_EQ_UINT(kiln_program_store_count(&port), KILN_PROGRAM_SLOTS); - CHECK_EQ_UINT(KILN_PROGRAM_SLOTS, KILN_MAX_PROGRAMS); - - const kiln_program_t one_too_many = named("overflow", 900); - CHECK_ERR(kiln_program_store_save(&port, &one_too_many, 1280.0f), KILN_ERR_NO_SPACE); - - /* But replacing an existing one still works when full, which is the case a - * naive "is there a free slot" check gets wrong. */ - const kiln_program_t replace = named("program 3", 950); - CHECK_OK(kiln_program_store_save(&port, &replace, 1280.0f)); -} - -/* - * @relation(SWR-NFR-19, scope=function) - */ -KILN_TEST(swrnfr19_a_stored_program_is_treated_as_untrusted_on_the_way_back_in) -{ - kiln_host_fs_t fs; - kiln_port_filestore_t port; - kiln_host_fs_init(&fs); - kiln_host_fs_bind(&fs, &port); - - const kiln_program_t p = named("ok", 900); - CHECK_OK(kiln_program_store_save(&port, &p, 1280.0f)); - - /* Scribble over the stored name so it has no terminator, then fix the CRC so - * it passes -- which is what a firmware with a different struct layout, or a - * sufficiently unlucky corruption, looks like. */ - CHECK(fs.files[0].used); - uint8_t *blob = fs.files[0].data; - memset(&blob[8], 'A', KILN_PROGRAM_NAME_LEN); - const size_t n = fs.files[0].len; - const uint16_t crc = kiln_crc16(blob, n - 2); - blob[n - 2] = (uint8_t)crc; - blob[n - 1] = (uint8_t)(crc >> 8u); - - kiln_program_t out; - CHECK_OK(kiln_program_store_get_slot(&port, 0, &out)); - /* Terminated on the way in, so nothing downstream reads off the end. */ - CHECK_EQ_UINT(strlen(out.name), KILN_PROGRAM_NAME_LEN - 1u); -} - -KILN_TEST(a_corrupt_program_file_is_skipped_not_returned) -{ - kiln_host_fs_t fs; - kiln_port_filestore_t port; - kiln_host_fs_init(&fs); - kiln_host_fs_bind(&fs, &port); - - const kiln_program_t a = named("good", 900); - CHECK_OK(kiln_program_store_save(&port, &a, 1280.0f)); - const kiln_program_t b = named("also good", 800); - CHECK_OK(kiln_program_store_save(&port, &b, 1280.0f)); - fs.files[0].data[20] ^= 0xFFu; /* corrupt the first */ + /* Names are distinct, because they are what the operator picks by. */ + for (uint8_t i = 0; i < n; i++) { + for (uint8_t k = (uint8_t)(i + 1u); k < n; k++) { + kiln_program_t a, b; + CHECK_OK(kiln_profile_example(i, &a)); + CHECK_OK(kiln_profile_example(k, &b)); + CHECK_MSG(strcmp(a.name, b.name) != 0, + "examples %u and %u are both called %s", i, k, a.name); + } + } - CHECK_EQ_UINT(kiln_program_store_count(&port), 1u); + /* And an index past the end is refused rather than returning whatever is + * next in memory: the id comes off a URL (/api/programs/{id}). */ kiln_program_t out; - CHECK_ERR(kiln_program_store_load(&port, "good", &out), KILN_ERR_NOT_FOUND); - CHECK_OK(kiln_program_store_load(&port, "also good", &out)); + CHECK_ERR(kiln_profile_example(n, &out), KILN_ERR_NOT_FOUND); + CHECK_ERR(kiln_profile_example(255, &out), KILN_ERR_NOT_FOUND); + CHECK_ERR(kiln_profile_example(0, nullptr), KILN_ERR_INVALID_ARG); } /* --- run records (SWR-RUN-07, SWR-LOG-09) -------------------------------- */ @@ -485,24 +394,28 @@ KILN_TEST(swrsaf12_the_baseline_comes_from_the_stored_run_history) KILN_TEST(an_atomic_replace_leaves_the_previous_content_on_a_power_cut) { - /* The property atomic-by-rename buys, and the one a plain write loses: half - * a program on disk is worse than the old program. */ + /* The property atomic-by-two-copies buys, and the one a plain write loses: + * half a record on disk is worse than the old record. Driven through the + * run index, which is what still writes to this store now that the + * programs are compiled into the image. */ kiln_host_fs_t fs; kiln_port_filestore_t port; kiln_host_fs_init(&fs); kiln_host_fs_bind(&fs, &port); - const kiln_program_t a = named("keeper", 900); - CHECK_OK(kiln_program_store_save(&port, &a, 1280.0f)); + kiln_run_record_t a = run(1, KILN_END_COMPLETE); + a.duration_s = 3600u; + CHECK_OK(kiln_run_index_append(&port, &a)); fs.cut_power_at_write = fs.writes + 1u; - const kiln_program_t b = named("keeper", 1000); - CHECK_ERR(kiln_program_store_save(&port, &b, 1280.0f), KILN_ERR_IO); + kiln_run_record_t b = a; + b.duration_s = 7200u; + (void)kiln_run_index_append(&port, &b); fs.powered = true; - kiln_program_t out; - CHECK_OK(kiln_program_store_load(&port, "keeper", &out)); - CHECK_EQ_UINT(out.segments[0].target_c, 900u); /* the old one, intact */ + kiln_run_record_t out = {}; + CHECK_OK(kiln_run_index_find(&port, 1u, &out)); + CHECK_EQ_UINT(out.duration_s, 3600u); /* the old one, intact */ } KILN_TEST(the_stores_validate_their_arguments) @@ -512,13 +425,7 @@ KILN_TEST(the_stores_validate_their_arguments) kiln_host_fs_init(&fs); kiln_host_fs_bind(&fs, &port); - kiln_program_t p; kiln_run_record_t r; - CHECK_ERR(kiln_program_store_seed(NULL), KILN_ERR_INVALID_ARG); - CHECK_ERR(kiln_program_store_save(&port, NULL, 1280.0f), KILN_ERR_INVALID_ARG); - CHECK_ERR(kiln_program_store_load(&port, NULL, &p), KILN_ERR_INVALID_ARG); - CHECK_ERR(kiln_program_store_get_slot(&port, 200, &p), KILN_ERR_INVALID_ARG); - CHECK_EQ_UINT(kiln_program_store_count(NULL), 0u); CHECK_ERR(kiln_run_index_append(&port, NULL), KILN_ERR_INVALID_ARG); CHECK_ERR(kiln_run_index_get_slot(&port, 200, &r), KILN_ERR_INVALID_ARG); diff --git a/tasklist.md b/tasklist.md index 7e5e8aa..91dbccf 100644 --- a/tasklist.md +++ b/tasklist.md @@ -35,7 +35,7 @@ reused: gaps in the numbering are items that have been closed. | | | Open | |---|---|---| | [A](#a-schematic-and-pcb) | Schematic and PCB | 22 | -| [C](#c-build-test-and-ci-infrastructure) | Build, test and CI | 8 | +| [C](#c-build-test-and-ci-infrastructure) | Build, test and CI | 7 | | [D](#d-documentation-and-open-questions) | Documentation and open questions | 2 | | [F](#f-static-analysis) | Static analysis | 2 | | [G](#g-door-interlock-sr-31) | Door interlock | 3 | @@ -47,7 +47,7 @@ reused: gaps in the numbering are items that have been closed. | [M](#m-kiln_hmi-the-local-interface) | `kiln_hmi` | 3 | | [N](#n-security-obligations-and-the-cyber-resilience-act) | Security obligations and the CRA | 5 | | [O](#o-wifi-fr-net) | WiFi | 4 | -| [P](#p-http-transport-and-the-api) | HTTP transport and the API | 4 | +| [P](#p-http-transport-and-the-api) | HTTP transport and the API | 3 | | [Q](#q-the-file-store-ad-21) | The file store | 5 | | [R](#r-the-independent-safety-supervisor-ad-22) | Independent safety supervisor | 6 | @@ -236,11 +236,6 @@ reused: gaps in the numbering are items that have been closed. - [ ] **C7. `tools/logdump`.** Decode a log partition dump to CSV, also the cross-check for the `logrec` codec. -- [ ] **C9. Vendor the web assets into the firmware.** `web/` is no longer empty: - it carries the UI, read-only and translated. What is missing is the build step - that gzips it into `kiln_web/assets` so `SWA-11` holds and the assets ship - inside the image, within the budget of architecture §12.4. - - [ ] **C11. `main.cpp` and `httpd.cpp` are analysed by nothing.** Found while starting section P. `tools/tidy.sh` drives from the host compile database, which cannot reach either file; `tools/tidy-target.sh` hardcodes @@ -447,15 +442,21 @@ question is whether it is worth its cost. ## J. German translation, SWR-NFR-23 -- [ ] **J1. The OLED font and encoding are unverified.** The labels avoid - umlauts for that reason, but nobody has yet confirmed what the SSD1306 font - in `kiln_hmi` will carry. If it does handle Latin-1 or a UTF-8 subset, the - labels can use proper German and the test's 16-character bound should be - re-checked against the real glyph widths rather than character count. - -- [ ] **J2. The long causes are not shown on the display yet.** `kiln_hmi` does - not exist, so only the web renders them. When the fault screen lands it will - need to wrap German text, which runs roughly 15 per cent longer than English. +- [x] **J1. The font carries ASCII and nothing else. Answered 2026-10-09.** + Not a hardware question after all: `draw.cpp` defines the 95 printable ASCII + glyphs, so there are no umlauts and no sharp s to draw with, at any encoding. + Every German string in the firmware is therefore transliterated (AE OE UE + SS), and a test over `kiln_hmi/strings` rejects any byte outside 0x20 to 0x7E + so that one umlaut typed into the table cannot reach a workshop as a blank. + The bound that matters is 21 characters, the panel's width at scale 1, and it + is asserted on every string rather than on a sample. + +- [x] **J2. The display renders the long causes. Done.** `kiln_hmi` exists and + the fault screen word-wraps the cause from `kiln_core/faults` in the + configured language, at 21 columns, wrapping on spaces rather than cutting + words -- these sentences are the operator's instructions, and a word split + across two lines in a hurry reads as a different word. German's extra 15 per + cent is absorbed by the wrap rather than by a shorter translation. - [ ] **J3. Config item names and units are still English.** `/api/config` returns keys like `safety.max_temp_c` with English descriptions. The keys @@ -471,29 +472,37 @@ question is whether it is worth its cost. ## K. Hardware interlock chain -- [ ] **K1. The hardware TC interlock is not firmware-independent, and the - documentation now says so.** The `FAULT` outputs are open-drain: an unpowered - or absent front end leaves the path closed. The MAX31856 also detects an open - circuit only once its fault mask is configured, so out of reset the interlock - does not act. It covers faults the device actively reports; `SWR-SAF-04` in - firmware remains the cover for a dead or unconfigured front end. The lid - contact has no such caveat. - -- [ ] **K2. Should an enclosure thermocouple fault really stop the kiln?** - Both front ends are in the chain, because the request said fault pins. But - `SWR-SAF-11` (enclosure over-temperature) is a backstop, and a failed enclosure - probe killing a firing mid-glaze is a nuisance trip, which `HZ-10` says is - how protections get disabled. Consider a fitted-by-default `0R` in `Q5`'s - drain so the enclosure branch can be depopulated without cutting a track. - - Decide this together with the parts analysis that used to live in - `docs/bom-optimisation.md` (removed from the repository; see its history), - which reached the same question from the parts end: if this branch is depopulatable then the - MAX31856 on the enclosure channel has nothing left to justify it, since it is - bought for its `FAULT` pin rather than for measuring 40 to 90 degC. That - document's preferred option removes the channel entirely, using the chamber - front end's own cold-junction reading for `SWR-SAF-11` and a bimetallic cutout in - the coil for a hardware trip. +**Superseded 2026-10-10 by the independent supervisor (`R7`).** The discrete +chain in this section existed to put the thermocouple front ends' `FAULT` +outputs in series with the contactor coil (`SYS-HW-24`). The supervisor reads +the chamber front end itself, decides on a persistent fault, and holds its own +series element, which is the same property delivered without the chain's +caveats: a front end that is dead, absent or unconfigured does not answer the +supervisor either, and not answering is a trip rather than a documented limit. + +`SYS-HW-24` is rewritten rather than deleted, because what it reached for is now +delivered differently, and it keeps the one part worth keeping: each `FAULT` net +retains its own test point. + +- [x] **K1. Answered rather than mitigated.** The chain was not + firmware-independent: open-drain outputs leave the path closed when a front + end is unpowered or absent, and a MAX31856 reports an open circuit only once + its fault mask has been configured, which is firmware work. That is why this + was recorded as a limitation rather than a protection. The supervisor has + neither caveat. + +- [x] **K2. Dissolved.** The question was whether an enclosure thermocouple + fault should stop a firing, given that `SWR-SAF-11` is a backstop and a + nuisance trip is how protections get disabled (`HZ-10`). The enclosure + channel is not in the supervisor's remit at all, by decision: it stays an + ESP32 rule and can never cause the supervisor's trip. There is no branch to + depopulate because there is no chain. + +- [x] **K5. Reduced to three tests, and they are still hardware tests.** Four + elements in series became three: the lid contact, the controller's charge + pump, and the supervisor's permit. Each must drop the contactor on its own, + which is `G2`'s class of claim and cannot be settled in simulation. The + `FAULT`-pin tests are gone with the chain. - [ ] **K3. `D7` is an unwired LED.** Pre-existing, not from this change: the coil indicator's cathode is on `COIL_DRV` and its anode goes nowhere. It @@ -501,17 +510,13 @@ question is whether it is worth its cost. `LID_SWITCH` rather than `+5V` makes it indicate "coil actually energised" rather than "the MCU asked". -- [ ] **K4. The PCB carries section K, but is not routed.** - `update_pcb_from_schematic` has been run and the interlock chain's parts are - on the board (`J9`, `Q5`, `Q6`, `R28`–`R31`); placement is started, with 74 - footprints positioned. **There are no tracks and no vias on the board at - all**, and the coil interrupt is a mains-adjacent net, so routing it is not - a formality: see `A23`. - -- [ ] **K5. HIL: verify each interrupt separately.** Four elements in series - means four tests: open the lid, pull each `FAULT` low, and halt the safety - task. Each should drop the contactor on its own. This is the same class of - claim as `G2` and cannot be settled in simulation. +- [ ] **K4. The board carries the chain that is no longer wanted, and is not + routed.** `J9`, `Q5`, `Q6` and `R28`–`R31` are placed on the PCB for an + interlock the design has dropped, and the supervisor's own parts are not on + it at all: that is `R6`, the schematic. **There are no tracks and no vias on + the board**, and the coil interrupt is a mains-adjacent net, so routing it is + not a formality: see `A23`. Depopulating the chain's parts is the smaller + half of this; the supervisor and its series element are the larger. --- @@ -641,24 +646,35 @@ weakness first, the deadline second, the sentence third, the engineering last. ## O. WiFi, FR-NET -- [ ] **O1. `SWR-NET-04` (mDNS, `kiln.local`) is not implemented.** mDNS left - the ESP-IDF tree for the component manager, and `UR-CON-04` forbids a - build-time fetch from an unpinned source. `E7` met the same constraint over - the file store and resolved it by not needing the dependency at all; mDNS has - no such escape, since the protocol is the feature. Adding a managed - dependency for a convenience feature would be the wrong trade against a - constraint the project applies everywhere else, so the device is reachable by - IP until mDNS is vendored deliberately. The address is on the HMI network screen, which is - where an operator would look anyway. - -- [ ] **O2. The image grew by 540 kB.** WiFi takes it from 292 kB to 831 kB, - which is 60.4 % of the OTA slot still free, so `SWR-NFR-13` holds comfortably. - Worth watching once the web assets are embedded (`C9`): that budget is - architecture 12.4's and it has not been measured against a real asset build. - -- [ ] **O3. Untested against a radio.** Same class as `L2`: it compiles and - passes analysis. Nobody has watched it associate, recover from a dropped - connection, or sync time. +- [x] **O2. The hardware image is 945 kB, 54 % of the slot free.** A record + rather than a task, re-measured 2026-10-10 after mDNS, the embedded assets, + the event stream and the German strings all landed: 0xec520 bytes against a + 2 MB slot. The + simulated build that CI publishes is **246 kB** and contains no WiFi and no + HTTP server at all, which is worth keeping straight when reading a size + report: `CONFIG_KILN_PLANT_SIM` compiles both out. + + `SWR-NFR-13` holds with room. The web assets measure **18 kB gzipped** in + total against architecture 12.4's 44 kB budget (`index.html` 2.2, `app.css` + 3.6, `app.js` 8.6, `chart.js` 4.2). That budget had never been measured; it + has now, and it passes. Of the growth since 888 kB, mDNS is 32 kB and the + assets 18 kB; the rest is the event stream, the string table and the + supervisor link. + +- [ ] **O3. Untested against a radio, and until today it did not even + compile.** The claim here used to be "it compiles and passes analysis", and + that was false in a way nothing could have caught: every CI job builds + `sdkconfig.qemu`, which sets `CONFIG_KILN_PLANT_SIM` and compiles the WiFi, + the HTTP server and the hardware path out of `main.cpp`. The configuration + that ships was guarded by no build at all. + + Built for the first time on 2026-10-09 and it failed on two + `-Wformat-truncation` errors, one of them older than the WiFi work. Both are + fixed and CI now builds the hardware configuration on every push, so this + cannot recur. + + What remains is what the heading says. Nobody has watched it associate, + recover from a dropped connection, or sync time. The WiFi setup of `O4` is in the same position and matters more, because it is now the **only** way credentials reach the device: a scan that returns @@ -668,8 +684,9 @@ weakness first, the deadline second, the sentence third, the engineering last. containing symbols, power-cycle and confirm it comes back, then join a different network to confirm it replaces rather than accumulates. -- [ ] **O4. The provisioning AP is gone; WiFi is set up at the display.** - Done in firmware and unproven on hardware. `SWR-NET-02` and `SWR-NET-05` are +- [x] **O4. The provisioning AP is gone; WiFi is set up at the display.** + Done in firmware and unproven on hardware, which is `O3`'s session rather + than a task of its own. `SWR-NET-02` and `SWR-NET-05` are withdrawn, `SWR-NET-11` and `SWR-NET-12` replace them, `net.ap_ssid` and `net.ap_pass` leave the schema, and `TH-11`, `SRR-08` and `OQ-S4` close with them: the surface is deleted rather than regulated. @@ -680,128 +697,287 @@ weakness first, the deadline second, the sentence third, the engineering last. through the composition root, persisting credentials before attempting the radio. - What is left: - - - **The German strings** (`J`). The new screens are English-only, which - `SWR-NFR-23` does not allow. - - **A rotary encoder is a poor keyboard**, and 95 characters is a long way - round. If hardware testing finds this painful, the cheapest improvement is - to start the knob on the character the previous press landed on rather than - at `a`, which costs one byte of state. - - **Nothing shows the join failing.** The display returns to the network - screen and the operator infers it from "not connected". A result line, - including the radio's reason code, belongs on that screen. + What was left on 2026-10-09 is now done, except the hardware session: + + - **The display speaks German.** Every screen title, the whole menu, both + answers to a confirmation and every footer were English literals at their + draw sites, so a device set to German announced its faults in German inside + an English frame. They are now one table in `kiln_hmi/strings`, 46 strings + in two languages, with the transliteration `kiln_core/faults` has always + used because the 5x7 font carries the 95 printable ASCII glyphs and nothing + else. That answers `J1` from the code rather than from a hardware session. + Six tests: nothing missing, nothing over the panel's 21 columns, no byte + the font cannot draw, an unknown language falls back to English, and the + menu and the confirmation actually change when the language does. `J4` + still stands: no native speaker has read it. + + - **The knob resumes where the last character left it**, and always did; what + was missing was a test saying so, which is now there. A passphrase repeats + characters and clusters inside a region of the set, so not sending the knob + back to `a` is usually several turns saved and never more, and it is the + sort of property one "sensible" reset silently reverses. + + - **A failed join says so, with the radio's reason code.** `last_reason` was + recorded in the adapter and reported to nobody. The network screen now + distinguishes a join in flight from one the radio refused, and prints the + code: 15 is a failed four-way handshake, which is a wrong passphrase in + almost every case, and 201 is "no AP of that name answered". The number is + there to be read out over a phone to somebody who can look it up, which + beats a sentence that guesses. With the display the only route in, guessing + was the entire cost of getting it wrong. + + What remains is the hardware session of `O3`, and nothing else. --- ## P. HTTP transport and the API -- [ ] **P1. The UI is not served.** The transport answers `/api/*` and nothing - else: `C9`'s asset embedding is not done, so there is no `index.html` in the - image and a browser at the device's address gets a 404. The API is usable - with `curl` today. `SWA-11` wants the assets gzipped into the image, which - also needs the budget of architecture 12.4 measured for the first time. - -- [ ] **P2. Server-Sent Events are not implemented.** `SWR-WEB-05` wants live - values pushed at least once a second, and `kiln_api_telemetry_event()` is - written and tested for exactly that, but the transport has no `/api/events` - handler. The UI falls back to nothing: it reads `/api/status` on a timer - already, so this is a refinement rather than a gap in function. - -- [ ] **P3. Where do firing programs come from now?** Recorded as `OQ-09`. - Authoring is gone from the web and there is no local editor, so a user - cannot create a curve of their own: they get the seeded examples of - `SWR-PRG-09` and nothing else. This is a real functional gap and the most - likely thing to make somebody reverse the read-only decision. A file import - at provisioning time is probably the cheapest answer. - -- [ ] **P4. Untested against a client.** It compiles and passes analysis. No - request has been made of it. The host API suite covers every route's - behaviour, so what is unverified is specifically the socket half: URI - splitting, chunked streaming, the four-session limit, and whether a slow - client can hold a buffer long enough to matter. +- [x] **P2. The telemetry stream is implemented. Done 2026-10-10.** The + payload had been written and tested since `kiln_api_telemetry_event` landed, + and the browser had been asking for it all along: `app.js` opens an + `EventSource` on `/api/events` and listens for a `telemetry` event. Nothing + answered, so the dashboard went stale after four seconds exactly as + `SWR-WEB-25` says it should. + + It does not stream from the handler, and that is the whole design. + `esp_http_server` runs one task and serialises handlers on it, so a handler + looping once a second would hold that task for as long as a browser tab + stayed open and the kiln would appear to hang the moment somebody left a + dashboard open and then loaded the log. Instead the handler parks the socket + with `httpd_req_async_handler_begin` and returns; one task at priority 3 + pushes to at most two parked subscribers once a second, with a comment frame + every fifteen pushes so an idle proxy does not drop a stream that is working. + A third subscriber gets `503` with `Retry-After`. Six sockets rather than + four, because `SWR-WEB-21` counts clients and a client with the dashboard + open needs a second socket for everything else. + + The framing is in `kiln_web/api`, host-tested, and shared with the + development harness: a frame that does not fit is not written at all and a + payload with a newline is refused, because half a frame does not fail + visibly, it moves the frame boundary into the middle of a JSON document and + every frame after it is misread. Five tests. + + Verified against a real client, not only by unit test: the harness served + four frames in three and a half seconds over a socket, each + `event: telemetry` with a parseable payload and a rising temperature. That + also found a bug nothing else would have: the harness announced + `Transfer-Encoding: chunked` on the event stream and then sent unframed SSE, + a response whose body contradicts its own headers, which a browser may + reject outright. The log stream is chunked and stays chunked; the event + stream now declares neither length nor chunking and ends with the + connection, which is what an event stream is. + +- [x] **P3. The program store is gone. Done 2026-10-10.** The decision was + pre-compiled profiles with nothing editing them, which left `SWR-PRG-04` + asking for 20 program slots in non-volatile storage to hold a copy of the + three programs that arrive in the image. It is withdrawn, the store is + deleted, and the profiles are read from `kiln_profile_example` by the API, + the HMI and the development harness alike. + + It is a deletion that makes the device better rather than smaller: + + - **The local start path worked for the first time.** `KILN_HMI_ACT_START` + returned `KILN_ERR_NOT_FOUND` on hardware with a note that there was no + program store, and the view builder never filled the program list, so a + device carrying three firing profiles in its own flash showed "none + stored" and its menu's first entry did nothing. Both are now wired to the + compiled-in profiles, which is `SWR-HMI-10` delivered rather than written. + - **A device whose flash will not mount still has its programs.** The list + used to answer `503` for programs the firmware was carrying. + - **A factory reset no longer costs the operator their programs.** The + partition holds run history, and losing history is what a reset is for. + - **The first boot has nothing to get wrong.** Seeding was idempotent and + tested, and still a flash write on every power-on of a new device. + + About 200 lines of implementation and nine tests went with it. The three + properties worth keeping were re-pointed rather than dropped: every + compiled-in example is a valid program with a distinct name and an index past + the end is refused, the atomic-replace-on-power-cut property is now driven + through `run_index`, which is what still writes to the store, and the + partition-sharing test became the measurement that 20 of 64 regions are used. + The mount cost of `Q2` halves with it: **488 reads and 31 888 bytes**. + + `SWR-PRG-05` stays, and the reason is `safety.max_temp_c`: the programs are + trusted as data, but an operator can lower the ceiling below a program's + peak, and validation on start is what refuses that firing rather than running + it into a limit. + +- [ ] **P4. The API has been driven by a client; `esp_http_server` has not.** + Narrowed on 2026-10-10 rather than closed. The development harness is a real + socket server running the same handler code, and it has now answered real + requests over TCP: the program routes, an id past the end as `404`, the + storage response, a log query with its chunk framing and terminator, and four + SSE frames in three and a half seconds each carrying a parseable payload. + That found a bug no unit test would have, the harness announcing + `Transfer-Encoding: chunked` and then sending unframed SSE. + + What remains unverified is specifically `esp_http_server`, which the harness + does not use and which no host test can reach: URI splitting in its parser, + `httpd_resp_send_chunk` against a real client, the socket limit with two + sessions parked on streams, the async handler's lifetime when a browser tab + closes mid-push, and whether a slow client can hold the shared 4 kB response + buffer long enough to matter. All of it needs the device. --- ## Q. The file store, SWA-21 -- [ ] **Q1. The store has never seen a worn sector.** Every test runs on a fake - that writes what it is told. A real NOR sector near the end of its life fails - a program or reads back something it was not given, and the store's answer to - that is the CRC, which rejects the copy and falls back to the other one. That - is the designed behaviour and it is tested, but it has been tested against - simulated corruption rather than against a worn part. There is no bad-region - retirement: a region whose both sectors have failed will keep being chosen - and keep failing, where a filesystem would have remapped it. At 40 regions of - two sectors each, written single-digit times a year against a 100 000 cycle - rating, that is a defensible trade rather than an oversight, but it is a trade - and this is where it is recorded. - -- [ ] **Q2. The index is built by reading every region at every boot.** 64 - regions, two copies each, each validated by streaming its payload through the - CRC. That is up to 128 sector reads and about 320 kB of CRC at startup, - measured at nothing in particular because it has only ever run under QEMU and - on the host. If it turns out to cost real time on a board it can be made lazy, - since the only thing mount actually needs eagerly is which copy of each region - wins. Worth measuring before it is worth optimising. - -- [ ] **Q3. `exists` and `list` are implemented and have no caller.** They are - in the port contract, so the store provides them and the tests cover them, but - `program_store` and `run_index` use neither. They are the natural way a local - program editor would enumerate what is there, which is `P3`, so they are kept - rather than removed. - -- [ ] **Q4. A name longer than 63 bytes is refused rather than truncated.** - `KILN_PATH_MAX` is 64 including the terminator and the name field is exactly - that, so a longer path returns `KILN_ERR_INVALID_ARG`. Nothing generates one - today, since both callers produce five-character slot paths, and refusing is - the right answer rather than silently storing a different file than the caller - asked for. Noted because a future caller with user-supplied names will meet it. - -- [ ] **Q5. The run index rewrites more than it needs to.** `kiln_run_index_append` - reads every slot to find the oldest, then writes one. On this store that is 20 - reads served from the in-RAM index plus one region write, which is cheap. But - `kiln_run_index_mark_truncated` and `kiln_run_index_baseline` both walk all 20 - slots too, and the walk is now the only reason `run_index` reads at all. Not a - problem, just the place where the store being an array rather than a directory - would let the index get simpler if it were ever revisited. +- [x] **Q1. Wear is now handled at the write. Done 2026-10-09.** The store had + never seen a worn sector, and the failure mode it was written against was the + wrong one: a NOR sector at the end of its life does not report an error, it + takes the write, returns success and holds something else. The CRC caught that + on the next read, which on this device is after a firing rather than during + the write it could still have redirected. + + Every write is now read back and re-validated through the same path a mount + uses before the index points at it. A copy that does not verify retires its + region and the write is re-attempted in a free region at a higher seq, up to + three regions; the caller gets `KILN_OK`, because the file is stored and only + its location changed. Retirement needs no bad-block table: the old region is + erased best effort after the new copy verifies, and if that erase fails, which + is what a finished sector does, the stale copy stays at a lower seq, two + regions claim one name, and mount resolves it by seq and retires the loser. + The erase is what stops a deleted file from coming back at the next mount. + + Still no wear levelling, and deliberately: the endurance arithmetic of + architecture 10.4 has four orders of magnitude of headroom, so a region keeps + its file for as long as it works. + + Five new host tests, driven by two new knobs on the flash fake that model a + sector which accepts a write and keeps zeros, and a sector that will not + erase. The retired count is logged at boot and is the only warning anybody + gets that the part is going. + +- [x] **Q2. The mount cost is measured. Done 2026-10-09.** It was described + rather than measured, and the description was low by a third. On the medium + this firmware actually produces, a mount costs **488 reads and 31 888 bytes** + of CRC: 20 run records of 554 bytes, both copies valid. It was 768 reads and + 47 808 bytes while the programs were stored too, which `P3` ended. A partition full + of maximum-size files with both copies valid costs **8 192 reads and 524 288 + bytes**, the whole partition. + + Both are asserted in `test_fileslots`, against a model derived in the test + rather than a constant, so making mount lazy fails the suite instead of + happening quietly. 47 kB of flash reads is not a start-up problem on this + part, so there is nothing here to optimise; if a board ever says otherwise, + the only thing mount needs eagerly is which copy of each region wins, and the + payload CRC could move to the read. + +- [x] **Q4. A name longer than 63 bytes is refused rather than truncated. + Recorded 2026-10-09.** `KILN_PATH_MAX` is 64 including the terminator and the + name field is exactly that, so a longer path returns `KILN_ERR_INVALID_ARG`. + Both callers produce five-character slot paths, so nothing generates one + today; storing a file under a name the caller did not ask for is the worse + failure. Now stated where it is enforced, in `port_filestore.h` and + architecture 10.6, with the test that proves it, rather than only here. + +- [x] **Q5. The run index walks all 20 slots, and that is where it stays. + Recorded 2026-10-09.** Append reads every slot to find the oldest, and + `mark_truncated` and `baseline` walk them too. Twenty reads answered from the + in-RAM index plus one region write, once per firing, is not a cost worth a + mechanism. It is the one place where the store being a fixed array rather + than a directory costs anything, and that is now recorded in `run_index.cpp` + where somebody revisiting the file will see it. --- ## R. The independent safety supervisor, SWA-22 -Designed in [`docs/safety-supervisor.md`](docs/safety-supervisor.md). Nothing -below can start until `R1` to `R4` are answered, because each of them changes -either the supervisor's pin count or a requirement. - -- [ ] **R9. Retire `sense.tc_type` from the chamber channel.** The reason is - now in the code: `kiln_suplink`'s `configure` returns - `KILN_ERR_UNSUPPORTED`, because the ESP32 no longer owns that front end. - The config item is therefore dead on this side. `SWR-ACQ-02` now - fixes the chamber couple as type K, and after `SWA-22` the ESP32 does not - configure that front end at all, so the config item is meaningless on this - side. Removing it is a schema change (`KILN_CFG_SCHEMA_VERSION`, - `SWR-CFG-05` migration), so do it in the same commit as the rest of the - supervisor's firmware work rather than bumping the schema twice. - -- [ ] **R5. Write the requirement deltas.** Six new requirements and nine - changed ones, listed in section 11 of the design. `SWR-SAF-23` and `SWR-ACQ-02` are - done; still to do are `SWR-ACQ-01`, `SYS-HW-02`, `SYS-HW-24` superseded, `SWA-04`, - `SWA-05`, `SG-01`, `SG-03` and `safety.md` sections 5 and 6. - - With `R3` and `R4` settled, §6's independence table is now writable and says - three specific things. A new row for the supervisor against the ESP32 that - genuinely says **yes**: separate silicon, firmware, clock and watchdog, - sharing only the 3V3 rail. A new row, or an amendment to the L1-vs-L2 one, - recording that the chamber couple is shared by *both* MCUs, so the - independence is against software and not against a plausible-but-wrong - reading. And the existing "thermal rules vs. current rules" row promoted from - defence in depth to **load-bearing**, because with one couple the CT is the - only physically independent detection channel left in the system. - - `SWR-SAF-17` also needs a sentence: the supervisor's latch does not survive a - power cycle by design, and the system-level obligation is met by the ESP32's - persisted fault. Section 3 of the design states the gap that leaves. +Designed in [`docs/safety-supervisor.md`](docs/safety-supervisor.md). That +intro said nothing below could start until `R1` to `R4` were answered, because +each of them changed the supervisor's pin count or a requirement. They are +answered, and the firmware, the requirement deltas and the link tests are done: +what is left in this section is **hardware**, which is `R6` the schematic and +`R11` the button on the panel. + +- [x] **R9. `sense.tc_type` is retired. Done 2026-10-10.** The chamber front + end belongs to the supervisor, which linearises for type K, so an item that + let this side change the type would change what the supervisor's backstop + means. `kiln_suplink`'s `configure` already refused, which made the config + item a setting an operator could set and believe while nothing acted on it. + + `KILN_CFG_SCHEMA_VERSION` is 2, and the migration is the interesting part. + Every earlier version change added fields, which a prefix copy handles; this + one *removes* a byte from the middle, so every field after the hole sits one + lower. A prefix copy would have landed the whole sensing, control and network + block one byte out of place -- the stored line filter frequency reading as + half of itself plus half a calibration offset -- and the CRC would have + passed on the way in, so nothing would have reported it. A device would come + back from an update quietly mis-calibrated. + + `kiln_config_decode` therefore splices version 1 explicitly, which is + `SWR-CFG-05`'s "older: migrate" met rather than answered with defaults. The + test forges a real version 1 blob and checks fields on both sides of the hole + plus a re-encode round trip. `SWR-CFG-02` records that there is no chamber TC + type, and a test asserts the item does not come back. + + `kiln_app_boot` now passes `KILN_TC_TYPE_K` to the chamber port from the code + rather than from configuration: refused on the product, which is the point, + and correct on the simulated build where the port accepts it. + +- [x] **R5. The requirement deltas are written. Done 2026-10-10.** Six new + requirements and nine changed ones. The supervisor appeared nowhere in the + system or safety requirements before this, which meant the design document + was the only place it existed: a part holding one element of the contactor + coil, with no requirement to satisfy and nothing to verify against. + + **New**, in `02_system_req.sdoc`: `SYS-HW-26` the separate microcontroller + with its own clock, watchdog, front end, series element and programming + interface, and the external pull-down the reset window needs; `SYS-HW-27` the + one-direction report-only link, with no message able to raise the threshold + or clear the latch; `SYS-HW-28` the local clear button, edge triggered, a + stuck line never arming it; `SYS-SAF-25` the compiled-in backstop and + persistent-fault trip; `SYS-SAF-26` the latch and what clears it, including + why it deliberately does not survive a power cycle; `SYS-SAF-27` the 4 Hz + report, silence as a sensor fault, and the trip acting within one acquisition + period whether or not anything is listening. + + **Changed**: `SWR-ACQ-01` (the chamber front end is the supervisor's, and a + report is the controller's sample), `SYS-HW-02` (separate buses on different + microcontrollers, where a shared bus is now exactly what must not happen), + `SYS-HW-24` (superseded, rewritten, test points kept), `SYS-HW-25` (its + freewheel reference follows), `SWA-04` (two supervisors, and which one every + sentence means), `SWA-05` (the charge pump is one of two paths, and why the + supervisor's element is deliberately a static level instead), `SG-01` + (strengthened on MCU failure, and more exposed on the sensor), `SG-03` ("two" + became "at least two", and "independent control" became true). + `SWR-SAF-23` and `SWR-ACQ-02` were already done. + + `safety.md` gains **L2S**, lettered rather than numbered because the layer + numbers are cited from `safety.sdoc`, the firmware and the hardware design, + and renumbering to open a gap would break every citation to no benefit. Its + section says what the layer protects against -- the failure of the entire + controller, which no layer above it could cover -- and what it deliberately + does not do. + + `safety.md`'s system-boundary diagram and its coil-path table are updated + with it: three elements break the coil now rather than four, and the diagram + carries a dashed line between the two front ends that is not a signal, so + that it cannot be read as two independent temperature channels when both + MAX31856 read one junction. + + Section 6's independence table gains the three rows the design asked for: a + genuine **yes** for the supervisor against the controller, sharing only the + 3V3 rail; an explicit **no** for the chamber couple, which both + microcontrollers read from one junction; and the thermal-versus-current row + **promoted from defence in depth to load-bearing**, because with one couple + the CT is the only physically independent detection channel left. The second + part made the processor-failure row writable and the couple row worse at the + same time, and both are said. + +- [x] **R7. What the supervisor supersedes is removed. Done 2026-10-10.** + Section K is superseded: `K1` is answered rather than mitigated, `K2` + dissolves, `K5` drops from four tests to three. `SYS-HW-24` is rewritten + rather than deleted and keeps its test points. `SWR-SAF-31` and `SYS-HW-21` + are unchanged, as the design said they should be: the lid's series contact + depends on no firmware at all, costs nothing and stays, and the lid sense + stays the controller's input because `SWR-SAF-31`'s latch is gated on a + heating state only the controller knows. A supervisor latching on the lid + regardless would trip on every cold load. + + What is left in section K is `K3`, an unwired indicator LED that predates all + of this, and `K4`, which has grown rather than shrunk: the board carries the + chain the design has dropped and does not carry the supervisor at all. - [ ] **R11. The clear button on the panel.** `R4` decided a local button and the firmware implements it (edge triggered, held 0.5 s, and a line stuck low @@ -828,21 +1004,28 @@ either the supervisor's pin count or a requirement. `KILN_PIN_EXP_IO2` or `KILN_PIN_EXP_IO42`, one pin, receive only; `UART0` is the console and must not be used. -- [ ] **R7. Remove what the supervisor supersedes.** `Q5`, `Q6` and `R28` to - `R31` of section K, and `SYS-HW-24` rewritten rather than deleted, because the - property it reached for is now delivered differently. Keep the lid's series - contact (`SYS-HW-21`): it depends on no firmware at all and costs nothing. `K1` - is answered by this change and `K2` dissolves, the enclosure channel not - being in the supervisor's remit. - - The lid is likewise not in it. Its switch breaks the coil in hardware, which - is already safe without firmware, and `SWR-SAF-31`'s latch is gated on a heating - state only the ESP32 knows; a supervisor latching on it regardless would trip - on every cold load. So `SWR-SAF-31` and `SYS-HW-21` are unchanged, the lid sense stays - on the ESP32, and `Q5`'s branch is the only one section K loses. - -- [ ] **R8. The supervisor's firmware, and its own test strategy.** Small - enough to read in one sitting, which is a design constraint and not an - aspiration. It needs its own host-testable core on the same argument as - `SWA-01`, and the link needs a test that proves the ESP32 withholds heat on - silence and on a stale sequence number, which are different failures. +- [x] **R8. The supervisor's firmware and its test strategy. Done 2026-10-10.** + The firmware is 3 488 bytes of program memory across four source files, which + is the design constraint met rather than aspired to, and it has its own + host-testable core on `SWA-01`'s argument: `trip`, `selfcheck`, `max31856` + and the wire protocol all build and run on a development host with no + hardware, with MC/DC over the trip logic and a generated Unit Design + Document. + + The link test this asked for is now there, and it was the piece genuinely + missing. Each stage was tested and the chain was not: `test_suplink` proved + silence and a stale sequence number both become `KILN_TC_FAULT_COMMS`, + `test_safety_thermal` proved that bit becomes `KILN_FAULT_TC_COMMS`, and + other tests proved a latched fault withholds heat. An assembly of proven + parts is not a proven assembly, and the chamber temperature enters this + firmware at exactly one place, over a wire. + + Two integration tests now run the real application with the real decoder + bound as the chamber port, the way `main.cpp` binds it on the board, and + watch the heater: **silence** takes the heat away and opens the contactor + after `SWR-SAF-04`'s grace, and so does **a supervisor repeating itself** -- + well-formed frames, good CRC, plausible temperature, sequence number + stuck -- which is the failure that looks like health and which a link check + counting bytes would have called a working channel while the kiln ran on a + reading from minutes ago. Both assert the kiln was heating first, so neither + can pass for the wrong reason. diff --git a/tools/sbom.py b/tools/sbom.py index d159ab3..a7a8ffd 100755 --- a/tools/sbom.py +++ b/tools/sbom.py @@ -465,6 +465,72 @@ def make_component(name, version, licenses, ctype="library", purl=None, } +def managed_component(name, directory): + """A component the component manager fetched, described as what it is. + + These are the only third-party sources in the image that did not arrive with + ESP-IDF, so they are the only ones whose version an advisory can be matched + against. Describing one as `esp-idf-x` at the IDF version, which is what the + generic path below would do, states the wrong version for the one entry where + the version is the whole point: UR-CON-04 requires the exact pin and the + committed lockfile precisely so that a CRA Article 13(1) question about a + known vulnerability can be answered from the repository. + + Returns None when the directory is not a managed component, so the caller + falls through to treating it as part of IDF. + """ + if "managed_components" not in pathlib.Path(directory).parts: + return None + d = pathlib.Path(directory) + meta = {} + manifest = d / "idf_component.yml" + if manifest.is_file(): + # Read by hand rather than with a YAML parser: this is two flat keys out + # of a file the component manager wrote, and pulling in PyYAML to get + # them would make a build-time gate depend on a package that may not be + # in the CI container's python. + lines = manifest.read_text(errors="replace").splitlines() + for i, line in enumerate(lines): + for key in ("version", "description"): + prefix = key + ":" + if not line.startswith(prefix) or key in meta: + continue + value = line[len(prefix):].strip().strip("'\"") + # The manager writes long values folded, so the rest of the + # sentence sits on the following indented lines. Without this + # the description ends mid-word, which reads as a bug in the + # SBOM rather than as what it is. + for cont in lines[i + 1:]: + if not cont[:1].isspace() or not cont.strip(): + break + value += " " + cont.strip() + meta[key] = value + # `espressif__mdns` is the directory name the manager uses for `espressif/mdns`. + registry_name = name.replace("__", "/", 1) + chash = "" + hashfile = d / ".component_hash" + if hashfile.is_file(): + chash = hashfile.read_text().strip() + version = meta.get("version", "") + description = ( + f"Managed component `{registry_name}` {version or 'at an unknown version'}, " + f"fetched from the ESP-IDF component registry and linked into the image. " + f"Pinned exactly and locked per UR-CON-04") + if chash: + description += f"; registry content hash {chash}" + if meta.get("description"): + description += f". Upstream describes it as: {meta['description']}" + licences = scan_licenses(d) + if not licences: + licences = [NOASSERTION] + return make_component( + registry_name, version, licences, + purl=f"pkg:idf/{registry_name}@{version}" if version else None, + description=description, + supplier="Espressif Systems", + ) + + def web_assets(version=None): """The browser assets, with hashes, and the point they make by being listed. @@ -583,6 +649,16 @@ def build_inventory(build_dir=None, image=None, supervisor_elf=None, repo=ROOT): )) for name, directory in sorted(desc["components"].items()): + managed = managed_component(name, directory) + if managed is not None: + if managed["licenses"] == [NOASSERTION]: + warnings.append(( + "licence", + f"managed component {managed['name']} carries no SPDX tag, " + "and UR-CON-04 allows a fetched component only when its " + "licence is recorded")) + components.append(managed) + continue licences = scan_licenses(directory) description = f"ESP-IDF component `{name}`, linked into the image" if not licences: @@ -932,6 +1008,46 @@ def raises(what, fn): raises("a build dir with no description", lambda: find_build_dir(d)) + # --- managed components, whose version is the point ------------------- + with tempfile.TemporaryDirectory() as tmp: + d = pathlib.Path(tmp) / "managed_components" / "espressif__mdns" + d.mkdir(parents=True) + (d / "idf_component.yml").write_text( + "dependencies:\n idf:\n version: '>=5.0'\n" + "description: Multicast UDP service used to provide local network\n" + " service and host discovery.\n" + "version: 1.14.0\n") + (d / ".component_hash").write_text("b5b3002\n") + (d / "mdns.c").write_bytes(b"/* SPDX-License-Identifier: Apache-2.0 */\n") + c = managed_component("espressif__mdns", str(d)) + check("a managed component is recognised", c is not None, True) + check("named as the registry names it", c["name"], "espressif/mdns") + check("its own version, not IDF's", c["version"], "1.14.0") + check("licence from its sources", c["licenses"], ["Apache-2.0"]) + check("purl identifies it for an advisory match", + c["purl"], "pkg:idf/espressif/mdns@1.14.0") + check("the locked hash is recorded", + "b5b3002" in c["description"], True) + check("and the upstream description, unfolded rather than cut mid-line", + c["description"].endswith( + "Multicast UDP service used to provide local network service " + "and host discovery."), True) + # The version key of a *dependency* is indented and must not be mistaken + # for the component's own, which is the one an advisory is matched on. + check("an indented version key is not the component's", + c["version"], "1.14.0") + plain = pathlib.Path(tmp) / "components" / "freertos" + plain.mkdir(parents=True) + check("a component inside IDF is not treated as managed", + managed_component("freertos", str(plain)), None) + bare = pathlib.Path(tmp) / "managed_components" / "someone__thing" + bare.mkdir(parents=True) + b = managed_component("someone__thing", str(bare)) + check("no manifest, no version asserted", b["version"], NOASSERTION) + check("and no purl rather than a wrong one", b["purl"], None) + check("an untagged managed component is NOASSERTION, not silent", + b["licenses"], [NOASSERTION]) + # --- the documents --------------------------------------------------- product = make_component( "safekiln", "1.4.0", [PROJECT_LICENCE], ctype="firmware", diff --git a/web/app.js b/web/app.js index d4ee7b9..5955bb4 100644 --- a/web/app.js +++ b/web/app.js @@ -86,7 +86,7 @@ const I18N = { save: 'Speichern', copy: 'Kopieren', delete: 'Löschen', - editor: 'Editor', + program: 'Programm', run_history: 'Brandverlauf', dl_csv: 'CSV herunterladen', dl_json: 'JSON herunterladen', @@ -372,24 +372,22 @@ function segRow(seg, i) { rm.title = 'delete segment'; rm.onclick = () => { editing.segments.splice(i, 1); - renderEditor(); + renderProgram(); }; btn.append(rm); tr.append(btn); return tr; } -/* SWR-WEB-26: a viewer, not an editor. Every field is disabled unconditionally - rather than by the program's readonly flag, because nothing here can be - saved whatever the flag says. */ -function renderEditor() { +/* A viewer, and since OQ-09 not a disabled editor either. The profiles are + compiled into the firmware (SWR-PRG-09) and nothing edits them, so the name + and description are text rather than inputs somebody has to discover are + dead, and the readonly flag is no longer worth showing: every program has + it. */ +function renderProgram() { if (!editing) return; $('ed-title').textContent = editing.name || 'Program'; - $('ed-ro').hidden = !editing.readonly; - $('ed-name').value = editing.name || ''; - $('ed-desc').value = editing.description || ''; - $('ed-name').disabled = true; - $('ed-desc').disabled = true; + $('ed-desc').textContent = editing.description || ''; const body = $('ed-body'); body.innerHTML = ''; @@ -398,9 +396,9 @@ function renderEditor() { previewEdit(); } -/* SWR-WEB-12: the resulting curve, previewed before saving. Computed locally so - * it follows every keystroke; the server still re-validates on save, which is - * SWR-WEB-13 and is the check that counts. */ +/* SWR-WEB-12: the curve the selected program describes, drawn locally from + * its segments. It used to preview an edit before saving; there is no saving + * now, so it draws what the firmware will run. */ function previewEdit() { if (!edChart || !editing) return; let t = 0, from = 20; @@ -421,7 +419,7 @@ function previewEdit() { async function editProgram(id) { const p = await get(`/api/programs/${id}`); editing = { ...p, segments: p.segments.map(s => ({ ...s })) }; - renderEditor(); + renderProgram(); } /* --- history (SWR-WEB-09, SWR-WEB-18) ------------------------------------ */ @@ -650,7 +648,7 @@ function show(name) { if (name === 'programs' && !edChart) { edChart = new Chart($('ed-chart'), { interactive: false }); } - if (name === 'programs') loadPrograms().then(renderEditor).catch(() => {}); + if (name === 'programs') loadPrograms().then(renderProgram).catch(() => {}); if (name === 'history') { if (!histChart) histChart = new Chart($('hist-chart')); loadRuns().catch(e => toast($('diag'), e.message, 'bad')); diff --git a/web/index.html b/web/index.html index 49f1de1..3f18ef2 100644 --- a/web/index.html +++ b/web/index.html @@ -118,12 +118,11 @@

Programs

    -

    Editor

    - - - + +

    Program

    +

    @@ -131,14 +130,13 @@

    Editor

    #Target °CRate °C/h
    -
    -

    - Display only. Firing programs are shown here but are - authored at the kiln, not over the network. + Display only. The firing programs are compiled into the + firmware. They are not edited here, and not at the kiln either: adding + one means building a firmware that carries it.