From 397518720de5d13023a5fc54f7f121b147ba919b Mon Sep 17 00:00:00 2001
From: Boernsman <5207214+Boernsman@users.noreply.github.com>
Date: Fri, 9 Oct 2026 22:42:18 +0200
Subject: [PATCH 01/14] feat: the firing profiles are compiled in, and nothing
edits them
OQ-09 asked where programs are authored now that the web is read-only, and
offered a display editor, a file import, or shipping only the examples. The
answer is the third, as a decision rather than as a default: the profiles are
compiled into the firmware and there is no authoring path anywhere.
SWR-PRG-07 (create, update, rename, duplicate, delete over the web), SWR-PRG-08
(export and import as JSON) and SWR-PRG-10 (edit the remaining segments of a
running program) are withdrawn with the reasoning kept. SWR-PRG-05 validates on
start, since there is no save. SWR-PRG-09 now says what it has become: the
examples are the device's whole repertoire.
THE COST IS REAL AND IS WRITTEN DOWN RATHER THAN 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. OQ-09 says so in those words, and names the
cheapest reversal if it proves too expensive in use: the file import at service
time, which needs physical access and so leaves the security argument intact.
What it buys is that there is nothing to secure. No write route, no file format
to validate, no parser taking operator data on the processor that commands a
heater, and no editor to design for a rotary encoder.
The web caught up with it. The Programs view called itself an Editor and was a
set of permanently disabled inputs with a note offering to copy an example so
you could make changes, which was untrue in three ways at once. It is a program
view now: the name and description as text rather than as dead fields, the
segments, the curve, and a notice that says where programs come from. The empty
save-result line and the empty controls bar are gone with it.
Verified by rendering it: three profiles listed, no editor, 25 host suites
still green.
What this decision leaves behind is recorded as P3 rather than done:
kiln_program_store_save and kiln_program_store_delete are now called by nothing
but their own tests, and SWR-PRG-04 asks for 20 persistent slots to hold three
programs that arrive with the firmware. Removing the store for programs is the
consistent finish and it deletes tested code, so it wants a decision rather
than momentum.
---
README.md | 3 ++-
docs/03_software_req.sdoc | 30 ++++++++++++++++++++++++------
tasklist.md | 31 +++++++++++++++++++++++++------
web/app.js | 30 ++++++++++++++----------------
web/index.html | 18 ++++++++----------
5 files changed, 73 insertions(+), 39 deletions(-)
diff --git a/README.md b/README.md
index e3e48cb..36e0a0e 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.
diff --git a/docs/03_software_req.sdoc b/docs/03_software_req.sdoc
index 29bb745..1290cbf 100644
--- a/docs/03_software_req.sdoc
+++ b/docs/03_software_req.sdoc
@@ -795,7 +795,7 @@ 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.
<<<
PRIORITY: M
VERIFICATION: T
@@ -811,7 +811,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 +823,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 +833,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 +845,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
@@ -2892,7 +2904,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/tasklist.md b/tasklist.md
index 7e5e8aa..0efca8d 100644
--- a/tasklist.md
+++ b/tasklist.md
@@ -708,12 +708,31 @@ weakness first, the deadline second, the sentence third, the engineering last.
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.
+- [ ] **P3. The program store has no writer, and 17 of its 20 slots have no
+ purpose.** Answered rather than open: `OQ-09` is resolved, the profiles are
+ compiled into the firmware and nothing edits them, so `SWR-PRG-07`,
+ `SWR-PRG-08` and `SWR-PRG-10` are withdrawn. What is left is the machinery
+ those requirements justified.
+
+ `kiln_program_store_save` and `kiln_program_store_delete` are now called by
+ **nothing but their own tests**. `SWR-PRG-04` asks for 20 program slots in
+ non-volatile storage persisting across a firmware update, and the three
+ programs that exist arrive with the firmware, so the persistence is storing
+ a copy of something the image already contains.
+
+ Two ways to finish it, and this is a decision rather than a task:
+
+ - **Keep the store, narrow the requirement.** Seeding stays, the slots stay,
+ `SWR-PRG-04` is reworded to say what it is now for. Cheapest, and leaves
+ tested code that nothing calls.
+ - **Read the profiles straight from `kiln_profile_example()`.** The program
+ store stops existing for programs; `kilnfs` keeps the run records, which
+ are the other half of `SWA-21`. Removes a subsystem, a partition's worth
+ of wear, the seeding path and its failure modes, and about 200 lines. It
+ also deletes tested code, which wants care rather than enthusiasm.
+
+ The second is the one consistent with the decision. Neither should be done
+ without someone deciding which.
- [ ] **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
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.
-
-
-
+
+
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.
From 62cd5a4c0a124cf8704b5c1f0f7a78133a63035d Mon Sep 17 00:00:00 2001
From: Boernsman <5207214+Boernsman@users.noreply.github.com>
Date: Fri, 9 Oct 2026 22:45:58 +0200
Subject: [PATCH 02/14] fix: the configuration that ships did not compile, and
nothing was building it
Checking tasklist O and P found something larger than any item in them. Every
CI job builds sdkconfig.qemu, which sets CONFIG_KILN_PLANT_SIM and compiles the
WiFi, the HTTP server and the whole hardware path out of main.cpp. The
configuration that will actually ship was therefore guarded by no build at all,
and O3 claimed "it compiles and passes analysis" on that basis.
Built for the first time today. It did not compile: two -Wformat-truncation
errors, one in the version string that has been there far longer than the WiFi
work and one mine from the network scan. Both are bounded explicitly now, which
is the same fix the SSID row in hmi.cpp needed last week and the same lesson:
truncation written as "%s" into a shorter buffer reads as an accident, and GCC
is right to say so.
CI builds the hardware configuration on every push now, with a size report in
the job summary. It is a build and not a test, because nothing in CI 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.
MEASUREMENTS, replacing numbers that predated several IDF releases:
the hardware image is 888 kB, 57 % of the OTA slot free, against O2's
claimed 831 kB. The simulated image CI publishes is 246 kB and has no WiFi
and no HTTP server in it at all, which is worth keeping straight when
reading a size report.
the web assets are 18 kB gzipped 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 against real assets, which P1 gave as a reason it was not
done. It passes with room, and embedding them takes the hardware image to
about 906 kB.
---
.github/workflows/ci.yml | 33 ++++++++++++++++++++++++++++++
firmware/controller/main/main.cpp | 15 ++++++++++++--
tasklist.md | 34 +++++++++++++++++++++++--------
3 files changed, 72 insertions(+), 10 deletions(-)
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/firmware/controller/main/main.cpp b/firmware/controller/main/main.cpp
index 9123972..399fbdb 100644
--- a/firmware/controller/main/main.cpp
+++ b/firmware/controller/main/main.cpp
@@ -525,7 +525,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
@@ -557,7 +563,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;
}
diff --git a/tasklist.md b/tasklist.md
index 0efca8d..08d0589 100644
--- a/tasklist.md
+++ b/tasklist.md
@@ -651,14 +651,32 @@ weakness first, the deadline second, the sentence third, the engineering last.
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.
+- [ ] **O2. The hardware image is 888 kB, 57 % of the slot free.** Measured
+ 2026-10-09, replacing numbers that predated several releases of ESP-IDF. 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), so embedding them takes the hardware
+ image to about 906 kB and 55 % free. That budget had never been measured;
+ it has now, and it passes.
+
+- [ ] **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
From 901a9ec5eb08a921ff6cfce29b13832bb93d8ba3 Mon Sep 17 00:00:00 2001
From: Boernsman <5207214+Boernsman@users.noreply.github.com>
Date: Fri, 9 Oct 2026 22:56:59 +0200
Subject: [PATCH 03/14] feat: the browser interface ships inside the image
P1 said the UI is not served: no index.html in the firmware, a browser at the
device gets a 404, and the API is usable with curl and nothing else. The four
assets are gzipped at build time and linked in as binary data now, and httpd
answers them before it consults the route table.
VERIFIED WITHOUT HARDWARE, which is the part worth describing. The assets were
extracted from the linked ELF by address, inflated, and compared byte for byte
against the files in web/. All four round-trip. That is not the same as having
served one over a socket, and P4 still says so, but it does establish that what
is in the image is the interface rather than something shaped like it.
index.html 2281 gz -> 7090
app.css 3656 gz -> 11772
app.js 8846 gz -> 24695
chart.js 4316 gz -> 12762
Architecture 12.4's budget was 44 kB gzipped and had never been measured
against real assets, which P1 gave as a reason the work was not done. It is
18.6 kB. The hardware image goes from 888 kB to 908 kB, 56 % of the slot free.
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, without anybody arranging that.
Decisions in here worth the reader's time:
Gzipped at BUILD time, not committed compressed, so the source of truth
stays the readable file in web/ and nobody has to remember to recompress.
`gzip -9 -n`, because without -n the header carries an mtime and the image
stops being reproducible from the same sources, which the SBOM and the
release both assume.
Served still compressed. Every browser that can run this can inflate it, and
doing it on the device would cost a window buffer per request on the
processor running the kiln.
Exact-match lookup against a table of four, no path walking. There is no
filesystem behind this, so ".." is not dangerous, it simply matches nothing.
Cache-Control: no-cache, which means revalidate rather than do not store. A
firmware update changes all four assets at once, and a browser holding a
stale app.js with a fresh index.html is the failure worth preventing; a 304
on a LAN costs nothing.
Found on the way: GCC rejects "/api/*" inside a block comment (-Wcomment), so
the comment says "the API routes" instead.
---
README.md | 3 +-
docs/architecture.md | 31 +++++--
.../components/kiln_web/CMakeLists.txt | 44 +++++++++
.../components/kiln_web/src/httpd.cpp | 91 +++++++++++++++++++
tasklist.md | 15 +--
5 files changed, 163 insertions(+), 21 deletions(-)
diff --git a/README.md b/README.md
index 36e0a0e..4f03491 100644
--- a/README.md
+++ b/README.md
@@ -61,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
diff --git a/docs/architecture.md b/docs/architecture.md
index 3b1598e..395b987 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -805,13 +805,30 @@ stale and reconnects with backoff rather than showing old data as current
### 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
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/src/httpd.cpp b/firmware/controller/components/kiln_web/src/httpd.cpp
index 79b916d..835b7af 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
@@ -54,6 +68,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) {
@@ -123,6 +198,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;
@@ -222,6 +307,12 @@ 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;
}
diff --git a/tasklist.md b/tasklist.md
index 08d0589..3c64a58 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
@@ -714,12 +709,6 @@ weakness first, the deadline second, the sentence third, the engineering last.
## 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`
From 1ccc0e7c56c68e76c970145a4c9eb02a5c862957 Mon Sep 17 00:00:00 2001
From: Boernsman <5207214+Boernsman@users.noreply.github.com>
Date: Fri, 9 Oct 2026 23:13:54 +0200
Subject: [PATCH 04/14] feat: kiln.local, by way of one dependency with
conditions on it
SWR-NET-04 has been open since mDNS left the ESP-IDF tree for the component
registry. The options were vendoring several thousand lines of somebody
else's protocol implementation, dropping the requirement, or taking the
dependency; UR-CON-04 forbade the third only insofar as it forbade an
unpinned fetch, so it is reworded to say what makes a fetched component
auditable rather than to forbid fetching:
1. pinned exactly, never to a range,
2. the resolved dependencies.lock committed, so what a release was built
from is recoverable from the repository alone,
3. present in the SBOM with its licence.
Web assets stay vendored at any version: they are reachable from the network
at run time and the component manager's integrity check does not cover them.
espressif/mdns 1.14.0 is Apache-2.0, which distributes under GPL-3.0.
tools/sbom.py gains managed_component(), because the generic path described
this as esp-idf-espressif__mdns at v6.0.1 -- the IDF version, for the one
entry in the inventory whose own version is the point. A CRA Article 13(1)
question about a known vulnerability is answered by matching a version, so it
is now listed as espressif/mdns 1.14.0 with pkg:idf/espressif/mdns@1.14.0 and
the registry content hash. Folded YAML values are unfolded rather than cut
mid-word. 12 new self-tests, 85/85.
ADV-6 no longer claims there is no package manager at build time, which had
become false. TH-13 pointed at the withdrawn SWR-NET-05 (the access point's
passphrase rule) for a threat about mDNS; it points at SWR-NET-04.
Hardware image 940 kB, 54 % of the slot free: mDNS costs 32 kB. The simulated
image is unchanged at 246 kB, since hal_net is compiled out of it.
---
.gitignore | 4 +-
README.md | 2 +-
docs/01_user_req.sdoc | 10 +-
docs/architecture.md | 2 +-
docs/security.sdoc | 6 +-
.../kiln_hal_esp32s3/idf_component.yml | 31 +++++
.../kiln_hal_esp32s3/src/hal_net.cpp | 37 ++++--
firmware/controller/dependencies.lock | 20 +++
tasklist.md | 18 +--
tools/sbom.py | 116 ++++++++++++++++++
10 files changed, 214 insertions(+), 32 deletions(-)
create mode 100644 firmware/controller/components/kiln_hal_esp32s3/idf_component.yml
create mode 100644 firmware/controller/dependencies.lock
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 4f03491..a0d53dd 100644
--- a/README.md
+++ b/README.md
@@ -223,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/architecture.md b/docs/architecture.md
index 395b987..6385f7d 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
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/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_net.cpp b/firmware/controller/components/kiln_hal_esp32s3/src/hal_net.cpp
index 3fca617..b05a178 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"
@@ -380,17 +381,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/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/tasklist.md b/tasklist.md
index 3c64a58..0063f44 100644
--- a/tasklist.md
+++ b/tasklist.md
@@ -636,17 +636,7 @@ 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 hardware image is 888 kB, 57 % of the slot free.** Measured
+- [ ] **O2. The hardware image is 940 kB, 54 % of the slot free.** Measured
2026-10-09, replacing numbers that predated several releases of ESP-IDF. 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
@@ -654,9 +644,9 @@ weakness first, the deadline second, the sentence third, the engineering last.
`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), so embedding them takes the hardware
- image to about 906 kB and 55 % free. That budget had never been measured;
- it has now, and it passes.
+ 3.6, `app.js` 8.6, `chart.js` 4.2). With them embedded and mDNS linked in,
+ the hardware image measures **940 kB, 54 % of the slot free**. That budget
+ had never been measured; it has now, and it passes.
- [ ] **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
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",
From d879471f9d27fa70bb91c65e73274eb6a6cf62ab Mon Sep 17 00:00:00 2001
From: Boernsman <5207214+Boernsman@users.noreply.github.com>
Date: Fri, 9 Oct 2026 23:33:27 +0200
Subject: [PATCH 05/14] feat: the file store proves its writes, and retires
what cannot hold them
Q1 was recorded as an accepted trade and the trade was made against the
wrong failure mode. 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
store's answer was the CRC 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 read_copy, the same
path a mount uses, before the in-RAM 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 KILN_FILESLOT_WRITE_TRIES regions. The caller gets
KILN_OK, because the file is stored; what changed is where.
Retirement needs no bad-block table. After the new copy verifies, the old
region is erased best effort:
- it succeeds, so no stale copy remains to undo a later remove 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 rather than a life
sentence passed on one bad write;
- it fails, which is what a finished sector does, so the stale copy stays
at a LOWER seq. 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, its
region is retired again, the higher seq is the file.
A power cut between the verify and the erase leaves the same state and is
resolved the same way. Still no wear levelling, deliberately: architecture
10.4 has four orders of magnitude of headroom.
Five tests, driven by two new knobs on the flash fake -- a sector that
accepts a write and keeps zeros, which is the failure no error code reports,
and a sector that will not erase, addressed by offset so the test says which
region is finished rather than how many erases have gone by.
Q2: mount cost measured rather than described, and the description was low
by a third. 768 reads and 47 808 bytes on the medium this firmware produces;
8 192 and 524 288 on a partition full of maximum-size files. Asserted against
a model derived in the test, so making mount lazy fails the suite instead of
happening quietly.
Q4 and Q5 were disclosures with nowhere to live: the name-length refusal is
now stated in port_filestore.h and architecture 10.6 beside the test that
proves it, and run_index's twenty-slot walk is explained in run_index.cpp
where somebody revisiting the file will see it.
The retired count is logged at boot and is the only warning anybody gets
that the part is going.
Verified: 25/25 host tests, the same under ASan/UBSan, kiln_core line
coverage 91.7 % against the 90 % floor, clang-tidy 20 clean on host and
target, both IDF configurations build, and the QEMU smoke test passes all
seven assertions on the emulated flash the store actually runs on.
---
docs/architecture.md | 58 ++-
.../components/kiln_app/src/run_index.cpp | 19 +-
.../kiln_core/include/kiln_core/fileslots.h | 78 +++-
.../components/kiln_core/src/fileslots.cpp | 191 ++++++++--
.../include/kiln_ports/port_filestore.h | 5 +
.../include/kiln_hal_host/hal_host.h | 22 ++
.../host/kiln_hal_host/src/hal_host.cpp | 20 +-
firmware/controller/main/main.cpp | 10 +
.../test/host/unit/test_fileslots.cpp | 350 ++++++++++++++++++
tasklist.md | 104 ++++--
10 files changed, 771 insertions(+), 86 deletions(-)
diff --git a/docs/architecture.md b/docs/architecture.md
index 6385f7d..a3502c0 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -689,17 +689,63 @@ 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.
+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 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.
+**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 programs of 398 bytes and 20 run records of
+554, costs **768 reads and 47 808 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 `program_store`
+and `run_index` over the real store in `test_stores_on_flash`, which is the
+combination that runs on the board.
## 11. Configuration
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/include/kiln_core/fileslots.h b/firmware/controller/components/kiln_core/include/kiln_core/fileslots.h
index 52e42c6..17f86d0 100644
--- a/firmware/controller/components/kiln_core/include/kiln_core/fileslots.h
+++ b/firmware/controller/components/kiln_core/include/kiln_core/fileslots.h
@@ -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/fileslots.cpp b/firmware/controller/components/kiln_core/src/fileslots.cpp
index 32357ee..358807f 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);
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..bc21fd0 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
@@ -15,6 +15,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 {
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..91239e3 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;
diff --git a/firmware/controller/main/main.cpp b/firmware/controller/main/main.cpp
index 399fbdb..e0cecb0 100644
--- a/firmware/controller/main/main.cpp
+++ b/firmware/controller/main/main.cpp
@@ -693,9 +693,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/unit/test_fileslots.cpp b/firmware/controller/test/host/unit/test_fileslots.cpp
index 3aade3c..96a77ca 100644
--- a/firmware/controller/test/host/unit/test_fileslots.cpp
+++ b/firmware/controller/test/host/unit/test_fileslots.cpp
@@ -10,10 +10,15 @@
* 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 two blob sizes the real callers store, in the mount-cost tests at the
+ * end of this file: a cost measured against made-up payloads measures nothing. */
+#include "kiln_app/program_store.h"
+#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
@@ -427,3 +432,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 two real callers store: 20 programs and 20 run records, at the
+ * sizes their own serialisers produce. The worst case above is what the
+ * partition permits; this is what the firmware puts in it, and the
+ * difference is a factor of nine. */
+ constexpr size_t PROG_BLOB = 8u + sizeof(kiln_program_t) + 2u;
+ 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), "/p/%02zu", i);
+ CHECK_OK(r->store.write_atomic(r->store.ctx, path, blob, PROG_BLOB));
+ CHECK_OK(r->store.write_atomic(r->store.ctx, path, blob, PROG_BLOB));
+ (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 pr = 0, pb = 0, rr = 0, rb = 0;
+ copy_cost(PROG_BLOB, &pr, &pb);
+ copy_cost(RUN_BLOB, &rr, &rb);
+
+ /* 40 regions hold a file; the other 24 cost one failed header read each. */
+ const uint32_t want_reads = 2u * 20u * (pr + rr) + (uint32_t)(COST_REGIONS - 40u) * 2u;
+ const uint32_t want_bytes = 2u * 20u * (pb + rb)
+ + (uint32_t)(COST_REGIONS - 40u) * 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 (program blob %zu, run blob %zu)\n",
+ r->flash.reads, r->flash.bytes_read, PROG_BLOB, RUN_BLOB);
+
+ /* Measured: 768 reads and 47 808 bytes, against 8 192 and 524 288 for a
+ * medium full of maximum-size files. A ninth of the worst case, and the
+ * reason the worst case is a bound rather than a budget. */
+ CHECK_EQ_UINT(r->flash.reads, 768u);
+ CHECK_EQ_UINT(r->flash.bytes_read, 47808u);
+}
diff --git a/tasklist.md b/tasklist.md
index 0063f44..6cd4e89 100644
--- a/tasklist.md
+++ b/tasklist.md
@@ -741,46 +741,70 @@ weakness first, the deadline second, the sentence third, the engineering last.
## 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, 20 programs of 398 bytes and 20 run records
+ of 554, a mount costs **768 reads and 47 808 bytes** of CRC. 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.
+
+- [ ] **Q3. `exists` and `list` are implemented and have no caller, and the
+ reason they were kept has gone.** They were kept for `P3`'s local program
+ editor, and the decision there was pre-compiled profiles with nothing
+ editing them, so there is no prospective caller left. Three implementations
+ (`fileslots`, the host HAL, the simulator) and two tests exist for an
+ interface nothing above uses. Either the port contract loses both and the
+ implementations go with them, or they stay with an honest reason written down.
+ **A decision rather than a task**, and it travels with `P3`.
+
+- [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.
---
From f8ee8657b2b5cd4a91f9a866942ea56c7bc9d565 Mon Sep 17 00:00:00 2001
From: Boernsman <5207214+Boernsman@users.noreply.github.com>
Date: Fri, 9 Oct 2026 23:35:57 +0200
Subject: [PATCH 06/14] refactor: the file store loses exists and list, which
nobody was going to call
Q3. Both were kept for the local program editor of P3, and the decision
there was pre-compiled profiles with nothing editing them, so the caller
they were waiting for is not coming. What was left was three
implementations -- fileslots, the host HAL, the simulator -- and two tests
for an interface the product never exercises: code that compiles, is
covered, and is dead, which is worse than no code because it reads as
supported.
exists was read with the answer thrown away, and anything that needs to
enumerate a fixed array of numbered slots can count to twenty.
The two tests that drove them now drive what has callers: the removal is
proved by a read rather than by exists, and the usage test additionally
checks that usage survives a remount, since it is answered from the in-RAM
index and the mount is the only thing that rebuilds it.
25/25 host tests, clang-tidy 20 clean, both IDF configurations build.
---
docs/architecture.md | 2 +-
.../kiln_core/include/kiln_core/fileslots.h | 6 +--
.../components/kiln_core/src/fileslots.cpp | 35 -----------------
.../include/kiln_ports/port_filestore.h | 15 +++++---
.../components/kiln_sim/src/sim.cpp | 35 -----------------
.../host/kiln_hal_host/src/hal_host.cpp | 38 -------------------
.../test/host/unit/test_fileslots.cpp | 24 +++++-------
tasklist.md | 9 -----
8 files changed, 24 insertions(+), 140 deletions(-)
diff --git a/docs/architecture.md b/docs/architecture.md
index a3502c0..77a288e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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`)
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 17f86d0..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
diff --git a/firmware/controller/components/kiln_core/src/fileslots.cpp b/firmware/controller/components/kiln_core/src/fileslots.cpp
index 358807f..f3e2f9a 100644
--- a/firmware/controller/components/kiln_core/src/fileslots.cpp
+++ b/firmware/controller/components/kiln_core/src/fileslots.cpp
@@ -485,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);
@@ -551,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_ports/include/kiln_ports/port_filestore.h b/firmware/controller/components/kiln_ports/include/kiln_ports/port_filestore.h
index bc21fd0..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
@@ -27,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_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/host/kiln_hal_host/src/hal_host.cpp b/firmware/controller/host/kiln_hal_host/src/hal_host.cpp
index 91239e3..5530844 100644
--- a/firmware/controller/host/kiln_hal_host/src/hal_host.cpp
+++ b/firmware/controller/host/kiln_hal_host/src/hal_host.cpp
@@ -415,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);
@@ -484,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/test/host/unit/test_fileslots.cpp b/firmware/controller/test/host/unit/test_fileslots.cpp
index 96a77ca..e6d8dda 100644
--- a/firmware/controller/test/host/unit/test_fileslots.cpp
+++ b/firmware/controller/test/host/unit/test_fileslots.cpp
@@ -285,11 +285,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);
@@ -356,7 +357,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);
@@ -370,17 +371,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);
}
/*
diff --git a/tasklist.md b/tasklist.md
index 6cd4e89..ca82c38 100644
--- a/tasklist.md
+++ b/tasklist.md
@@ -781,15 +781,6 @@ weakness first, the deadline second, the sentence third, the engineering last.
the only thing mount needs eagerly is which copy of each region wins, and the
payload CRC could move to the read.
-- [ ] **Q3. `exists` and `list` are implemented and have no caller, and the
- reason they were kept has gone.** They were kept for `P3`'s local program
- editor, and the decision there was pre-compiled profiles with nothing
- editing them, so there is no prospective caller left. Three implementations
- (`fileslots`, the host HAL, the simulator) and two tests exist for an
- interface nothing above uses. Either the port contract loses both and the
- implementations go with them, or they stay with an honest reason written down.
- **A decision rather than a task**, and it travels with `P3`.
-
- [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`.
From 5b4e5b43d92847ce855bdb151f2b0b1eb97504d4 Mon Sep 17 00:00:00 2001
From: Boernsman <5207214+Boernsman@users.noreply.github.com>
Date: Fri, 9 Oct 2026 23:43:31 +0200
Subject: [PATCH 07/14] feat: the buzzer says "finished" briefly and "something
is wrong" relentlessly
The firing-finished sound was fifteen repetitions of a chirp over thirty
seconds, and the fault sound was a 200/200 ms beat that is more polite than
the situation deserves. Both are rewritten:
completion three 90 ms chirps, then five seconds of quiet. A firing that
took nine hours has finished correctly: that is good news and
wants a notification, not an alarm. hmi.alarm_duration_s
defaults to 4 s rather than 30, so the operator hears the burst
once; the 0 to 600 s range is unchanged for anyone who wants
the kiln to keep saying so until somebody comes.
fault six 60 ms chirps at twice that rate, a 400 ms gap, and again
without end until acknowledged. Meant to be unpleasant and to
carry through a wall.
They differ in rhythm, rate and duty -- a third of the cycle sounding against
a twentieth, over a cycle five times shorter -- which is what SWR-SAF-20's
"audibly distinguishable" has to mean for a part with one tone and no volume
control.
The rhythms moved out of the adapter into kiln_core/alarmptn, a table and a
step function with no clock and no hardware, because in an esp_timer callback
the one thing SWR-SAF-20 requires was asserted by nobody and "continues until
acknowledged" was a comment. The adapter now sets a pin and arms a one-shot
timer for the interval it is handed. Eight host tests, including the
comparison between the two patterns as a comparison, that the fault pattern
never stops on its own, and that a buzzer which cannot be commanded -- null
sequence, or a pattern value that is not one of the three -- is silent rather
than stuck on, since an operator's only remedy for that is the mains switch.
SWR-SAF-20 now states the two patterns and its verification method changes
from Demonstration to Test. SWR-RUN-06 records the new default and why.
Architecture 10.7 carries the numbers.
26/26 host tests, clang-tidy 20 clean on host and target, hardware image
builds, StrictDoc exports clean.
---
docs/03_software_req.sdoc | 13 +-
docs/architecture.md | 29 ++-
.../clangd/index/api.cpp.9028AD392AB399C7.idx | Bin 0 -> 24168 bytes
.../clangd/index/api.h.7C57622043E2F1F4.idx | Bin 0 -> 4836 bytes
.../index/api_log.cpp.ECD6E8B698B80465.idx | Bin 0 -> 9972 bytes
.../clangd/index/app.cpp.429EE0B06C87FF78.idx | Bin 0 -> 34728 bytes
.../clangd/index/app.h.66207A1D80971935.idx | Bin 0 -> 9442 bytes
.../index/autotune.cpp.5E644C8450ADBC62.idx | Bin 0 -> 8014 bytes
.../index/autotune.h.ABA8346B3BC72970.idx | Bin 0 -> 5520 bytes
.../index/board_pins.h.003CC979DEBB4325.idx | Bin 0 -> 2856 bytes
.../configmodel.cpp.38BC0F6BEB86FCF3.idx | Bin 0 -> 29068 bytes
.../index/configmodel.h.74606DBA908BC1F1.idx | Bin 0 -> 10892 bytes
.../index/current.cpp.AD2AE87B3A3BCB6E.idx | Bin 0 -> 10698 bytes
.../index/current.h.620B7FB1FF26858B.idx | Bin 0 -> 7302 bytes
.../index/draw.cpp.CE59529FCD350067.idx | Bin 0 -> 3938 bytes
.../clangd/index/draw.h.824614D74BA33394.idx | Bin 0 -> 1634 bytes
.../clangd/index/err.cpp.AF9C8548C8887FF6.idx | Bin 0 -> 1684 bytes
.../clangd/index/err.h.21E50DB0E8019E3F.idx | Bin 0 -> 1188 bytes
.../index/faults.cpp.D3BCC87829A3272E.idx | Bin 0 -> 7706 bytes
.../index/faults.h.3B90034CCCD79A17.idx | Bin 0 -> 2078 bytes
.../index/fileslots.cpp.B4E990AA46522936.idx | Bin 0 -> 11676 bytes
.../index/fileslots.h.7D454C4314D7D133.idx | Bin 0 -> 3332 bytes
.../index/hal_clock.cpp.3444033FF17237DA.idx | Bin 0 -> 2566 bytes
.../hal_counters.cpp.BBCB2E13707BCF16.idx | Bin 0 -> 4866 bytes
.../hal_current.cpp.3D3F77BE12D7401F.idx | Bin 0 -> 7334 bytes
.../hal_display.cpp.B0F7B92DB11BD7D8.idx | Bin 0 -> 7046 bytes
.../index/hal_esp32s3.h.F7C571DF8DED891E.idx | Bin 0 -> 3900 bytes
.../index/hal_flash.cpp.BD735EDE9CCE05A3.idx | Bin 0 -> 4124 bytes
.../index/hal_heat.cpp.B12AB124127403BA.idx | Bin 0 -> 5776 bytes
.../index/hal_input.cpp.10B8CEC564533626.idx | Bin 0 -> 6238 bytes
.../index/hal_io.cpp.EC5F8CD87CBB0D9D.idx | Bin 0 -> 5100 bytes
.../hal_kvstore.cpp.B7DF5B21F17B1AEA.idx | Bin 0 -> 4388 bytes
.../index/hal_net.cpp.5928048DFBE34E30.idx | Bin 0 -> 13292 bytes
.../index/hal_prod.cpp.E2093DEBAC13EC5B.idx | Bin 0 -> 4760 bytes
.../hal_suplink.cpp.F952145BD4011FA3.idx | Bin 0 -> 3904 bytes
.../index/hal_system.cpp.051C49FACAE9C053.idx | Bin 0 -> 4976 bytes
.../index/hal_tc.cpp.A936EE8D2D953264.idx | Bin 0 -> 8198 bytes
.../clangd/index/hmi.cpp.4C133F4B3BACFE6A.idx | Bin 0 -> 15274 bytes
.../clangd/index/hmi.h.55E5381D963422F3.idx | Bin 0 -> 7808 bytes
.../index/httpd.cpp.3949980EE3DBFFF2.idx | Bin 0 -> 8688 bytes
.../clangd/index/httpd.h.AE03CFFCC5598233.idx | Bin 0 -> 538 bytes
.../index/json.cpp.71CA25D08E6E4912.idx | Bin 0 -> 11688 bytes
.../clangd/index/json.h.A5CC97710F05390F.idx | Bin 0 -> 5010 bytes
.../index/logrec.cpp.C765F885F08DE1A6.idx | Bin 0 -> 11032 bytes
.../index/logrec.h.44464D377E7E4D85.idx | Bin 0 -> 7194 bytes
.../index/logring.cpp.D4136777A6514E28.idx | Bin 0 -> 11466 bytes
.../index/logring.h.906F81554E8AE5F1.idx | Bin 0 -> 3206 bytes
.../index/main.cpp.1D658C8212876481.idx | Bin 0 -> 20164 bytes
.../clangd/index/mdns.h.C76CB2B1CB5F31EF.idx | Bin 0 -> 15928 bytes
.../index/mdns_browser.c.C25D87CE840B727B.idx | Bin 0 -> 11548 bytes
.../index/mdns_browser.h.BA6CAACB78CB669F.idx | Bin 0 -> 2092 bytes
.../index/mdns_cache.c.294254DF303FA182.idx | Bin 0 -> 26902 bytes
.../index/mdns_cache.h.EADAE4363ADEBE57.idx | Bin 0 -> 3064 bytes
.../index/mdns_console.c.0C11AABAE1148BC3.idx | Bin 0 -> 45850 bytes
.../index/mdns_debug.c.70AFC7F558EBD732.idx | Bin 0 -> 9046 bytes
.../index/mdns_debug.h.190DA7C4B218495A.idx | Bin 0 -> 412 bytes
.../mdns_mem_caps.c.F3CF0DFBFC63F33E.idx | Bin 0 -> 2974 bytes
.../mdns_mem_caps.h.3FECC6AB3E87B33A.idx | Bin 0 -> 1092 bytes
.../index/mdns_netif.c.505B78302CC0E92E.idx | Bin 0 -> 13254 bytes
.../index/mdns_netif.h.D0D872E6E68AF30A.idx | Bin 0 -> 1334 bytes
.../mdns_networking.h.FFFA02FFE676A743.idx | Bin 0 -> 1380 bytes
...dns_networking_lwip.c.BC1C86DD66B32A39.idx | Bin 0 -> 12790 bytes
.../index/mdns_pcb.c.A1C59CE9F92A2EB9.idx | Bin 0 -> 11792 bytes
.../index/mdns_pcb.h.65809FE99D54C152.idx | Bin 0 -> 2596 bytes
.../index/mdns_private.h.E6C7E8B4460B3C26.idx | Bin 0 -> 21350 bytes
.../index/mdns_querier.c.1B1086BFD1CC4A05.idx | Bin 0 -> 26136 bytes
.../index/mdns_querier.h.8D9747B017BBA1BD.idx | Bin 0 -> 2838 bytes
.../index/mdns_receive.c.48FB52E1C144A520.idx | Bin 0 -> 28754 bytes
.../index/mdns_receive.h.DE63D8312E409545.idx | Bin 0 -> 524 bytes
.../mdns_responder.c.400ADCEFAE8349E5.idx | Bin 0 -> 49360 bytes
.../mdns_responder.h.C2F515428F349B06.idx | Bin 0 -> 1896 bytes
.../index/mdns_send.c.70C72B8F58248918.idx | Bin 0 -> 34200 bytes
.../index/mdns_send.h.4A1D72CBC50AEB40.idx | Bin 0 -> 3516 bytes
.../index/mdns_service.c.92D5EB5771567036.idx | Bin 0 -> 10430 bytes
.../index/mdns_service.h.7C851492F5E0D8AB.idx | Bin 0 -> 602 bytes
.../index/mdns_utils.c.F534F6547BABC748.idx | Bin 0 -> 7792 bytes
.../index/mdns_utils.h.82A7215ABE807ABD.idx | Bin 0 -> 3040 bytes
.../clangd/index/pid.cpp.7A2C4FF97B438BF3.idx | Bin 0 -> 4162 bytes
.../clangd/index/pid.h.21295E2653B28799.idx | Bin 0 -> 2870 bytes
.../index/port_alarm.h.398A6B39659208EF.idx | Bin 0 -> 792 bytes
.../index/port_clock.h.DB1153E770FCA061.idx | Bin 0 -> 636 bytes
.../port_counters.h.C8FD845045631D2B.idx | Bin 0 -> 1110 bytes
.../index/port_current.h.DA521570B5762B1B.idx | Bin 0 -> 2248 bytes
.../index/port_display.h.0D9E490EBFE7255B.idx | Bin 0 -> 1022 bytes
.../index/port_door.h.21BF44FA16DF7DC2.idx | Bin 0 -> 532 bytes
.../port_filestore.h.2CDC67F6137FFA29.idx | Bin 0 -> 1192 bytes
.../index/port_flash.h.32C19661C6CD7505.idx | Bin 0 -> 1156 bytes
.../index/port_heat.h.E8AB1E4F6E4B178D.idx | Bin 0 -> 1334 bytes
.../index/port_input.h.09A927FB6F93BEF1.idx | Bin 0 -> 906 bytes
.../index/port_kvstore.h.5EAB128A42CCE10A.idx | Bin 0 -> 844 bytes
.../port_logstore.h.4AAE0293EEF7E78B.idx | Bin 0 -> 2500 bytes
.../index/port_net.h.92B924E7C7938803.idx | Bin 0 -> 2300 bytes
.../port_supervisor.h.5FDAC2A543564004.idx | Bin 0 -> 1922 bytes
.../index/port_system.h.AE99EF9BA20AE698.idx | Bin 0 -> 3894 bytes
.../index/port_tc.h.77F0CC7425708CC7.idx | Bin 0 -> 926 bytes
.../index/port_update.h.07DD0CA770131E6B.idx | Bin 0 -> 2744 bytes
.../index/profile.cpp.B87D6AA6848F66A1.idx | Bin 0 -> 6252 bytes
.../index/profile.h.130A26E1FEB83C21.idx | Bin 0 -> 3104 bytes
.../program_store.cpp.BC4CCB967AEDF9AE.idx | Bin 0 -> 6196 bytes
.../program_store.h.2EDFE6D17879C6F7.idx | Bin 0 -> 1328 bytes
.../index/run_index.cpp.D330C06CDA62E5DC.idx | Bin 0 -> 5680 bytes
.../index/run_index.h.394D0BEB2FC87A2E.idx | Bin 0 -> 1554 bytes
.../index/runstate.cpp.AADA49FF2FC36235.idx | Bin 0 -> 5122 bytes
.../index/runstate.h.AA7C2D3C583492F7.idx | Bin 0 -> 5208 bytes
.../index/safety.cpp.627B6D98AF3BA28C.idx | Bin 0 -> 19572 bytes
.../index/safety.h.0E0EE6BC58EF2778.idx | Bin 0 -> 10804 bytes
.../index/setpoint.cpp.B979F998BBBBEE03.idx | Bin 0 -> 7568 bytes
.../index/setpoint.h.ADEF0AAE1F9E133E.idx | Bin 0 -> 3912 bytes
.../index/settings.cpp.FE374ADAC354D9D8.idx | Bin 0 -> 5880 bytes
.../index/settings.h.BC2BAF94023B13A2.idx | Bin 0 -> 2412 bytes
.../clangd/index/sim.cpp.E87AB3FC8C65FB87.idx | Bin 0 -> 16610 bytes
.../clangd/index/sim.h.D849C2DDB57F058C.idx | Bin 0 -> 9102 bytes
.../index/suplink.cpp.994C7AA985D1F7EC.idx | Bin 0 -> 6246 bytes
.../index/suplink.h.DAAAB212E792F5BB.idx | Bin 0 -> 2474 bytes
.../index/tempfilt.cpp.4AF84C324F2390E5.idx | Bin 0 -> 3972 bytes
.../index/tempfilt.h.2BED8E89BF6BCFA8.idx | Bin 0 -> 2704 bytes
.../clangd/index/types.h.AB46E6CB91327A24.idx | Bin 0 -> 14198 bytes
.../index/window.cpp.AA9D9AA70187704C.idx | Bin 0 -> 5470 bytes
.../index/window.h.5D39ED538CF8C810.idx | Bin 0 -> 3016 bytes
.../components/kiln_core/CMakeLists.txt | 1 +
.../kiln_core/include/kiln_core/alarmptn.h | 81 +++++++
.../components/kiln_core/src/alarmptn.cpp | 124 +++++++++++
.../components/kiln_core/src/configmodel.cpp | 2 +-
.../kiln_hal_esp32s3/src/hal_io.cpp | 47 ++--
firmware/controller/test/host/CMakeLists.txt | 1 +
.../test/host/unit/test_alarmptn.cpp | 204 ++++++++++++++++++
126 files changed, 467 insertions(+), 35 deletions(-)
create mode 100644 firmware/controller/.cache/clangd/index/api.cpp.9028AD392AB399C7.idx
create mode 100644 firmware/controller/.cache/clangd/index/api.h.7C57622043E2F1F4.idx
create mode 100644 firmware/controller/.cache/clangd/index/api_log.cpp.ECD6E8B698B80465.idx
create mode 100644 firmware/controller/.cache/clangd/index/app.cpp.429EE0B06C87FF78.idx
create mode 100644 firmware/controller/.cache/clangd/index/app.h.66207A1D80971935.idx
create mode 100644 firmware/controller/.cache/clangd/index/autotune.cpp.5E644C8450ADBC62.idx
create mode 100644 firmware/controller/.cache/clangd/index/autotune.h.ABA8346B3BC72970.idx
create mode 100644 firmware/controller/.cache/clangd/index/board_pins.h.003CC979DEBB4325.idx
create mode 100644 firmware/controller/.cache/clangd/index/configmodel.cpp.38BC0F6BEB86FCF3.idx
create mode 100644 firmware/controller/.cache/clangd/index/configmodel.h.74606DBA908BC1F1.idx
create mode 100644 firmware/controller/.cache/clangd/index/current.cpp.AD2AE87B3A3BCB6E.idx
create mode 100644 firmware/controller/.cache/clangd/index/current.h.620B7FB1FF26858B.idx
create mode 100644 firmware/controller/.cache/clangd/index/draw.cpp.CE59529FCD350067.idx
create mode 100644 firmware/controller/.cache/clangd/index/draw.h.824614D74BA33394.idx
create mode 100644 firmware/controller/.cache/clangd/index/err.cpp.AF9C8548C8887FF6.idx
create mode 100644 firmware/controller/.cache/clangd/index/err.h.21E50DB0E8019E3F.idx
create mode 100644 firmware/controller/.cache/clangd/index/faults.cpp.D3BCC87829A3272E.idx
create mode 100644 firmware/controller/.cache/clangd/index/faults.h.3B90034CCCD79A17.idx
create mode 100644 firmware/controller/.cache/clangd/index/fileslots.cpp.B4E990AA46522936.idx
create mode 100644 firmware/controller/.cache/clangd/index/fileslots.h.7D454C4314D7D133.idx
create mode 100644 firmware/controller/.cache/clangd/index/hal_clock.cpp.3444033FF17237DA.idx
create mode 100644 firmware/controller/.cache/clangd/index/hal_counters.cpp.BBCB2E13707BCF16.idx
create mode 100644 firmware/controller/.cache/clangd/index/hal_current.cpp.3D3F77BE12D7401F.idx
create mode 100644 firmware/controller/.cache/clangd/index/hal_display.cpp.B0F7B92DB11BD7D8.idx
create mode 100644 firmware/controller/.cache/clangd/index/hal_esp32s3.h.F7C571DF8DED891E.idx
create mode 100644 firmware/controller/.cache/clangd/index/hal_flash.cpp.BD735EDE9CCE05A3.idx
create mode 100644 firmware/controller/.cache/clangd/index/hal_heat.cpp.B12AB124127403BA.idx
create mode 100644 firmware/controller/.cache/clangd/index/hal_input.cpp.10B8CEC564533626.idx
create mode 100644 firmware/controller/.cache/clangd/index/hal_io.cpp.EC5F8CD87CBB0D9D.idx
create mode 100644 firmware/controller/.cache/clangd/index/hal_kvstore.cpp.B7DF5B21F17B1AEA.idx
create mode 100644 firmware/controller/.cache/clangd/index/hal_net.cpp.5928048DFBE34E30.idx
create mode 100644 firmware/controller/.cache/clangd/index/hal_prod.cpp.E2093DEBAC13EC5B.idx
create mode 100644 firmware/controller/.cache/clangd/index/hal_suplink.cpp.F952145BD4011FA3.idx
create mode 100644 firmware/controller/.cache/clangd/index/hal_system.cpp.051C49FACAE9C053.idx
create mode 100644 firmware/controller/.cache/clangd/index/hal_tc.cpp.A936EE8D2D953264.idx
create mode 100644 firmware/controller/.cache/clangd/index/hmi.cpp.4C133F4B3BACFE6A.idx
create mode 100644 firmware/controller/.cache/clangd/index/hmi.h.55E5381D963422F3.idx
create mode 100644 firmware/controller/.cache/clangd/index/httpd.cpp.3949980EE3DBFFF2.idx
create mode 100644 firmware/controller/.cache/clangd/index/httpd.h.AE03CFFCC5598233.idx
create mode 100644 firmware/controller/.cache/clangd/index/json.cpp.71CA25D08E6E4912.idx
create mode 100644 firmware/controller/.cache/clangd/index/json.h.A5CC97710F05390F.idx
create mode 100644 firmware/controller/.cache/clangd/index/logrec.cpp.C765F885F08DE1A6.idx
create mode 100644 firmware/controller/.cache/clangd/index/logrec.h.44464D377E7E4D85.idx
create mode 100644 firmware/controller/.cache/clangd/index/logring.cpp.D4136777A6514E28.idx
create mode 100644 firmware/controller/.cache/clangd/index/logring.h.906F81554E8AE5F1.idx
create mode 100644 firmware/controller/.cache/clangd/index/main.cpp.1D658C8212876481.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns.h.C76CB2B1CB5F31EF.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_browser.c.C25D87CE840B727B.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_browser.h.BA6CAACB78CB669F.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_cache.c.294254DF303FA182.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_cache.h.EADAE4363ADEBE57.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_console.c.0C11AABAE1148BC3.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_debug.c.70AFC7F558EBD732.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_debug.h.190DA7C4B218495A.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_mem_caps.c.F3CF0DFBFC63F33E.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_mem_caps.h.3FECC6AB3E87B33A.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_netif.c.505B78302CC0E92E.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_netif.h.D0D872E6E68AF30A.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_networking.h.FFFA02FFE676A743.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_networking_lwip.c.BC1C86DD66B32A39.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_pcb.c.A1C59CE9F92A2EB9.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_pcb.h.65809FE99D54C152.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_private.h.E6C7E8B4460B3C26.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_querier.c.1B1086BFD1CC4A05.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_querier.h.8D9747B017BBA1BD.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_receive.c.48FB52E1C144A520.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_receive.h.DE63D8312E409545.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_responder.c.400ADCEFAE8349E5.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_responder.h.C2F515428F349B06.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_send.c.70C72B8F58248918.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_send.h.4A1D72CBC50AEB40.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_service.c.92D5EB5771567036.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_service.h.7C851492F5E0D8AB.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_utils.c.F534F6547BABC748.idx
create mode 100644 firmware/controller/.cache/clangd/index/mdns_utils.h.82A7215ABE807ABD.idx
create mode 100644 firmware/controller/.cache/clangd/index/pid.cpp.7A2C4FF97B438BF3.idx
create mode 100644 firmware/controller/.cache/clangd/index/pid.h.21295E2653B28799.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_alarm.h.398A6B39659208EF.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_clock.h.DB1153E770FCA061.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_counters.h.C8FD845045631D2B.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_current.h.DA521570B5762B1B.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_display.h.0D9E490EBFE7255B.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_door.h.21BF44FA16DF7DC2.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_filestore.h.2CDC67F6137FFA29.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_flash.h.32C19661C6CD7505.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_heat.h.E8AB1E4F6E4B178D.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_input.h.09A927FB6F93BEF1.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_kvstore.h.5EAB128A42CCE10A.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_logstore.h.4AAE0293EEF7E78B.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_net.h.92B924E7C7938803.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_supervisor.h.5FDAC2A543564004.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_system.h.AE99EF9BA20AE698.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_tc.h.77F0CC7425708CC7.idx
create mode 100644 firmware/controller/.cache/clangd/index/port_update.h.07DD0CA770131E6B.idx
create mode 100644 firmware/controller/.cache/clangd/index/profile.cpp.B87D6AA6848F66A1.idx
create mode 100644 firmware/controller/.cache/clangd/index/profile.h.130A26E1FEB83C21.idx
create mode 100644 firmware/controller/.cache/clangd/index/program_store.cpp.BC4CCB967AEDF9AE.idx
create mode 100644 firmware/controller/.cache/clangd/index/program_store.h.2EDFE6D17879C6F7.idx
create mode 100644 firmware/controller/.cache/clangd/index/run_index.cpp.D330C06CDA62E5DC.idx
create mode 100644 firmware/controller/.cache/clangd/index/run_index.h.394D0BEB2FC87A2E.idx
create mode 100644 firmware/controller/.cache/clangd/index/runstate.cpp.AADA49FF2FC36235.idx
create mode 100644 firmware/controller/.cache/clangd/index/runstate.h.AA7C2D3C583492F7.idx
create mode 100644 firmware/controller/.cache/clangd/index/safety.cpp.627B6D98AF3BA28C.idx
create mode 100644 firmware/controller/.cache/clangd/index/safety.h.0E0EE6BC58EF2778.idx
create mode 100644 firmware/controller/.cache/clangd/index/setpoint.cpp.B979F998BBBBEE03.idx
create mode 100644 firmware/controller/.cache/clangd/index/setpoint.h.ADEF0AAE1F9E133E.idx
create mode 100644 firmware/controller/.cache/clangd/index/settings.cpp.FE374ADAC354D9D8.idx
create mode 100644 firmware/controller/.cache/clangd/index/settings.h.BC2BAF94023B13A2.idx
create mode 100644 firmware/controller/.cache/clangd/index/sim.cpp.E87AB3FC8C65FB87.idx
create mode 100644 firmware/controller/.cache/clangd/index/sim.h.D849C2DDB57F058C.idx
create mode 100644 firmware/controller/.cache/clangd/index/suplink.cpp.994C7AA985D1F7EC.idx
create mode 100644 firmware/controller/.cache/clangd/index/suplink.h.DAAAB212E792F5BB.idx
create mode 100644 firmware/controller/.cache/clangd/index/tempfilt.cpp.4AF84C324F2390E5.idx
create mode 100644 firmware/controller/.cache/clangd/index/tempfilt.h.2BED8E89BF6BCFA8.idx
create mode 100644 firmware/controller/.cache/clangd/index/types.h.AB46E6CB91327A24.idx
create mode 100644 firmware/controller/.cache/clangd/index/window.cpp.AA9D9AA70187704C.idx
create mode 100644 firmware/controller/.cache/clangd/index/window.h.5D39ED538CF8C810.idx
create mode 100644 firmware/controller/components/kiln_core/include/kiln_core/alarmptn.h
create mode 100644 firmware/controller/components/kiln_core/src/alarmptn.cpp
create mode 100644 firmware/controller/test/host/unit/test_alarmptn.cpp
diff --git a/docs/03_software_req.sdoc b/docs/03_software_req.sdoc
index 1290cbf..7d6e5ac 100644
--- a/docs/03_software_req.sdoc
+++ b/docs/03_software_req.sdoc
@@ -909,7 +909,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
@@ -2550,8 +2552,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
diff --git a/docs/architecture.md b/docs/architecture.md
index 77a288e..8dc4538 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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 |
@@ -747,6 +747,33 @@ so instead of losing the previous content. 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.
+### 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
`configmodel` holds a static table, one row per item, with key, type, unit,
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 0000000000000000000000000000000000000000..00bd768c129f25cc0281dd4e2c5279d12abf1013
GIT binary patch
literal 24168
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
e)~sEsD;qykx_+t-pCmH~y}?UGjgp`6DI(
literal 0
HcmV?d00001
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 0000000000000000000000000000000000000000..297e960d3dbace19f216cd78b58498b9aaa2acce
GIT binary patch
literal 4836
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
F{|DBJKCb`(
literal 0
HcmV?d00001
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 0000000000000000000000000000000000000000..4bc9907e79e526983f492601d657b39abe54951a
GIT binary patch
literal 34728
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