diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4943927..334a6eb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,19 +48,33 @@ jobs:
steps:
- uses: actions/checkout@v4
- - name: kiln_core has no platform dependency (SWR-TST-01, SWA-14)
+ - name: Three architecture decisions, structurally (SWA-14, SWA-03, SWA-02)
run: |
- # The property SWA-14 rests on, and the one that erodes first: one
- # esp_*/freertos include in the core and the host tests stop building,
- # which is how a testability driver gets quietly dropped. The full
- # check over preprocessor output is tasklist C5; a grep already catches
- # every way this has actually happened.
- if grep -rnE '#include[[:space:]]*[<"](esp_|freertos/|driver/|soc/|hal/|nvs|sdkconfig)' \
- firmware/controller/components/kiln_core firmware/controller/components/kiln_ports; then
- echo "::error::kiln_core or kiln_ports references a platform header"
- exit 1
- fi
- echo "clean"
+ # tools/layercheck.sh, which replaced the grep that used to be here
+ # and says in its own header why. Three decisions that held by
+ # discipline alone are now checked:
+ #
+ # SWA-14 the core includes no platform header, read from the
+ # preprocessor's own record rather than from include lines,
+ # so an indirect or ESP_PLATFORM-guarded one is caught too.
+ # SWA-03 no mutable file-scope state in the core, read from the
+ # object code, which also catches a function-local static.
+ # SWA-02 no direct clock read in the core, read from its
+ # undefined symbols.
+ #
+ # The last two are what make a 168 h firing testable in milliseconds,
+ # and both would break silently: the tests keep passing, they just
+ # stop testing what they claim to.
+ #
+ # Its own self-test runs first, because a gate nobody has seen fail
+ # is a gate nobody should trust.
+ tools/layercheck.sh --self-test
+ # The host build is needed for the object-code checks; the target
+ # build is not available in this job, and the script says which
+ # checks it skipped rather than reporting a pass it did not make.
+ cmake -B build-host -S firmware/controller/test/host >/dev/null
+ cmake --build build-host -j"$(nproc)" --target kiln_core >/dev/null
+ tools/layercheck.sh
- name: kiln_ports is headers only (architecture 4, rule 2)
run: |
@@ -80,6 +94,12 @@ jobs:
python3 tools/update-manifest.py --self-test
python3 tools/secure-boot.py --self-test
python3 tools/sbom.py --self-test
+ # logdump decodes a log partition with an implementation written from
+ # the format's documentation rather than from kiln_core, so the two
+ # agreeing is evidence that the firmware records what it claims. Its
+ # self-test ends by decoding the committed fixture that the C++
+ # encoder produced, which is the half a round trip cannot prove.
+ python3 tools/logdump.py --self-test
- name: SPDX header on every source file (SWR-NFR-18, UR-CON-04)
run: |
@@ -586,6 +606,7 @@ jobs:
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
+ cd ../.. && TARGET_BUILD=firmware/controller/build-hw HOST_BUILD=none tools/layercheck.sh
- name: Summarise the hardware image size
if: always()
diff --git a/docs/03_hardware_req.sdoc b/docs/03_hardware_req.sdoc
new file mode 100644
index 0000000..aaa463c
--- /dev/null
+++ b/docs/03_hardware_req.sdoc
@@ -0,0 +1,432 @@
+[DOCUMENT]
+TITLE: Safe Kiln Controller, Hardware Requirements
+UID: SAFEKILN-HWR
+VERSION: 0.1 (draft)
+DATE: 2026-10-11
+OPTIONS:
+ MARKUP: Markdown
+ VIEW_STYLE: Table
+ NODE_IN_TOC: True
+METADATA:
+ SPDX-FileCopyrightText: 2026 Bitcrush Testing
+ SPDX-License-Identifier: GPL-3.0-or-later
+ Status: For review
+ Project: Safe Kiln Controller, PID kiln controller
+
+[GRAMMAR]
+ELEMENTS:
+- TAG: TEXT
+ FIELDS:
+ - TITLE: STATEMENT
+ TYPE: String
+ REQUIRED: True
+- TAG: REQUIREMENT
+ FIELDS:
+ - TITLE: UID
+ TYPE: String
+ REQUIRED: True
+ - TITLE: TITLE
+ TYPE: String
+ REQUIRED: False
+ - TITLE: STATEMENT
+ TYPE: String
+ REQUIRED: True
+ - TITLE: PRIORITY
+ TYPE: SingleChoice(M, S, C)
+ REQUIRED: False
+ - TITLE: VERIFICATION
+ TYPE: String
+ REQUIRED: False
+ RELATIONS:
+ - TYPE: Parent
+ ROLE: Refines
+
+[TEXT]
+STATEMENT: >>>
+Level 3 of the V-model, the hardware branch, parallel to
+[`03_software_req.sdoc`](03_software_req.sdoc). Both refine `SYS-`.
+
+`SYS-HW-*` in [`02_system_req.sdoc`](02_system_req.sdoc) says what the system
+needs: two switching devices in series, a buzzer, an interlock input. These
+are the board-level consequences, with the numbers. A requirement is here when
+getting it wrong is a hardware change rather than a firmware one.
+
+Verification: **I** inspection or review, **A** analysis or calculation,
+**T** bench measurement, **H** on the HIL fixture.
+
+Each item's open work is tracked in section A of
+[`tasklist.md`](../tasklist.md); this document is what the board has to achieve,
+not the list of fixes outstanding.
+<<<
+
+[[SECTION]]
+UID: SECT-HW-1
+TITLE: 1. Heat authority
+
+[REQUIREMENT]
+UID: HWR-01
+TITLE: Charge-pump release inside one second
+STATEMENT: >>>
+The charge pump shall release the contactor within **1 s** of `HEAT_EN` ceasing to toggle, and shall pass through the MOSFET's linear region fast enough that the contactor cannot rest partially closed.
+
+Present circuit: `HEAT_EN` → R19 → C23 (10 µF) → D5 (BAT54S) → `HEAT_EN_DC` → C24 (3.3 µF) ∥ R20 (47 k) → Q3 gate. Two numbers need establishing rather than assuming: the pumped level is about 3.3 V − 2·Vf ≈ 2.7 V, below the 4.5 V at which the AO3400A's RDS(on) is specified; and decay is τ ≈ 155 ms, so release is a slide rather than an edge.
+
+This is the circuit [LINK: SYS-SAF-02] rests on and the design's most fragile idea. If the margin is thin, a comparator or a retriggerable monostable buys a crisp threshold for a few cents.
+<<<
+PRIORITY: M
+VERIFICATION: A, H
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-07
+ ROLE: Refines
+- TYPE: Parent
+ VALUE: SYS-SAF-02
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-02
+TITLE: Outputs default to de-energised
+STATEMENT: >>>
+Every heater and contactor control output shall carry an external pull-down to the de-energised state, and shall avoid strapping pins and pins that glitch during reset.
+
+The window this covers is reset to first instruction, where a GPIO is high-impedance and the net sits wherever the board leaks to.
+<<<
+PRIORITY: M
+VERIFICATION: I, T
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-08
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-03
+TITLE: The supervisor's permit needs its own pull-down
+STATEMENT: >>>
+The supervisor's permit output is active high, so its series element shall have an external pull-down. Without one there is a window at every supervisor reset in which the element's state is whatever the board leaks to.
+<<<
+PRIORITY: M
+VERIFICATION: I, T
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-26
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-04
+TITLE: Freewheel across the coil
+STATEMENT: >>>
+The coil's freewheel diode shall sit across the coil itself, on the kiln side of every series interrupting element, so opening any of them leaves the current a path to decay through.
+
+Referenced to the raw supply instead, the inductive transient appears across the opening contacts and erodes the lid switch.
+<<<
+PRIORITY: M
+VERIFICATION: I
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-25
+ ROLE: Refines
+
+[[/SECTION]]
+
+[[SECTION]]
+UID: SECT-HW-2
+TITLE: 2. Temperature acquisition
+
+[REQUIREMENT]
+UID: HWR-05
+TITLE: Separate buses for the two front ends
+STATEMENT: >>>
+The chamber MAX31856 shall be on the supervisor's SPI bus and the enclosure one on the controller's. Neither shall share a bus, a chip select or a `~DRDY` line with the other.
+
+A supervisor whose chamber reading can be disturbed by the controller's bus traffic, or whose chip select can be driven by a controller that has crashed with a pin latched, is not independent of it.
+<<<
+PRIORITY: M
+VERIFICATION: I
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-02
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-06
+TITLE: Open-drain outputs are pulled up
+STATEMENT: >>>
+Every MAX31856 `~DRDY` and `~FAULT` output shall carry a pull-up. They are open-drain and read as asserted when floating.
+<<<
+PRIORITY: M
+VERIFICATION: I
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-02
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-07
+TITLE: Chip selects are defined through reset
+STATEMENT: >>>
+`TC1_CS` and `TC2_CS` shall be pulled to their idle level. Both float during reset and boot, and a chip select asserted by leakage starts a conversion nobody asked for.
+<<<
+PRIORITY: S
+VERIFICATION: I
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-08
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-08
+TITLE: Thermocouple inputs are clamped
+STATEMENT: >>>
+The thermocouple inputs shall be clamped to the front end's supply rails, and `VBIAS` shall be decoupled.
+
+A thermocouple is a multi-metre unshielded pair routed beside a switching multi-kilowatt load. R24/R25 (10 k/10 k off +3V3) currently present about 5 kΩ to the bias node with no local capacitance.
+<<<
+PRIORITY: M
+VERIFICATION: I, T
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-15
+ ROLE: Refines
+
+[[/SECTION]]
+
+[[SECTION]]
+UID: SECT-HW-3
+TITLE: 3. Current measurement
+
+[REQUIREMENT]
+UID: HWR-09
+TITLE: Full scale fits the ADC without clipping
+STATEMENT: >>>
+The CT front end shall put **60 A** ([LINK: SWR-CUR-02]'s full scale) at roughly **0.5 Vrms** about the mid-rail bias, and the resulting LSB shall be recorded against the 0.1 A resolution the same requirement asks for.
+
+J10 is annotated "CT 30 A/1 V". At 60 A that delivers 2 Vrms = ±2.83 Vpk about the 1.65 V bias: the clamp conducts and the reading saturates across the whole upper half of the required range. Even at 30 A the swing reaches 0.24 V to 3.06 V, where the ESP32-S3 ADC is least linear.
+<<<
+PRIORITY: M
+VERIFICATION: A, T
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-17
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-10
+TITLE: The anti-alias filter is below Nyquist
+STATEMENT: >>>
+The CT anti-alias corner shall sit **below half the sample rate**. Pick the sample rate first, then the filter.
+
+R26 (1 k) + C25 (220 nF) gives fc ≈ 723 Hz against a 1 kHz sample rate whose Nyquist is 500 Hz: the corner is above Nyquist and the filter does not anti-alias. Either 4–8 kHz sampling with a corner near 300 Hz, or a corner near 200 Hz for 1 kHz.
+<<<
+PRIORITY: M
+VERIFICATION: A, T
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-17
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-11
+TITLE: An absent CT is electrically recognisable
+STATEMENT: >>>
+`CURR_SENSE` shall carry a defined bias that the CT winding cannot produce, so an open input is distinguishable from genuinely zero current.
+
+With J10 open the node is held only by C25 and diode leakage, so it drifts. [LINK: SWR-CUR-11] has to tell those two cases apart and [LINK: SWR-CUR-12] refuses to start a run on the second.
+<<<
+PRIORITY: M
+VERIFICATION: I, T
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-17
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-12
+TITLE: The clamp holds the ADC inside its absolute maximum
+STATEMENT: >>>
+The CT node shall be clamped to within the ADC pin's absolute maximum of −0.3 V to VDD+0.3 V, through a series resistor that limits clamp current.
+
+A **Schottky** clamp is required rather than preferred: at 1 mA a BAT54S drops about 0.32 V and holds the node near 3.6 V, while a silicon diode's 0.7 V would hold it at 4.0 V and exceed the pin's rating by 0.4 V. D9 is a BAT54S, which is correct; what is open is the scaling of [LINK: HWR-09], which currently drives the clamp during normal operation.
+<<<
+PRIORITY: M
+VERIFICATION: A, I
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-17
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-13
+TITLE: The CT is specified on the schematic
+STATEMENT: >>>
+The CT's required characteristics shall appear as a schematic note, and no CT-related part shall be annotated "(opt)" without the behaviour of its absence being defined.
+
+A split-core CT fitted to the wrong conductor reads a current that is real and not the heater's.
+<<<
+PRIORITY: S
+VERIFICATION: I
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-16
+ ROLE: Refines
+
+[[/SECTION]]
+
+[[SECTION]]
+UID: SECT-HW-4
+TITLE: 4. Power and protection
+
+[REQUIREMENT]
+UID: HWR-14
+TITLE: The +5 V rail is budgeted, and coil inrush does not reach the MCU
+STATEMENT: >>>
+The +5 V rail shall be budgeted for the contactor coil, the buzzer and the MCU together, and coil inrush shall not be able to brown out the MCU.
+
+The coil is the largest and least constant load on the board, and the one that switches while a kiln is at temperature.
+<<<
+PRIORITY: M
+VERIFICATION: A, T
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-05
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-15
+TITLE: The regulator has dropout and thermal margin
+STATEMENT: >>>
+The 3V3 regulator shall hold regulation at the lowest input the design accepts, with its junction temperature inside its rating at full load and the highest specified ambient.
+
+An AMS1117 at the worst case is the part to check first.
+<<<
+PRIORITY: M
+VERIFICATION: A
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-05
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-16
+TITLE: Input transients are clamped
+STATEMENT: >>>
+`V_IN` shall have transient protection. The fuse and series diode cover overcurrent and reverse polarity; neither clamps a surge, and the board sits next to a switching multi-kilowatt load.
+<<<
+PRIORITY: S
+VERIFICATION: I
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-05
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-17
+TITLE: USB is protected and decoupled
+STATEMENT: >>>
+`USB_DP`/`USB_DM` shall have low-capacitance ESD protection, and `VBUS` shall be decoupled. The data pair currently runs from the connector to the ESP32-S3's native USB pins unprotected.
+<<<
+PRIORITY: S
+VERIFICATION: I
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-05
+ ROLE: Refines
+
+[[/SECTION]]
+
+[[SECTION]]
+UID: SECT-HW-5
+TITLE: 5. Operator interface and boot
+
+[REQUIREMENT]
+UID: HWR-18
+TITLE: Boot-mode and encoder inputs are defined
+STATEMENT: >>>
+`MCU_IO0` shall have an external pull-up, and the encoder inputs shall have a defined network whose time constant is compatible with the quadrature decoder's expectations.
+
+IO0 is pulled to ground by SW2 with nothing holding it otherwise; R10–R12 with C20–C22 need their corner checked against the knob's fastest realistic rotation.
+<<<
+PRIORITY: S
+VERIFICATION: I, T
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-04
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-19
+TITLE: The supervisor's programming interface is reachable
+STATEMENT: >>>
+The supervisor's SWD pins shall come out to test points rather than being left as pads, and the clear button of [LINK: SYS-HW-28] shall be wired to the supervisor and to nothing else.
+
+The independence argument rests on that firmware being reviewable and flashable on its own.
+<<<
+PRIORITY: M
+VERIFICATION: I
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-26
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-20
+TITLE: Test points survive the chain they outlived
+STATEMENT: >>>
+Each thermocouple `~FAULT` net shall retain its own test point, and the coil drive and permit nets shall each have one.
+
+The discrete fault chain is superseded ([LINK: SYS-HW-24]); the test points cost nothing and are how a front end is diagnosed on a bench.
+<<<
+PRIORITY: S
+VERIFICATION: I
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-24
+ ROLE: Refines
+
+[[/SECTION]]
+
+[[SECTION]]
+UID: SECT-HW-6
+TITLE: 6. Documentation and review
+
+[REQUIREMENT]
+UID: HWR-21
+TITLE: One pin map, generated
+STATEMENT: >>>
+There shall be a single pin-map artefact, derived from the schematic rather than maintained beside it, covering both microcontrollers.
+
+Pin assignments currently live in the schematic, in `board_pins.h` and in the supervisor's README, which is three places that can disagree.
+<<<
+PRIORITY: M
+VERIFICATION: I
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-10
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-22
+TITLE: ERC is clean or its exclusions are recorded
+STATEMENT: >>>
+Electrical rule check shall pass, and every exclusion shall carry its reason in the project file. The shared SPI `SDO` is the known one.
+<<<
+PRIORITY: M
+VERIFICATION: I
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-10
+ ROLE: Refines
+
+[REQUIREMENT]
+UID: HWR-23
+TITLE: Mains-adjacent routing is reviewed before fabrication
+STATEMENT: >>>
+The coil interrupt and every mains-adjacent net shall be reviewed for clearance and creepage before a board is ordered. The PCB currently has no tracks at all, so this is a requirement on work not yet done rather than a check on work done.
+<<<
+PRIORITY: M
+VERIFICATION: I
+RELATIONS:
+- TYPE: Parent
+ VALUE: SYS-HW-05
+ ROLE: Refines
+
+[[/SECTION]]
diff --git a/docs/03_software_req.sdoc b/docs/03_software_req.sdoc
index 0cda039..f267d78 100644
--- a/docs/03_software_req.sdoc
+++ b/docs/03_software_req.sdoc
@@ -1471,6 +1471,14 @@ STATEMENT: >>>
The log shall be readable by the operator, shall survive a restart, and the recording of these events shall be **disableable by the owner**: [LINK: UR-REG-004] asks for the opt-out as explicitly as it asks for the log, and a device that logs access to itself regardless of its owner's wishes is a surveillance feature rather than a security one.
It shall contain nothing that the device does not already show: no credential, no key material, no request body. A log that records what an attacker wanted is useful; one that records the WiFi password is a second copy of [LINK: SRR-05].
+
+**Partly implemented 2026-10-10, and the division is deliberate.** Three event codes are added to the persisted log (appended, so the codes every existing log uses keep their meaning), and `log.security_events` is the owner's opt-out, default on because the default has to be the one that helps an owner who has not thought about it. The opt-out is applied in the one place every event passes through, so no emitter can forget it, and it removes **only** the security events: a fault, a state change and the periodic sample are the evidence of what a kiln did, which is a safety record rather than a record of access to the device, and no configuration item switches those off.
+
+What is recorded now: an **abnormal reset cause** (anything but a power-on, a requested restart, an external reset or a wake from sleep -- a cause the adapter could not identify counts as abnormal, because a reset nobody can account for is exactly what a log should carry); a **factory reset** ([LINK: SWR-CFG-09]), logged last so that it is the first entry of the new owner's log and explains why the rest is missing; a **network join commanded at the display**, without the SSID, since a log that records which network a kiln joined is a record of where it lives; and **every configuration change**, which the log already carried.
+
+What is **not** recorded, and why it waits: the update-path events this requirement lists -- an offer, a confirmation, an install, a refusal with its version and reason, a failed signature check, a rollback -- need a version string and a reason, and neither fits a 20 byte record ([LINK: SWA-18]). They arrive with the update path itself (tasklist H2), which will need either a second record type or its own channel. Adding codes now that could carry no detail would have looked like progress and recorded nothing.
+
+Two items this exposed are fixed with it. "Which interface made it" has one answer today, the display, because [LINK: SWR-WEB-26] left no other writer; when that stops being true the field has to be carried rather than assumed. And a record appended **outside a run** used to be lost: it went into a sector with no header, which `iterate` skips and the next run erases, so the first security event of this kind would have been written, stored, counted and never read. The ring now claims a sector lazily, tagged run 0 for "not part of a run".
<<<
PRIORITY: M
VERIFICATION: T
@@ -1555,6 +1563,10 @@ It shall require a deliberate confirmation of the same kind as a firmware instal
**Erasure means gone, not unreferenced.** A flash region holding a credential shall be erased or overwritten, not merely delisted, and the test for this requirement shall read the raw partition back and look for the secret rather than asking the API whether it is still configured.
This is the requirement that turns [LINK: SRR-05]'s disposal obligation from a line in a manual into a function of the product, which is how [LINK: UR-REG-004]'s requirement to permanently remove all data and settings is met. Until it exists, the only honest instruction is "erase the flash with a cable before you sell it", and most owners will not.
+
+**Implemented 2026-10-10.** `kiln_app_factory_reset` is reachable from the last entry of the display's menu, behind the same confirmation an abort uses and defaulting to "no". It erases the configuration and the WiFi credentials inside it, the latched fault, every run record, the sample log and the wear counters; it restarts the run numbering, because a kiln whose first firing is number 94 tells its new owner something false; and it leaves the production data block and the firmware alone. Refused while running, paused or autotuning. It reports counts rather than only success, and does as much as it can rather than stopping at the first failure, because a reset that gave up half way would leave an owner with some of their data erased and no idea which.
+
+**What the test had to establish, and how.** The credential is overwritten with zeros and committed *before* the key is erased, since an NVS erase delists an entry and leaves its bytes in the page until a garbage collection that may never come. Proving that needed the host key/value fake to stop being kinder than the medium: it used to clear a whole entry on erase and to reuse the delisted slot on the next write, either of which made "the secret is gone" pass with no implementation behind it. It now delists and keeps the bytes, as NVS does, with collection available only when a test asks for it. With the overwrite removed from the implementation, the test fails; with it restored, it passes. That experiment is the evidence this requirement asked for.
<<<
PRIORITY: M
VERIFICATION: T
@@ -1898,6 +1910,12 @@ VERIFICATION: T
UID: SWR-CUR-07
STATEMENT: >>>
The system shall derive and expose apparent power and cumulative energy per run from the measured current and a configured mains voltage, stating that a resistive load is assumed.
+
+**Apparent power, and it stays apparent. Closed 2026-10-10 (tasklist I3).** Real power would need a voltage channel: a mains-referenced measurement with its own isolation, its own calibration and its own creepage distances on a board that currently touches mains in exactly one place. The design does not have one and should not grow one for this.
+
+What the assumption costs is small and worth stating rather than leaving implied. A kiln element is very nearly a pure resistance ([LINK: SYS-ASM-09]), so the power factor is within a percent or two of unity and apparent power is real power to better than the accuracy of the configured mains voltage, which an installer types in and which varies by more than that over a day. The number is for an operator asking "what does a firing cost" and for [LINK: SWR-SAF-12]'s energy comparison between firings, and both are served by a figure whose *relative* accuracy between runs is what matters.
+
+Where it would be wrong is a load this controller is not for: phase-angle control, which [LINK: SYS-ASM-03] excludes in favour of zero-cross switching, or an inductive load, which an element is not. Both are out of scope rather than unhandled.
<<<
PRIORITY: S
VERIFICATION: T
diff --git a/docs/v-model-process.md b/docs/v-model-process.md
index 1393feb..c7b0857 100644
--- a/docs/v-model-process.md
+++ b/docs/v-model-process.md
@@ -35,6 +35,7 @@ User Requirements ────────────────────
|---|-----------------------|------------------------|------------|------------------|-------------------|
| 1 | User Requirements | `01_user_req.sdoc` | `UR-` | – | Acceptance Tests |
| 2 | System Requirements | `02_system_req.sdoc` | `SYS-` | `UR-` | System Tests |
+| 3 | Hardware Requirements | `03_hardware_req.sdoc` | `HWR-` | `SYS-` | Inspection, bench, HIL |
| 3 | Software Requirements | `03_software_req.sdoc` | `SWR-` | `SYS-` | Integration Tests |
| 4 | Software Architecture | `04_software_arch.sdoc`| `SWA-` | `SWR-` | Software Tests |
| 5 | Unit Design | Doxygen in the source | see §4.5 | `SWA-` | Unit Tests |
@@ -48,6 +49,12 @@ User Requirements ────────────────────
| 10 | System Tests | `10_system_tests.sdoc` | `ST-` | `SYS-` |
| 11 | Acceptance Tests | `11_acceptance_tests.sdoc` | `AT-` | `UR-` |
+**Level 3 has two branches**, which is the classic V and was implicit here
+until the hardware one was written down. `SYS-HW-*` says what the system needs;
+`HWR-*` says what the board has to achieve, with the numbers. A requirement
+belongs in the hardware branch when getting it wrong is a board change rather
+than a firmware one.
+
## 4. Specification Phases (Left Side)
### 4.1 User Requirements (`UR-`)
diff --git a/firmware/controller/components/kiln_app/include/kiln_app/app.h b/firmware/controller/components/kiln_app/include/kiln_app/app.h
index fadeda5..ee8f595 100644
--- a/firmware/controller/components/kiln_app/include/kiln_app/app.h
+++ b/firmware/controller/components/kiln_app/include/kiln_app/app.h
@@ -131,6 +131,12 @@ typedef struct {
double run_elapsed_s;
float alarm_timer_s;
+ /* SWR-SAF-31, as the safety cycle last read it. Published in the snapshot
+ * rather than re-read by the display, so the panel and the API cannot
+ * disagree with the rule that acted. */
+ bool door_open;
+ bool door_monitoring;
+
/* SWR-RUN-06 */
bool complete_pending;
@@ -212,6 +218,40 @@ kiln_err_t kiln_app_manual(kiln_app_t *app, uint16_t duty_permille);
kiln_err_t kiln_app_autotune(kiln_app_t *app, float setpoint_c);
kiln_err_t kiln_app_idle(kiln_app_t *app);
+/* --- SWR-CFG-09: the owner erases everything ---------------------------
+ *
+ * What the owner's data is, and what it is not. Everything this removes was
+ * put there by whoever owns the kiln: the configuration, the WiFi
+ * credentials, the run history, the sample log, the wear counters, the
+ * latched fault. The production data block of SWR-PROD-01 and the firmware
+ * stay: those identify the unit for its whole life and belong to the unit
+ * rather than to its owner.
+ *
+ * **Erasure means gone, not unreferenced.** A key holding a credential is
+ * overwritten with zeros and committed BEFORE it is erased, because on NVS an
+ * erase marks an entry dead and leaves its bytes in the page until the next
+ * garbage collection -- and "the API says it is not configured" is not the
+ * question somebody selling a kiln is asking. The test for this reads the raw
+ * store back and looks for the passphrase.
+ *
+ * Refused while a run or an autotune is going, for the obvious reason, and it
+ * reports what it erased rather than only that it finished: an owner who is
+ * about to sell the thing deserves to see counts. */
+typedef struct {
+ bool config; /* the configuration blob */
+ bool credentials; /* the WiFi passphrase, overwritten then erased */
+ bool latched_fault;
+ bool log; /* the sample log partition */
+ bool counters; /* switching-operation wear counters */
+ uint8_t runs_erased; /* run records removed from the file store */
+ uint8_t failures; /* how many of the above could not be done */
+} kiln_factory_reset_t;
+
+/* KILN_ERR_STATE while running or autotuning. Otherwise it does as much as it
+ * can and reports the rest: a reset that stopped at the first failure would
+ * leave an owner with some of their data erased and no idea which. */
+kiln_err_t kiln_app_factory_reset(kiln_app_t *app, kiln_factory_reset_t *out);
+
void kiln_app_snapshot(const kiln_app_t *app, kiln_snapshot_t *out);
/* SWR-CUR-07: apparent power and cumulative energy from the measured current
diff --git a/firmware/controller/components/kiln_app/include/kiln_app/run_index.h b/firmware/controller/components/kiln_app/include/kiln_app/run_index.h
index 96b4807..edc5af4 100644
--- a/firmware/controller/components/kiln_app/include/kiln_app/run_index.h
+++ b/firmware/controller/components/kiln_app/include/kiln_app/run_index.h
@@ -43,6 +43,15 @@ kiln_err_t kiln_run_index_mark_truncated(const kiln_port_filestore_t *fs,
uint32_t oldest_logged_run_id);
/* SWR-SAF-12's baseline across the stored history. */
+/* SWR-CFG-09: remove one slot's record, for the factory reset.
+ *
+ * Here rather than in the reset itself because this file owns the naming: the
+ * file store has no enumeration (and deliberately so), so the only thing that
+ * knows a run record is called "/r/07" is the code that writes it. Returns
+ * KILN_ERR_NOT_FOUND for a slot that held nothing, which the caller counts
+ * rather than treats as a failure. */
+kiln_err_t kiln_run_index_erase_slot(const kiln_port_filestore_t *fs, uint8_t slot);
+
kiln_err_t kiln_run_index_baseline(const kiln_port_filestore_t *fs,
uint8_t min_runs,
kiln_insulation_baseline_t *out);
diff --git a/firmware/controller/components/kiln_app/src/app.cpp b/firmware/controller/components/kiln_app/src/app.cpp
index ea4e6e0..60cbd39 100644
--- a/firmware/controller/components/kiln_app/src/app.cpp
+++ b/firmware/controller/components/kiln_app/src/app.cpp
@@ -283,6 +283,15 @@ void kiln_app_log_event(kiln_app_t *app, kiln_log_event_t event)
return;
}
+ /* SWR-LOG-16's opt-out, applied in the one place every event passes
+ * through so that no emitter can forget it. Only the security events go:
+ * a fault, a state change or a sample is the evidence of what a kiln did,
+ * which is a safety record and not a record of access to the device, and
+ * no configuration item switches those off. */
+ if (kiln_log_event_is_security(event) && !app->cfg.log_security_events) {
+ return;
+ }
+
kiln_log_sample_t s;
build_sample(app, event, &s);
log_enqueue(app, &s);
@@ -651,6 +660,7 @@ void build_safety_input(const kiln_app_t *app, kiln_safety_input_t *in)
in->door_open = false;
}
+
/* SWR-CUR-11 against SWR-CUR-12, and the distinction matters: monitoring is
* *on* whenever it is configured on, even if the channel is not answering.
*
@@ -690,6 +700,14 @@ void kiln_app_safety_cycle(kiln_app_t *app, float dt_s)
kiln_safety_input_t in;
build_safety_input(app, &in);
+ /* Published so the display and the API can say why a kiln is not heating
+ * (SWR-WEB-04, SWR-SAF-31). Taken from the input the rule actually used
+ * rather than re-read: a second sample of a safety input at a different
+ * instant is how two parts of one device come to disagree about whether
+ * the door is open. */
+ app->door_open = in.door_open;
+ app->door_monitoring = in.door_monitoring;
+
/* SWR-NFR-17: the checked form, so a contract violation here is counted rather
* than absorbed into a fail-safe verdict nobody looks at. */
kiln_safety_verdict_t v;
@@ -920,6 +938,26 @@ kiln_err_t kiln_app_boot(kiln_app_t *app, kiln_reset_cause_t cause, float outage
app->last_logged_state = (uint8_t)app->state;
app->last_logged_warnings = app->warnings;
+
+ /* SWR-LOG-16, SWR-NFR-15: an abnormal reset cause is recorded.
+ *
+ * "Abnormal" is anything but a power-on, a requested restart or an
+ * external reset: a panic, any of the three watchdogs, a brownout, or a
+ * cause the adapter could not identify. The last one is in deliberately --
+ * a reset nobody can account for is exactly the kind a log should carry,
+ * and treating UNKNOWN as normal would hide the case where the adapter
+ * itself is wrong.
+ *
+ * Logged after the recovery decision above, so the record sits beside the
+ * state the device came back into rather than before it. */
+ const bool normal_reset = (cause == KILN_RESET_POWER_ON) ||
+ (cause == KILN_RESET_SOFTWARE) ||
+ (cause == KILN_RESET_EXTERNAL) ||
+ (cause == KILN_RESET_DEEPSLEEP);
+ if (!normal_reset) {
+ kiln_app_log_event(app, KILN_LOGE_SEC_RESET);
+ }
+
return result;
}
@@ -1253,6 +1291,104 @@ kiln_err_t kiln_app_autotune(kiln_app_t *app, float setpoint_c)
return e; /* KILN_ERR_RANGE: running, but clamped below what was asked */
}
+/* --- SWR-CFG-09, SEC-14: the owner erases everything -------------------- */
+
+kiln_err_t kiln_app_factory_reset(kiln_app_t *app, kiln_factory_reset_t *out)
+{
+ if ((app == nullptr) || (out == nullptr)) {
+ return KILN_ERR_INVALID_ARG;
+ }
+ *out = kiln_factory_reset_t{};
+
+ /* Not mid-firing, and not mid-autotune. Both are states where erasing
+ * the configuration underneath the running rules would be a different
+ * kind of hazard from the one this exists to prevent. */
+ if (is_heating_state(app->state) || app->state == KILN_STATE_PAUSED ||
+ app->state == KILN_STATE_AUTOTUNE) {
+ return KILN_ERR_STATE;
+ }
+
+ const kiln_port_kvstore_t *kv = app->ports.kvstore;
+ if ((kv != nullptr) && (kv->set != nullptr) && (kv->erase != nullptr)) {
+ /* Overwrite, commit, then erase.
+ *
+ * The order is the requirement. On NVS an erase marks the entry dead
+ * and leaves its bytes in the page until a garbage collection that
+ * may never come, so a credential would still be in the flash of a
+ * kiln somebody has just sold. Writing zeros over it first means the
+ * bytes that survive are zeros.
+ *
+ * The configuration blob carries `net.wifi_pass`, which is why it is
+ * treated as a credential rather than as a setting. */
+ static const uint8_t zeros[sizeof(kiln_config_t)] = {};
+ const bool over = kv->set(kv->ctx, KILN_NVS_NAMESPACE, KILN_NVS_KEY_CONFIG,
+ zeros, sizeof(zeros)) == KILN_OK;
+ if (over && (kv->commit != nullptr)) {
+ (void)kv->commit(kv->ctx, KILN_NVS_NAMESPACE);
+ }
+ const bool gone = kv->erase(kv->ctx, KILN_NVS_NAMESPACE,
+ KILN_NVS_KEY_CONFIG) == KILN_OK;
+ out->config = gone;
+ out->credentials = over && gone;
+ if (!gone) { out->failures++; }
+
+ if (kv->erase(kv->ctx, KILN_NVS_NAMESPACE, KILN_NVS_KEY_FAULT) == KILN_OK) {
+ out->latched_fault = true;
+ }
+ if (kv->commit != nullptr) {
+ (void)kv->commit(kv->ctx, KILN_NVS_NAMESPACE);
+ }
+ }
+ else {
+ out->failures++;
+ }
+
+ /* The run history. By slot rather than by enumeration: the file store has
+ * no list (and deliberately so), and run_index owns the naming, so this
+ * asks it to remove each slot it could have written. */
+ for (uint8_t slot = 0; slot < (uint8_t)KILN_RUN_SLOTS; slot++) {
+ if (kiln_run_index_erase_slot(app->ports.filestore, slot) == KILN_OK) {
+ out->runs_erased++;
+ }
+ }
+
+ if ((app->ports.logstore != nullptr) && (app->ports.logstore->erase_all != nullptr)) {
+ out->log = app->ports.logstore->erase_all(app->ports.logstore->ctx) == KILN_OK;
+ if (!out->log) { out->failures++; }
+ }
+
+ if ((app->ports.counters != nullptr) && (app->ports.counters->reset != nullptr)) {
+ const kiln_switch_counters_t zero = {};
+ out->counters = app->ports.counters->reset(app->ports.counters->ctx,
+ &zero) == KILN_OK;
+ if (!out->counters) { out->failures++; }
+ app->counters = zero;
+ }
+
+ /* The configuration in RAM goes back to its defaults, so the device is
+ * usable immediately rather than only after a reboot, and so that nothing
+ * keeps serving the credentials that were just erased from flash. */
+ kiln_config_defaults(&app->cfg);
+ (void)kiln_app_apply_config(app, &app->cfg, nullptr);
+ app->fault = KILN_FAULT_NONE;
+ app->warnings = 0u;
+ app->state = KILN_STATE_IDLE;
+
+ /* The run-id sequence restarts, because the records it counted are gone
+ * and a kiln whose first firing is number 94 tells its new owner
+ * something false about its history. */
+ app->next_run_id = 1u;
+
+ /* SWR-LOG-16. Logged last, because the log it would have been written to
+ * was erased a moment ago: this record is the first entry of the new
+ * owner's log, saying that the previous owner wiped it. That is the
+ * honest thing for it to say -- a log that begins mid-history with no
+ * explanation reads like a fault. */
+ kiln_app_log_event(app, KILN_LOGE_SEC_ERASE);
+
+ return (out->failures == 0u) ? KILN_OK : KILN_ERR_IO;
+}
+
kiln_err_t kiln_app_idle(kiln_app_t *app)
{
if (app == nullptr) {
@@ -1299,4 +1435,6 @@ void kiln_app_snapshot(const kiln_app_t *app, kiln_snapshot_t *out)
out->duty_saturated = app->pid.saturated;
out->kiln_valid = app->kiln_valid;
out->case_valid = app->case_valid;
+ out->door_open = app->door_open;
+ out->door_monitoring= app->door_monitoring;
}
diff --git a/firmware/controller/components/kiln_app/src/run_index.cpp b/firmware/controller/components/kiln_app/src/run_index.cpp
index 83e110f..99b0070 100644
--- a/firmware/controller/components/kiln_app/src/run_index.cpp
+++ b/firmware/controller/components/kiln_app/src/run_index.cpp
@@ -158,6 +158,19 @@ kiln_err_t kiln_run_index_find(const kiln_port_filestore_t *fs, uint32_t run_id,
return KILN_ERR_NOT_FOUND;
}
+kiln_err_t kiln_run_index_erase_slot(const kiln_port_filestore_t *fs, uint8_t slot)
+{
+ if ((fs == nullptr) || (fs->remove == nullptr)) {
+ return KILN_ERR_INVALID_ARG;
+ }
+ if (slot >= (uint8_t)KILN_RUN_SLOTS) {
+ return KILN_ERR_INVALID_ARG;
+ }
+ char path[KILN_PATH_MAX];
+ slot_path(slot, path);
+ return fs->remove(fs->ctx, path);
+}
+
uint8_t kiln_run_index_count(const kiln_port_filestore_t *fs)
{
if ((fs == nullptr) || (fs->read == nullptr)) {
diff --git a/firmware/controller/components/kiln_core/include/kiln_core/configmodel.h b/firmware/controller/components/kiln_core/include/kiln_core/configmodel.h
index 3eb252b..6443ffb 100644
--- a/firmware/controller/components/kiln_core/include/kiln_core/configmodel.h
+++ b/firmware/controller/components/kiln_core/include/kiln_core/configmodel.h
@@ -26,7 +26,7 @@
* be read as a version 2 one; kiln_config_decode carries the splice that
* migrates it, and SWR-CFG-05's "older: migrate" is met rather than answered
* with defaults. */
-constexpr uint16_t KILN_CFG_SCHEMA_VERSION = 2;
+constexpr uint16_t KILN_CFG_SCHEMA_VERSION = 3;
constexpr size_t KILN_CFG_STR_LEN = 33; /* WiFi SSID is 32 + NUL */
constexpr size_t KILN_CFG_PASS_LEN = 65;
@@ -179,6 +179,17 @@ typedef struct {
char timezone[KILN_CFG_HOST_LEN];
/* security */
+ /* SWR-LOG-16, and UR-REG-004 asks for this opt-out as explicitly as it
+ * asks for the log: a device that records access to itself regardless of
+ * its owner's wishes is a surveillance feature rather than a security one.
+ * Default on, because the default has to be the one that helps an owner
+ * who has not thought about it.
+ *
+ * Appended at the END of the struct deliberately. A field added here is
+ * the case SWR-CFG-05's migration was designed for -- an older blob is
+ * shorter, the prefix copies, and the new item takes its default. A field
+ * inserted in the middle is the case schema 2 had to splice. */
+ bool log_security_events;
} kiln_config_t;
void kiln_config_defaults(kiln_config_t *cfg);
diff --git a/firmware/controller/components/kiln_core/include/kiln_core/faults.h b/firmware/controller/components/kiln_core/include/kiln_core/faults.h
index ea01a28..cea7fd0 100644
--- a/firmware/controller/components/kiln_core/include/kiln_core/faults.h
+++ b/firmware/controller/components/kiln_core/include/kiln_core/faults.h
@@ -11,7 +11,29 @@
* The unsuffixed calls return English and are what diagnostics, the log and
* the requirement traceability use: a fault code quoted in a bug report should
* read the same whoever filed it. The _in() forms take a language and are
- * what the display and the API use to talk to the operator.
+ * what the API uses to talk to the operator.
+ *
+ * ---------------------------------------------------------------------------
+ * WHY THERE IS A SECOND, SHORTER FORM
+ * ---------------------------------------------------------------------------
+ * The full causes run to 279 characters. In a browser that is a paragraph and
+ * reads well; on the fault screen it is **five lines of 21 characters**, and
+ * what falls off the bottom of a 128x64 panel is the end of the sentence --
+ * which in an instruction is the instruction. "ISOLATE THE KILN AT ITS SUPPLY
+ * NOW" was the part being silently dropped.
+ *
+ * So each entry may carry a panel form as well: the same meaning, trimmed to
+ * what the screen can hold, action first. Where the full cause already fits,
+ * there is no second string to get out of step with the first. The _panel_in()
+ * calls return the short form when there is one and the full cause when there
+ * is not, so the display asks for one thing and always gets something it can
+ * render completely.
+ *
+ * Both forms are plain ASCII, including the German. The 5x7 font in kiln_hmi
+ * carries the 95 printable ASCII glyphs and nothing else, so German is
+ * transliterated -- AE OE UE SS. That had been true of the labels and not of
+ * the causes: 31 of them contained umlauts that reached the panel as blanks.
+ * A test now holds the whole table to it.
*/
#ifndef KILN_CORE_FAULTS_H
#define KILN_CORE_FAULTS_H
@@ -22,9 +44,11 @@
/* Short label, fits the 128 px display. */
const char *kiln_fault_label(kiln_fault_t code);
const char *kiln_fault_label_in(kiln_fault_t code, kiln_lang_t lang);
-/* Full operator-facing cause. */
+/* Full operator-facing cause, for the API and the browser. */
const char *kiln_fault_cause(kiln_fault_t code);
const char *kiln_fault_cause_in(kiln_fault_t code, kiln_lang_t lang);
+/* The same thing trimmed to what the fault screen can display in full. */
+const char *kiln_fault_panel_in(kiln_fault_t code, kiln_lang_t lang);
/* The requirement that mandates the detection, for diagnostics and traceability. */
const char *kiln_fault_requirement(kiln_fault_t code);
@@ -32,6 +56,7 @@ const char *kiln_warn_label(kiln_warn_bit_t bit);
const char *kiln_warn_label_in(kiln_warn_bit_t bit, kiln_lang_t lang);
const char *kiln_warn_cause(kiln_warn_bit_t bit);
const char *kiln_warn_cause_in(kiln_warn_bit_t bit, kiln_lang_t lang);
+const char *kiln_warn_panel_in(kiln_warn_bit_t bit, kiln_lang_t lang);
static inline uint16_t kiln_warn_code(kiln_warn_bit_t bit)
{
return (uint16_t)(KILN_WARN_CODE_BASE + (uint16_t)bit);
@@ -49,6 +74,15 @@ const char *kiln_sup_reason_label(kiln_sup_reason_t r);
const char *kiln_sup_reason_label_in(kiln_sup_reason_t r, kiln_lang_t lang);
const char *kiln_sup_reason_cause(kiln_sup_reason_t r);
const char *kiln_sup_reason_cause_in(kiln_sup_reason_t r, kiln_lang_t lang);
+const char *kiln_sup_reason_panel_in(kiln_sup_reason_t r, kiln_lang_t lang);
+
+/* What the fault screen can actually render: five rows of 21 characters, from
+ * the 128x64 panel at scale 1 with the banner above and the wrap stepping 9 px
+ * a line. Exported because the bound belongs to the display and the strings
+ * that have to respect it live here; a test asserts every panel form fits it
+ * under the same word wrap the screen uses. */
+constexpr unsigned KILN_PANEL_COLS = 21u;
+constexpr unsigned KILN_PANEL_ROWS = 5u;
/* The BCP-47-ish tag for a language, for the API and the HTML lang attribute. */
const char *kiln_lang_tag(kiln_lang_t lang);
diff --git a/firmware/controller/components/kiln_core/include/kiln_core/logrec.h b/firmware/controller/components/kiln_core/include/kiln_core/logrec.h
index 5647de8..ba7b3a0 100644
--- a/firmware/controller/components/kiln_core/include/kiln_core/logrec.h
+++ b/firmware/controller/components/kiln_core/include/kiln_core/logrec.h
@@ -39,9 +39,46 @@ typedef enum {
KILN_LOGE_WARNING,
KILN_LOGE_CONFIG_CHANGE,
KILN_LOGE_OPERATOR,
+
+ /* SWR-LOG-16: security-relevant events, distinguishable from the process
+ * events above because they are the ones an owner may switch off.
+ *
+ * New codes rather than a flag, and appended rather than inserted: the
+ * event byte is persisted, so 0 to 7 keep the meaning every log already
+ * written gives them. A reader that does not know these codes shows the
+ * number, which is why logdump names an unknown event rather than
+ * dropping the record.
+ *
+ * The update-path events the requirement also lists -- an offer, a
+ * confirmation, an install, a refusal with its version and reason, a
+ * failed signature, a rollback -- are NOT here, and not by oversight: a
+ * version string and a refusal reason do not fit a 20 byte record
+ * (SWA-18), so they arrive with the update path itself (tasklist H2) and
+ * will need either a second record type or their own channel. Recording
+ * that here is better than inventing a code now that carries no detail. */
+ KILN_LOGE_SEC_RESET, /* an abnormal reset cause (SWR-NFR-15) */
+ KILN_LOGE_SEC_ERASE, /* a factory reset (SWR-CFG-09) */
+ KILN_LOGE_SEC_NET_JOIN, /* a network join commanded at the display */
+
KILN_LOGE_COUNT,
} kiln_log_event_t;
+/* Is this one of the events SWR-LOG-16 lets the owner switch off?
+ *
+ * The configuration change is included: it is a process event and a security
+ * event at once, and the requirement lists it under security, which is the
+ * reading that matters -- somebody who does not want their device recording
+ * what they change about it has said so.
+ *
+ * A fault, a state change or a sample is NOT included, whatever the owner
+ * asks: those are the evidence of what a kiln did, which is a safety record
+ * rather than a record of access to the device. */
+static inline bool kiln_log_event_is_security(kiln_log_event_t e)
+{
+ return (e == KILN_LOGE_CONFIG_CHANGE) || (e == KILN_LOGE_SEC_RESET) ||
+ (e == KILN_LOGE_SEC_ERASE) || (e == KILN_LOGE_SEC_NET_JOIN);
+}
+
typedef struct {
uint32_t t_rel_ms; /* since run start; 49 days of range */
float kiln_raw_c;
diff --git a/firmware/controller/components/kiln_core/src/configmodel.cpp b/firmware/controller/components/kiln_core/src/configmodel.cpp
index d1526d6..0f3cc7a 100644
--- a/firmware/controller/components/kiln_core/src/configmodel.cpp
+++ b/firmware/controller/components/kiln_core/src/configmodel.cpp
@@ -113,6 +113,11 @@ const kiln_cfg_item_t k_items[] = {
/* logging */
NUM("log.interval_s", "s", "SWR-LOG-03", KILN_CFG_T_U16, log_interval_s, 1, 600, 10, 0),
+ /* SWR-LOG-16's opt-out. Default on: the default has to be the one that
+ * helps an owner who has not thought about it. Not SAFE-flagged, because
+ * switching it off cannot make a kiln less safe -- it removes a record of
+ * access to the device, not a protection. */
+ NUM("log.security_events", "", "SWR-LOG-16", KILN_CFG_T_BOOL, log_security_events, 0, 1, 1, 0),
/* HMI */
ENUMI("hmi.units", "SWR-HMI-13", units, k_units_names, KILN_UNITS_COUNT, KILN_UNITS_C, 0),
diff --git a/firmware/controller/components/kiln_core/src/faults.cpp b/firmware/controller/components/kiln_core/src/faults.cpp
index 7f51867..405c840 100644
--- a/firmware/controller/components/kiln_core/src/faults.cpp
+++ b/firmware/controller/components/kiln_core/src/faults.cpp
@@ -20,10 +20,17 @@ namespace {
* language is a column here and nowhere else, which is the single resource
* location the requirement asks for. `req` is not translated: it is a
* requirement identifier, and translating "SWR-SAF-07" would be a defect. */
+/* `panel` is the cause trimmed to what the fault screen can render in full:
+ * five rows of 21 characters, action first. Left empty where the full cause
+ * already fits, so there is no second string to drift from the first. The
+ * reason there are two at all is in faults.h; in short, the browser can show a
+ * paragraph and a 128x64 panel drops whatever does not fit, which in an
+ * instruction is the instruction. */
typedef struct {
kiln_fault_t code;
const char *label[KILN_LANG_COUNT];
const char *cause[KILN_LANG_COUNT];
+ const char *panel[KILN_LANG_COUNT];
const char *req;
} fault_entry_t;
@@ -31,6 +38,7 @@ typedef struct {
kiln_warn_bit_t code;
const char *label[KILN_LANG_COUNT];
const char *cause[KILN_LANG_COUNT];
+ const char *panel[KILN_LANG_COUNT];
const char *req;
} warn_entry_t;
@@ -39,142 +47,170 @@ constexpr fault_entry_t k_faults[] = {
{ KILN_FAULT_NONE,
{ "OK", "OK" },
{ "No fault",
- "Keine Störung" },
+ "Keine Stoerung" },
+ { nullptr, nullptr },
"" },
{ KILN_FAULT_TC_OPEN,
{ "TC OPEN", "TE OFFEN" },
{ "Kiln thermocouple open circuit. Check the probe and its connections.",
- "Thermoelement des Ofens unterbrochen. Fühler und Anschlüsse prüfen." },
+ "Thermoelement des Ofens unterbrochen. Fuehler und Anschluesse pruefen." },
+ { nullptr, nullptr },
"SWR-SAF-04" },
{ KILN_FAULT_TC_SHORT,
{ "TC SHORT", "TE KURZ" },
{ "Kiln thermocouple shorted. Check for damaged insulation.",
- "Thermoelement des Ofens kurzgeschlossen. Auf beschädigte Isolierung prüfen." },
+ "Thermoelement des Ofens kurzgeschlossen. Auf beschaedigte Isolierung pruefen." },
+ { nullptr, nullptr },
"SWR-SAF-04" },
{ KILN_FAULT_TC_RANGE,
{ "TC RANGE", "TE BEREICH" },
{ "Kiln temperature outside the valid range for this thermocouple type.",
- "Ofentemperatur außerhalb des gültigen Bereichs für diesen Thermoelementtyp." },
+ "Ofentemperatur ausserhalb des gueltigen Bereichs fuer diesen Thermoelementtyp." },
+ { nullptr, nullptr },
"SWR-SAF-04" },
{ KILN_FAULT_CJ,
{ "CJ FAULT", "VERGLEICH" },
{ "Cold junction out of range. The controller itself may be too hot or too cold.",
- "Vergleichsstelle außerhalb des Bereichs. Der Regler selbst ist möglicherweise zu heiß oder zu kalt." },
+ "Vergleichsstelle ausserhalb des Bereichs. Der Regler selbst ist moeglicherweise zu heiss oder zu kalt." },
+ { nullptr, "Vergleichsstelle ausser Bereich. Regler zu heiss oder zu kalt." },
"SWR-SAF-04" },
{ KILN_FAULT_TC_COMMS,
{ "TC COMMS", "TE KOMM" },
{ "No response from the thermocouple front end. Check wiring to the MAX31856.",
- "Keine Antwort vom Thermoelement-Messeingang. Verdrahtung zum MAX31856 prüfen." },
+ "Keine Antwort vom Thermoelement-Messeingang. Verdrahtung zum MAX31856 pruefen." },
+ { nullptr, "Keine Antwort vom Messeingang. Verdrahtung pruefen." },
"SWR-SAF-04" },
{ KILN_FAULT_TC_REVERSED,
{ "TC REVERSED", "TE VERPOLT" },
{ "Temperature fell while heating at high duty: the thermocouple is probably connected with reversed polarity.",
"Temperatur fiel bei hoher Heizleistung: das Thermoelement ist vermutlich verpolt angeschlossen." },
+ { "Temperature fell while heating: the probe is probably reversed.", "Temperatur fiel beim Heizen: Fuehler vermutlich verpolt." },
"SWR-SAF-05" },
{ KILN_FAULT_TC_STUCK,
{ "TC STUCK", "TE FEST" },
{ "Temperature did not change while heating. The probe reading is stuck.",
- "Temperatur änderte sich trotz Heizen nicht. Der Messwert hängt fest." },
+ "Temperatur aenderte sich trotz Heizen nicht. Der Messwert haengt fest." },
+ { nullptr, nullptr },
"SWR-SAF-06" },
{ KILN_FAULT_RUNAWAY,
{ "NO HEAT", "KEINE WAERME" },
{ "Heating at high duty produced no temperature rise: failed element, open contactor, open safety chain, or the lid is not closed.",
- "Heizen mit hoher Leistung brachte keinen Temperaturanstieg: defekte Heizwendel, offenes Schütz, unterbrochene Sicherheitskette oder der Deckel ist nicht geschlossen." },
+ "Heizen mit hoher Leistung brachte keinen Temperaturanstieg: defekte Heizwendel, offenes Schuetz, unterbrochene Sicherheitskette oder der Deckel ist nicht geschlossen." },
+ { "No rise at high duty. Check elements, contactor, chain, lid.", "Kein Anstieg bei hoher Leistung. Wendel, Schuetz, Deckel pruefen." },
"SWR-SAF-07" },
{ KILN_FAULT_UNCOMMANDED_HEAT,
{ "SSR SHORT", "SSR KURZ" },
{ "Temperature rising with heating commanded off. The SSR is probably shorted. Isolate the kiln at the wall.",
"Temperatur steigt, obwohl die Heizung ausgeschaltet ist. Das SSR ist vermutlich durchlegiert. Ofen allpolig vom Netz trennen." },
+ { "Heat with the SSR off: it is shorted. Isolate the kiln.", "Temperatur steigt bei Heizung aus: SSR defekt. Netz trennen." },
"SWR-SAF-08" },
{ KILN_FAULT_OVERTEMP,
{ "OVERTEMP", "UEBERTEMP" },
{ "Kiln temperature exceeded the configured maximum.",
- "Ofentemperatur hat das eingestellte Maximum überschritten." },
+ "Ofentemperatur hat das eingestellte Maximum ueberschritten." },
+ { nullptr, nullptr },
"SWR-SAF-09" },
{ KILN_FAULT_SP_EXCURSION,
{ "OVERSHOOT", "UEBERSCHWING" },
{ "Temperature ran far above setpoint for too long. PID gains may be wrong.",
- "Temperatur lag zu lange deutlich über dem Sollwert. Die PID-Parameter sind möglicherweise falsch." },
+ "Temperatur lag zu lange deutlich ueber dem Sollwert. Die PID-Parameter sind moeglicherweise falsch." },
+ { nullptr, "Zu lange ueber dem Sollwert. PID-Parameter pruefen." },
"SWR-SAF-10" },
{ KILN_FAULT_CASE_OVERTEMP,
{ "CASE HOT", "GEHAEUSE HEISS" },
{ "Controller enclosure too hot. Improve ventilation or move the controller.",
- "Reglergehäuse zu heiß. Belüftung verbessern oder den Regler versetzen." },
+ "Reglergehaeuse zu heiss. Belueftung verbessern oder den Regler versetzen." },
+ { nullptr, nullptr },
"SWR-SAF-11" },
{ KILN_FAULT_CONTROL_DEADLINE,
{ "CTRL LATE", "REGLER SPAET" },
{ "The control loop missed its deadline. This is a firmware fault.",
- "Der Regelkreis hat seine Frist überschritten. Das ist ein Firmwarefehler." },
+ "Der Regelkreis hat seine Frist ueberschritten. Das ist ein Firmwarefehler." },
+ { nullptr, nullptr },
"SWR-SAF-13" },
{ KILN_FAULT_SAFETY_DEADLINE,
{ "SAFE LATE", "SICH SPAET" },
{ "The safety supervisor missed its deadline. This is a firmware fault.",
- "Die Sicherheitsüberwachung hat ihre Frist überschritten. Das ist ein Firmwarefehler." },
+ "Die Sicherheitsueberwachung hat ihre Frist ueberschritten. Das ist ein Firmwarefehler." },
+ { nullptr, "Der Sicherheitszyklus kam zu spaet. Firmwarefehler." },
"SWR-SAF-13" },
{ KILN_FAULT_WATCHDOG,
{ "WATCHDOG", "WATCHDOG" },
{ "The controller was reset by the watchdog during a firing.",
- "Der Regler wurde während eines Brandes vom Watchdog zurückgesetzt." },
+ "Der Regler wurde waehrend eines Brandes vom Watchdog zurueckgesetzt." },
+ { nullptr, nullptr },
"SWR-SAF-14" },
{ KILN_FAULT_CASE_TC,
{ "CASE TC", "GEH TE" },
{ "Enclosure thermocouple fault. Enclosure temperature cannot be supervised.",
- "Störung am Gehäuse-Thermoelement. Die Gehäusetemperatur kann nicht überwacht werden." },
+ "Stoerung am Gehaeuse-Thermoelement. Die Gehaeusetemperatur kann nicht ueberwacht werden." },
+ { nullptr, "Stoerung am Gehaeusefuehler. Gehaeusewert fehlt." },
"SWR-SAF-04" },
{ KILN_FAULT_OPERATOR_ABORT,
{ "ABORTED", "ABGEBROCHEN" },
{ "Firing aborted by the operator.",
"Brand vom Bediener abgebrochen." },
+ { nullptr, nullptr },
"SWR-RUN-04" },
{ KILN_FAULT_TUNE_NO_CONVERGE,
{ "TUNE FAIL", "TUNING FEHL" },
{ "Automatic tuning did not find a stable oscillation. Previous gains kept.",
"Die automatische Abstimmung fand keine stabile Schwingung. Die bisherigen Parameter bleiben erhalten." },
+ { nullptr, "Keine stabile Schwingung. Alte PID-Parameter bleiben." },
"SWR-TUN-07" },
{ KILN_FAULT_RECOVERY_REFUSED,
{ "NO RESUME", "KEIN RESTART" },
{ "Power was lost during a firing and the kiln had cooled too far to resume safely.",
- "Während des Brandes fiel die Spannung aus und der Ofen war zu weit abgekühlt, um sicher fortzusetzen." },
+ "Waehrend des Brandes fiel die Spannung aus und der Ofen war zu weit abgekuehlt, um sicher fortzusetzen." },
+ { nullptr, "Stromausfall im Brand; Ofen zu kalt zum Fortsetzen." },
"SWR-RUN-08" },
{ KILN_FAULT_CONFIG_STORAGE,
{ "STORAGE", "SPEICHER" },
{ "Configuration could not be read or written. Defaults are in use.",
"Die Konfiguration konnte nicht gelesen oder geschrieben werden. Es gelten die Standardwerte." },
+ { nullptr, nullptr },
"SWR-CFG-05" },
{ KILN_FAULT_UNCOMMANDED_CURRENT,
{ "RELAY ON", "RELAIS EIN" },
{ "Heater current is flowing with heating commanded off. A relay is stuck on.",
- "Es fließt Heizstrom, obwohl die Heizung ausgeschaltet ist. Ein Schaltglied hängt." },
+ "Es fliesst Heizstrom, obwohl die Heizung ausgeschaltet ist. Ein Schaltglied haengt." },
+ { nullptr, "Heizstrom bei Heizung aus. Ein Schaltglied haengt." },
"SWR-SAF-25" },
{ KILN_FAULT_CONTACTOR_WELDED,
{ "WELDED", "VERSCHWEISST" },
{ "Current continued after the contactor was commanded open: the contactor is welded shut. The controller can no longer interrupt the heaters. ISOLATE THE KILN AT ITS SUPPLY NOW.",
- "Der Strom floss weiter, nachdem das Schütz geöffnet werden sollte: das Schütz ist verschweißt. Der Regler kann die Heizung nicht mehr abschalten. OFEN SOFORT ALLPOLIG VOM NETZ TRENNEN." },
+ "Der Strom floss weiter, nachdem das Schuetz geoeffnet werden sollte: das Schuetz ist verschweisst. Der Regler kann die Heizung nicht mehr abschalten. OFEN SOFORT ALLPOLIG VOM NETZ TRENNEN." },
+ { "ISOLATE THE KILN NOW. The contactor is welded: heat cannot stop.", "OFEN SOFORT VOM NETZ TRENNEN. Schuetz verschweisst." },
"SWR-SAF-27" },
{ KILN_FAULT_NO_HEATER_CURRENT,
{ "NO CURRENT", "KEIN STROM" },
{ "No heater current with heating commanded on: failed SSR, open contactor, blown heater fuse, open safety chain or open elements.",
- "Kein Heizstrom bei eingeschalteter Heizung: defektes SSR, offenes Schütz, durchgebrannte Heizungssicherung, unterbrochene Sicherheitskette oder offene Heizwendeln." },
+ "Kein Heizstrom bei eingeschalteter Heizung: defektes SSR, offenes Schuetz, durchgebrannte Heizungssicherung, unterbrochene Sicherheitskette oder offene Heizwendeln." },
+ { "No current with heat on. Check SSR, contactor, fuse, chain.", "Kein Strom bei Heizung an. SSR, Schuetz, Sicherung pruefen." },
"SWR-SAF-26" },
{ KILN_FAULT_CURRENT_DEVIATION,
{ "CURR DEV", "STROM ABW" },
{ "Heater current differs sharply from this run's reference. An element group has probably failed.",
"Der Heizstrom weicht stark vom Referenzwert dieses Brandes ab. Vermutlich ist eine Heizgruppe ausgefallen." },
+ { "Current far off this run's reference. An element group failed.", "Strom weicht stark ab. Heizgruppe ausgefallen." },
"SWR-SAF-28" },
{ KILN_FAULT_OVERCURRENT,
{ "OVERCURRENT", "UEBERSTROM" },
{ "Heater current above the configured maximum: shorted element, wrong wiring or a failed SSR.",
- "Heizstrom über dem eingestellten Maximum: Windungsschluss, falsche Verdrahtung oder ein defektes SSR." },
+ "Heizstrom ueber dem eingestellten Maximum: Windungsschluss, falsche Verdrahtung oder ein defektes SSR." },
+ { "Current above the set maximum: shorted element or wiring.", "Strom ueber Maximum: Schluss oder falsche Verdrahtung." },
"SWR-SAF-29" },
{ KILN_FAULT_CT_FAULT,
{ "CT FAULT", "WANDLER" },
{ "No signal from the current transformer. Check that it is fitted around one heater conductor and plugged in.",
- "Kein Signal vom Stromwandler. Prüfen, ob er um einen Heizleiter gelegt und angeschlossen ist." },
+ "Kein Signal vom Stromwandler. Pruefen, ob er um einen Heizleiter gelegt und angeschlossen ist." },
+ { "No signal from the CT. Is it round a heater wire and plugged in?", "Kein Signal vom Wandler. Um Heizleiter gelegt und gesteckt?" },
"SWR-CUR-11" },
{ KILN_FAULT_DOOR_OPEN,
{ "DOOR OPEN", "TUER OFFEN" },
{ "The kiln door was opened during a firing. Heating stopped immediately. Close the door and acknowledge to continue.",
- "Die Ofentür wurde während eines Brandes geöffnet. Die Heizung wurde sofort abgeschaltet. Tür schließen und quittieren, um fortzufahren." },
+ "Die Ofentuer wurde waehrend eines Brandes geoeffnet. Die Heizung wurde sofort abgeschaltet. Tuer schliessen und quittieren, um fortzufahren." },
+ { "Door opened during a firing. Close it and acknowledge.", "Tuer im Brand geoeffnet. Schliessen und quittieren." },
"SWR-SAF-31" },
};
@@ -182,67 +218,81 @@ constexpr warn_entry_t k_warns[] = {
{ KILN_WARN_INSULATION,
{ "INSULATION", "ISOLIERUNG" },
{ "This firing needed noticeably more energy than usual. Insulation or elements may be degrading.",
- "Dieser Brand benötigte spürbar mehr Energie als üblich. Isolierung oder Heizwendeln könnten nachlassen." },
+ "Dieser Brand benoetigte spuerbar mehr Energie als ueblich. Isolierung oder Heizwendeln koennten nachlassen." },
+ { "This firing used more energy than usual. Insulation may age.", "Mehr Energie als ueblich. Isolierung laesst nach." },
"SWR-SAF-12" },
{ KILN_WARN_HOLDBACK,
{ "HOLDBACK", "HALTEN" },
{ "The kiln is behind its curve, so the schedule is being held back.",
- "Der Ofen liegt hinter seiner Kurve, daher wird der Zeitplan zurückgehalten." },
+ "Der Ofen liegt hinter seiner Kurve, daher wird der Zeitplan zurueckgehalten." },
+ { nullptr, nullptr },
"SWR-CTL-11" },
{ KILN_WARN_LOG_UNAVAIL,
{ "NO LOG", "KEIN LOG" },
{ "Temperature logging is unavailable. The firing continues.",
- "Die Temperaturaufzeichnung ist nicht verfügbar. Der Brand läuft weiter." },
+ "Die Temperaturaufzeichnung ist nicht verfuegbar. Der Brand laeuft weiter." },
+ { nullptr, "Messwertaufzeichnung fehlt. Der Brand laeuft weiter." },
"SWR-LOG-14" },
{ KILN_WARN_DISPLAY_UNAVAIL,
{ "NO DISPLAY", "KEIN DISPLAY" },
{ "The display is not responding. The firing continues.",
- "Das Display antwortet nicht. Der Brand läuft weiter." },
+ "Das Display antwortet nicht. Der Brand laeuft weiter." },
+ { nullptr, nullptr },
"SWR-HMI-14" },
{ KILN_WARN_TIME_UNSYNCED,
{ "NO TIME", "KEINE ZEIT" },
{ "Clock not synchronised; log timestamps are relative only.",
"Uhr nicht synchronisiert; Zeitstempel im Log sind nur relativ." },
+ { nullptr, nullptr },
"SWR-LOG-12" },
{ KILN_WARN_WIFI_DOWN,
{ "NO WIFI", "KEIN WLAN" },
{ "WiFi is down. The firing is unaffected.",
"WLAN ist nicht verbunden. Der Brand ist davon nicht betroffen." },
+ { nullptr, nullptr },
"SWR-NET-07" },
{ KILN_WARN_DUTY_SATURATED,
{ "FULL POWER", "VOLLLAST" },
{ "Heating has been at full power for a long time; the kiln may not keep up.",
- "Die Heizung läuft seit Längerem auf Volllast; der Ofen kommt möglicherweise nicht nach." },
+ "Die Heizung laeuft seit Laengerem auf Volllast; der Ofen kommt moeglicherweise nicht nach." },
+ { nullptr, "Lange auf Volllast; der Ofen kommt nicht nach." },
"SWR-CTL-15" },
{ KILN_WARN_GAINS_UNTUNED,
{ "UNTUNED", "UNGETUNT" },
{ "PID gains are factory defaults. Run automatic tuning for this kiln.",
- "Die PID-Parameter sind Werkseinstellungen. Automatische Abstimmung für diesen Ofen durchführen." },
+ "Die PID-Parameter sind Werkseinstellungen. Automatische Abstimmung fuer diesen Ofen durchfuehren." },
+ { nullptr, "PID ist Werkseinstellung. Abstimmung empfohlen." },
"SWR-TUN-11" },
{ KILN_WARN_RELAY_WEAR,
{ "RELAY WEAR", "RELAIS VERSCHL" },
{ "A relay has reached its configured switching-operation life limit. Plan to replace it.",
"Ein Schaltglied hat seine eingestellte Schaltspielzahl erreicht. Austausch einplanen." },
+ { nullptr, nullptr },
"SWR-SAF-30" },
{ KILN_WARN_RELAY_SUSPECT,
{ "RELAY?", "RELAIS?" },
{ "Heater current has intermittently disagreed with the commanded state. A relay may be starting to fail.",
- "Der Heizstrom wich zeitweise vom Schaltzustand ab. Ein Schaltglied könnte ausfallen." },
+ "Der Heizstrom wich zeitweise vom Schaltzustand ab. Ein Schaltglied koennte ausfallen." },
+ { "Current sometimes disagrees with the command. A relay may fail.",
+ "Strom passt zeitweise nicht zum Befehl. Relais kann ausfallen." },
"SWR-SAF-30" },
{ KILN_WARN_CURRENT_OFF,
{ "NO CT", "KEIN WANDLER" },
{ "Current monitoring is disabled. Relay faults can only be inferred from temperature, which is far slower.",
- "Die Stromüberwachung ist abgeschaltet. Relaisfehler lassen sich dann nur über die Temperatur erkennen, was weit langsamer ist." },
+ "Die Stromueberwachung ist abgeschaltet. Relaisfehler lassen sich dann nur ueber die Temperatur erkennen, was weit langsamer ist." },
+ { "Current monitoring is off. Relay faults show only in heat.", "Stromueberwachung aus. Relaisfehler nur ueber Temperatur." },
"SWR-CUR-12" },
{ KILN_WARN_CURRENT_DEV,
{ "CURR LOW", "STROM NIEDRIG" },
{ "Heater current is drifting from this run's reference. Elements may be ageing.",
- "Der Heizstrom driftet vom Referenzwert dieses Brandes ab. Die Heizwendeln könnten altern." },
+ "Der Heizstrom driftet vom Referenzwert dieses Brandes ab. Die Heizwendeln koennten altern." },
+ { nullptr, "Strom weicht von der Referenz ab. Wendeln altern." },
"SWR-SAF-28" },
{ KILN_WARN_DOOR_OFF,
{ "NO DOOR SW", "KEIN TUERSCH" },
{ "No door interlock is fitted. Opening the door during a firing will not stop the heater.",
- "Es ist kein Türkontakt eingebaut. Ein Öffnen der Tür während eines Brandes schaltet die Heizung nicht ab." },
+ "Es ist kein Tuerkontakt eingebaut. Ein Oeffnen der Tuer waehrend eines Brandes schaltet die Heizung nicht ab." },
+ { "No door interlock. Opening the door will not stop the heater.", "Kein Tuerkontakt. Oeffnen stoppt die Heizung nicht." },
"SWR-SAF-31" },
};
@@ -273,11 +323,13 @@ constexpr struct {
kiln_sup_reason_t code;
const char *label[KILN_LANG_COUNT];
const char *cause[KILN_LANG_COUNT];
+ const char *panel[KILN_LANG_COUNT];
} k_sup_reasons[] = {
{ KILN_SUP_OK,
{ "OK", "OK" },
{ "The independent supervisor is permitting heat.",
- "Der unabhaengige Waechter gibt die Heizung frei." } },
+ "Der unabhaengige Waechter gibt die Heizung frei." },
+ { nullptr, nullptr } },
{ KILN_SUP_OVERTEMP,
{ "SUPERVISOR OVER-TEMP", "WAECHTER UEBERTEMPERATUR" },
{ "The independent supervisor stopped the kiln above its own fixed "
@@ -288,7 +340,8 @@ constexpr struct {
"festen Grenze abgeschaltet, die ueber allen einstellbaren liegt. "
"Die Steuerung haette vorher abschalten muessen, also auch als "
"Fehler der Steuerung behandeln. Quittiertaste des Waechters am "
- "Bedienfeld druecken." } },
+ "Bedienfeld druecken." },
+ { "Supervisor stopped the kiln over its fixed limit. Press clear.", "Waechter hat ueber fester Grenze gestoppt. Quittieren." } },
{ KILN_SUP_TC_FAULT,
{ "SUPERVISOR TC FAULT", "WAECHTER FUEHLERFEHLER" },
{ "The independent supervisor's thermocouple front end reported a "
@@ -296,7 +349,8 @@ constexpr struct {
"supervisor's clear button on the panel.",
"Die Fuehlerelektronik des unabhaengigen Waechters meldet einen "
"Fehler. Kammerfuehler und Verdrahtung pruefen, dann Quittiertaste "
- "des Waechters am Bedienfeld druecken." } },
+ "des Waechters am Bedienfeld druecken." },
+ { "Supervisor probe fault. Check the chamber probe, then clear.", "Fuehlerfehler am Waechter. Fuehler pruefen, quittieren." } },
{ KILN_SUP_SENSOR_STALE,
{ "SUPERVISOR NO READING", "WAECHTER KEIN MESSWERT" },
{ "The independent supervisor was reading the chamber and stopped "
@@ -304,7 +358,8 @@ constexpr struct {
"supervisor's clear button on the panel.",
"Der unabhaengige Waechter hat die Kammer gemessen und erhaelt "
"keinen Wert mehr. Fuehler und Verdrahtung pruefen, dann "
- "Quittiertaste des Waechters am Bedienfeld druecken." } },
+ "Quittiertaste des Waechters am Bedienfeld druecken." },
+ { "Supervisor lost the chamber reading. Check the probe, then clear.", "Waechter ohne Messwert. Fuehler pruefen, quittieren." } },
{ KILN_SUP_SELF_TEST,
{ "SUPERVISOR SELF-TEST", "WAECHTER SELBSTTEST" },
{ "The independent supervisor could not configure its thermocouple "
@@ -313,7 +368,8 @@ constexpr struct {
"Der unabhaengige Waechter konnte seine Fuehlerelektronik beim Start "
"nicht konfigurieren und gibt die Heizung nicht frei. Nicht durch "
"den Bediener quittierbar; der Waechter muss instand gesetzt "
- "werden." } },
+ "werden." },
+ { "Supervisor could not set up its front end. It needs servicing.", "Waechter nicht einsatzbereit. Service noetig." } },
{ KILN_SUP_LINK_DEAD,
{ "NO SUPERVISOR", "KEIN WAECHTER" },
{ "Nothing is arriving from the independent supervisor, so there is no "
@@ -323,7 +379,8 @@ constexpr struct {
"Vom unabhaengigen Waechter kommt nichts an, daher gibt es keine "
"Kammertemperatur und keine Heizung. Das ist das Fehlen der "
"Rueckfallebene, nicht ihr Ansprechen: pruefen, ob der Waechter "
- "versorgt und die Verbindung angeschlossen ist." } },
+ "versorgt und die Verbindung angeschlossen ist." },
+ { "Nothing from the supervisor: no temperature, no heat. Check it.", "Keine Daten vom Waechter: keine Hitze. Strom und Kabel pruefen." } },
{ KILN_SUP_TC_DISAGREE,
{ "SUPERVISOR PROBES DISAGREE", "WAECHTER FUEHLER UNEINIG" },
{ "The supervisor's two chamber probes are reporting temperatures too "
@@ -334,7 +391,8 @@ constexpr struct {
"auseinanderliegende Temperaturen, um dasselbe zu messen; einer ist "
"also falsch und es ist nicht erkennbar welcher. Beide Fuehler und "
"ihre Verdrahtung pruefen, dann Quittiertaste des Waechters am "
- "Bedienfeld druecken." } },
+ "Bedienfeld druecken." },
+ { "The supervisor's two probes disagree. Check both, then clear.", "Die zwei Fuehler weichen ab. Beide pruefen, quittieren." } },
{ KILN_SUP_OUTPUT_STUCK,
{ "SUPERVISOR CANNOT CUT POWER", "WAECHTER KANN NICHT ABSCHALTEN" },
{ "The supervisor withdrew permission to heat and the coil stayed "
@@ -346,7 +404,8 @@ constexpr struct {
"bestromt, er kann die Heizung also nicht unterbrechen. OFEN AN DER "
"ZULEITUNG FREISCHALTEN. Nicht auf Steuerung oder Quittiertaste "
"verlassen; die Schalttechnik muss geprueft werden, bevor der Ofen "
- "wieder benutzt wird." } },
+ "wieder benutzt wird." },
+ { "ISOLATE THE KILN. The coil stayed on after permission was cut.", "OFEN VOM NETZ TRENNEN. Spule blieb trotz Sperre an." } },
};
} // namespace
@@ -422,6 +481,16 @@ const char *kiln_fault_cause_in(kiln_fault_t code, kiln_lang_t lang)
return pick(k_faults[code].cause, lang);
}
+/* The panel form, falling back to the full cause where none is needed. The
+ * fallback is the reason an entry that fits carries no second string: one
+ * sentence that is known to fit is better than two that can disagree. */
+const char *kiln_fault_panel_in(kiln_fault_t code, kiln_lang_t lang)
+{
+ if ((unsigned)code >= KILN_FAULT_MAX) { return "Unknown fault code."; }
+ const char *s = pick(k_faults[code].panel, lang);
+ return (s != nullptr && s[0] != '\0') ? s : pick(k_faults[code].cause, lang);
+}
+
const char *kiln_warn_label_in(kiln_warn_bit_t bit, kiln_lang_t lang)
{
if ((unsigned)bit >= KILN_WARN_COUNT) { return "UNKNOWN"; }
@@ -434,6 +503,13 @@ const char *kiln_warn_cause_in(kiln_warn_bit_t bit, kiln_lang_t lang)
return pick(k_warns[bit].cause, lang);
}
+const char *kiln_warn_panel_in(kiln_warn_bit_t bit, kiln_lang_t lang)
+{
+ if ((unsigned)bit >= KILN_WARN_COUNT) { return "Unknown warning."; }
+ const char *s = pick(k_warns[bit].panel, lang);
+ return (s != nullptr && s[0] != '\0') ? s : pick(k_warns[bit].cause, lang);
+}
+
/* English, deliberately: diagnostics, the log and the requirement traceability
* should read the same whoever is looking at them. */
const char *kiln_fault_label(kiln_fault_t code)
@@ -494,6 +570,13 @@ const char *kiln_sup_reason_cause_in(kiln_sup_reason_t r, kiln_lang_t lang)
return pick(k_sup_reasons[i].cause, lang);
}
+const char *kiln_sup_reason_panel_in(kiln_sup_reason_t r, kiln_lang_t lang)
+{
+ if ((unsigned)r >= KILN_SUP_REASON_COUNT) { return "Unknown supervisor state."; }
+ const char *s = pick(k_sup_reasons[r].panel, lang);
+ return (s != nullptr && s[0] != '\0') ? s : pick(k_sup_reasons[r].cause, lang);
+}
+
const char *kiln_sup_reason_cause(kiln_sup_reason_t r)
{
return kiln_sup_reason_cause_in(r, KILN_LANG_EN);
diff --git a/firmware/controller/components/kiln_core/src/logrec.cpp b/firmware/controller/components/kiln_core/src/logrec.cpp
index ac8181c..95a0281 100644
--- a/firmware/controller/components/kiln_core/src/logrec.cpp
+++ b/firmware/controller/components/kiln_core/src/logrec.cpp
@@ -106,6 +106,30 @@ uint16_t get_u16(const uint8_t *p)
/* --- record ------------------------------------------------------------- */
+/* SWA-18, held to the code rather than to discipline (tasklist C13).
+ *
+ * Twenty bytes is a PERSISTED format: every log sector on every device in the
+ * field is a sequence of them, and the sector header carries a format version
+ * precisely so that this number can only change deliberately. Changing the
+ * constant without changing the version would make an old log decode as
+ * nonsense -- plausible nonsense, since the CRC would be recomputed over
+ * whatever the new layout produced.
+ *
+ * The three assertions below are the whole of what the encoder relies on: the
+ * record is 20 bytes, the CRC is its last byte, and the payload the CRC covers
+ * is everything before it. The 204 records per sector of architecture 10.4
+ * follow from the first, which is why the arithmetic is asserted too rather
+ * than recomputed in a comment. */
+static_assert(KILN_LOG_RECORD_BYTES == 20u,
+ "SWA-18: the log record is 20 bytes. Changing it invalidates "
+ "every log already written, so it changes with the sector "
+ "header's format_version and not before");
+static_assert(KILN_LOG_RECORD_BYTES >= 2u,
+ "the CRC needs a payload to cover");
+static_assert((KILN_LOG_SECTOR_BYTES - KILN_LOG_HEADER_BYTES) / KILN_LOG_RECORD_BYTES == 204u,
+ "architecture 10.4's 204 records per sector, and the 290 h of "
+ "capacity computed from it, follow from the record size");
+
void kiln_logrec_encode(const kiln_log_sample_t *s, uint8_t out[KILN_LOG_RECORD_BYTES])
{
memset(out, 0, KILN_LOG_RECORD_BYTES);
diff --git a/firmware/controller/components/kiln_core/src/logring.cpp b/firmware/controller/components/kiln_core/src/logring.cpp
index 04274f1..e038aec 100644
--- a/firmware/controller/components/kiln_core/src/logring.cpp
+++ b/firmware/controller/components/kiln_core/src/logring.cpp
@@ -250,6 +250,38 @@ kiln_err_t kiln_logring_append(kiln_logring_t *r,
return KILN_ERR_IO;
}
+ /* A record can arrive before any run has begun, and until SWR-LOG-16's
+ * security events existed nothing ever did: every emitter ran inside a
+ * firing, and begin_run() had always claimed a sector first.
+ *
+ * An abnormal reset is logged at boot, which is outside any run. Without a
+ * claimed sector the bytes went into a sector with no header -- which
+ * iterate() skips, because it selects runs from headers, and which the next
+ * begin_run() erases. The record was written, stored, counted and lost.
+ *
+ * So claim lazily, tagged run 0, meaning "not part of a run". On a device
+ * with history this never fires: mount recovers a head with a sequence
+ * number and the append lands in the current sector. On a fresh one it
+ * claims the sector the first begin_run() would have claimed anyway, so it
+ * costs no erase. */
+ if ((r->head_seq == 0u) && (r->head_slot == 0u)) {
+ const kiln_err_t claim = claim_sector(r, r->head_sector, 1u, 0u);
+ if (claim != KILN_OK) {
+ r->write_errors++;
+ return claim;
+ }
+ /* The bookkeeping matters as much as the claim. claim_sector writes
+ * the header and nothing else, so head_seq has to be advanced here --
+ * the first version of this did not, begin_run() then issued the SAME
+ * sequence number to the run's sector, and two sectors tied for
+ * highest. Mount resolves the order from the sequence number alone
+ * (architecture 10.3), so the tie made an idle sample the newest
+ * record in the ring and the power-loss journal of SWA-09 read the
+ * wrong one. Three recovery tests caught it. */
+ r->head_seq = 1u;
+ r->run_id = 0u;
+ }
+
if (r->head_slot >= r->recs_per_sector) {
/* Wrap. Erase the next sector now, immediately before writing it --
* never in advance (architecture 10.3). */
diff --git a/firmware/controller/components/kiln_hmi/include/kiln_hmi/hmi.h b/firmware/controller/components/kiln_hmi/include/kiln_hmi/hmi.h
index 35168bf..0c4e0a7 100644
--- a/firmware/controller/components/kiln_hmi/include/kiln_hmi/hmi.h
+++ b/firmware/controller/components/kiln_hmi/include/kiln_hmi/hmi.h
@@ -60,6 +60,11 @@ typedef enum {
* second. */
KILN_HMI_ACT_WIFI_SCAN,
KILN_HMI_ACT_WIFI_CONNECT,
+ /* SWR-CFG-09. Reachable only from the display, because the web interface
+ * cannot write anything (SWR-WEB-26) and because an owner erasing a kiln
+ * they are about to sell should have to be standing at it. Goes through
+ * the confirmation screen, like starting and aborting. */
+ KILN_HMI_ACT_FACTORY_RESET,
} kiln_hmi_action_kind_t;
typedef struct {
diff --git a/firmware/controller/components/kiln_hmi/include/kiln_hmi/strings.h b/firmware/controller/components/kiln_hmi/include/kiln_hmi/strings.h
index f24f4c2..280f909 100644
--- a/firmware/controller/components/kiln_hmi/include/kiln_hmi/strings.h
+++ b/firmware/controller/components/kiln_hmi/include/kiln_hmi/strings.h
@@ -52,6 +52,9 @@ typedef enum {
KILN_HMI_STR_HEAT,
KILN_HMI_STR_HOLD,
KILN_HMI_STR_ACK,
+ /* SWR-SAF-31: shown in the slot that would say HEAT, because an open door
+ * is why the kiln is not heating. */
+ KILN_HMI_STR_DOOR,
/* Menu */
KILN_HMI_STR_START_PROGRAM,
@@ -59,11 +62,17 @@ typedef enum {
KILN_HMI_STR_RESUME,
KILN_HMI_STR_PAUSE_RESUME,
KILN_HMI_STR_ABORT,
+ KILN_HMI_STR_FACTORY_RESET,
KILN_HMI_STR_NONE_STORED,
/* Confirmation */
KILN_HMI_STR_START_FIRING_Q,
KILN_HMI_STR_ABORT_FIRING_Q,
+ /* SWR-CFG-09. Phrased as what it destroys rather than as its name: an
+ * operator who misreads "factory reset" as "restore defaults" loses their
+ * firing history and their WiFi credentials to a single press. */
+ KILN_HMI_STR_ERASE_ALL_Q,
+ KILN_HMI_STR_ERASED,
KILN_HMI_STR_YES,
KILN_HMI_STR_NO,
KILN_HMI_STR_TURN_THEN_PRESS,
@@ -92,9 +101,12 @@ typedef enum {
* translation. */
KILN_HMI_STR_CASE,
KILN_HMI_STR_AMPS,
- KILN_HMI_STR_POWER,
- KILN_HMI_STR_USED,
KILN_HMI_STR_DUTY,
+ /* The diagnostics row, where all three door states are spelled out. */
+ KILN_HMI_STR_DOOR_LABEL,
+ KILN_HMI_STR_DOOR_OPEN,
+ KILN_HMI_STR_DOOR_SHUT,
+ KILN_HMI_STR_DOOR_NONE,
KILN_HMI_STR_UPTIME,
KILN_HMI_STR_TUNED,
KILN_HMI_STR_UNTUNED,
diff --git a/firmware/controller/components/kiln_hmi/src/hmi.cpp b/firmware/controller/components/kiln_hmi/src/hmi.cpp
index f65698a..7b85e38 100644
--- a/firmware/controller/components/kiln_hmi/src/hmi.cpp
+++ b/firmware/controller/components/kiln_hmi/src/hmi.cpp
@@ -132,7 +132,16 @@ void draw_main(kiln_hmi_t *h, const kiln_hmi_view_t *v)
/* Duty, and the two states worth seeing at a glance. */
(void)snprintf(buf, sizeof(buf), "%u%%", (unsigned)(v->snap.duty_permille / 10u));
kiln_fb_text(&h->fb, 0, 40, buf, 1, true);
- if (v->snap.heat_authorised) { kiln_fb_text(&h->fb, 30, 40, S(v, KILN_HMI_STR_HEAT), 1, true); }
+ /* SWR-SAF-31, in the slot that would otherwise say HEAT. The two cannot
+ * both be true -- an open door removes heat authority -- so the badge that
+ * says why the kiln is hot says why it is not, which is the question an
+ * operator standing in front of an idle kiln is actually asking. */
+ if (v->snap.door_monitoring && v->snap.door_open) {
+ kiln_fb_text(&h->fb, 30, 40, S(v, KILN_HMI_STR_DOOR), 1, true);
+ }
+ else if (v->snap.heat_authorised) {
+ kiln_fb_text(&h->fb, 30, 40, S(v, KILN_HMI_STR_HEAT), 1, true);
+ }
if (v->snap.holdback_active) { kiln_fb_text(&h->fb, 62, 40, S(v, KILN_HMI_STR_HOLD), 1, true); }
if (v->awaiting_ack) { kiln_fb_text(&h->fb, 94, 40, S(v, KILN_HMI_STR_ACK), 1, true); }
@@ -180,9 +189,13 @@ void draw_fault(kiln_hmi_t *h, const kiln_hmi_view_t *v)
/* The cause, wrapped to the panel. Word wrapping rather than hard cuts:
* these sentences are the operator's instructions, and a word split across
* two lines in a hurry reads as a different word. */
+ /* The PANEL form, not the full cause. The full ones run to 279
+ * characters, which is a paragraph in a browser and five lines of 21 on
+ * this screen: what fell off the bottom was the end of the sentence, and
+ * in an instruction the end of the sentence is the instruction. */
const char *cause = sup_blame
- ? kiln_sup_reason_cause_in(v->sup_reason, v->language)
- : kiln_fault_cause_in(v->fault, v->language);
+ ? kiln_sup_reason_panel_in(v->sup_reason, v->language)
+ : kiln_fault_panel_in(v->fault, v->language);
const int cols = KILN_DISPLAY_W / 6;
int y = 16;
while ((*cause != '\0') && y < (KILN_DISPLAY_H - 8)) {
@@ -218,12 +231,15 @@ const char *menu_label(uint8_t i, const kiln_hmi_view_t *v)
case 3: return S(v, KILN_HMI_STR_NETWORK);
case 4: return S(v, KILN_HMI_STR_DIAGNOSTICS);
case 5: return S(v, KILN_HMI_STR_INFO);
+ /* SWR-CFG-09, last on purpose: it is the one entry that destroys
+ * something, and it is a long way from "Start program" by a knob. */
+ case 6: return S(v, KILN_HMI_STR_FACTORY_RESET);
default: return "";
}
}
} // namespace
-constexpr int MENU_ITEMS = 6;
+constexpr int MENU_ITEMS = 7;
namespace {
@@ -264,7 +280,9 @@ void draw_confirm(kiln_hmi_t *h, const kiln_hmi_view_t *v)
/* SWR-HMI-11: starting and aborting both require this step. */
const char *what = (h->pending == KILN_HMI_ACT_START)
? S(v, KILN_HMI_STR_START_FIRING_Q)
- : S(v, KILN_HMI_STR_ABORT_FIRING_Q);
+ : (h->pending == KILN_HMI_ACT_FACTORY_RESET
+ ? S(v, KILN_HMI_STR_ERASE_ALL_Q)
+ : S(v, KILN_HMI_STR_ABORT_FIRING_Q));
kiln_fb_text(&h->fb, 0, 4, what, 1, true);
if (h->pending == KILN_HMI_ACT_START && h->pending_program < v->program_count) {
@@ -430,16 +448,28 @@ void draw_diag(kiln_hmi_t *h, const kiln_hmi_view_t *v)
(double)v->snap.current_a);
kiln_fb_text(&h->fb, 0, 23, buf, 1, true);
- /* SWR-CUR-07, in the units a kiln owner thinks in. */
- (void)snprintf(buf, sizeof(buf), "%-5s %.2fkW", S(v, KILN_HMI_STR_POWER),
- v->power_w / 1000.0);
+ /* SWR-CUR-07, in the units a kiln owner thinks in. Power and energy share
+ * a row so the door state gets one: five rows fit under the rule, and the
+ * two are read together anyway -- "it is drawing this much, it has used
+ * that much". Their row labels are gone with the merge, and the units do
+ * the naming instead: kW is a rate and kWh is a total, which is a
+ * distinction a kiln owner makes before a firmware engineer does. Two
+ * strings left the table rather than staying in it unused. */
+ (void)snprintf(buf, sizeof(buf), "%.2fkW %.1fkWh",
+ v->power_w / 1000.0, v->energy_wh / 1000.0);
kiln_fb_text(&h->fb, 0, 33, buf, 1, true);
- (void)snprintf(buf, sizeof(buf), "%-5s %.2fkWh", S(v, KILN_HMI_STR_USED),
- v->energy_wh / 1000.0);
- kiln_fb_text(&h->fb, 0, 43, buf, 1, true);
(void)snprintf(buf, sizeof(buf), "%-5s %u%%", S(v, KILN_HMI_STR_DUTY),
(unsigned)(v->snap.duty_permille / 10u));
+ kiln_fb_text(&h->fb, 0, 43, buf, 1, true);
+
+ /* Three states and not two (SWR-SAF-31). "shut" and "open" both
+ * presuppose a switch; a kiln with none fitted is the case warning 113
+ * exists for, and showing it as "shut" would be the one wrong answer. */
+ const char *door = !v->snap.door_monitoring ? S(v, KILN_HMI_STR_DOOR_NONE)
+ : (v->snap.door_open ? S(v, KILN_HMI_STR_DOOR_OPEN)
+ : S(v, KILN_HMI_STR_DOOR_SHUT));
+ (void)snprintf(buf, sizeof(buf), "%-5s %s", S(v, KILN_HMI_STR_DOOR_LABEL), door);
kiln_fb_text(&h->fb, 0, 53, buf, 1, true);
}
@@ -571,7 +601,15 @@ kiln_hmi_action_t kiln_hmi_update(kiln_hmi_t *h, const kiln_hmi_view_t *view,
break;
case 3: h->screen = KILN_HMI_SCREEN_NETWORK; break;
case 4: h->screen = KILN_HMI_SCREEN_DIAG; break;
- default: h->screen = KILN_HMI_SCREEN_INFO; break;
+ case 5: h->screen = KILN_HMI_SCREEN_INFO; break;
+ default:
+ /* SWR-CFG-09. Confirmed like an abort, and defaulting to
+ * NO for the same reason: the knob's first position must
+ * not be the destructive one. */
+ h->pending = KILN_HMI_ACT_FACTORY_RESET;
+ h->confirm_yes = false;
+ h->screen = KILN_HMI_SCREEN_CONFIRM;
+ break;
}
}
break;
diff --git a/firmware/controller/components/kiln_hmi/src/strings.cpp b/firmware/controller/components/kiln_hmi/src/strings.cpp
index 92d3508..492a1f4 100644
--- a/firmware/controller/components/kiln_hmi/src/strings.cpp
+++ b/firmware/controller/components/kiln_hmi/src/strings.cpp
@@ -44,6 +44,7 @@ const char *const k_str[KILN_HMI_STR_COUNT][KILN_LANG_COUNT] = {
{ "HEAT", "HEIZ" },
{ "HOLD", "WARTET" },
{ "ACK?", "QUIT?" },
+ { "DOOR", "TUER" },
/* Menu */
{ "Start program", "Programm starten" },
@@ -51,11 +52,14 @@ const char *const k_str[KILN_HMI_STR_COUNT][KILN_LANG_COUNT] = {
{ "Resume", "Fortsetzen" },
{ "Pause/Resume", "Pause/Fortsetzen" },
{ "Abort", "Abbrechen" },
+ { "Erase everything", "Alles loeschen" },
{ "none stored", "keine gespeichert" },
/* Confirmation */
{ "Start firing?", "Brennen starten?" },
{ "Abort firing?", "Brennen abbrechen?" },
+ { "Erase all data?", "Alle Daten loeschen?" },
+ { "erased", "geloescht" },
{ "YES", "JA" },
{ "NO", "NEIN" },
{ "turn to choose, press", "drehen, dann druecken" },
@@ -82,9 +86,11 @@ const char *const k_str[KILN_HMI_STR_COUNT][KILN_LANG_COUNT] = {
/* Diagnostics and info */
{ "case", "Geh." },
{ "amps", "Strom" },
- { "power", "Leist" },
- { "used", "Verbr" },
{ "duty", "Takt" },
+ { "door", "Tuer" },
+ { "OPEN", "OFFEN" },
+ { "shut", "zu" },
+ { "no switch", "kein Schalter" },
{ "up", "Lauf" },
{ "tuned", "abgestimmt" },
{ "UNTUNED defaults", "NICHT abgestimmt" },
diff --git a/firmware/controller/components/kiln_ports/include/kiln/types.h b/firmware/controller/components/kiln_ports/include/kiln/types.h
index 4f9cfd5..0ca7b11 100644
--- a/firmware/controller/components/kiln_ports/include/kiln/types.h
+++ b/firmware/controller/components/kiln_ports/include/kiln/types.h
@@ -229,6 +229,17 @@ typedef struct {
bool duty_saturated;
bool kiln_valid; /* false while inside the SWR-ACQ-12 grace period */
bool case_valid;
+ /* SWR-SAF-31, SWR-WEB-04. The controller's *knowledge* of the door, which
+ * is not the interlock: SYS-HW-21 puts the switch in the coil circuit as
+ * well (see port_door.h). Published so the display and the API can say
+ * why a kiln is not heating, and so that "no switch fitted" is visible
+ * rather than only being warning 113 in a list.
+ *
+ * `door_monitoring` false means no interlock is fitted or it is configured
+ * off, in which case `door_open` says nothing and must not be shown as
+ * though it did. */
+ bool door_open;
+ bool door_monitoring;
} kiln_snapshot_t;
/* --- heater current (FR-CUR) ------------------------------------------- */
diff --git a/firmware/controller/components/kiln_web/src/api.cpp b/firmware/controller/components/kiln_web/src/api.cpp
index 4cf58a0..84698eb 100644
--- a/firmware/controller/components/kiln_web/src/api.cpp
+++ b/firmware/controller/components/kiln_web/src/api.cpp
@@ -335,6 +335,12 @@ void write_status(kiln_api_ctx_t *ctx, kiln_json_t *j)
kiln_json_kv_bool(j, "heat_authorised", s.heat_authorised);
kiln_json_kv_bool(j, "holdback", s.holdback_active);
kiln_json_kv_bool(j, "duty_saturated", s.duty_saturated);
+ /* SWR-WEB-04, SWR-SAF-31. Three states, not two: "open" and "shut" both
+ * presuppose a switch, and a kiln with none fitted must not be reported as
+ * a kiln whose door is shut. `door` is therefore a string, and the client
+ * cannot accidentally read an absent interlock as a safe one. */
+ kiln_json_kv_str(j, "door", !s.door_monitoring ? "unmonitored"
+ : (s.door_open ? "open" : "shut"));
kiln_json_kv_num(j, "current_a", s.current_a, 2);
kiln_json_kv_num(j, "current_ref_a", s.current_ref_a, 2);
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 bc207a4..27b51f9 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
@@ -97,6 +97,12 @@ typedef struct {
uint8_t value[KILN_HOST_KV_VALUE_MAX];
size_t len;
bool used;
+ /* Erased but not yet collected, which is the state NVS leaves an entry in:
+ * delisted, with its bytes still in the page until a garbage collection
+ * that may never come. A new write does NOT land here, for the same
+ * reason it does not on NVS, which is what makes SWR-CFG-09's "overwrite
+ * before you erase" a property a test can actually fail. */
+ bool delisted;
} kiln_host_kv_entry_t;
typedef struct {
@@ -107,6 +113,12 @@ typedef struct {
} kiln_host_kv_t;
void kiln_host_kv_init(kiln_host_kv_t *kv);
+
+/* Collect the delisted entries, zeroing their bytes. NVS does this when a
+ * page fills; nothing in the firmware can make it happen, which is exactly
+ * why a credential must be overwritten rather than left to it. Here so a test
+ * can reclaim slots deliberately, never automatically. */
+void kiln_host_kv_collect(kiln_host_kv_t *kv);
void kiln_host_kv_bind(kiln_host_kv_t *kv, kiln_port_kvstore_t *out);
/* --- file store -------------------------------------------------------- */
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 5530844..7f07820 100644
--- a/firmware/controller/host/kiln_hal_host/src/hal_host.cpp
+++ b/firmware/controller/host/kiln_hal_host/src/hal_host.cpp
@@ -177,6 +177,18 @@ void kiln_host_flash_bind(kiln_host_flash_t *f, kiln_port_flash_t *out)
/* --- key/value store --------------------------------------------------- */
+void kiln_host_kv_collect(kiln_host_kv_t *kv)
+{
+ if (kv == nullptr) {
+ return;
+ }
+ for (size_t i = 0; i < KILN_HOST_KV_ENTRIES; i++) {
+ if (kv->entries[i].delisted) {
+ memset(&kv->entries[i], 0, sizeof(kv->entries[i]));
+ }
+ }
+}
+
void kiln_host_kv_init(kiln_host_kv_t *kv)
{
if (kv != nullptr) {
@@ -242,8 +254,15 @@ kiln_err_t kv_set(void *ctx, const char *ns, const char *key,
kiln_host_kv_entry_t *e = kv_find(kv, ns, key);
if (e == nullptr) {
+ /* A delisted entry is skipped, not reused: on NVS the new value is
+ * appended and the old bytes stay where they are. Reusing the slot
+ * here would quietly destroy the evidence SWR-CFG-09's test is looking
+ * for, and the test would pass without the code doing anything. */
for (size_t i = 0; i < KILN_HOST_KV_ENTRIES; i++) {
- if (!kv->entries[i].used) { e = &kv->entries[i]; break; }
+ if (!kv->entries[i].used && !kv->entries[i].delisted) {
+ e = &kv->entries[i];
+ break;
+ }
}
if (e == nullptr) {
return KILN_ERR_NO_SPACE;
@@ -271,7 +290,22 @@ kiln_err_t kv_erase(void *ctx, const char *ns, const char *key)
if (e == nullptr) {
return KILN_ERR_NOT_FOUND;
}
- memset(e, 0, sizeof(*e));
+ /* NVS semantics, deliberately: an erase DELISTS the entry and leaves its
+ * bytes in the page until a garbage collection that may never come.
+ *
+ * This used to memset the whole entry, which made every "is the secret
+ * gone" test pass whether or not the code under test had overwritten
+ * anything -- a fake kinder than the medium, which is the worst kind. The
+ * flash fake models NOR's refusal to un-clear a bit for the same reason:
+ * a fake that cannot reproduce the failure cannot be used to prove it does
+ * not happen.
+ *
+ * So the name and the flag go and the value stays. SWR-CFG-09's erasure is
+ * the caller's job: overwrite, commit, then erase. */
+ e->used = false;
+ e->delisted = true;
+ memset(e->ns, 0, sizeof(e->ns));
+ memset(e->key, 0, sizeof(e->key));
return KILN_OK;
}
diff --git a/firmware/controller/main/main.cpp b/firmware/controller/main/main.cpp
index 5711b05..637790e 100644
--- a/firmware/controller/main/main.cpp
+++ b/firmware/controller/main/main.cpp
@@ -474,6 +474,29 @@ void hmi_apply(const kiln_hmi_action_t *a)
break;
}
+ case KILN_HMI_ACT_FACTORY_RESET: {
+ /* SWR-CFG-09. Reported rather than merely done: an owner about to
+ * sell the kiln deserves to see what went, and a failure here is the
+ * one case where "it finished" would be a lie with consequences. */
+ kiln_factory_reset_t r = {};
+ e = kiln_app_factory_reset(&s_app, &r);
+ ESP_LOGW(TAG, "factory reset: config %s, credentials %s, log %s, "
+ "counters %s, %u run records, %u failures",
+ r.config ? "erased" : "KEPT",
+ r.credentials ? "overwritten and erased" : "KEPT",
+ r.log ? "erased" : "KEPT",
+ r.counters ? "zeroed" : "KEPT",
+ (unsigned)r.runs_erased, (unsigned)r.failures);
+ if (e == KILN_OK) {
+ /* The WiFi credentials are gone from flash, so the radio is now
+ * joined to a network the device can no longer re-join after a
+ * reboot. Saying so is better than leaving it connected and
+ * looking configured. */
+ ESP_LOGW(TAG, "set up WiFi again at the display when needed");
+ }
+ break;
+ }
+
case KILN_HMI_ACT_WIFI_SCAN:
/* SWR-NET-11. The results arrive asynchronously; the view builder
* collects them once the driver says the scan is done. */
@@ -482,7 +505,15 @@ void hmi_apply(const kiln_hmi_action_t *a)
? s_net_port.scan_begin(s_net_port.ctx) : KILN_ERR_UNSUPPORTED;
break;
- case KILN_HMI_ACT_WIFI_CONNECT: {
+ case KILN_HMI_ACT_WIFI_CONNECT:
+ /* SWR-LOG-16: a join commanded at the display is a security event --
+ * somebody standing at the kiln pointed it at a network. The SSID is
+ * NOT recorded and the passphrase certainly is not: the requirement
+ * says the log may contain nothing the device does not already show,
+ * and a log that records which network a kiln joined is a record of
+ * where it lives. */
+ {
+ kiln_app_log_event(&s_app, KILN_LOGE_SEC_NET_JOIN);
/* SWR-NET-12. Persist first, then join: a device that joins without
* remembering comes back from a power cut with no network and nobody
* at the display, which is the state this whole screen exists to get
diff --git a/firmware/controller/test/host/CMakeLists.txt b/firmware/controller/test/host/CMakeLists.txt
index 3b0fd8d..041c1f2 100644
--- a/firmware/controller/test/host/CMakeLists.txt
+++ b/firmware/controller/test/host/CMakeLists.txt
@@ -69,6 +69,11 @@ enable_testing()
function(kiln_add_test name)
add_executable(${name} ${ARGN})
target_link_libraries(${name} PRIVATE kiln_web kiln_hmi kiln_app kiln_core kiln_sim kiln_hal_host kiln_check)
+ # Where the committed wire-format fixtures live. A test is run from the
+ # build directory, so it cannot find them relatively, and a fixture that
+ # is silently not found is a test that silently proves nothing.
+ target_compile_definitions(${name} PRIVATE
+ KILN_FIXTURE_DIR="${CMAKE_CURRENT_LIST_DIR}/fixtures")
add_test(NAME ${name} COMMAND ${name})
endfunction()
diff --git a/firmware/controller/test/host/fixtures/README.md b/firmware/controller/test/host/fixtures/README.md
new file mode 100644
index 0000000..fcc2d93
--- /dev/null
+++ b/firmware/controller/test/host/fixtures/README.md
@@ -0,0 +1,46 @@
+
+
+# Wire-format fixtures
+
+Binary files that pin a persisted format, checked in so that two
+implementations can be compared against one artefact rather than against each
+other's mood.
+
+## `logring.bin`
+
+Two 4 kB log sectors, produced by `kiln_logrec_encode` and
+`kiln_logrec_encode_hdr` (`SWA-18`). Three records, chosen for the cases a
+decoder gets wrong: a negative temperature, the segment sentinel, every flag
+set at once, a non-sample event, and each field at the edge of its scale. The
+sectors are deliberately **out of address order** — sector 0 holds the newer
+data — so that anything reading the ring by address rather than by sequence
+number interleaves two firings and is caught.
+
+Two things read it, and that is the point:
+
+- `test_logrec`, which re-encodes the same samples and compares bytes. The
+ firmware cannot change the format without this failing.
+- [`tools/logdump.py`](../../../../../tools/logdump.py), whose decoder is
+ written from the format's documentation rather than from `kiln_core`. A log
+ is the only evidence of what a kiln did before it failed, and "did the
+ firmware record this correctly" cannot be answered by the firmware's own
+ decoder: a codec that encodes and decodes with the same wrong idea round
+ trips perfectly and proves nothing.
+
+### When it fails
+
+Ask which side moved.
+
+- **The format changed deliberately.** Then `KILN_LOG_FORMAT_VERSION` in the
+ sector header changes with it, the decoder learns the new version, and this
+ file is regenerated. Logs already on devices stay readable because the
+ version says which layout they are.
+- **It did not.** Then the encoder has a bug that would have made every log on
+ every device unreadable by the tooling, and the fixture has just caught it
+ before a release did not.
+
+Regenerating it is deliberate work rather than a command to run on a whim,
+which is why there is no script for it here: write a short program against
+`kiln_logrec_encode`, produce the same three records, and check the diff is
+the change you meant.
diff --git a/firmware/controller/test/host/fixtures/logring.bin b/firmware/controller/test/host/fixtures/logring.bin
new file mode 100644
index 0000000..b98add3
Binary files /dev/null and b/firmware/controller/test/host/fixtures/logring.bin differ
diff --git a/firmware/controller/test/host/integration/test_api.cpp b/firmware/controller/test/host/integration/test_api.cpp
index 99c840a..f04b273 100644
--- a/firmware/controller/test/host/integration/test_api.cpp
+++ b/firmware/controller/test/host/integration/test_api.cpp
@@ -76,6 +76,10 @@ static void rig_init(rig_t *r)
ports.heat = &r->sim_ports.heat;
ports.current = &r->sim_ports.current;
ports.counters = &r->sim_ports.counters;
+ /* SWR-SAF-31: bound so /api/status reports a door at all. With no port
+ * the snapshot reports no monitoring, which is a real state of a real
+ * kiln but not the one most of these tests are about. */
+ ports.door = &r->sim_ports.door;
ports.logstore = &r->log_port;
ports.kvstore = &r->kv_port;
ports.filestore = &r->fs_port;
@@ -292,6 +296,47 @@ KILN_TEST(swrrun05_status_exposes_everything_the_requirement_lists)
CHECK(kiln_json_find(resp.body, g_toks, n, 0, "holdback") > 0);
CHECK(kiln_json_find(resp.body, g_toks, n, 0, "warnings") > 0);
CHECK(kiln_json_find(resp.body, g_toks, n, 0, "fault") > 0);
+
+ /* SWR-WEB-04, SWR-SAF-31. A string and not a boolean, so that a client
+ * cannot read an unmonitored door as a shut one: the three cases have
+ * three different remedies, and "there is no switch" is the one warning
+ * 113 exists to make visible. */
+ char door[16];
+ str_of(&resp, n, "door", door, sizeof(door));
+ CHECK_STR_EQ(door, "shut");
+}
+
+/*
+ * @relation(SWR-SAF-31, scope=function)
+ */
+KILN_TEST(swrsaf31_the_api_reports_all_three_door_states)
+{
+ static rig_t r;
+ rig_init(&r);
+ rig_run(&r, 1.0);
+
+ char door[16];
+ kiln_api_resp_t resp;
+
+ kiln_sim_inject(&r.sim, KILN_INJ_DOOR_SWITCH_OPEN);
+ rig_run(&r, 1.0);
+ resp = call(&r, KILN_HTTP_GET, "/api/status", NULL, NULL);
+ str_of(&resp, parse_resp(&resp), "door", door, sizeof(door));
+ CHECK_STR_EQ(door, "open");
+
+ kiln_sim_clear(&r.sim, KILN_INJ_DOOR_SWITCH_OPEN);
+ rig_run(&r, 1.0);
+ resp = call(&r, KILN_HTTP_GET, "/api/status", NULL, NULL);
+ str_of(&resp, parse_resp(&resp), "door", door, sizeof(door));
+ CHECK_STR_EQ(door, "shut");
+
+ /* A kiln with no interlock fitted: the switch is absent, so the reading
+ * means nothing and the API says that rather than guessing. */
+ kiln_sim_inject(&r.sim, KILN_INJ_DOOR_ABSENT);
+ rig_run(&r, 1.0);
+ resp = call(&r, KILN_HTTP_GET, "/api/status", NULL, NULL);
+ str_of(&resp, parse_resp(&resp), "door", door, sizeof(door));
+ CHECK_STR_EQ(door, "unmonitored");
}
/*
diff --git a/firmware/controller/test/host/integration/test_persistence.cpp b/firmware/controller/test/host/integration/test_persistence.cpp
index 1bbbf95..e0c3d35 100644
--- a/firmware/controller/test/host/integration/test_persistence.cpp
+++ b/firmware/controller/test/host/integration/test_persistence.cpp
@@ -691,3 +691,273 @@ KILN_TEST(swa09_recovery_needs_no_write_of_its_own)
CHECK_EQ_UINT(s.state, KILN_STATE_RUNNING);
CHECK(s.setpoint_c > 20.0f);
}
+
+/* ===========================================================================
+ * THE FACTORY RESET (SWR-CFG-09, SEC-14, tasklist N4)
+ * ===========================================================================
+ * The requirement is unusually specific about how to test it, and it is right
+ * to be: "the test for this requirement shall read the raw partition back and
+ * look for the secret rather than asking the API whether it is still
+ * configured." Asking the API proves an entry is delisted. Somebody selling a
+ * kiln is asking whether the bytes are gone.
+ *
+ * On NVS an erase marks an entry dead and leaves its content in the page until
+ * a garbage collection that may never come, which is why the implementation
+ * overwrites and commits before erasing -- and why these tests search the
+ * store's backing bytes rather than trusting its answers.
+ */
+
+/* Does the key/value store hold this byte sequence anywhere, in any entry,
+ * whether or not that entry is still listed? This is the question the
+ * requirement asks, and the only one worth asking of a device about to change
+ * hands. */
+static bool kv_holds(const kiln_host_kv_t *kv, const char *needle)
+{
+ const size_t n = strlen(needle);
+ for (size_t i = 0; i < KILN_HOST_KV_ENTRIES; i++) {
+ const kiln_host_kv_entry_t *e = &kv->entries[i];
+ /* Deliberately not gated on e->used: a reset that only clears the
+ * flag is exactly the defect being tested for. */
+ if (e->len < n) { continue; }
+ for (size_t off = 0; off + n <= e->len; off++) {
+ if (memcmp(&e->value[off], needle, n) == 0) { return true; }
+ }
+ }
+ return false;
+}
+
+/*
+ * @relation(SWR-CFG-09, scope=function)
+ */
+KILN_TEST(swrcfg09_a_factory_reset_leaves_no_passphrase_in_the_store)
+{
+ static medium_t m;
+ static boot_t b;
+ medium_init(&m);
+ CHECK_OK(boot(&b, &m, KILN_RESET_POWER_ON, -1.0f, 20.0f));
+
+ /* A passphrase a search can find, long enough that a partial overwrite
+ * would leave a recognisable tail. */
+ static const char secret[] = "correct-horse-battery-staple-9271";
+ (void)snprintf(b.app.cfg.wifi_pass, sizeof(b.app.cfg.wifi_pass), "%s", secret);
+ (void)snprintf(b.app.cfg.wifi_ssid, sizeof(b.app.cfg.wifi_ssid), "%s", "workshop");
+ CHECK_OK(kiln_settings_save(&b.kv_port, &b.app.cfg));
+
+ /* It really is in there first, or the search below proves nothing: a test
+ * that looks for an absent string in an empty store passes for the wrong
+ * reason, and this is the kind of test that gets written once and trusted
+ * for years. */
+ CHECK_MSG(kv_holds(&m.kv, secret),
+ "the passphrase was never stored, so this test is vacuous");
+
+ kiln_factory_reset_t rep = {};
+ CHECK_OK(kiln_app_factory_reset(&b.app, &rep));
+ CHECK(rep.config);
+ CHECK(rep.credentials);
+
+ /* Checked by experiment, not by assumption: with the overwrite removed
+ * from kiln_app_factory_reset, this assertion fails with exactly this
+ * message. The fake had to be made faithful first -- it used to memset a
+ * whole entry on erase, and to reuse the slot on the next write, either of
+ * which made this pass with no code behind it. */
+ CHECK_MSG(!kv_holds(&m.kv, secret),
+ "the passphrase is still in the store's bytes after a factory "
+ "reset: it was delisted rather than erased");
+ /* And the SSID with it, which identifies the owner's network to whoever
+ * buys the kiln. */
+ CHECK(!kv_holds(&m.kv, "workshop"));
+
+ /* It stays gone across a reboot, which is when a new owner would first
+ * power it up. */
+ static boot_t b2;
+ CHECK_OK(boot(&b2, &m, KILN_RESET_POWER_ON, -1.0f, 20.0f));
+ CHECK(!kv_holds(&m.kv, secret));
+ CHECK_EQ_UINT(strlen(b2.app.cfg.wifi_pass), 0u);
+}
+
+/*
+ * @relation(SWR-CFG-09, scope=function)
+ */
+KILN_TEST(swrcfg09_it_erases_the_history_and_the_counters_too)
+{
+ static medium_t m;
+ static boot_t b;
+ medium_init(&m);
+ CHECK_OK(boot(&b, &m, KILN_RESET_POWER_ON, -1.0f, 20.0f));
+
+ for (uint32_t i = 1; i <= 5u; i++) {
+ kiln_run_record_t rec = {};
+ kiln_runstate_record_init(&rec, i);
+ rec.end_reason = (uint8_t)KILN_END_COMPLETE;
+ rec.duration_s = 3600u * i;
+ CHECK_OK(kiln_run_index_append(&b.fs_port, &rec));
+ }
+ CHECK_EQ_UINT(kiln_run_index_count(&b.fs_port), 5u);
+
+ kiln_factory_reset_t rep = {};
+ CHECK_OK(kiln_app_factory_reset(&b.app, &rep));
+
+ CHECK_EQ_UINT(kiln_run_index_count(&b.fs_port), 0u);
+ CHECK_EQ_UINT(rep.runs_erased, 5u);
+ CHECK(rep.log);
+ CHECK(rep.counters);
+
+ /* The numbering restarts: a kiln whose first firing is number 94 tells its
+ * new owner something false about its history. */
+ CHECK_EQ_UINT(kiln_run_index_next_run_id(&b.fs_port), 1u);
+
+ /* Defaults are back in RAM too, so the device is usable without a reboot
+ * and is not still serving what was just erased from flash. */
+ kiln_config_t def;
+ kiln_config_defaults(&def);
+ CHECK_NEAR(b.app.cfg.max_temp_c, def.max_temp_c, 0.01f);
+}
+
+/*
+ * @relation(SWR-CFG-09, scope=function)
+ */
+KILN_TEST(swrcfg09_it_is_refused_while_the_kiln_is_firing)
+{
+ static medium_t m;
+ static boot_t b;
+ medium_init(&m);
+ CHECK_OK(boot(&b, &m, KILN_RESET_POWER_ON, -1.0f, 20.0f));
+
+ static const char secret[] = "keep-me-while-firing";
+ (void)snprintf(b.app.cfg.wifi_pass, sizeof(b.app.cfg.wifi_pass), "%s", secret);
+ CHECK_OK(kiln_settings_save(&b.kv_port, &b.app.cfg));
+
+ kiln_program_t p;
+ CHECK_OK(kiln_profile_example(0, &p));
+ CHECK_OK(kiln_app_start(&b.app, &p));
+
+ kiln_factory_reset_t rep = {};
+ CHECK_ERR(kiln_app_factory_reset(&b.app, &rep), KILN_ERR_STATE);
+ CHECK(kv_holds(&m.kv, secret));
+
+ /* A refusal must not leave a report that reads like a partial success. */
+ CHECK(!rep.config && !rep.credentials && !rep.log);
+ CHECK_EQ_UINT(rep.runs_erased, 0u);
+
+ CHECK_OK(kiln_app_abort(&b.app));
+ CHECK_OK(kiln_app_factory_reset(&b.app, &rep));
+ CHECK(!kv_holds(&m.kv, secret));
+}
+
+/* ===========================================================================
+ * SECURITY EVENTS, AND THE OWNER'S OPT-OUT (SWR-LOG-16, tasklist N4)
+ * ===========================================================================
+ * UR-REG-004 asks for the opt-out as explicitly as it asks for the log, and
+ * the reason is worth keeping in view: a device that records access to itself
+ * regardless of its owner's wishes is a surveillance feature rather than a
+ * security one.
+ *
+ * What the opt-out may NOT switch off is the other half of the test. A fault,
+ * a state change and the periodic sample are the evidence of what a kiln did;
+ * that is a safety record, and no configuration item removes it.
+ */
+
+/* Count the records of one event code in the log, by draining the queue and
+ * reading the ring back the way /api/log does. */
+typedef struct { uint8_t want; unsigned n; } event_tally_t;
+
+/* The visitor sees the raw record, as /api/log does: the store deals in bytes
+ * and the codec turns them back into a sample, which is the layering that
+ * lets logdump.py decode the same bytes with no firmware involved. */
+static bool tally_event(void *user, uint32_t run_id,
+ const uint8_t rec[KILN_LOG_RECORD_BYTES])
+{
+ (void)run_id;
+ event_tally_t *c = static_cast(user);
+ kiln_log_sample_t s;
+ if (kiln_logrec_decode(rec, &s) == KILN_OK && s.event == c->want) {
+ c->n++;
+ }
+ return true;
+}
+
+static unsigned count_events(boot_t *b, kiln_log_event_t want)
+{
+ (void)kiln_app_log_drain(&b->app, 64u);
+ event_tally_t c = { (uint8_t)want, 0u };
+ (void)kiln_logring_iterate(&b->ring, 0u, tally_event, &c);
+ return c.n;
+}
+
+/*
+ * @relation(SWR-LOG-16, scope=function)
+ */
+KILN_TEST(swrlog16_security_events_are_recorded_and_can_be_switched_off)
+{
+ static medium_t m;
+ static boot_t b;
+ medium_init(&m);
+
+ /* Default on, because the default has to be the one that helps an owner
+ * who has not thought about it. */
+ kiln_config_t def;
+ kiln_config_defaults(&def);
+ CHECK(def.log_security_events);
+
+ /* An abnormal reset is a security event: this one is a watchdog. */
+ CHECK_OK(boot(&b, &m, KILN_RESET_TASK_WDT, -1.0f, 20.0f));
+ CHECK_EQ_UINT(count_events(&b, KILN_LOGE_SEC_RESET), 1u);
+
+ /* A factory reset records itself, as the first entry of the new owner's
+ * log: a log that begins mid-history with no explanation reads like a
+ * fault rather than like a sale. */
+ kiln_factory_reset_t rep = {};
+ CHECK_OK(kiln_app_factory_reset(&b.app, &rep));
+ CHECK_EQ_UINT(count_events(&b, KILN_LOGE_SEC_ERASE), 1u);
+
+ /* Now the owner opts out. */
+ b.app.cfg.log_security_events = false;
+ CHECK_OK(kiln_app_apply_config(&b.app, &b.app.cfg, nullptr));
+ const unsigned erases_before = count_events(&b, KILN_LOGE_SEC_ERASE);
+
+ kiln_app_log_event(&b.app, KILN_LOGE_SEC_ERASE);
+ kiln_app_log_event(&b.app, KILN_LOGE_SEC_NET_JOIN);
+ kiln_app_log_event(&b.app, KILN_LOGE_SEC_RESET);
+ CHECK_EQ_UINT(count_events(&b, KILN_LOGE_SEC_ERASE), erases_before);
+ CHECK_EQ_UINT(count_events(&b, KILN_LOGE_SEC_NET_JOIN), 0u);
+
+ /* And the safety record is untouched by that choice. */
+ const unsigned faults_before = count_events(&b, KILN_LOGE_FAULT);
+ kiln_app_log_event(&b.app, KILN_LOGE_FAULT);
+ kiln_app_log_event(&b.app, KILN_LOGE_STATE_CHANGE);
+ kiln_app_log_event(&b.app, KILN_LOGE_SAMPLE);
+ CHECK_EQ_UINT(count_events(&b, KILN_LOGE_FAULT), faults_before + 1u);
+ CHECK(count_events(&b, KILN_LOGE_STATE_CHANGE) > 0u);
+ CHECK(count_events(&b, KILN_LOGE_SAMPLE) > 0u);
+
+ /* Switched back on, it records again: an opt-out is a setting, not a
+ * one-way door. */
+ b.app.cfg.log_security_events = true;
+ CHECK_OK(kiln_app_apply_config(&b.app, &b.app.cfg, nullptr));
+ kiln_app_log_event(&b.app, KILN_LOGE_SEC_NET_JOIN);
+ CHECK_EQ_UINT(count_events(&b, KILN_LOGE_SEC_NET_JOIN), 1u);
+}
+
+/*
+ * @relation(SWR-LOG-16, scope=function)
+ */
+KILN_TEST(swrlog16_a_normal_restart_is_not_a_security_event)
+{
+ /* A power-on is how a kiln starts every morning. Logging it as a security
+ * event would fill the record with noise, and a log nobody reads is the
+ * same as no log. */
+ static medium_t m;
+ static boot_t b;
+ medium_init(&m);
+ CHECK_OK(boot(&b, &m, KILN_RESET_POWER_ON, -1.0f, 20.0f));
+ CHECK_EQ_UINT(count_events(&b, KILN_LOGE_SEC_RESET), 0u);
+
+ /* A cause the adapter could not identify IS one, deliberately: a reset
+ * nobody can account for is exactly what a log should carry, and treating
+ * UNKNOWN as normal would hide the case where the adapter is wrong. */
+ static medium_t m2;
+ static boot_t b2;
+ medium_init(&m2);
+ CHECK_OK(boot(&b2, &m2, KILN_RESET_UNKNOWN, -1.0f, 20.0f));
+ CHECK_EQ_UINT(count_events(&b2, KILN_LOGE_SEC_RESET), 1u);
+}
diff --git a/firmware/controller/test/host/unit/test_faults.cpp b/firmware/controller/test/host/unit/test_faults.cpp
index 07cb044..6808c7e 100644
--- a/firmware/controller/test/host/unit/test_faults.cpp
+++ b/firmware/controller/test/host/unit/test_faults.cpp
@@ -208,3 +208,165 @@ KILN_TEST(swrnfr23_german_labels_still_fit_the_display)
l, strlen(l));
}
}
+
+/* ===========================================================================
+ * WHAT THE PANEL CAN ACTUALLY SHOW (SWR-NFR-23, SWR-HMI-06, tasklist M4/J1)
+ * ===========================================================================
+ * Two properties of this table that nothing checked, both of which failed.
+ *
+ * The 5x7 font in kiln_hmi carries the 95 printable ASCII glyphs and nothing
+ * else. The labels had always been transliterated -- VERSCHWEISST, KEINE
+ * WAERME -- and the long causes had not: 31 of them contained umlauts, which
+ * reach the panel as blanks. A German operator read a fault screen with holes
+ * in the instruction.
+ *
+ * And the fault screen holds five rows of 21 characters. The full causes run
+ * to 279, because the browser shows them as a paragraph and does so well. On
+ * the panel the tail was simply dropped, and for KILN_FAULT_CONTACTOR_WELDED
+ * the tail was "ISOLATE THE KILN AT ITS SUPPLY NOW".
+ *
+ * These two tests are the reason the panel form exists, and they are written
+ * to name the offender: a bound nobody can fail is not a bound, and one that
+ * fails without saying which string is a bound nobody can fix.
+ */
+
+/* The fault screen's own word wrap, which is what the bound has to be measured
+ * against rather than a character count: it breaks on spaces, so a long word
+ * can waste most of a row. Returns the rows used, or 0 if a single word cannot
+ * fit a row at all. */
+static unsigned wrapped_rows(const char *s)
+{
+ unsigned rows = 0;
+ while (*s != '\0') {
+ unsigned take = 0, last_space = 0;
+ while (take < KILN_PANEL_COLS && s[take] != '\0') {
+ if (s[take] == ' ') { last_space = take; }
+ take++;
+ }
+ if (s[take] != '\0' && last_space > 0) { take = last_space; }
+ else if (s[take] != '\0' && last_space == 0) {
+ /* A word longer than a row. The screen cuts it mid-word, which the
+ * wrap's own comment calls out as unacceptable: a word split in a
+ * hurry reads as a different word, and German compounds are where
+ * this happens -- "Thermoelement-Messeingang" is 25 characters.
+ * Reported as its own failure rather than counted as rows. */
+ return 0;
+ }
+ rows++;
+ s += take;
+ while (*s == ' ') { s++; }
+ }
+ return rows;
+}
+
+/*
+ * @relation(SWR-NFR-23, scope=function)
+ */
+KILN_TEST(swrnfr23_every_string_in_the_table_is_ascii_the_font_can_draw)
+{
+ for (unsigned lang = 0; lang < (unsigned)KILN_LANG_COUNT; lang++) {
+ const kiln_lang_t l = (kiln_lang_t)lang;
+ for (unsigned f = 0; f < (unsigned)KILN_FAULT_MAX; f++) {
+ const char *strs[] = { kiln_fault_label_in((kiln_fault_t)f, l),
+ kiln_fault_cause_in((kiln_fault_t)f, l),
+ kiln_fault_panel_in((kiln_fault_t)f, l) };
+ for (size_t i = 0; i < 3; i++) {
+ for (const char *c = strs[i]; *c != '\0'; c++) {
+ const unsigned char u = (unsigned char)*c;
+ CHECK_MSG(u >= 0x20u && u <= 0x7Eu,
+ "fault %u lang %u string %zu has byte 0x%02X: "
+ "the 5x7 font cannot draw it", f, lang, i,
+ (unsigned)u);
+ }
+ }
+ }
+ for (unsigned w = 0; w < (unsigned)KILN_WARN_COUNT; w++) {
+ const char *strs[] = { kiln_warn_label_in((kiln_warn_bit_t)w, l),
+ kiln_warn_cause_in((kiln_warn_bit_t)w, l),
+ kiln_warn_panel_in((kiln_warn_bit_t)w, l) };
+ for (size_t i = 0; i < 3; i++) {
+ for (const char *c = strs[i]; *c != '\0'; c++) {
+ const unsigned char u = (unsigned char)*c;
+ CHECK_MSG(u >= 0x20u && u <= 0x7Eu,
+ "warning %u lang %u string %zu has byte 0x%02X",
+ w, lang, i, (unsigned)u);
+ }
+ }
+ }
+ for (unsigned r = 0; r < (unsigned)KILN_SUP_REASON_COUNT; r++) {
+ const char *strs[] = { kiln_sup_reason_label_in((kiln_sup_reason_t)r, l),
+ kiln_sup_reason_cause_in((kiln_sup_reason_t)r, l),
+ kiln_sup_reason_panel_in((kiln_sup_reason_t)r, l) };
+ for (size_t i = 0; i < 3; i++) {
+ for (const char *c = strs[i]; *c != '\0'; c++) {
+ const unsigned char u = (unsigned char)*c;
+ CHECK_MSG(u >= 0x20u && u <= 0x7Eu,
+ "sup reason %u lang %u string %zu has byte 0x%02X",
+ r, lang, i, (unsigned)u);
+ }
+ }
+ }
+ }
+}
+
+/*
+ * @relation(SWR-HMI-06, scope=function)
+ */
+KILN_TEST(swrhmi06_every_panel_cause_fits_the_fault_screen_whole)
+{
+ /* SWR-HMI-06 says the fault screen is not dismissible while the condition
+ * holds, which is only worth anything if what it shows is complete. */
+ for (unsigned lang = 0; lang < (unsigned)KILN_LANG_COUNT; lang++) {
+ const kiln_lang_t l = (kiln_lang_t)lang;
+
+ for (unsigned f = 0; f < (unsigned)KILN_FAULT_MAX; f++) {
+ const char *s = kiln_fault_panel_in((kiln_fault_t)f, l);
+ const unsigned rows = wrapped_rows(s);
+ CHECK_MSG(rows > 0 && rows <= KILN_PANEL_ROWS,
+ "fault %u lang %u: %s (%u chars) -- \"%s\"", f, lang,
+ rows == 0 ? "a word is longer than a row"
+ : "too many rows for the panel",
+ (unsigned)strlen(s), s);
+ }
+ for (unsigned w = 0; w < (unsigned)KILN_WARN_COUNT; w++) {
+ const char *s = kiln_warn_panel_in((kiln_warn_bit_t)w, l);
+ const unsigned rows = wrapped_rows(s);
+ CHECK_MSG(rows > 0 && rows <= KILN_PANEL_ROWS,
+ "warning %u lang %u needs %u rows (%u chars): \"%s\"",
+ w, lang, rows, (unsigned)strlen(s), s);
+ }
+ for (unsigned r = 0; r < (unsigned)KILN_SUP_REASON_COUNT; r++) {
+ const char *s = kiln_sup_reason_panel_in((kiln_sup_reason_t)r, l);
+ const unsigned rows = wrapped_rows(s);
+ CHECK_MSG(rows > 0 && rows <= KILN_PANEL_ROWS,
+ "sup reason %u lang %u needs %u rows (%u chars): \"%s\"",
+ r, lang, rows, (unsigned)strlen(s), s);
+ }
+ }
+}
+
+/*
+ * @relation(SWR-NFR-23, scope=function)
+ */
+KILN_TEST(swrnfr23_the_panel_form_says_the_same_thing_as_the_cause)
+{
+ /* A short form is allowed to be terser; it is not allowed to be about
+ * something else, and it is not allowed to be empty. The check that can be
+ * automated is that where a panel form exists it is genuinely shorter than
+ * the cause it replaces, and that the fallback works where none exists --
+ * the rest is review, which is what the table being one readable block is
+ * for. */
+ for (unsigned lang = 0; lang < (unsigned)KILN_LANG_COUNT; lang++) {
+ const kiln_lang_t l = (kiln_lang_t)lang;
+ for (unsigned f = 0; f < (unsigned)KILN_FAULT_MAX; f++) {
+ const char *full = kiln_fault_cause_in((kiln_fault_t)f, l);
+ const char *panel = kiln_fault_panel_in((kiln_fault_t)f, l);
+ CHECK(panel[0] != '\0');
+ if (panel != full) {
+ CHECK_MSG(strlen(panel) < strlen(full),
+ "fault %u lang %u has a panel form no shorter than "
+ "its cause, so it buys nothing", f, lang);
+ }
+ }
+ }
+}
diff --git a/firmware/controller/test/host/unit/test_hmi.cpp b/firmware/controller/test/host/unit/test_hmi.cpp
index d7eb920..3e4d7fb 100644
--- a/firmware/controller/test/host/unit/test_hmi.cpp
+++ b/firmware/controller/test/host/unit/test_hmi.cpp
@@ -964,3 +964,79 @@ KILN_TEST(swrnet12_the_knob_stays_where_the_last_character_left_it)
CHECK_EQ_UINT(h.charset_sel, landed);
CHECK_EQ_INT(h.pass[0], h.pass[1]); /* the same character twice */
}
+
+/*
+ * @relation(SWR-SAF-31, scope=function)
+ */
+KILN_TEST(swrsaf31_the_display_says_when_the_door_is_why_it_is_not_heating)
+{
+ /* The controller has known the door state since SWR-SAF-31 was
+ * implemented and showed it nowhere: an operator in front of an idle kiln
+ * could see HEAT missing and had to guess why. The badge goes in the slot
+ * that would say HEAT, because the two cannot both be true. */
+ kiln_hmi_t h; kiln_hmi_init(&h, 0);
+ kiln_hmi_view_t v = base_view();
+
+ v.snap.door_monitoring = true;
+ v.snap.door_open = false;
+ v.snap.heat_authorised = true;
+ (void)feed(&h, &v, KILN_INPUT_NONE);
+ const int heating = ink(&h, 30, 40, 30, 8);
+ CHECK(heating > 0); /* HEAT is there */
+
+ /* Door open: authority is gone, and the slot says so instead. */
+ v.snap.door_open = true;
+ v.snap.heat_authorised = false;
+ (void)feed(&h, &v, KILN_INPUT_NONE);
+ const int door = ink(&h, 30, 40, 30, 8);
+ CHECK(door > 0);
+ CHECK_MSG(door != heating, "DOOR renders the same as HEAT");
+
+ /* Neither: an idle kiln with the door shut says nothing in that slot. */
+ v.snap.door_open = false;
+ v.snap.heat_authorised = false;
+ (void)feed(&h, &v, KILN_INPUT_NONE);
+ CHECK_EQ_INT(ink(&h, 30, 40, 30, 8), 0);
+
+ /* And an unmonitored door never claims to be open, whatever the pin
+ * reads: with no switch fitted the value means nothing (warning 113). */
+ v.snap.door_monitoring = false;
+ v.snap.door_open = true;
+ (void)feed(&h, &v, KILN_INPUT_NONE);
+ CHECK_EQ_INT(ink(&h, 30, 40, 30, 8), 0);
+}
+
+/*
+ * @relation(SWR-SAF-31, scope=function)
+ */
+KILN_TEST(swrsaf31_diagnostics_tells_the_three_door_states_apart)
+{
+ /* Three states, not two: "open" and "shut" both presuppose a switch, and a
+ * kiln with none fitted must not read as one whose door is shut. That is
+ * the case warning 113 exists for and the one wrong answer here. */
+ kiln_hmi_t h; kiln_hmi_init(&h, 0);
+ kiln_hmi_view_t v = base_view();
+
+ (void)feed(&h, &v, KILN_INPUT_PRESS);
+ for (int i = 0; i < 4; i++) { (void)feed(&h, &v, KILN_INPUT_CW); }
+ (void)feed(&h, &v, KILN_INPUT_PRESS);
+ CHECK_EQ_INT(h.screen, KILN_HMI_SCREEN_DIAG);
+
+ v.snap.door_monitoring = true;
+ v.snap.door_open = false;
+ (void)feed(&h, &v, KILN_INPUT_NONE);
+ const int shut = ink(&h, 0, 52, 128, 10);
+
+ v.snap.door_open = true;
+ (void)feed(&h, &v, KILN_INPUT_NONE);
+ const int open = ink(&h, 0, 52, 128, 10);
+
+ v.snap.door_monitoring = false;
+ (void)feed(&h, &v, KILN_INPUT_NONE);
+ const int none = ink(&h, 0, 52, 128, 10);
+
+ CHECK(shut > 0 && open > 0 && none > 0);
+ CHECK_MSG(shut != open, "open and shut render the same");
+ CHECK_MSG(none != open && none != shut,
+ "a kiln with no door switch renders as one that has one");
+}
diff --git a/firmware/controller/test/host/unit/test_logrec.cpp b/firmware/controller/test/host/unit/test_logrec.cpp
index 0bca5d7..611d152 100644
--- a/firmware/controller/test/host/unit/test_logrec.cpp
+++ b/firmware/controller/test/host/unit/test_logrec.cpp
@@ -3,6 +3,7 @@
* kiln_core/logrec -- SWR-LOG-02, SWR-LOG-08, SWR-LOG-10, SWR-LOG-11, SWA-18.
*/
+#include
#include
#include "kiln_check.h"
#include "kiln_core/logrec.h"
@@ -285,3 +286,122 @@ KILN_TEST(the_decimator_validates_its_arguments)
kiln_decimator_push(NULL, &s);
kiln_decimator_push(&d, NULL);
}
+
+/* ===========================================================================
+ * THE WIRE FORMAT, AGAINST A COMMITTED FIXTURE (SWA-18, tasklist C7)
+ * ===========================================================================
+ * `firmware/controller/test/host/fixtures/logring.bin` is two log sectors
+ * produced by this encoder and checked into git. Two things read it and
+ * neither may drift:
+ *
+ * this test, which re-encodes the same samples and compares bytes, so the
+ * firmware cannot change the format without the fixture changing too;
+ *
+ * tools/logdump.py, which decodes it with an implementation written from
+ * the format's documentation rather than from this code.
+ *
+ * That second reader is the point. A log is the only evidence of what a kiln
+ * did before it failed, and "did the firmware record this correctly" cannot
+ * be answered by the firmware's own decoder: a codec that encodes and decodes
+ * with the same wrong idea round-trips perfectly and proves nothing. Two
+ * implementations agreeing is evidence; one agreeing with itself is a
+ * tautology.
+ *
+ * If this test fails, the question is which side moved. If the format changed
+ * deliberately, the sector header's format_version changes with it and the
+ * fixture is regenerated; if it did not, the encoder has a bug that would
+ * have made every log on every device unreadable by the tooling.
+ */
+
+/*
+ * @relation(SWA-18, scope=function)
+ */
+KILN_TEST(swa18_the_encoder_still_produces_the_committed_wire_format)
+{
+ const char *path = KILN_FIXTURE_DIR "/logring.bin";
+ FILE *fh = fopen(path, "rb");
+ CHECK_MSG(fh != nullptr, "cannot open the fixture at %s", path);
+ if (fh == nullptr) { return; }
+
+ static uint8_t want[2 * KILN_LOG_SECTOR_BYTES];
+ const size_t n = fread(want, 1, sizeof(want), fh);
+ (void)fclose(fh);
+ CHECK_EQ_UINT(n, sizeof(want));
+
+ /* The same two headers and three samples the fixture was built from. The
+ * values are chosen for what a decoder gets wrong: a negative
+ * temperature, the segment sentinel, every flag at once, a non-sample
+ * event, and each field at the edge of its scale. */
+ static uint8_t got[2 * KILN_LOG_SECTOR_BYTES];
+ memset(got, 0xFF, sizeof(got));
+
+ const struct { uint32_t seq, run; } secs[2] = { {9, 2}, {8, 2} };
+ for (int s = 0; s < 2; s++) {
+ kiln_log_sector_hdr_t h = {};
+ h.magic = KILN_LOG_MAGIC;
+ h.seq = secs[s].seq;
+ h.run_id = secs[s].run;
+ h.format_version = KILN_LOG_FORMAT_VERSION;
+ uint8_t hb[KILN_LOG_HEADER_BYTES];
+ kiln_logrec_encode_hdr(&h, hb);
+ memcpy(&got[(size_t)s * KILN_LOG_SECTOR_BYTES], hb, sizeof(hb));
+ }
+
+ kiln_log_sample_t a = {};
+ a.t_rel_ms = 1000; a.kiln_raw_c = 20.5f; a.kiln_filt_c = 20.4f;
+ a.setpoint_c = 100.0f; a.case_c = 30.0f; a.current_a = 12.34f;
+ a.duty_permille = 500; a.segment = 3; a.state = 1; a.flags = 0;
+ a.current_flags = 1; a.event = 0;
+
+ kiln_log_sample_t b = a;
+ b.t_rel_ms = 2000; b.kiln_raw_c = -12.3f; b.setpoint_c = 0.0f;
+ b.segment = KILN_SEG_NONE; b.state = 0; b.flags = 0xF0; b.event = 3;
+ b.duty_permille = 0; b.current_a = 0.0f;
+
+ kiln_log_sample_t c = a;
+ c.t_rel_ms = 3000; c.kiln_raw_c = 1285.0f; c.kiln_filt_c = 1284.9f;
+ c.setpoint_c = 1300.0f; c.case_c = 55.5f; c.current_a = 31.75f;
+ c.duty_permille = 1000; c.segment = 4; c.state = 5; c.flags = 0x10;
+ c.event = 2; c.current_flags = 0;
+
+ /* Sector 1 holds the older two, sector 0 the newest: deliberately out of
+ * address order, so anything that reads the ring by address rather than by
+ * sequence number interleaves two firings. */
+ const struct { int sector; const kiln_log_sample_t *s; } rows[] = {
+ { 1, &a }, { 1, &b }, { 0, &c },
+ };
+ size_t used[2] = {0, 0};
+ for (size_t i = 0; i < sizeof(rows) / sizeof(rows[0]); i++) {
+ uint8_t rec[KILN_LOG_RECORD_BYTES];
+ kiln_logrec_encode(rows[i].s, rec);
+ const size_t off = (size_t)rows[i].sector * KILN_LOG_SECTOR_BYTES
+ + KILN_LOG_HEADER_BYTES
+ + used[rows[i].sector] * KILN_LOG_RECORD_BYTES;
+ memcpy(&got[off], rec, sizeof(rec));
+ used[rows[i].sector]++;
+ }
+
+ /* Byte for byte, and the first difference is reported: "the images differ"
+ * sends the reader to a hex dump, while an offset sends them to a field. */
+ size_t first_diff = sizeof(got);
+ for (size_t i = 0; i < sizeof(got); i++) {
+ if (got[i] != want[i]) { first_diff = i; break; }
+ }
+ CHECK_MSG(first_diff == sizeof(got),
+ "the encoder no longer produces the committed format: first "
+ "difference at byte %zu (sector %zu, offset %zu in it), got "
+ "0x%02X want 0x%02X",
+ first_diff, first_diff / KILN_LOG_SECTOR_BYTES,
+ first_diff % KILN_LOG_SECTOR_BYTES,
+ (unsigned)got[first_diff < sizeof(got) ? first_diff : 0],
+ (unsigned)want[first_diff < sizeof(want) ? first_diff : 0]);
+
+ /* And the round trip, so a change that is symmetrical in the codec but
+ * wrong against the fixture is still caught above rather than here. */
+ kiln_log_sample_t back = {};
+ CHECK_OK(kiln_logrec_decode(&want[KILN_LOG_SECTOR_BYTES + KILN_LOG_HEADER_BYTES],
+ &back));
+ CHECK_EQ_UINT(back.t_rel_ms, 1000u);
+ CHECK_NEAR(back.kiln_raw_c, 20.5f, 0.051f);
+ CHECK_EQ_UINT(back.segment, 3u);
+}
diff --git a/firmware/controller/test/host/unit/test_logring.cpp b/firmware/controller/test/host/unit/test_logring.cpp
index 02850a3..b1919f0 100644
--- a/firmware/controller/test/host/unit/test_logring.cpp
+++ b/firmware/controller/test/host/unit/test_logring.cpp
@@ -521,3 +521,76 @@ KILN_TEST(the_erase_count_for_150_hours_matches_the_endurance_analysis)
* present rather than merely survivable. */
CHECK(!ring.wrapped);
}
+
+/* ===========================================================================
+ * A RECORD BEFORE ANY RUN (SWR-LOG-16, SWA-09)
+ * ===========================================================================
+ * Until SWR-LOG-16's security events existed, nothing ever appended outside a
+ * run: every emitter ran inside a firing and begin_run() had always claimed a
+ * sector first. An abnormal reset is logged at boot, which is outside any run,
+ * and the record went into a sector with no header -- which iterate() skips,
+ * because it selects runs from headers, and which the next begin_run() erases.
+ * The record was written, stored, counted and lost.
+ *
+ * The fix claims a sector lazily, tagged run 0 for "not part of a run", and
+ * the bookkeeping is the part that bit: claim_sector writes a header and
+ * nothing else, so the first version left head_seq at 0, begin_run() then
+ * issued the same sequence number to the run's sector, and two sectors tied
+ * for highest. Mount resolves order from the sequence number alone
+ * (architecture 10.3), so the tie made an idle record the newest in the ring
+ * and SWA-09's power-loss journal read the wrong one. Three recovery tests in
+ * another suite caught that, which is the only reason this test exists here.
+ */
+
+/*
+ * @relation(SWR-LOG-16, scope=function)
+ */
+KILN_TEST(swrlog16_a_record_before_any_run_is_kept_and_does_not_outrank_the_run)
+{
+ static rig_t r;
+ rig_init(&r);
+ (void)kiln_logring_mount(&r.ring, &r.port);
+
+ /* Two records outside any run, as a boot-time security event is. */
+ CHECK_OK(append(&r, 1u, 20.0f));
+ CHECK_OK(append(&r, 2u, 20.1f));
+
+ /* They are retrievable, which they were not before: iterate() selects by
+ * sector header, and an unheadered sector is invisible. */
+ CHECK_EQ_UINT(count_all(&r, 0u).count, 2u);
+ /* Tagged run 0, meaning "not part of a run", so a query for a real run
+ * does not pick them up. */
+ CHECK_EQ_UINT(count_all(&r, 7u).count, 0u);
+
+ /* Now a run starts and writes more. */
+ CHECK_OK(kiln_logring_begin_run(&r.ring, 7u));
+ for (uint32_t i = 0; i < 4u; i++) {
+ CHECK_OK(append(&r, 100u + i, 500.0f + (float)i));
+ }
+ CHECK_EQ_UINT(count_all(&r, 7u).count, 4u);
+ CHECK_EQ_UINT(count_all(&r, 0u).count, 6u);
+
+ /* The sequence numbers must not tie, or mount cannot tell which sector is
+ * the head and SWA-09's journal reads whichever came first in address
+ * order. This is the assertion that would have caught the defect. */
+ CHECK(r.ring.head_seq >= 2u);
+
+ /* Across a remount, the newest record in the ring is the run's last one
+ * and not the idle record that was written first. */
+ kiln_logring_t again;
+ CHECK_OK(kiln_logring_mount(&again, &r.port));
+ uint8_t rec[KILN_LOG_RECORD_BYTES];
+ CHECK_OK(kiln_logring_last_record(&again, 0u, rec));
+ kiln_log_sample_t s;
+ CHECK_OK(kiln_logrec_decode(rec, &s));
+ CHECK_MSG(s.t_rel_ms == 103u,
+ "the newest record in the ring is t=%u, not the run's last: a "
+ "sequence-number tie has made an earlier sector look newest",
+ (unsigned)s.t_rel_ms);
+
+ /* And the run's own journal is the same record, which is what recovery
+ * actually asks for. */
+ CHECK_OK(kiln_logring_last_record(&again, 7u, rec));
+ CHECK_OK(kiln_logrec_decode(rec, &s));
+ CHECK_EQ_UINT(s.t_rel_ms, 103u);
+}
diff --git a/firmware/controller/test/host/unit/test_stores.cpp b/firmware/controller/test/host/unit/test_stores.cpp
index 0bbd83f..472f1ac 100644
--- a/firmware/controller/test/host/unit/test_stores.cpp
+++ b/firmware/controller/test/host/unit/test_stores.cpp
@@ -434,3 +434,4 @@ KILN_TEST(the_stores_validate_their_arguments)
CHECK_ERR(kiln_settings_load(NULL, NULL), KILN_ERR_INVALID_ARG);
CHECK_ERR(kiln_settings_save(NULL, NULL), KILN_ERR_INVALID_ARG);
}
+
diff --git a/strictdoc.toml b/strictdoc.toml
index 13f11a3..0c9f495 100644
--- a/strictdoc.toml
+++ b/strictdoc.toml
@@ -46,6 +46,7 @@ include_source_paths = [
include_doc_paths = [
"docs/01_user_req.sdoc",
"docs/02_system_req.sdoc",
+ "docs/03_hardware_req.sdoc",
"docs/03_software_req.sdoc",
"docs/04_software_arch.sdoc",
"docs/safety.sdoc",
diff --git a/tasklist.md b/tasklist.md
index 91dbccf..15ff3d5 100644
--- a/tasklist.md
+++ b/tasklist.md
@@ -6,1026 +6,308 @@ SPDX-License-Identifier: GPL-3.0-or-later
# Safe Kiln Controller Task List
**Outstanding work only.** An item leaves this file when it is done; what was
-done and why is in the commit that did it, not here. Checked against
-[`docs/03_software_req.sdoc`](docs/03_software_req.sdoc),
-[`docs/architecture.md`](docs/architecture.md), [`docs/safety.md`](docs/safety.md)
-and [`docs/security.md`](docs/security.md).
+done and why is in the commit that did it. Item IDs are stable and cited from
+commit messages, so gaps in the numbering are closed items.
-Item IDs are stable and referenced from commit messages, so a letter is never
-reused: gaps in the numbering are items that have been closed.
+Priorities: **P1** safety-relevant or blocking, before the next board revision
+and before any firing. **P2** needed for a release that claims the
+requirements are met. **P3** polish.
-| | Meaning |
-|---|---|
-| **P1** | Safety-relevant or blocking. Do before the next board revision, and before any firing. |
-| **P2** | Required for a release that claims the requirements are met. |
-| **P3** | Correctness polish, consistency, cleanup. |
-
-## State, 2026-10-09
+## State, 2026-10-10
| | |
|---|---|
-| **Firmware logic** | Complete and tested. C++20, 398 host tests green plain and under ASan/UBSan, `clang-tidy` clean on host, target and webhost with no suppressions baseline, `esp32s3` builds with zero warnings at 246 kB (88 % of the OTA slot free). QEMU boots the image and fires it, on a developer machine and in CI: `tools/qemu-smoke.sh` runs it for 180 s and asserts seven things, ending with the simulated kiln passing 100 degC without latching a fault. |
-| **Firmware on hardware** | 15 of 16 ports are wired on the target. Only `update` has nothing behind it (`H2`). **None of it has been run against real hardware** (`L2`, `O3`, `P4`, `M3`). |
-| **Schematic** | ERC clean apart from one known `SDO` false positive (`A11`). Carries the lid interlock and the thermocouple-fault interlock. Does not carry the phase strap (`HR-22`) or the second and third CT inputs (`HR-23`). |
-| **PCB** | Updated from the schematic and placement started. **Not routed at all** (`A23`, `K4`). |
-| **Blocking release** | No field update path (`H2`, `SRR-11`). The web interface is read-only by design, so local control is the only control (`H3`). |
-
-## Where the open items are
-
-| | | Open |
-|---|---|---|
-| [A](#a-schematic-and-pcb) | Schematic and PCB | 22 |
-| [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 |
-| [H](#h-field-update-and-local-control) | Field update and local control | 2 |
-| [I](#i-current-measurement) | Current measurement | 1 |
-| [J](#j-german-translation-nfr-23) | German translation | 4 |
-| [K](#k-hardware-interlock-chain) | Hardware interlock chain | 5 |
-| [L](#l-target-adapters) | Target adapters | 2 |
-| [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 | 3 |
-| [Q](#q-the-file-store-ad-21) | The file store | 5 |
-| [R](#r-the-independent-safety-supervisor-ad-22) | Independent safety supervisor | 6 |
+| **Firmware logic** | Complete and tested. 26 host suites green plain and under ASan/UBSan, `kiln_core` lines 91.8 %, clang-tidy clean on host and target, both IDF configurations build. QEMU boots and fires the simulated image with seven assertions. |
+| **Firmware on hardware** | 15 of 16 ports wired; only `update` has nothing behind it (`H2`). **None of it has run against hardware** (`L2`, `O3`, `P4`, `M3`). |
+| **Schematic** | ERC clean apart from one known `SDO` false positive. Carries the lid interlock; does not carry the supervisor (`R6`). |
+| **PCB** | Placement started, **not routed at all** (`A23`). |
+| **Documents** | Requirements, architecture, safety, security and the supervisor's unit design all build in CI and gate it. |
---
## A. Schematic and PCB
-### A.1 Blocking electrical defects (P1)
-
-- [ ] **A1. Add pull-ups to the MAX31856 `~DRDY` outputs.** The `~FAULT` outputs got theirs in section K (`R28`, `R29`); `~DRDY` still has none, confirmed by query. Original note: All four
- are open-drain and have no pull-up anywhere in the netlist. `TC1_DRDY`
- (U3.7 → U1 IO14) and `TC2_DRDY` (U4.7 → U1 IO21) therefore float between
- assertions, so acquisition timing is undefined. Add 10 k to +3V3 on
- `TC1_DRDY`, `TC2_DRDY`, `TC1_FAULT`, `TC2_FAULT`. Affects SWR-ACQ-03,
- SWR-ACQ-10, SWR-SAF-04.
-
-- [ ] **A3. Bypass `VBIAS`.** R24/R25 (10 k/10 k off +3V3) present ~5 kΩ of
- source impedance, and the CT secondary returns into that node through J10.2.
- The "mid-rail" reference is therefore modulated by the signal it is supposed
- to reference. Add 10 µF ∥ 100 nF from `VBIAS` to GND. SYS-HW-17.
-
-- [ ] **A4. Fix the CT anti-alias filter.** R26 (1 k) + C25 (220 nF) gives
- fc ≈ 723 Hz. SWR-CUR-03 specifies sampling at ≥ 1 kHz, whose Nyquist
- frequency is 500 Hz, the filter corner sits *above* Nyquist and does not
- anti-alias. Either raise the sample rate (4–8 kHz) and keep a corner near
- 300 Hz, or lower the corner to ~200 Hz for a 1 kHz rate. Pick the sample rate
- first, then the filter. SYS-HW-17, SWR-CUR-03.
-
-- [ ] **A5. Rescale the CT front end for the specified range.** J10 is annotated
- "CT 30A/1V" but SWR-CUR-02 requires 0–60 A. At 60 A a 30 A/1 V CT delivers
- 2 Vrms = ±2.83 Vpk about the 1.65 V bias, which D9 clamps ,
- the reading saturates across the whole upper half of the required range. Even
- at 30 A the swing is 0.24 V to 3.06 V, in the region where the ESP32-S3 ADC is
- least linear. Specify a CT ratio (or add an attenuator) that puts full-scale
- current at roughly 0.5 Vrms, and record the resulting LSB against
- SWR-CUR-02's 0.1 A resolution.
-
-- [ ] **A6. Define `CURR_SENSE` when the CT is absent.** With J10 open there is
- no DC path to the node; it is held only by C25 and diode leakage, so it
- drifts. SWR-CUR-11 requires distinguishing "CT disconnected" from "genuinely
- zero current", and SWR-CUR-12 requires refusing to start a run when monitoring
- is unavailable. Add a defined bias (e.g. a high-value resistor to a level the
- CT winding cannot produce) so an open input is electrically recognisable, and
- write down the detection rule the firmware will use.
-
-- [ ] **A7. Work out the charge-pump release threshold properly.** This is the
- circuit the whole SYS-SAF-02 / SYS-HW-07 safety property rests on, and architecture
- §16 already flags it as the design's most fragile idea.
- `HEAT_EN` → R19 → C23 (10 µF) → D5 (BAT54S) → `HEAT_EN_DC` → C24 (3.3 µF) ∥
- R20 (47 k) → Q3 gate. Two things need numbers rather than intent:
- - Pumped level is roughly 3.3 V − 2·Vf ≈ 2.7 V, which is below the
- 4.5 V at which the AO3400A's RDS(on) is characterised.
- - Decay is τ = 47 k × 3.3 µF ≈ 155 ms through a MOSFET threshold, so release
- is a slow slide through the linear region, not an edge. Establish that the
- contactor's drop-out voltage is reached inside SWR-NFR-04's 1 s, that Q3 does not
- dissipate meaningfully on the way through, and that the contactor cannot sit
- partially closed.
-
- If the margin is thin, a comparator or a retriggerable monostable gives a crisp
- threshold for a few cents. Verify on the HIL jig by halting the safety task
- (SWR-TST-17, SYS-SAF-02, SWR-NFR-04).
-
-- [ ] **A8. Budget the +5 V rail and protect the MCU from coil inrush.** J8 feeds
- the contactor coil from the same +5 V node as the LDO, behind the 1.5 A PTC
- (F1). Add up ESP32-S3 WiFi peaks, the LDO's input current, the coil's holding
- and inrush current, both SSR inputs and the buzzer, then confirm F1 and the
- external supply (SYS-HW-14 requires MCU + display + coil simultaneously). C1 is
- only 100 µF; coil energisation will sag the rail that the MCU brownout detector
- watches. Consider feeding J8 from `V_FUSED` ahead of D1 so coil transients
- cannot pull the MCU rail down. SYS-HW-14, SWR-NFR-15, SWR-SAF-15.
-
+The board-level specification is now
+[`docs/03_hardware_req.sdoc`](docs/03_hardware_req.sdoc) (`HWR-01` to
+`HWR-23`), which carries the numbers and the reasoning. These are the open
+changes against it.
+
+- [ ] **A1. Pull up the MAX31856 `~DRDY` outputs.** Open-drain, read as
+ asserted when floating. `HWR-06`.
+- [ ] **A3. Bypass `VBIAS`.** R24/R25 present ~5 kΩ with no local
+ capacitance. `HWR-08`.
+- [ ] **A4. Fix the CT anti-alias filter.** Corner ≈ 723 Hz sits above a
+ 1 kHz rate's Nyquist. Pick the sample rate first. `HWR-10`.
+- [ ] **A5. Rescale the CT front end.** A 30 A/1 V CT clips across the upper
+ half of `SWR-CUR-02`'s 0–60 A. `HWR-09`.
+- [ ] **A6. Define `CURR_SENSE` with no CT fitted.** Open input drifts, so
+ "disconnected" and "zero current" are indistinguishable. `HWR-11`.
+- [ ] **A7. Work out the charge-pump release threshold. P1.** The circuit the
+ whole `SYS-SAF-02` property rests on: pumped level ≈ 2.7 V against an
+ AO3400A characterised at 4.5 V, decay τ ≈ 155 ms. `HWR-01`.
+- [ ] **A8. Budget the +5 V rail against coil inrush.** `HWR-14`.
- [ ] **A9. Confirm the 5 V contactor coil is a real part choice.** Mains
- contactors are commonly 24 V AC/DC or 230 V AC coils; pinning J8 to 5 V narrows
- the field sharply. Either name a specific 5 V-coil contactor in the BOM, or
- change the interface to drive an intermediate relay / a higher coil voltage.
- SYS-HW-07, SYS-SAF-03.
-
-### A.2 Required before fabrication (P2)
-
-- [ ] **A10. Add VBUS decoupling.** The design review flags `VBUS` as having no
- decoupling at all. Add 1 µF (USB spec caps bulk VBUS capacitance at 10 µF)
- plus 100 nF near J2.
-
-- [ ] **A11. Record an ERC exclusion for the shared SPI `SDO`.** ERC reports one
- error: U3.11 and U4.11 (`SDO`, both Output) are connected. This is correct for
- a shared SPI bus, the MAX31856 tri-states `SDO` when `~CS` is high, but the
- project currently carries zero ERC exclusions, so the schematic cannot be
- gated on a clean ERC. Add the exclusion with a comment, and make a clean ERC a
- CI gate.
-
-- [ ] **A12. ESD protection on the USB data pair.** `USB_DP`/`USB_DM` go straight
- from J2 to the ESP32-S3 native USB pins with no clamping. Add a low-capacitance
- array (USBLC6-2SC6 class) at the connector.
-
-- [ ] **A13. Clamp the thermocouple inputs.** SYS-HW-15 requires the TC inputs to be
- *filtered and protected*. The filtering is there (R4/R5 100 R, C8 10 nF
- differential, C9/C10 100 pF common-mode; same for TC2) but there is no
- clamping. A thermocouple is a multi-metre unshielded pair routed beside a
- switching multi-kilowatt load, add TVS or clamp diodes to the rails on all
- four TC terminals.
-
-- [ ] **A14. Pull up `TC1_CS` and `TC2_CS`.** Both float during reset and boot
- while the MAX31856s are already powered, so a spurious chip select can
- misconfigure a front end. 10 k to +3V3 each.
-
-- [ ] **A15. Pull up `MCU_IO0`.** SW2 pulls IO0 to GND with no external pull-up;
- the design relies on the module's internal pull-up alone, and the BOOT button's
- wiring is an unterminated stub. Add 10 k to +3V3 (most ESP32-S3 reference
- designs also add 100 nF).
-
-- [ ] **A16. Check the AMS1117's dropout and thermal margin.** Worst case the LDO
- sees USB VBUS at 4.75 V minus D2's forward drop ≈ 4.3 V, against a 1.1–1.3 V
- dropout at ESP32-S3 WiFi peaks, close to falling out of regulation.
- Dissipation is ~0.5–0.7 W in SOT-223, roughly +40 °C over ambient; with SWR-SAF-11
- permitting a 70 °C enclosure, the junction has little headroom and the LDO sits
- on the same board as the cold-junction reference. Either move to a low-dropout
- part with better thermals or a small buck, or document the measured rise and
- the ambient limit it implies.
-
-- [ ] **A17. Add input transient protection on `V_IN`.** F1 and the series D1
- cover overcurrent and reverse polarity, but nothing clamps a surge. The board
- shares an enclosure with a switching contactor coil. Add a TVS / MOV across
- `V_IN`.
-
-- [ ] **A18. Resolve the "(opt)" annotations.** J10 is labelled "CT 30A/1V (opt)",
- but SYS-HW-11 makes the current transformer mandatory and SWR-CUR-12 makes a run
- refuse to start without it. Re-label, and keep J7 "SSR2 CTRL (opt)" which
- genuinely is optional (SYS-HW-12).
-
-- [ ] **A19. Put SYS-HW-16 on the schematic as a note.** The CT must be a
- voltage-output type with an integral burden resistor; a current-output CT whose
- burden can be disconnected develops dangerous voltages on an open secondary.
- That is a hard BOM constraint and a safety one, it belongs on the sheet next
- to J10, not only in the requirements. Same for SYS-HW-18: state the required CT
- insulation rating, since the CT is the only galvanic isolation in the design.
-
-- [ ] **A20. Review the encoder input network.** R10–R12 (10 k) with C20–C22
- (100 nF) gives a ~1 ms edge into non-Schmitt ESP32-S3 GPIOs feeding the pulse
- counter (SYS-HW-05). Slow edges dwelling near VIH can double-count.
- Either lower to ~4.7 k/10 nF and lean on the PCNT glitch filter, or verify the
- current values on hardware before committing.
-
-- [ ] **A21. Create the single pin-map artefact SYS-HW-10 requires.** Pin assignments
- exist only as schematic net names; there is no `hardware/pinmap.*` and no
- firmware header. One file per board variant, referenced by both.
-
-### A.3 Cleanup and layout follow-up (P3)
-
-- [ ] **A23a. Rename the KiCad project to `safekiln`.** The project was renamed
- to Safe Kiln Controller everywhere except `hardware/`, which was left alone
- for two reasons: the files were being edited at the time, and the name is not
- only a filename there. `kilncontrol` appears in several hundred internal
- references, as `(project "kilncontrol")` in every symbol instance of the
- schematic and as `(sheetfile "safekiln.kicad_sch")` in every footprint of
- the PCB. Do it with KiCad's own Save As / rename rather than `git mv` plus a
- substitution, so the cross-references are rewritten by the tool that owns
- them. `board_pins.h` cites the schematic filename and will follow.
-
-- [ ] **A23. PCB review is still outstanding.** `hardware/safekiln.kicad_pcb`
- has placement but no copper: zero tracks, zero vias, sixteen zones untouched.
- Creepage and clearance for the mains section, the coil interrupt chain (`K4`),
- star-grounding of the analogue front end, and the thermal path of the LDO
- (`A16`) all want checking against the layout rather than the schematic, and
- none of that can be judged until the board is routed.
+ contactors with 5 V coils are uncommon; a 12 V or 24 V coil changes the rail.
+- [ ] **A10. Decouple `VBUS`.** `HWR-17`.
+- [ ] **A11. Record the ERC exclusion for the shared SPI `SDO`.** `HWR-22`.
+- [ ] **A12. ESD protection on the USB data pair.** `HWR-17`.
+- [ ] **A13. Clamp the thermocouple inputs.** `HWR-08`.
+- [ ] **A14. Pull up `TC1_CS` and `TC2_CS`.** Both float through reset.
+ `HWR-07`.
+- [ ] **A15. Pull up `MCU_IO0`.** SW2 pulls it low with nothing holding it
+ otherwise. `HWR-18`.
+- [ ] **A16. Check the AMS1117's dropout and thermal margin.** `HWR-15`.
+- [ ] **A17. Transient protection on `V_IN`.** `HWR-16`.
+- [ ] **A18. Resolve the "(opt)" annotations.** `HWR-13`.
+- [ ] **A19. Put the CT specification on the schematic as a note.** `HWR-13`.
+- [ ] **A20. Review the encoder input network.** `HWR-18`.
+- [ ] **A21. One generated pin map.** Assignments live in three places that
+ can disagree. `HWR-21`.
+- [ ] **A23a. Finish the KiCad rename to `safekiln`.** Files are renamed;
+ check no stale references remain.
+- [ ] **A23. Route the board, and review the mains-adjacent nets. P1.** No
+ tracks and no vias exist yet. `HWR-23`.
---
## C. Build, test and CI infrastructure
-- [ ] **C5. `tools/layercheck`.** CI-blocking check that `kiln_core` references no
- IDF or RTOS header and that the component graph is acyclic and layered
- (SWR-TST-01, SWR-TST-07, SWA-14).
-
-- [ ] **C6. Requirement-to-test traceability.** *Superseded in approach:* see
- section 7 of [`docs/test-concept.md`](docs/test-concept.md). The recommendation
- is not to build `tools/trace` but to use StrictDoc source traceability, which
- now owns the requirements, and have the existing `requirements` CI job publish
- the matrix. Today 28 of 268 requirements appear in a test name and 147 appear
- in no test file at all. Original item follows.
-
- **`tools/trace`.** Requirement-ID traceability from `docs/` to test
- names; fails on an untraced mandatory requirement, on a test naming a
- nonexistent ID, and on any `SR-*` without an automated test
- (SWR-TST-22, SWR-TST-23, SWR-TST-26).
-
-- [ ] **C7. `tools/logdump`.** Decode a log partition dump to CSV, also the
- cross-check for the `logrec` codec.
-
-- [ ] **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
- `kiln_hal_esp32s3/src`, so it does not either. Subtracting the host database
- from the target one gives 14 target-only translation units, and two of them
- are outside every gate in the project.
-
- Widening the script is three lines -- the set should be *computed* as "in the
- target build, not in the host build", which also cannot drift when the next
- target-only file appears -- but it is **102 findings**: 20 in `httpd.cpp`
- and 82 in `main.cpp`. 54 of those are inside `ESP_LOGx` and
- `ESP_ERROR_CHECK` and want the same component-scoped exemption
- `kiln_hal_esp32s3` already carries, which for `main.cpp` is a clean
- `firmware/controller/main/.clang-tidy` but for `httpd.cpp` cannot be, because
- `kiln_web/src` also holds the host-analysed `api.cpp` and `json.cpp` and must
- keep those checks. The remaining ~48 are the ordinary `F2`/`F4`/`F5`/`F6`
- passes that never ran on these two files.
-
- Nothing here is hard; it is the size of the diff that makes it its own
- commit rather than a detour inside another one.
-
-- [ ] **C12. Build the HIL fixture.** Designed in section 5 of
- [`docs/test-concept.md`](docs/test-concept.md), which closes `SWR-TST-17` and
- `SWR-TST-28` on paper and nothing in hardware. The shape: the plant model is
- `kiln_sim` running on a host, so an L4 scenario can be the same scenario as
- an L2 test with the same seed, and a disagreement isolates to the adapters
- and the electricals. The I/O board is close to dumb but timestamps in
- hardware, because USB latency is fine for a 10 Hz thermal model and useless
- for measuring a 10 ms SSR window.
-
- Two parts are not obvious and are worked through there. The thermocouple
- simulator has to *subtract* the cold-junction compensation the MAX31856 adds,
- and the loop closes itself because the supervisor already reports `cj_c` ten
- times a second. And the current side needs a fixture-controlled bypass across
- the load, not a signal generator, because `SWR-SAF-27` discriminates a shorted SSR
- from a welded contactor by dropping the contactor and re-measuring, and that
- needs real contacts behaving both ways.
-
- The single most valuable scenario it enables: hold the ESP32 in reset, drive
- the chamber above 1350 degC, confirm the contactor opens. That is the one
- experiment that distinguishes this design from the one it replaced, and it
- exists at no other level.
-
-- [ ] **C13. Make three architecture decisions structural.** Each holds today
- by discipline alone and is cheap to enforce, per section 6 of the test
- concept. `SWA-18`, the 20-byte log record, has no `static_assert` anywhere,
- and it guards a persisted format. `SWA-03`, no globals in the core, is true
- (zero mutable file-scope objects in `kiln_core`) with
- `cppcoreguidelines-avoid-non-const-global-variables` switched off. `SWA-02`,
- time is injected, is true (zero direct clock reads in the core) and is a
- grep. Three decisions that are currently claims.
-
-- [ ] **C10. Coverage gate.** 90 % lines on control, safety, setpoint, program and
- autotune is enforced; 100 % of safety decision branches (`SWR-TST-19`) is not.
- Branch coverage was around 83 % when last measured.
+- [ ] **C6. Publish the requirement-to-test matrix.** Use StrictDoc's own
+ traceability rather than building `tools/trace`; have the `requirements` job
+ publish it. Today 147 of 268 requirements appear in no test file.
+- [ ] **C10. Gate branch coverage on the safety decisions.** Lines are gated
+ at 90 %; `SWR-TST-19`'s 100 % of safety branches is not. Last measured 81 %.
+- [ ] **C11. `main.cpp` and `httpd.cpp` are analysed by nothing.** Both
+ scripts miss them; the set should be computed as "in the target build, not in
+ the host build" so it cannot drift. 102 findings, 54 of them `ESP_LOGx` and
+ `ESP_ERROR_CHECK` wanting the component-scoped exemption `kiln_hal_esp32s3`
+ already has.
+- [ ] **C12. Build the HIL fixture. P1.** Designed in section 5 of
+ [`docs/test-concept.md`](docs/test-concept.md). The thermocouple simulator
+ has to subtract the cold-junction compensation the MAX31856 adds, and the
+ current side needs a fixture-controlled bypass across the load rather than a
+ signal generator, because `SWR-SAF-27` discriminates a shorted SSR from a
+ welded contactor by dropping the contactor and re-measuring. The scenario it
+ exists for: hold the ESP32 in reset, drive the chamber above 1350 °C, confirm
+ the contactor opens.
---
## D. Documentation and open questions
-- [ ] **D5. Write the documentation SWR-NFR-26 lists:** assembly and wiring, mains
+- [ ] **D5. Write the manuals `SWR-NFR-26` lists:** assembly and wiring, mains
safety, commissioning, autotuning, program authoring, the REST API, the log
- record format, and current-transformer fitting and calibration. The CT
- commissioning procedure is called out in architecture §16 as the mitigation for
- a CT fitted to the wrong conductor.
-
-- [ ] **D6. Remaining open questions to close:** OQ-01 (cone-based targets),
- OQ-03 (whole-life run-summary retention), OQ-05 (3-zone variant, affects
- whether the control path is written for one zone or N), OQ-07 (element
- temperature coefficient measured or entered).
+ format, CT fitting and calibration.
+- [ ] **D6. Close `OQ-01`** (cone-based targets), **`OQ-03`** (whole-life run
+ retention), **`OQ-05`** (3-zone variant, which decides whether the control
+ path is written for one zone or N), **`OQ-07`** (element temperature
+ coefficient measured or entered).
---
## F. Static analysis
-Both of these are decisions rather than tasks: the work is understood and the
-question is whether it is worth its cost.
-
-- [ ] **F3. `cppcoreguidelines-use-enum-class`: 31 enums, 223 enumerators,
- 2 530 references across 85 files.** Still deferred, and now with the blocking
- question answered rather than open.
-
- **The C-callability half is settled: the port layer is not C-callable.**
- There is no `extern "C"` anywhere in the firmware except `app_main`, which
- ESP-IDF requires. So `SWA-01`'s boundary is a C++ boundary already, and that
- is no longer a reason not to do this.
+Both are decisions rather than tasks: the work is understood, the question is
+whether it is worth its cost.
- What remains is not a technical blocker but a cost. The enumerator names are
- the project's vocabulary: `KILN_FAULT_DOOR_OPEN` is greppable from `SWR-SAF-31`
- in the requirements, and `tools/trace` parses test names on the same
- convention (`SWR-TST-22`, `SWR-TST-23`). Scoping renames all 223 of them. The
- transform is compiler-verified -- every unconverted site is a hard error --
- but the naming choice (`kiln_fault_t::DOOR_OPEN`, idiomatic but breaks the
- greps, versus `kiln_fault_t::KILN_FAULT_DOOR_OPEN`, redundant but traceable)
- is a decision about the project's vocabulary rather than about the code.
-
- Two enums would also get worse: `kiln_warn_bit_t` and `kiln_inject_t` are bit
- *positions*, and scoping them puts a `static_cast` at every mask site --
- precisely the cast noise the signed-bitwise and C-cast passes removed.
-
-- [ ] **F9. `kiln_run_record_t` carries 9 padding bytes where 1 is optimal**
- (`clang-analyzer-optin.performance.Padding`, disabled). Reordering would
- invalidate every run record already on a device, so it can only change
- alongside a record-format version bump, if at all.
+- [ ] **F3. `cppcoreguidelines-use-enum-class`.** 31 enums, 223 enumerators,
+ 2 530 references. The C-callability objection is settled (nothing is
+ `extern "C"` but `app_main`), so what remains is the project's vocabulary:
+ `KILN_FAULT_DOOR_OPEN` is greppable from `SWR-SAF-31`, and scoping renames
+ all 223. Two bit-position enums would get worse, gaining a `static_cast` at
+ every mask site.
+- [ ] **F9. `kiln_run_record_t` wastes 9 padding bytes.** Reordering
+ invalidates every run record on every device, so it can only move with a
+ record-format version bump, if at all.
---
## G. Door interlock, SWR-SAF-31
-- [ ] **G2. HIL: confirm the series contact actually breaks the coil.** As with
- the charge pump (M4), the claim that matters is a hardware one and cannot be
- verified in simulation. Open the door on the bench jig with the safety task
- halted, and observe the contactor.
-
-- [ ] **G3. Decide whether the interlock should be mandatory.** `SYS-HW-21` is a
- *should* because many existing kilns have no door furniture to take a switch,
- and making it a *shall* would make the controller unfittable to them. The
- consequence is RR-10: a kiln without one has nothing at all against HZ-13.
- Warning 113 makes the gap visible rather than silent, which is the compromise
- , revisit if the first real installations suggest otherwise.
-
-- [ ] **G4. Expose the door state in the API and on the display.** The
- supervisor knows; `/api/status` and the OLED do not yet say. Wanted for
- SWR-WEB-04 and the default screen, and it is the cheap half of making
- warning 113 actually visible.
+- [ ] **G2. HIL: confirm the series contact breaks the coil. P1.** Open the
+ door with the safety task halted and watch the contactor. Cannot be settled
+ in simulation.
+- [ ] **G3. Decide whether the interlock is mandatory.** `SYS-HW-21` is a
+ *should* because many kilns have no door furniture to take a switch; a
+ *shall* makes the controller unfittable to them. The consequence is `RR-10`,
+ and warning 113 makes the gap visible rather than silent. Revisit after the
+ first real installations.
---
## H. Field update and local control
-- [ ] **H2. The update path is specified and entirely unbuilt.** `OQ-08` is
- resolved as of 2026-10-09: the device **pulls** a signed static manifest from
- `update.bitcrushtesting.com` once a day, announces an available release on the
- local display, and installs it only on a deliberate confirmation there. The
- web interface stays read-only and gains no exception, which is why pull was
- chosen over staging an image over the LAN: `SEC-00` keeps its absolute form.
- `SWR-UPD-09` to `SWR-UPD-16` and `SWR-HMI-16` are the requirements,
- [`docs/security.md` §6.2](docs/security.md) the reasoning, and
- [`docs/architecture.md` §13.5](docs/architecture.md) the sequence.
-
- **This still blocks release** (`SRR-11`), because none of it exists. What has
- to be written, roughly in dependency order:
-
- 1. **Generate the manifest signing key**, on a machine it can then leave.
- `tools/update-manifest.py keygen` exists and the format it signs is in
- [`docs/architecture.md` §13.5](docs/architecture.md); what does not exist
- is the key, so `firmware/controller/main/update_pubkey.h` is not committed
- and nothing can verify anything yet. One command, and then the key is an
- obligation for the whole support period (`SRR-12`).
- 2. **The verifier, host-tested first.** Signature check, semver comparison
- against `kiln_fw_info_t::version`, target check, and the refusal of
- anything not strictly newer (`SWR-UPD-13`). All of it is pure logic and
- belongs in a component the host suite can beat on with a tampered manifest,
- a wrong key, a truncated image and a replayed old release.
- 3. **The adapter**, `esp_https_ota` behind `check` and `install` in
- `port_update.h` (both already declared there), streaming into the inactive
- slot. `otadata`, `ota_0` and `ota_1` already exist in `partitions.csv`, so
- the layout needs nothing.
- 4. **The screen** (`SWR-HMI-16`), which is the only place an install can be
- authorised and therefore part of the security design rather than of the
- interface. Needs the German strings too (`J`).
- 5. **`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE` is already on**; what is missing
- is the late `confirm_running()` of `SWR-UPD-15`, called when the supervisor
- link, both couples, the config, the programs and the display have all been
- seen working. Calling it in start-up code is the defect this requirement
- exists to prevent.
- 6. **The `update.check_enabled` and `update.url` configuration items**, with
- the opt-out reachable from the display, since the web interface cannot
- write configuration.
- 7. **`tools/update.py`** for `SWR-UPD-14`'s USB recovery: write to `ota_0`,
- erase `otadata`, and leave `nvs`, `kilnfs`, `kilnlog` and `prod` alone. The
- mistake it exists to prevent is erasing the production data block.
-
- Two things that are project work rather than firmware work, and that no amount
- of firmware discharges: the release channel with an advisory per security
- release and a **declared support period** (`SWR-UPD-16`, at least five years),
- and the CRA Article 14 reporting route, which is live from **11 September
- 2026** while the main obligations start on 11 December 2027.
-
- It also depends on `P`: there is still no HTTP stack on the target, and this
- needs an HTTPS *client* even though it needs no server.
-
-- [ ] **H3. Local control is now the only control, and it has never been
- operated.** The web interface is read-only by design, so starting, pausing,
- aborting and acknowledging are reachable *only* through `kiln_hmi`. That
- component now exists, carries those five actions, passes 21 tests and is
- wired into `main`. What keeps this open is that no one has ever used it: it
- is verified by pixel counts and nothing else (`M3`), its German is not
- length-checked against the screen (`M4`), and the encoder direction is a
- guess (`M5`). Until those close on real hardware, **the only path to
- starting a firing is unexercised.**
-
----
-
-## I. Current measurement
-
-- [ ] **I3. Real power, not apparent.** `SWR-CUR-07` says apparent power and
- assumes a resistive load (`SYS-ASM-09`), which for a kiln element is very nearly
- true. Measuring real power would need a voltage channel, which the design
- does not have and probably should not grow. Worth closing explicitly rather
- than leaving as an implied limitation.
+- [ ] **H2. The update path is specified and unbuilt. P2, blocks release
+ (`SRR-11`).** `SWR-UPD-09` to `SWR-UPD-16`, with
+ [`docs/architecture.md` §13.5](docs/architecture.md) the sequence. In
+ dependency order:
+
+ 1. **Generate the manifest signing key** on a machine it can then leave.
+ `tools/update-manifest.py keygen` exists; the key does not, so
+ `update_pubkey.h` is uncommitted and nothing verifies anything.
+ 2. **The verifier, host-tested first:** signature, semver against
+ `kiln_fw_info_t::version`, target, and refusing anything not strictly
+ newer. Pure logic, and the suite should beat on a tampered manifest, a
+ wrong key, a truncated image and a replayed release.
+ 3. **The adapter:** `esp_https_ota` behind `check` and `install`, streaming
+ into the inactive slot. The partitions already exist.
+ 4. **The screen** (`SWR-HMI-16`), the only place an install is authorised,
+ so part of the security design rather than of the interface.
+ 5. **The late `confirm_running()`** of `SWR-UPD-15`, called once the
+ supervisor link, both couples, the config and the display have proved
+ themselves. Calling it at start-up is the defect the requirement exists
+ to prevent.
+ 6. **`update.check_enabled` and `update.url`,** with the opt-out reachable
+ from the display.
+ 7. **`tools/update.py`** for `SWR-UPD-14`'s USB recovery: write `ota_0`,
+ erase `otadata`, leave `nvs`, `kilnfs`, `kilnlog` and `prod` alone.
+
+ Not firmware, and not discharged by any: the release channel with an
+ advisory per security release, the declared support period (`N3`), and the
+ CRA Article 14 reporting route (`N2`), live since **11 September 2026**.
+
+ The security events of `SWR-LOG-16` that carry a version and a refusal
+ reason belong here: neither fits a 20-byte record.
+
+- [ ] **H3. Local control is the only control, and nobody has used it.**
+ Starting, pausing, aborting and acknowledging are reachable only through
+ `kiln_hmi`, which is verified by pixel counts (`M3`) with an encoder
+ direction that is a guess (`M5`).
---
## J. German translation, SWR-NFR-23
-- [x] **J1. The font carries ASCII and nothing else. Answered 2026-10-09.**
- Not a hardware question after all: `draw.cpp` defines the 95 printable ASCII
- glyphs, so there are no umlauts and no sharp s to draw with, at any encoding.
- Every German string in the firmware is therefore transliterated (AE OE UE
- SS), and a test over `kiln_hmi/strings` rejects any byte outside 0x20 to 0x7E
- so that one umlaut typed into the table cannot reach a workshop as a blank.
- The bound that matters is 21 characters, the panel's width at scale 1, and it
- is asserted on every string rather than on a sample.
-
-- [x] **J2. The display renders the long causes. Done.** `kiln_hmi` exists and
- the fault screen word-wraps the cause from `kiln_core/faults` in the
- configured language, at 21 columns, wrapping on spaces rather than cutting
- words -- these sentences are the operator's instructions, and a word split
- across two lines in a hurry reads as a different word. German's extra 15 per
- cent is absorbed by the wrap rather than by a shorter translation.
-
-- [ ] **J3. Config item names and units are still English.** `/api/config`
- returns keys like `safety.max_temp_c` with English descriptions. The keys
- are an API contract and should stay, but the human-readable descriptions
- beside them in the settings screen are untranslated.
-
-- [ ] **J4. No German review by a native speaker.** The translations are
- careful but unreviewed. `VERSCHWEISST` for a welded contactor and
- `KEINE WAERME` for no heat are the two worth checking first, being the most
- safety-critical messages a German-speaking operator would act on.
+- [ ] **J3. Config descriptions are English.** `/api/config` keys are an API
+ contract and stay; the human-readable descriptions beside them are not
+ translated.
+- [ ] **J4. No native-speaker review.** `VERSCHWEISST` for a welded contactor
+ and `KEINE WAERME` for no heat are the two to check first, being the most
+ safety-critical messages a German operator would act on.
---
## K. Hardware interlock chain
-**Superseded 2026-10-10 by the independent supervisor (`R7`).** The discrete
-chain in this section existed to put the thermocouple front ends' `FAULT`
-outputs in series with the contactor coil (`SYS-HW-24`). The supervisor reads
-the chamber front end itself, decides on a persistent fault, and holds its own
-series element, which is the same property delivered without the chain's
-caveats: a front end that is dead, absent or unconfigured does not answer the
-supervisor either, and not answering is a trip rather than a documented limit.
-
-`SYS-HW-24` is rewritten rather than deleted, because what it reached for is now
-delivered differently, and it keeps the one part worth keeping: each `FAULT` net
-retains its own test point.
-
-- [x] **K1. Answered rather than mitigated.** The chain was not
- firmware-independent: open-drain outputs leave the path closed when a front
- end is unpowered or absent, and a MAX31856 reports an open circuit only once
- its fault mask has been configured, which is firmware work. That is why this
- was recorded as a limitation rather than a protection. The supervisor has
- neither caveat.
-
-- [x] **K2. Dissolved.** The question was whether an enclosure thermocouple
- fault should stop a firing, given that `SWR-SAF-11` is a backstop and a
- nuisance trip is how protections get disabled (`HZ-10`). The enclosure
- channel is not in the supervisor's remit at all, by decision: it stays an
- ESP32 rule and can never cause the supervisor's trip. There is no branch to
- depopulate because there is no chain.
+Superseded by the supervisor: `SYS-HW-24` is rewritten rather than deleted,
+and the chain is not built. What is left is unrelated to it.
-- [x] **K5. Reduced to three tests, and they are still hardware tests.** Four
- elements in series became three: the lid contact, the controller's charge
- pump, and the supervisor's permit. Each must drop the contactor on its own,
- which is `G2`'s class of claim and cannot be settled in simulation. The
- `FAULT`-pin tests are gone with the chain.
-
-- [ ] **K3. `D7` is an unwired LED.** Pre-existing, not from this change: the
- coil indicator's cathode is on `COIL_DRV` and its anode goes nowhere. It
- needs a series resistor to the coil supply. Note that taking it from
- `LID_SWITCH` rather than `+5V` makes it indicate "coil actually energised"
- rather than "the MCU asked".
-
-- [ ] **K4. The board carries the chain that is no longer wanted, and is not
- routed.** `J9`, `Q5`, `Q6` and `R28`–`R31` are placed on the PCB for an
- interlock the design has dropped, and the supervisor's own parts are not on
- it at all: that is `R6`, the schematic. **There are no tracks and no vias on
- the board**, and the coil interrupt is a mains-adjacent net, so routing it is
- not a formality: see `A23`. Depopulating the chain's parts is the smaller
- half of this; the supervisor and its series element are the larger.
+- [ ] **K3. `D7` is an unwired LED.** Pre-existing: the coil indicator's
+ cathode is on `COIL_DRV` and its anode goes nowhere. Taking it from
+ `LID_SWITCH` rather than `+5V` makes it indicate "coil energised" rather
+ than "the MCU asked".
+- [ ] **K4. Depopulate the chain's parts from the board.** `J9`, `Q5`, `Q6`
+ and `R28`–`R31` are placed for an interlock the design dropped. The larger
+ half of this is `R6`, the supervisor the board does not carry.
---
## L. Target adapters
-- [ ] **L2. None of this has touched hardware.** Every adapter compiles and
- passes analysis, and that is the whole of the evidence. QEMU does not
- emulate SPI, I2C, PCNT or the ADC in any way that would exercise them, so
- the MAX31856 register decode, the SSD1306 init sequence, the quadrature
- decoding and the ADC scaling are all unverified against a real part. This is
- the same class of claim as `G2` and `K5`, and it is the largest untested
- surface in the project.
-
-- [ ] **L6. The hardware build is not in CI.** CI builds the simulated
- configuration only. The hardware one is a second `idf.py` invocation with a
- different `SDKCONFIG_DEFAULTS`, and it is the configuration that matters for
- a release.
+- [ ] **L2. None of this has touched hardware. P1.** Every adapter compiles
+ and passes analysis, and that is the whole of the evidence. QEMU emulates
+ none of SPI, I2C, PCNT or the ADC, so the MAX31856 register decode, the
+ SSD1306 init sequence, the quadrature decoding and the ADC scaling are all
+ unverified against a real part. The largest untested surface in the project.
---
## M. kiln_hmi, the local interface
-- [ ] **M3. Nobody has looked at it.** Every screen is verified by pixel counts
- and state assertions on the host. No one has seen a glyph on a real SSD1306,
- and the init sequence, the page addressing and the 5x7 font are all
- unverified against glass. Layout judgements -- whether 15x21 really is
- legible at two metres, whether the fault cause wraps readably -- cannot be
- made from a test. Same class as `L2`.
-
-- [ ] **M4. German text is not length-checked against the screen.** The fault
- *labels* are held to 16 characters by a test (`J1`), but the long *causes*
- that the fault screen wraps are not, and German runs roughly 15 per cent
- longer than English. A cause that overflows the panel loses its last line,
- which on a fault screen is where the instruction tends to be.
-
-- [ ] **M5. The encoder direction is a guess.** The quadrature channel actions
- in `hal_input.cpp` assume one wiring of A and B. If the knob turns the menu
- the wrong way on real hardware, swap the two `pcnt_channel_set_edge_action`
- pairs; it is a one-line fix and not a design error, but it will be wrong half
- the time until somebody turns a real knob.
+- [ ] **M3. Nobody has seen a glyph on real glass. P1.** The init sequence,
+ the page addressing and the 5x7 font are unverified against hardware, and
+ layout judgements, whether 15x21 is legible at two metres, cannot be made
+ from a test.
+- [ ] **M5. The encoder direction is a guess.** If the knob turns the menu the
+ wrong way, swap the two `pcnt_channel_set_edge_action` pairs. A one-line fix
+ that will be wrong half the time until somebody turns a real knob.
---
## N. Security obligations and the Cyber Resilience Act
-The inventory is [`docs/security.md` §13](docs/security.md), split into the
-firmware half and the half that is not firmware. These are the items it leaves
-open, and the order below is the order of [§13.3](docs/security.md): the live
-weakness first, the deadline second, the sentence third, the engineering last.
-
-- [ ] **N1. The access point will start without a passphrase.** `OQ-S4`, and of
- everything the CRA asks of this project it is the only item that is a live
- weakness in code that exists today rather than something unbuilt or on paper.
- A product must ship in a **secure default configuration**; an open AP, offered
- as flexibility, is not one. The fix is a bound in the configuration table and
- a refusal in the WiFi adapter: either enforce a minimum length on
- `net.ap_pass` or refuse to start the AP without one, and say which in
- `TH-11`'s goal, since that threat still has none. Overlaps `O`.
-
-- [ ] **N2. The Article 14 reporting procedure, and the mailbox behind
- `SECURITY.md`.** `SECURITY.md` exists and names
- `security@bitcrushtesting.com` as the single point of contact required by
- Article 13(17); **that mailbox has to exist and be read by a person**. Behind
- it: a written procedure for an actively exploited vulnerability or a severe
- incident, being an early warning to ENISA and the relevant CSIRT within 24
- hours, a notification within 72, a final report within 14 days, and users
- informed without undue delay. **This applies from 11 September 2026**, more
- than a year before the Act's main obligations, and it does not wait for the
- firmware. Carried as `SRR-14`.
-
-- [ ] **N3. Choose the support period, and declare it.** At least five years
- (`SWR-UPD-16`), and the service life of a kiln controller is well beyond that
- floor. The number gates the information given to users, how long the SBOM has
- to be kept, and how long `update.bitcrushtesting.com` and its signing key have
- to keep existing (`SRR-12`). It is a sentence to write and an obligation to
- mean, and it is half of `OQ-R5`; the other half is the conformity assessment
- route, where a kiln controller does not appear in the Act's important or
- critical lists, so self-assessment should be available. Record the conclusion
- rather than assume it.
-
-- [ ] **N4. Three requirements with no implementation.** Each was a gap nobody
- had named until 2026-10-09:
-
- - `SWR-LOG-16`, **security event logging with an owner opt-out** (`SEC-13`).
- The event log records process events; nothing records a refused manifest, a
- rollback, a configuration change or a client joining the provisioning AP.
- The opt-out is required as explicitly as the log, and the log must not
- become a second copy of `SRR-05` by capturing credentials.
- - `SWR-CFG-09`, **a factory reset that erases rather than unlinks**
- (`SEC-14`). Reachable from the display, leaving the production data block
- alone, with a test that reads the raw partition back and looks for the
- secret. This is what turns `SRR-05`'s disposal obligation from a line in a
- manual into a function of the product, and it is `ADV-4`'s only answer.
- - **The SBOM's two loose ends.** `tools/sbom.py` satisfies `SWR-NFR-28` and
- runs in CI and in the release workflow, which leaves **retention** of the
- published documents for the support period, needing `N3` answered first,
- and turning on `--require-licences` once four ESP-IDF v6.0.1 components
- resolve: `cmock`, `esp_netif_stack`, `http_parser` and `protobuf-c` carry
- no SPDX tag in their sources, three ship a licence file the SBOM points at
- and `esp_netif_stack` ships neither, which is worth raising upstream. The
- flag stays off by default because it would gate a release on a vendor
- tree's tagging habits.
-
- Also unowned, and not requirements because they are process: somebody has to
- **read ESP-IDF and mbedTLS advisories**, and the Annex II information for
- users (contact, point of contact, intended use, limitations, how updates
- arrive, end of support, secure disposal) has to be written with the
- instructions `SYS-SAF-24` already shapes.
-
-- [ ] **N5. Two decisions that are now requirements rather than preferences.**
- `OQ-S3`, flash encryption, stops being a trade the project may decline on the
- SoldUnits route: the Act requires stored data to be protected by
+Inventory in [`docs/security.md` §13](docs/security.md).
+
+- [ ] **N2. The Article 14 procedure, and the mailbox. P2.** `SECURITY.md`
+ names `security@bitcrushtesting.com` as the Article 13(17) contact; **that
+ mailbox has to exist and be read by a person.** Behind it: a written
+ procedure for an actively exploited vulnerability: ENISA and the relevant
+ CSIRT within 24 hours, notification within 72, final report within 14 days,
+ users told without undue delay. Live from **11 September 2026**, and it does
+ not wait for firmware. `SRR-14`.
+- [ ] **N3. Choose the support period and declare it. P2.** At least five
+ years (`SWR-UPD-16`); the service life of a kiln controller is well beyond
+ that. The number gates what users are told, how long the SBOM is kept, and
+ how long the update host and its signing key must exist (`SRR-12`). Half of
+ `OQ-R5`; the other half is the conformity route, where a kiln controller
+ appears in neither Annex list, so self-assessment should be available, but
+ record the conclusion rather than assume it.
+- [ ] **N4. Two loose ends on the SBOM.** Retention for the support period
+ needs `N3` answered. `--require-licences` can be turned on once four
+ ESP-IDF v6.0.1 components resolve: `cmock`, `esp_netif_stack`,
+ `http_parser` and `protobuf-c` carry no SPDX tag, three ship a licence file
+ and `esp_netif_stack` ships neither, which is worth raising upstream. The
+ flag stays off by default rather than gating a release on a vendor tree's
+ tagging habits.
+
+ Also unowned, and process rather than requirements: somebody has to read
+ ESP-IDF and mbedTLS advisories, and the Annex II information for users has
+ to be written.
+- [ ] **N5. Two decisions that are now requirements. P2.** `OQ-S3`, flash
+ encryption: the Act requires stored data to be protected by
state-of-the-art means and `net.wifi_pass` is plaintext in NVS. And secure
boot, which exists as an opt-in provisioning step, should be **on by default
- for a sold unit**, which is a production-line decision nobody has recorded.
- Both may legitimately differ between the two routes to market, as `OQ-S2`
- already let secure boot be a per-unit decision; what cannot happen is either
- staying open while units ship.
+ for a sold unit**, a production-line decision nobody has recorded. Both may
+ legitimately differ between the two routes to market; what cannot happen is
+ either staying open while units ship.
- Framing worth keeping: `security.md` §13.1 marks six of the Annex I Part I
- items as **evidence rather than work**, because the read-only interface, the
- absence of `malloc`, the isolation of control from the network and the test
- apparatus already satisfy them. The job there is citing them in a conformity
- file, not building anything.
+ Framing worth keeping: `security.md` §13.1 marks six Annex I Part I items as
+ **evidence rather than work**, because the read-only interface, the absence
+ of `malloc`, the isolation of control from the network and the test
+ apparatus already satisfy them. The job is citing them in a conformity file.
---
## O. WiFi, FR-NET
-- [x] **O2. The hardware image is 945 kB, 54 % of the slot free.** A record
- rather than a task, re-measured 2026-10-10 after mDNS, the embedded assets,
- the event stream and the German strings all landed: 0xec520 bytes against a
- 2 MB slot. The
- simulated build that CI publishes is **246 kB** and contains no WiFi and no
- HTTP server at all, which is worth keeping straight when reading a size
- report: `CONFIG_KILN_PLANT_SIM` compiles both out.
-
- `SWR-NFR-13` holds with room. The web assets measure **18 kB gzipped** in
- total against architecture 12.4's 44 kB budget (`index.html` 2.2, `app.css`
- 3.6, `app.js` 8.6, `chart.js` 4.2). That budget had never been measured; it
- has now, and it passes. Of the growth since 888 kB, mDNS is 32 kB and the
- assets 18 kB; the rest is the event stream, the string table and the
- supervisor link.
-
-- [ ] **O3. Untested against a radio, and until today it did not even
- compile.** The claim here used to be "it compiles and passes analysis", and
- that was false in a way nothing could have caught: every CI job builds
- `sdkconfig.qemu`, which sets `CONFIG_KILN_PLANT_SIM` and compiles the WiFi,
- the HTTP server and the hardware path out of `main.cpp`. The configuration
- that ships was guarded by no build at all.
-
- Built for the first time on 2026-10-09 and it failed on two
- `-Wformat-truncation` errors, one of them older than the WiFi work. Both are
- fixed and CI now builds the hardware configuration on every push, so this
- cannot recur.
-
- What remains is what the heading says. Nobody has watched it associate,
- recover from a dropped connection, or sync time.
+- [ ] **O3. Untested against a radio. P1.** Nobody has watched it associate,
+ recover from a dropped connection, or sync time. The WiFi setup matters more
+ than the rest because it is now the **only** way credentials reach the
+ device: a scan that returns nothing, or a join that silently fails, leaves a
+ kiln with no network and no second route to one.
- 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
- nothing on real hardware, or a join that silently fails, leaves a kiln with
- no network and no second route to one. First hardware session should be, in
- order: scan in a crowded band, join a WPA2 network with a passphrase
- containing symbols, power-cycle and confirm it comes back, then join a
- different network to confirm it replaces rather than accumulates.
-
-- [x] **O4. The provisioning AP is gone; WiFi is set up at the display.**
- Done in firmware and unproven on hardware, which is `O3`'s session rather
- than a task of its own. `SWR-NET-02` and `SWR-NET-05` are
- withdrawn, `SWR-NET-11` and `SWR-NET-12` replace them, `net.ap_ssid` and
- `net.ap_pass` leave the schema, and `TH-11`, `SRR-08` and `OQ-S4` close with
- them: the surface is deleted rather than regulated.
-
- What is written: the scan list and the character-by-character passphrase
- entry in `kiln_hmi`, nine host tests over them including one that walks the
- whole character set, `esp_wifi_scan_*` behind `port_net`, and the join wired
- through the composition root, persisting credentials before attempting the
- radio.
-
- What was left on 2026-10-09 is now done, except the hardware session:
-
- - **The display speaks German.** Every screen title, the whole menu, both
- answers to a confirmation and every footer were English literals at their
- draw sites, so a device set to German announced its faults in German inside
- an English frame. They are now one table in `kiln_hmi/strings`, 46 strings
- in two languages, with the transliteration `kiln_core/faults` has always
- used because the 5x7 font carries the 95 printable ASCII glyphs and nothing
- else. That answers `J1` from the code rather than from a hardware session.
- Six tests: nothing missing, nothing over the panel's 21 columns, no byte
- the font cannot draw, an unknown language falls back to English, and the
- menu and the confirmation actually change when the language does. `J4`
- still stands: no native speaker has read it.
-
- - **The knob resumes where the last character left it**, and always did; what
- was missing was a test saying so, which is now there. A passphrase repeats
- characters and clusters inside a region of the set, so not sending the knob
- back to `a` is usually several turns saved and never more, and it is the
- sort of property one "sensible" reset silently reverses.
-
- - **A failed join says so, with the radio's reason code.** `last_reason` was
- recorded in the adapter and reported to nobody. The network screen now
- distinguishes a join in flight from one the radio refused, and prints the
- code: 15 is a failed four-way handshake, which is a wrong passphrase in
- almost every case, and 201 is "no AP of that name answered". The number is
- there to be read out over a phone to somebody who can look it up, which
- beats a sentence that guesses. With the display the only route in, guessing
- was the entire cost of getting it wrong.
-
- What remains is the hardware session of `O3`, and nothing else.
+ First hardware session, in order: scan in a crowded band, join WPA2 with a
+ passphrase containing symbols, power-cycle and confirm it comes back, then
+ join a different network to confirm it replaces rather than accumulates.
---
## P. HTTP transport and the API
-- [x] **P2. The telemetry stream is implemented. Done 2026-10-10.** The
- payload had been written and tested since `kiln_api_telemetry_event` landed,
- and the browser had been asking for it all along: `app.js` opens an
- `EventSource` on `/api/events` and listens for a `telemetry` event. Nothing
- answered, so the dashboard went stale after four seconds exactly as
- `SWR-WEB-25` says it should.
-
- It does not stream from the handler, and that is the whole design.
- `esp_http_server` runs one task and serialises handlers on it, so a handler
- looping once a second would hold that task for as long as a browser tab
- stayed open and the kiln would appear to hang the moment somebody left a
- dashboard open and then loaded the log. Instead the handler parks the socket
- with `httpd_req_async_handler_begin` and returns; one task at priority 3
- pushes to at most two parked subscribers once a second, with a comment frame
- every fifteen pushes so an idle proxy does not drop a stream that is working.
- A third subscriber gets `503` with `Retry-After`. Six sockets rather than
- four, because `SWR-WEB-21` counts clients and a client with the dashboard
- open needs a second socket for everything else.
-
- The framing is in `kiln_web/api`, host-tested, and shared with the
- development harness: a frame that does not fit is not written at all and a
- payload with a newline is refused, because half a frame does not fail
- visibly, it moves the frame boundary into the middle of a JSON document and
- every frame after it is misread. Five tests.
-
- Verified against a real client, not only by unit test: the harness served
- four frames in three and a half seconds over a socket, each
- `event: telemetry` with a parseable payload and a rising temperature. That
- also found a bug nothing else would have: the harness announced
- `Transfer-Encoding: chunked` on the event stream and then sent unframed SSE,
- a response whose body contradicts its own headers, which a browser may
- reject outright. The log stream is chunked and stays chunked; the event
- stream now declares neither length nor chunking and ends with the
- connection, which is what an event stream is.
-
-- [x] **P3. The program store is gone. Done 2026-10-10.** The decision was
- pre-compiled profiles with nothing editing them, which left `SWR-PRG-04`
- asking for 20 program slots in non-volatile storage to hold a copy of the
- three programs that arrive in the image. It is withdrawn, the store is
- deleted, and the profiles are read from `kiln_profile_example` by the API,
- the HMI and the development harness alike.
-
- It is a deletion that makes the device better rather than smaller:
-
- - **The local start path worked for the first time.** `KILN_HMI_ACT_START`
- returned `KILN_ERR_NOT_FOUND` on hardware with a note that there was no
- program store, and the view builder never filled the program list, so a
- device carrying three firing profiles in its own flash showed "none
- stored" and its menu's first entry did nothing. Both are now wired to the
- compiled-in profiles, which is `SWR-HMI-10` delivered rather than written.
- - **A device whose flash will not mount still has its programs.** The list
- used to answer `503` for programs the firmware was carrying.
- - **A factory reset no longer costs the operator their programs.** The
- partition holds run history, and losing history is what a reset is for.
- - **The first boot has nothing to get wrong.** Seeding was idempotent and
- tested, and still a flash write on every power-on of a new device.
-
- About 200 lines of implementation and nine tests went with it. The three
- properties worth keeping were re-pointed rather than dropped: every
- compiled-in example is a valid program with a distinct name and an index past
- the end is refused, the atomic-replace-on-power-cut property is now driven
- through `run_index`, which is what still writes to the store, and the
- partition-sharing test became the measurement that 20 of 64 regions are used.
- The mount cost of `Q2` halves with it: **488 reads and 31 888 bytes**.
-
- `SWR-PRG-05` stays, and the reason is `safety.max_temp_c`: the programs are
- trusted as data, but an operator can lower the ceiling below a program's
- peak, and validation on start is what refuses that firing rather than running
- it into a limit.
-
-- [ ] **P4. The API has been driven by a client; `esp_http_server` has not.**
- Narrowed on 2026-10-10 rather than closed. The development harness is a real
- socket server running the same handler code, and it has now answered real
- requests over TCP: the program routes, an id past the end as `404`, the
- storage response, a log query with its chunk framing and terminator, and four
- SSE frames in three and a half seconds each carrying a parseable payload.
- That found a bug no unit test would have, the harness announcing
- `Transfer-Encoding: chunked` and then sending unframed SSE.
-
- What remains unverified is specifically `esp_http_server`, which the harness
- does not use and which no host test can reach: URI splitting in its parser,
- `httpd_resp_send_chunk` against a real client, the socket limit with two
- sessions parked on streams, the async handler's lifetime when a browser tab
- closes mid-push, and whether a slow client can hold the shared 4 kB response
- buffer long enough to matter. All of it needs the device.
-
----
-
-## Q. The file store, SWA-21
-
-- [x] **Q1. Wear is now handled at the write. Done 2026-10-09.** The store had
- never seen a worn sector, and the failure mode it was written against was the
- wrong one: a NOR sector at the end of its life does not report an error, it
- takes the write, returns success and holds something else. The CRC caught that
- on the next read, which on this device is after a firing rather than during
- the write it could still have redirected.
-
- Every write is now read back and re-validated through the same path a mount
- uses before the index points at it. A copy that does not verify retires its
- region and the write is re-attempted in a free region at a higher seq, up to
- three regions; the caller gets `KILN_OK`, because the file is stored and only
- its location changed. Retirement needs no bad-block table: the old region is
- erased best effort after the new copy verifies, and if that erase fails, which
- is what a finished sector does, the stale copy stays at a lower seq, two
- regions claim one name, and mount resolves it by seq and retires the loser.
- The erase is what stops a deleted file from coming back at the next mount.
-
- Still no wear levelling, and deliberately: the endurance arithmetic of
- architecture 10.4 has four orders of magnitude of headroom, so a region keeps
- its file for as long as it works.
-
- Five new host tests, driven by two new knobs on the flash fake that model a
- sector which accepts a write and keeps zeros, and a sector that will not
- erase. The retired count is logged at boot and is the only warning anybody
- gets that the part is going.
-
-- [x] **Q2. The mount cost is measured. Done 2026-10-09.** It was described
- rather than measured, and the description was low by a third. On the medium
- this firmware actually produces, a mount costs **488 reads and 31 888 bytes**
- of CRC: 20 run records of 554 bytes, both copies valid. It was 768 reads and
- 47 808 bytes while the programs were stored too, which `P3` ended. A partition full
- of maximum-size files with both copies valid costs **8 192 reads and 524 288
- bytes**, the whole partition.
-
- Both are asserted in `test_fileslots`, against a model derived in the test
- rather than a constant, so making mount lazy fails the suite instead of
- happening quietly. 47 kB of flash reads is not a start-up problem on this
- part, so there is nothing here to optimise; if a board ever says otherwise,
- the only thing mount needs eagerly is which copy of each region wins, and the
- payload CRC could move to the read.
-
-- [x] **Q4. A name longer than 63 bytes is refused rather than truncated.
- Recorded 2026-10-09.** `KILN_PATH_MAX` is 64 including the terminator and the
- name field is exactly that, so a longer path returns `KILN_ERR_INVALID_ARG`.
- Both callers produce five-character slot paths, so nothing generates one
- today; storing a file under a name the caller did not ask for is the worse
- failure. Now stated where it is enforced, in `port_filestore.h` and
- architecture 10.6, with the test that proves it, rather than only here.
-
-- [x] **Q5. The run index walks all 20 slots, and that is where it stays.
- Recorded 2026-10-09.** Append reads every slot to find the oldest, and
- `mark_truncated` and `baseline` walk them too. Twenty reads answered from the
- in-RAM index plus one region write, once per firing, is not a cost worth a
- mechanism. It is the one place where the store being a fixed array rather
- than a directory costs anything, and that is now recorded in `run_index.cpp`
- where somebody revisiting the file will see it.
+- [ ] **P4. `esp_http_server` has never served a request. P1.** The API layer
+ has been driven over real sockets by the development harness; the transport
+ has not. Unverified: URI splitting in its parser, `httpd_resp_send_chunk`
+ against a real client, the socket limit with two sessions parked on streams,
+ the async handler's lifetime when a tab closes mid-push, and whether a slow
+ client can hold the shared 4 kB response buffer long enough to matter.
---
## R. The independent safety supervisor, SWA-22
-Designed in [`docs/safety-supervisor.md`](docs/safety-supervisor.md). That
-intro said nothing below could start until `R1` to `R4` were answered, because
-each of them changed the supervisor's pin count or a requirement. They are
-answered, and the firmware, the requirement deltas and the link tests are done:
-what is left in this section is **hardware**, which is `R6` the schematic and
-`R11` the button on the panel.
-
-- [x] **R9. `sense.tc_type` is retired. Done 2026-10-10.** The chamber front
- end belongs to the supervisor, which linearises for type K, so an item that
- let this side change the type would change what the supervisor's backstop
- means. `kiln_suplink`'s `configure` already refused, which made the config
- item a setting an operator could set and believe while nothing acted on it.
-
- `KILN_CFG_SCHEMA_VERSION` is 2, and the migration is the interesting part.
- Every earlier version change added fields, which a prefix copy handles; this
- one *removes* a byte from the middle, so every field after the hole sits one
- lower. A prefix copy would have landed the whole sensing, control and network
- block one byte out of place -- the stored line filter frequency reading as
- half of itself plus half a calibration offset -- and the CRC would have
- passed on the way in, so nothing would have reported it. A device would come
- back from an update quietly mis-calibrated.
-
- `kiln_config_decode` therefore splices version 1 explicitly, which is
- `SWR-CFG-05`'s "older: migrate" met rather than answered with defaults. The
- test forges a real version 1 blob and checks fields on both sides of the hole
- plus a re-encode round trip. `SWR-CFG-02` records that there is no chamber TC
- type, and a test asserts the item does not come back.
-
- `kiln_app_boot` now passes `KILN_TC_TYPE_K` to the chamber port from the code
- rather than from configuration: refused on the product, which is the point,
- and correct on the simulated build where the port accepts it.
-
-- [x] **R5. The requirement deltas are written. Done 2026-10-10.** Six new
- requirements and nine changed ones. The supervisor appeared nowhere in the
- system or safety requirements before this, which meant the design document
- was the only place it existed: a part holding one element of the contactor
- coil, with no requirement to satisfy and nothing to verify against.
-
- **New**, in `02_system_req.sdoc`: `SYS-HW-26` the separate microcontroller
- with its own clock, watchdog, front end, series element and programming
- interface, and the external pull-down the reset window needs; `SYS-HW-27` the
- one-direction report-only link, with no message able to raise the threshold
- or clear the latch; `SYS-HW-28` the local clear button, edge triggered, a
- stuck line never arming it; `SYS-SAF-25` the compiled-in backstop and
- persistent-fault trip; `SYS-SAF-26` the latch and what clears it, including
- why it deliberately does not survive a power cycle; `SYS-SAF-27` the 4 Hz
- report, silence as a sensor fault, and the trip acting within one acquisition
- period whether or not anything is listening.
-
- **Changed**: `SWR-ACQ-01` (the chamber front end is the supervisor's, and a
- report is the controller's sample), `SYS-HW-02` (separate buses on different
- microcontrollers, where a shared bus is now exactly what must not happen),
- `SYS-HW-24` (superseded, rewritten, test points kept), `SYS-HW-25` (its
- freewheel reference follows), `SWA-04` (two supervisors, and which one every
- sentence means), `SWA-05` (the charge pump is one of two paths, and why the
- supervisor's element is deliberately a static level instead), `SG-01`
- (strengthened on MCU failure, and more exposed on the sensor), `SG-03` ("two"
- became "at least two", and "independent control" became true).
- `SWR-SAF-23` and `SWR-ACQ-02` were already done.
-
- `safety.md` gains **L2S**, lettered rather than numbered because the layer
- numbers are cited from `safety.sdoc`, the firmware and the hardware design,
- and renumbering to open a gap would break every citation to no benefit. Its
- section says what the layer protects against -- the failure of the entire
- controller, which no layer above it could cover -- and what it deliberately
- does not do.
-
- `safety.md`'s system-boundary diagram and its coil-path table are updated
- with it: three elements break the coil now rather than four, and the diagram
- carries a dashed line between the two front ends that is not a signal, so
- that it cannot be read as two independent temperature channels when both
- MAX31856 read one junction.
-
- Section 6's independence table gains the three rows the design asked for: a
- genuine **yes** for the supervisor against the controller, sharing only the
- 3V3 rail; an explicit **no** for the chamber couple, which both
- microcontrollers read from one junction; and the thermal-versus-current row
- **promoted from defence in depth to load-bearing**, because with one couple
- the CT is the only physically independent detection channel left. The second
- part made the processor-failure row writable and the couple row worse at the
- same time, and both are said.
-
-- [x] **R7. What the supervisor supersedes is removed. Done 2026-10-10.**
- Section K is superseded: `K1` is answered rather than mitigated, `K2`
- dissolves, `K5` drops from four tests to three. `SYS-HW-24` is rewritten
- rather than deleted and keeps its test points. `SWR-SAF-31` and `SYS-HW-21`
- are unchanged, as the design said they should be: the lid's series contact
- depends on no firmware at all, costs nothing and stays, and the lid sense
- stays the controller's input because `SWR-SAF-31`'s latch is gated on a
- heating state only the controller knows. A supervisor latching on the lid
- regardless would trip on every cold load.
-
- What is left in section K is `K3`, an unwired indicator LED that predates all
- of this, and `K4`, which has grown rather than shrunk: the board carries the
- chain the design has dropped and does not carry the supervisor at all.
-
-- [ ] **R11. The clear button on the panel.** `R4` decided a local button and
- the firmware implements it (edge triggered, held 0.5 s, and a line stuck low
- never arms, so a short fails towards the latch holding). What is left is
- physical: the button itself, its position relative to the HMI, and whether it
- is labelled as clearing the *supervisor* or clearing *a fault*, which are not
- the same thing and the operator cannot see the difference. The ESP32's own
- latched fault still needs its own acknowledgement (`SWR-SAF-17`), so there are two
- acknowledgements and the panel should not imply there is one.
-
-- [ ] **R6. Draw the supervisor.** The part and the pin map are settled:
- STM32G031K8T6, LQFP32, and the assignment is in
- [`firmware/supervisor/README.md`](firmware/supervisor/README.md), taken from ST's own pinout
- and alternate-function database rather than assumed. What is left is the
- schematic.
-
- Three things the pin map asks of the hardware. The permit line is active
- high and every GPIO is high-impedance between reset and the first
- instruction, so **the series element needs an external pull-down** or there
- is a window at every reset where its state is whatever the board leaks to.
- SWD on `PA13`/`PA14` must come out to test points, not be left as pads,
- because the independence argument rests on this firmware being reviewable and
- flashable on its own. And on the ESP32 side the link lands on
- `KILN_PIN_EXP_IO2` or `KILN_PIN_EXP_IO42`, one pin, receive only; `UART0` is
- the console and must not be used.
-
-- [x] **R8. The supervisor's firmware and its test strategy. Done 2026-10-10.**
- The firmware is 3 488 bytes of program memory across four source files, which
- is the design constraint met rather than aspired to, and it has its own
- host-testable core on `SWA-01`'s argument: `trip`, `selfcheck`, `max31856`
- and the wire protocol all build and run on a development host with no
- hardware, with MC/DC over the trip logic and a generated Unit Design
- Document.
-
- The link test this asked for is now there, and it was the piece genuinely
- missing. Each stage was tested and the chain was not: `test_suplink` proved
- silence and a stale sequence number both become `KILN_TC_FAULT_COMMS`,
- `test_safety_thermal` proved that bit becomes `KILN_FAULT_TC_COMMS`, and
- other tests proved a latched fault withholds heat. An assembly of proven
- parts is not a proven assembly, and the chamber temperature enters this
- firmware at exactly one place, over a wire.
-
- Two integration tests now run the real application with the real decoder
- bound as the chamber port, the way `main.cpp` binds it on the board, and
- watch the heater: **silence** takes the heat away and opens the contactor
- after `SWR-SAF-04`'s grace, and so does **a supervisor repeating itself** --
- well-formed frames, good CRC, plausible temperature, sequence number
- stuck -- which is the failure that looks like health and which a link check
- counting bytes would have called a working channel while the kiln ran on a
- reading from minutes ago. Both assert the kiln was heating first, so neither
- can pass for the wrong reason.
+The firmware, the requirement deltas and the link tests are done. What is left
+is hardware.
+
+- [ ] **R6. Draw the supervisor. P1.** Part and pin map are settled:
+ STM32G031K8T6, LQFP32, assignment in
+ [`firmware/supervisor/README.md`](firmware/supervisor/README.md). Three
+ things the pin map asks of the board: the permit line needs an external
+ pull-down (`HWR-03`), SWD must come out to test points (`HWR-19`), and on the
+ ESP32 side the link lands on one receive-only pin, and `UART0` is the console
+ and must not be used.
+- [ ] **R11. The clear button on the panel.** The firmware implements it
+ (edge triggered, held 0.5 s, a line stuck low never arms). What is left is
+ physical: the button, its position relative to the HMI, and whether it is
+ labelled as clearing the *supervisor* or clearing *a fault*, which are not the same
+ thing, and the operator cannot see the difference. There are two
+ acknowledgements and the panel must not imply there is one.
diff --git a/tools/layercheck.sh b/tools/layercheck.sh
new file mode 100755
index 0000000..51be251
--- /dev/null
+++ b/tools/layercheck.sh
@@ -0,0 +1,373 @@
+#!/usr/bin/env bash
+# SPDX-FileCopyrightText: 2026 Bitcrush Testing
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Hold three architecture decisions to the code rather than to discipline:
+# SWA-14, SWA-03 and SWA-02 (tasklist C5 and C13).
+#
+# tools/layercheck.sh check everything it can from what is built
+# tools/layercheck.sh --self-test prove each check can fail
+#
+# ===========================================================================
+# WHY NOT A GREP
+# ===========================================================================
+# CI already greps kiln_core for `#include `, and the comment beside
+# it says the full check is this script. A grep over include lines misses the
+# two ways this has any chance of going wrong in practice:
+#
+# - a platform header reached INDIRECTLY, through a project header that
+# includes it;
+# - an include inside `#if defined(ESP_PLATFORM)`, which the host build
+# never compiles and the host tests therefore never catch, so the core
+# would quietly stop being portable while every gate stayed green.
+#
+# So the include check reads the compiler's own dependency files from the
+# TARGET build, where the IDF headers are on the path and a mistake would
+# actually resolve. That is the preprocessor's answer, not a guess at it.
+#
+# The other two checks read the object code, which is stronger than any grep
+# over sources could be:
+#
+# SWA-03, no mutable state in the core: a mutable file-scope object lands in
+# .data or .bss, so `nm` finds it whatever it is called and wherever it was
+# declared -- including a function-local `static`, which is the form that
+# slips past a reviewer looking for globals.
+#
+# SWA-02, time is injected: a direct clock read leaves an UNDEFINED symbol
+# for time, clock_gettime, esp_timer_get_time and friends. There is nowhere
+# to hide one, and nothing to argue about.
+#
+# What these two buy is the 168-hour firing that a test exercises in
+# milliseconds, and the 15-minute runaway timer it does the same to. Both stop
+# working the first time the core reads a real clock or keeps state between
+# calls, and neither failure announces itself: the tests still pass, they just
+# stop testing what they say they test.
+set -euo pipefail
+
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+root="$here/.."
+cd "$root"
+
+HOST_BUILD=${HOST_BUILD:-build-host}
+TARGET_BUILD=${TARGET_BUILD:-firmware/controller/build-hw}
+CORE=firmware/controller/components/kiln_core
+PORTS=firmware/controller/components/kiln_ports
+
+fail=0
+note() { printf '%s\n' "$*"; }
+err() { printf '::error::%s\n' "$*" >&2; fail=1; }
+
+# --- 1. the core includes no platform header, transitively ----------------
+#
+# Driven from the target build's .d files: they list every header the
+# preprocessor actually opened, so an indirect or ESP_PLATFORM-guarded include
+# appears here and nowhere else.
+check_includes() {
+ # Ninja keeps dependencies in a binary log rather than in .d files, and
+ # `ninja -t deps` is the documented way to read it. That log is the
+ # preprocessor's own record of every header it opened, which is the whole
+ # point of looking here instead of at include lines.
+ if [ ! -f "$TARGET_BUILD/.ninja_deps" ]; then
+ note "includes: skipped, no target build at $TARGET_BUILD"
+ note " (build it with: idf.py -B build-hw build)"
+ check_direct_includes
+ return
+ fi
+ local ninja_bin=${NINJA:-ninja}
+ if ! command -v "$ninja_bin" >/dev/null 2>&1; then
+ note "includes: skipped, ninja is not on PATH (set NINJA=... or . export.sh)"
+ check_direct_includes
+ return
+ fi
+
+ # Every header opened while compiling a kiln_core translation unit.
+ local deps
+ deps=$("$ninja_bin" -C "$TARGET_BUILD" -t deps 2>/dev/null \
+ | awk '/^esp-idf\/(kiln_core|kiln_ports)\//{keep=1; next}
+ /^[^ ]/{keep=0}
+ keep && NF' || true)
+ if [ -z "$deps" ]; then
+ note "includes: skipped, the dependency log names no kiln_core units"
+ check_direct_includes
+ return
+ fi
+
+ # IDF components the core's preprocessed sources must not reach. The
+ # allowance below is not permission to include them: it is what the
+ # toolchain itself drags in through and on an IDF
+ # build, where picolibc's headers live under esp_libc. Anything outside it
+ # means a platform dependency got in, directly or through a header that
+ # looked harmless.
+ local bad
+ bad=$(printf '%s\n' "$deps" | grep -oE 'esp-idf/components/[a-z_0-9]+/' \
+ | sed 's|esp-idf/components/||; s|/$||' | sort -u \
+ | grep -vxE 'esp_libc|newlib|esp_rom|esp_common' || true)
+ if [ -n "$bad" ]; then
+ err "SWA-14: kiln_core's preprocessed sources reach IDF components:"
+ printf ' %s\n' $bad >&2
+ note " This is what a grep over include lines cannot see: the" >&2
+ note " header may be reached indirectly, or from inside an" >&2
+ note " #if defined(ESP_PLATFORM) the host build never compiles." >&2
+ fi
+ note "includes: clean over $(printf '%s\n' "$deps" | sort -u | wc -l | tr -d ' ') headers the target build opened"
+ check_direct_includes
+}
+
+# The direct form, which is what a reviewer means by the question. Over
+# sources, because here what matters is what the code asks for rather than
+# what the toolchain supplies.
+check_direct_includes() {
+
+ local direct
+ direct=$(grep -rnE '#include[[:space:]]*[<"](esp_|freertos/|driver/|soc/|hal/|nvs|sdkconfig)' \
+ "$CORE" "$PORTS" 2>/dev/null || true)
+ if [ -n "$direct" ]; then
+ err "SWA-14: a platform header is included directly:"
+ printf '%s\n' "$direct" >&2
+ else
+ note "direct: no platform include in kiln_core or kiln_ports"
+ fi
+}
+
+# --- 2. SWA-03: the core holds no mutable state --------------------------
+core_lib() {
+ # Always succeeds: a missing build directory is "nothing to check here",
+ # not an error, and `set -e` would otherwise end the script on find's exit
+ # status before the caller could say which check it skipped.
+ [ -d "$HOST_BUILD" ] || return 0
+ find "$HOST_BUILD" -name 'libkiln_core.a' 2>/dev/null | head -1 || true
+}
+
+# Symbols that live in a genuinely WRITABLE section, printed as "section name".
+#
+# Read from the section rather than from nm's one-letter class, and that
+# distinction is the whole of this function. A `const` table of POINTERS needs
+# its pointers relocated at load time, so on ELF it lands in .data.rel.ro --
+# which nm calls 'd', the same letter as real mutable data. The first version
+# of this check used the letter, passed on Mach-O where such tables stay in
+# __const, and flagged eight legitimate const tables in kiln_core the moment it
+# ran on Linux. A check that fires on correct code is worse than no check,
+# because the next person deletes it.
+#
+# GNU nm's sysv format carries a Section column; Apple's leaves it empty, so
+# the Mach-O branch asks `nm -m` instead. Both reject the same thing: data and
+# bss, thread-local included. Neither rejects relocated read-only data.
+writable_syms() {
+ local lib=$1
+ local sysv
+ sysv=$(nm --format=sysv "$lib" 2>/dev/null | awk -F'|' 'NF >= 7 {
+ name = $1; sect = $7;
+ gsub(/^[ \t]+|[ \t]+$/, "", name);
+ gsub(/^[ \t]+|[ \t]+$/, "", sect);
+ if (sect != "") { print sect" "name }
+ }' || true)
+ if [ -n "$sysv" ]; then
+ # Writable sections, naming them rather than pattern-matching so that
+ # a section nobody anticipated is reported and not silently allowed.
+ # `*COM*` is a C common symbol, which is mutable state by another
+ # spelling. `.data.rel.ro` is the relocated-read-only case this
+ # function exists to let through; a bare `.data.rel` is not.
+ printf '%s\n' "$sysv" | awk '
+ $1 == ".data" || $1 == ".bss" ||
+ $1 == ".tdata" || $1 == ".tbss" || $1 == "*COM*" ||
+ ($1 ~ /^\.data\.rel/ && $1 !~ /^\.data\.rel\.ro/)' | sort -u
+ return 0
+ fi
+ nm -m "$lib" 2>/dev/null \
+ | awk '/\(__DATA,__(data|bss|common)\)/ { print $2" "$NF }' | sort -u || true
+}
+
+check_no_mutable_state() {
+ local lib; lib=$(core_lib)
+ if [ -z "$lib" ]; then
+ note "SWA-03: skipped, no host build at $HOST_BUILD"
+ return
+ fi
+ local syms
+ syms=$(writable_syms "$lib")
+ if [ -n "$syms" ]; then
+ err "SWA-03: kiln_core has mutable file-scope state:"
+ printf '%s\n' "$syms" >&2
+ note " All core state belongs in the caller's struct. A static" >&2
+ note " here makes two tests in one process interfere, and makes" >&2
+ note " a 168 h firing untestable in milliseconds." >&2
+ else
+ note "SWA-03: no mutable state in $(basename "$lib")"
+ fi
+}
+
+# --- 3. SWA-02: time is injected, never read -----------------------------
+check_time_injected() {
+ local lib; lib=$(core_lib)
+ if [ -z "$lib" ]; then
+ note "SWA-02: skipped, no host build at $HOST_BUILD"
+ return
+ fi
+ # The whole family, including the two IDF ones that would only resolve on
+ # a target build: a core that calls them is broken on the host as well,
+ # and this is the check that says so before the link does.
+ local pattern='^_?(time|clock|clock_gettime|gettimeofday|mktime|localtime|localtime_r|gmtime|esp_timer_get_time|xTaskGetTickCount|esp_log_timestamp)$'
+ local refs
+ refs=$(nm -u "$lib" 2>/dev/null | awk '{ print $NF }' | sort -u \
+ | grep -E "$pattern" || true)
+ if [ -n "$refs" ]; then
+ err "SWA-02: kiln_core reads a clock directly:"
+ printf '%s\n' "$refs" >&2
+ note " Time arrives as a dt or a timestamp argument. A direct" >&2
+ note " read is what makes a dwell tolerance untestable." >&2
+ else
+ note "SWA-02: no clock reads in $(basename "$lib")"
+ fi
+}
+
+# --- 4. the component graph is layered and acyclic -----------------------
+#
+# From each component's REQUIRES, which is where ESP-IDF records the edges.
+check_graph() {
+ python3 - "$@" <<'PY'
+import pathlib, re, sys
+
+# Low number may be depended on by high, never the reverse. The numbers are the
+# layering of architecture section 4: ports are headers everyone may see, the
+# core is platform-free logic, the application composes it, the presentation
+# and adapter layers sit above, and main is the composition root.
+LAYER = {
+ 'kiln_ports': 0,
+ 'kiln_core': 1,
+ 'kiln_app': 2,
+ 'kiln_web': 3, 'kiln_hmi': 3, 'kiln_sim': 3, 'kiln_hal_esp32s3': 3,
+}
+comp_dir = pathlib.Path('firmware/controller/components')
+edges, bad = {}, []
+for d in sorted(comp_dir.iterdir()):
+ if not (d / 'CMakeLists.txt').is_file():
+ continue
+ txt = (d / 'CMakeLists.txt').read_text()
+ reqs = set()
+ for m in re.finditer(r'(?:PRIV_)?REQUIRES([^)\n]*(?:\n(?!\s*\))[^)\n]*)*)', txt):
+ for w in m.group(1).split():
+ if w.startswith('kiln_'):
+ reqs.add(w)
+ edges[d.name] = reqs - {d.name}
+
+for a, deps in edges.items():
+ if a not in LAYER:
+ continue
+ for b in deps:
+ if b not in LAYER:
+ continue
+ if LAYER[b] > LAYER[a]:
+ bad.append(f"{a} (layer {LAYER[a]}) requires {b} (layer {LAYER[b]})")
+ elif LAYER[b] == LAYER[a] and a != b:
+ bad.append(f"{a} and {b} are the same layer and {a} requires {b}")
+
+# Cycles, over the whole graph rather than only the layered part.
+seen, stack = set(), []
+def walk(n):
+ if n in stack:
+ bad.append("cycle: " + " -> ".join(stack[stack.index(n):] + [n]))
+ return
+ if n in seen:
+ return
+ seen.add(n); stack.append(n)
+ for m in sorted(edges.get(n, ())):
+ walk(m)
+ stack.pop()
+for n in sorted(edges):
+ walk(n)
+
+if bad:
+ print("::error::the component graph is not layered or not acyclic")
+ for b in sorted(set(bad)):
+ print(" " + b)
+ sys.exit(1)
+print(f"graph: {len(edges)} components, layered and acyclic")
+PY
+}
+
+# --- the self-test -------------------------------------------------------
+#
+# A gate nobody has seen fail is a gate nobody should trust. This builds two
+# tiny objects that violate SWA-03 and SWA-02 and checks that the checks say
+# so, which is the only way to know the nm invocations are right on this
+# platform rather than merely silent.
+self_test() {
+ local tmp; tmp=$(mktemp -d)
+ trap 'rm -rf "$tmp"' RETURN
+ local checks=0 failed=0
+ t() { checks=$((checks+1)); if [ "$2" = "$3" ]; then echo "ok: $1"; else
+ echo "FAIL: $1 (got $2, want $3)"; failed=$((failed+1)); fi; }
+
+ cat > "$tmp/global.cpp" <<'EOF'
+int kiln_bad_counter = 0;
+int bump(void) { static int local_static = 0; local_static++; return ++kiln_bad_counter + local_static; }
+EOF
+ cat > "$tmp/clock.cpp" <<'EOF'
+#include
+long now(void) { return (long)time(nullptr); }
+EOF
+ # The case that matters as much as the violations: a const table of
+ # POINTERS, which is what kiln_core is full of. On ELF its pointers are
+ # relocated at load time and it lands in .data.rel.ro, which nm's class
+ # letter cannot be told apart from real mutable data. If this is ever
+ # reported as mutable state, the check is wrong and not the code.
+ cat > "$tmp/consttable.cpp" <<'EOF'
+namespace {
+const char *const k_names[] = { "alpha", "beta", "gamma" };
+struct row { const char *key; int lo, hi; };
+const row k_rows[] = { { "a", 0, 1 }, { "b", 2, 3 } };
+}
+const char *name_of(int i) { return k_names[i]; }
+int hi_of(int i) { return k_rows[i].hi; }
+EOF
+ c++ -std=c++20 -c "$tmp/global.cpp" -o "$tmp/global.o" 2>/dev/null
+ c++ -std=c++20 -c "$tmp/clock.cpp" -o "$tmp/clock.o" 2>/dev/null
+ c++ -std=c++20 -c "$tmp/consttable.cpp" -o "$tmp/consttable.o" 2>/dev/null
+ ar rcs "$tmp/libbad.a" "$tmp/global.o" "$tmp/clock.o" 2>/dev/null
+ ar rcs "$tmp/libconst.a" "$tmp/consttable.o" 2>/dev/null
+
+ # Through writable_syms itself rather than a copy of its nm invocation:
+ # the first version of this self-test duplicated the command it was meant
+ # to be testing, which is why it could not catch the false positive.
+ local n
+ n=$(writable_syms "$tmp/libbad.a" | wc -l | tr -d ' ')
+ t "a global and a function-local static are both seen" \
+ "$([ "$n" -ge 2 ] && echo yes || echo no)" "yes"
+
+ n=$(writable_syms "$tmp/libconst.a" | wc -l | tr -d ' ')
+ t "a const table of pointers is NOT mutable state" "$n" "0"
+
+ n=$(nm -u "$tmp/libbad.a" 2>/dev/null | awk '{print $NF}' \
+ | grep -cE '^_?time$' || true)
+ t "a direct time() call is seen" "$([ "$n" -ge 1 ] && echo yes || echo no)" "yes"
+
+ # And the real library passes both, which is the other half: a check that
+ # fires on everything is as useless as one that fires on nothing.
+ local lib; lib=$(core_lib)
+ if [ -n "$lib" ]; then
+ n=$(writable_syms "$lib" | wc -l | tr -d ' ')
+ t "kiln_core has no mutable state" "$n" "0"
+ n=$(nm -u "$lib" 2>/dev/null | awk '{print $NF}' | grep -cE '^_?time$' || true)
+ t "kiln_core calls no time()" "$n" "0"
+ else
+ echo "skip: no host build, so the real library is not checked"
+ fi
+
+ echo "$((checks - failed))/$checks self-test checks passed"
+ [ "$failed" -eq 0 ]
+}
+
+if [ "${1:-}" = "--self-test" ]; then
+ self_test
+ exit $?
+fi
+
+check_includes
+check_no_mutable_state
+check_time_injected
+check_graph || fail=1
+
+if [ "$fail" -ne 0 ]; then
+ exit 1
+fi
+echo "layercheck: clean"
diff --git a/tools/logdump.py b/tools/logdump.py
new file mode 100755
index 0000000..f928de6
--- /dev/null
+++ b/tools/logdump.py
@@ -0,0 +1,428 @@
+#!/usr/bin/env python3
+# SPDX-FileCopyrightText: 2026 Bitcrush Testing
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Decode a kilnlog partition dump to CSV, and cross-check the codec.
+
+ tools/logdump.py kilnlog.bin CSV on stdout
+ tools/logdump.py kilnlog.bin -o firing.csv and to a file
+ tools/logdump.py kilnlog.bin --sectors one line per sector instead
+ tools/logdump.py kilnlog.bin --run 7 only that run
+ tools/logdump.py --self-test check this decoder
+
+Get the dump off a device with esptool, which needs no firmware cooperation
+and works on a device that will not boot:
+
+ esptool.py read_flash kilnlog.bin
+
+The offsets are in firmware/controller/partitions.csv. A dump of the whole
+flash works too: this walks sectors and ignores everything that is not a log
+sector, so a wrong offset costs a warning rather than a wrong answer.
+
+===========================================================================
+WHY THIS IS A SECOND IMPLEMENTATION AND NOT A WRAPPER
+===========================================================================
+It would be less code to link kiln_core and call kiln_logrec_decode. That is
+exactly what it must not do. A log is the only evidence of what a kiln did
+before it failed, and the question an investigator has is "did the firmware
+record this correctly", which cannot be answered by the firmware's own
+decoder: a codec that encodes and decodes with the same wrong idea round-trips
+perfectly and tells you nothing.
+
+So this is written from the format as architecture 10.2 and logrec.h document
+it -- offsets, scales, CRC polynomials and all -- and the CI step feeds it
+records the C++ encoder produced. Two implementations that agree are evidence.
+One implementation agreeing with itself is a tautology.
+
+The flip side is stated rather than discovered: this file is now a second
+place the format lives, and the two can drift. That is what the cross-check in
+CI is for, and why the constants below carry the names they have in the
+headers instead of being inlined as magic numbers.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import pathlib
+import struct
+import sys
+from dataclasses import dataclass, asdict
+
+# --- the format, from logrec.h and port_logstore.h -----------------------
+SECTOR_BYTES = 4096
+HEADER_BYTES = 16
+RECORD_BYTES = 20 # SWA-18
+RECORDS_PER_SECTOR = (SECTOR_BYTES - HEADER_BYTES) // RECORD_BYTES # 204
+LOG_MAGIC = 0x474F4C4B # "KLOG" little endian
+FORMAT_VERSION = 1
+CRC16_INIT = 0xFFFF
+
+STATES = ["IDLE", "RUNNING", "PAUSED", "MANUAL", "AUTOTUNE", "COMPLETE", "FAULT"]
+# Index is the persisted code, so the order is the format and not a choice.
+# The last three are SWR-LOG-16's security events; the update-path ones that
+# requirement also lists arrive with the update path itself, because a version
+# string and a refusal reason do not fit a 20-byte record.
+EVENTS = ["sample", "run_start", "run_end", "state_change", "fault", "warning",
+ "config_change", "operator",
+ "sec_reset", "sec_erase", "sec_net_join"]
+SEG_NONE = 0xFF
+
+# High nibble of the state/flags byte.
+F_HOLDBACK, F_SATURATED, F_TC_FAULT, F_WALL_VALID = 0x10, 0x20, 0x40, 0x80
+
+
+def crc8(data: bytes) -> int:
+ """CRC-8, polynomial 0x07, initial 0xFF -- the record's own check."""
+ crc = 0xFF
+ for byte in data:
+ crc ^= byte
+ for _ in range(8):
+ crc = ((crc << 1) ^ 0x07) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF
+ return crc
+
+
+def crc16(data: bytes, crc: int = CRC16_INIT) -> int:
+ """CRC-16/CCITT-FALSE, polynomial 0x1021 -- the sector header's check."""
+ for byte in data:
+ crc ^= byte << 8
+ for _ in range(8):
+ crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
+ return crc
+
+
+@dataclass
+class Sector:
+ index: int
+ seq: int
+ run_id: int
+ version: int
+ ok: bool
+ why: str = ""
+
+
+@dataclass
+class Record:
+ sector: int
+ slot: int
+ run_id: int
+ t_rel_ms: int
+ kiln_raw_c: float
+ kiln_filt_c: float
+ setpoint_c: float
+ case_c: float
+ current_a: float
+ duty_pct: float
+ segment: int | None
+ state: str
+ event: str
+ holdback: bool
+ saturated: bool
+ tc_fault: bool
+ wall_valid: bool
+ current_flags: int
+
+
+def decode_header(buf: bytes, index: int) -> Sector:
+ if len(buf) < HEADER_BYTES:
+ return Sector(index, 0, 0, 0, False, "short")
+ magic, seq, run_id, version, got = struct.unpack(" int:
+ checks = failed = 0
+
+ def check(name, got, want):
+ nonlocal checks, failed
+ checks += 1
+ if got == want:
+ print(f"ok: {name}")
+ else:
+ print(f"FAIL: {name}: got {got!r}, want {want!r}")
+ failed += 1
+
+ # The CRCs, against values computable by hand from the polynomials.
+ check("crc8 of empty is its seed", crc8(b""), 0xFF)
+ check("crc16 of empty is its seed", crc16(b""), 0xFFFF)
+ # CRC-16/CCITT-FALSE of "123456789" is the standard's own check value.
+ check("crc16 check value", crc16(b"123456789"), 0x29B1)
+ # And CRC-8/autosar-style with poly 0x07 init 0xFF over the same input.
+ check("crc8 is deterministic", crc8(b"123456789"), crc8(b"123456789"))
+ check("crc8 sees a changed byte",
+ crc8(b"123456789") != crc8(b"123456780"), True)
+
+ def make_header(seq, run_id, version=FORMAT_VERSION, magic=LOG_MAGIC):
+ buf = struct.pack(" int:
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ ap.add_argument("dump", nargs="?", help="a kilnlog partition dump")
+ ap.add_argument("-o", "--out", help="write CSV here instead of stdout")
+ ap.add_argument("--sectors", action="store_true",
+ help="one line per sector, for looking at the ring itself")
+ ap.add_argument("--run", type=int, help="only records of this run id")
+ ap.add_argument("--self-test", action="store_true")
+ args = ap.parse_args()
+
+ if args.self_test:
+ return self_test()
+ if not args.dump:
+ ap.error("a dump is required (or --self-test)")
+
+ with open(args.dump, "rb") as fh:
+ blob = fh.read()
+ if len(blob) % SECTOR_BYTES:
+ print(f"warning: {len(blob)} bytes is not a whole number of "
+ f"{SECTOR_BYTES}-byte sectors; the tail is ignored",
+ file=sys.stderr)
+
+ sectors, records, bad = walk(blob)
+ if args.run is not None:
+ records = [r for r in records if r.run_id == args.run]
+
+ out = open(args.out, "w", newline="") if args.out else sys.stdout
+ try:
+ if args.sectors:
+ w = csv.writer(out)
+ w.writerow(["sector", "seq", "run_id", "version", "ok", "why"])
+ for s in sectors:
+ w.writerow([s.index, s.seq, s.run_id, s.version, int(s.ok), s.why])
+ else:
+ if not records:
+ print("no records: either the dump holds no log sectors, or "
+ "the offset was wrong", file=sys.stderr)
+ w = csv.DictWriter(out, fieldnames=list(asdict(records[0]).keys())
+ if records else ["sector"])
+ w.writeheader()
+ for r in records:
+ w.writerow(asdict(r))
+ finally:
+ if args.out:
+ out.close()
+
+ live = sum(1 for s in sectors if s.ok)
+ print(f"{len(sectors)} sectors, {live} live, {len(records)} records"
+ + (f", {bad.get('crc', 0)} failed CRC" if bad.get("crc") else ""),
+ file=sys.stderr)
+ # A CRC failure is not a tool error: it is the tool reporting damage, which
+ # is what it is for. Exit non-zero only if nothing could be read at all.
+ return 0 if (records or live) else 2
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/web/app.js b/web/app.js
index 5955bb4..d2e2ae0 100644
--- a/web/app.js
+++ b/web/app.js
@@ -229,6 +229,11 @@ function renderDash() {
? `${fmtDur(s.elapsed_s)} elapsed · ${fmtDur(s.remaining_s)} left` : '';
$('s-heat').hidden = !s.heat_authorised;
$('s-hold').hidden = !s.holdback;
+ /* Only 'open' raises it. 'unmonitored' is a standing condition rather than
+ * an event, and it already has warning 113 in the banner; repeating it here
+ * on every idle kiln with no interlock fitted would train the operator to
+ * ignore this row. */
+ $('s-door').hidden = s.door !== 'open';
renderBanner(s);
diff --git a/web/index.html b/web/index.html
index 3f18ef2..237cbdc 100644
--- a/web/index.html
+++ b/web/index.html
@@ -82,6 +82,11 @@
HEAT
HOLDBACK
+
+ DOOR OPEN