#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
-
- A built-in example is read-only. Copy it to make changes.
-
-
Name
-
Description
+
+
Program
+
# Target °C Rate °C/h
@@ -131,14 +130,13 @@ Editor
-
-
- 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.