How it works
Startup and diagnostics
Every Wellfed boot puts the feeder in a known safe state before any firmware code runs: motor stopped, infrared emitters off, indicator lights dark. Then it proves it, by reading every output back and printing the result on the UART. This page explains that startup, what each UART record means, and how the firmware watches its own network link and its own hangs. It is for owners who want to know why the feeder is safe at power-on, and for contributors reading a capture.
The bring-up probes that once ran at startup, and the startup policies the audit still checks for them, are in bring-up probes.
What every boot does#
- KM0 powers the shared pads. Before it starts KM4, KM0 enables the audio-shared pads, sets the audio LDO's voltage trim and logs
PLPWR1(below). It also starts the WLAN firmware, which runs on KM0. - KM4 stops every output. The motor goes to STOP and the emitters to OFF, and each pin is read back (stopped outputs).
- A synthetic diagnostic runs. It exercises the feeding core with no hardware attached and reports pass or fail.
- The indicators are checked LOW. With outputs still stopped, every light is verified dark.
- The runtime starts. Wi-Fi, the device API and the firmware's tasks start. Probes open the sensors and the ADC only in the firmwares that run them.
Startup itself never opens the feeding owner. That the feeding backend is unopened does not mean startup has no electrical side effects: it drives pins, and their transients are measured separately (motor driver).
The startup report#
Each build carries an identity that ties a capture to the exact sources it was built from. The SDK wrapper hashes canonical JSON containing the profile and the complete root-relative map of source-input hashes. That hash is startup_identity in the manifest and WELLFED_BUILD_ID in the firmware. It excludes timestamps, and the final OTA hash stays a separate identity for the artifact. Compiling the port for the target requires a lowercase 64-digit ID; host tests use host.
A boot prints:
PLFW1 start build=<64 lowercase hex digits> bank=1 reset=00000000 mmu=00000003,0c000000,03e7a000
PLFW1 result diagnostic=pass backend=unopened
bankcomes from live RSIP mapping entry 0. Virtual0x0c000000mapped to0x08006000is bank 0, and to0x08186000bank 1. Any other mapping reports unknown.mmuis the raw mapping, andresetthe factory-preserved reset flags. A zero reset word does not establish a particular physical reset cause.
wellfed-audit startup (src/tools/wellfed-audit) checks a capture against its build. It verifies the frozen OTA image and manifest, the embedded identity, the finalized receive-only capture hashes, event coverage, the actual bank mapping, ordering and the observation interval. A missing or wrong build, faults, incomplete or malformed records, a repeated startup and each policy's own failures are distinct failing outcomes. It emits fixed fields, never arbitrary device text. With your own complete evidence bundle:
cargo run --locked -p wellfed-audit -- startup \
--capture private/captures/normal-boot \
--manifest private/verification/review/build-manifest.json \
--image private/verification/review/image.bin \
--minimum-seconds 60 \
--output private/verification/startup-result.json
A passing report shows that this build started cleanly once. It does not show long-term uptime, persistent boot counters or the physical reset cause.
Stopped outputs#
The stopped state is the stock-derived STOP and OFF state. The initializer sets it up in an order that never lets the motor driver see a half-driven pair:
- It enables and checks the LP GPIO clock and function bits, leaving unrelated controls alone (logged as
PLCLK1, before and after). - With the interrupt mask held, it preloads the latches: motor PA13 and PA14 HIGH and HIGH, infrared PA15 and PB3 LOW and LOW.
- It enables both motor pins as outputs in one direction-register write, so the driver never sees one pin driven while the other floats.
- It verifies the masked values and directions, then restores the interrupt mask.
PB3 is also SWD_CLK. Before configuring it, startup clears REG_SWD_PMUX_EN bit 0, as stock does, and stopped readback requires that bit clear (why).
Infrared OFF needs LOW output latches as well as LOW pad inputs. A pad with a dead input path reads LOW whatever is driven, as PA0–PA7 do on our unit, so the input alone proves nothing.
PLFW1 outputs reports the PA and PB values and directions before the startup banner. If any check fails, startup logs an error and does not start the diagnostic task. A synthetic diagnostic pass can never hide an output mismatch. The manifest's policies must match the record formats in the hashed image.
If you need to isolate the motor, note that the CON3 harness carries both motor leads and board power. Unplugging it is not an established way to cut the motor while keeping the board powered.
Indicators at startup#
Startup leaves every indicator dark. After the stopped outputs and a diagnostic pass, it verifies each light LOW, prints PLLED1 begin low_only and PLLED1 done low=all, and starts the firmware's tasks at once. This is policy low-only-v1, with GPIO routing policy read-only-v3, whose only stage is startup.
The lights themselves were proved by an earlier blinking probe, bounded-probe-v3 (bring-up probes). It cost every boot about 15 s before Wi-Fi, the device API and the OTA watcher could start, so it no longer runs. What the lights mean is the indicator service.
The pins, all lit HIGH on our unit: PA0 Wi-Fi, PA2 low food, PA4 locked, PB22 warning. Only the warning's red color is recorded. PA0, PA2 and PA4 have no usable input readback, so they are output-only; PB22 is read back in full.
Shared-pad power#
The audio-shared pads need power before they work as GPIO. Stock and the vendor SDK set this up, but a minimal KM0 main leaves it out. Our KM0 restores the AMACRO and analog pad enables and calls the SDK's calibrated SOCPS_AudioLDO(ENABLE) before it enters the scheduler. It does not initialize audio playback.
Two details matter:
- Order. Stock and the SDK power the pads after booting KM4. We do it before
km4_boot_on(), so the pads are powered before KM4 configures them andPLPWR1completes before KM4 logs anything. - Trim.
SOCPS_AudioLDO(ENABLE)restores a voltage trim (bits 7:1) that only its disable path saves, so a cold start gets zero. Stock KM4CODEC_Initand the SDK'sCODEC_Initwrite 0x2A, or 0x68 on a 1.8 V supply. Our KM0 writes that trim after the enable.
With the trim applied, the low Port A pads still read LOW. Raw Port A input sampled after each supply step gave the same word every time, with PA0–PA7 already reading 0 before the first step. So the shared-pad supply does not explain the readback: those pads appear to have no usable input path on our unit. PA13, PA14 and PB22 read back correctly.
What the UART records mean#
Every record starts with a fixed prefix. The ones a current firmware prints:
| Prefix | Written by | Meaning |
|---|---|---|
PLPWR1 |
KM0 | Shared-pad supply controls before and after |
PLCLK1 |
KM4 startup | GPIO clock and function enable, and what was preserved |
PLFW1 |
KM4 startup | Stopped outputs, build identity, bank and reset, diagnostic result or errors |
PLIO1 |
KM4 startup | Read-only pad-group, AON and GPIO routing observations |
PLDBG1 |
Indicator checks | Requested level, latch, input, directions, stopped state, pad control |
PLLED1 |
KM4 startup | Indicator sequence begin, results, terminal LOW or failure |
PLFLS1 |
KM4 and KM0 | Boot handshake between the cores (probes) |
PLWIF3 |
Wi-Fi task | Join, keep, gateway and leave events (link liveness) |
PLSUP1 |
Watchdog feeder | Supervision start, stop, lease faults and hang reports |
PLFLT1 |
KM4 fault handler | Hard-fault registers |
PLPNC1 |
KM4 | Panic location |
PLOTA1, PLWCH1 |
Update stage | Updates over the device API |
PLBAT1 |
Power monitor | Power samples (power) |
PLBTN1 |
Button probe | Raw button changes (buttons) |
PLKEY1 |
Button service | Gestures (buttons) |
Notes:
PLPWR1policies.before-km4-v1requires one intact record before any KM4 report.v2adds the LDO trim, andv3the raw Port A input after each supply step. An image linking thev3record format is refused unless its manifest declaresbefore-km4-v3.PLIO1.read-only-v2adds KM4's view of the shared-pad supply control and the raw Port A input word.PLDBG1.bounded-probe-v2addshighs=N/64, the extra input samples (probes).PLFLS1. Underfw06-hello-v1it carries HELLO, READY, an injected peer change and a terminal watch result. Underfw06-park-v1,fw06-read-v1,fw06-write-v1,fw15-journal-v1,fw15-settings-v1andfw15-settings-v2it carries only HELLO and READY.PLWIF3. Policywifi-gateway-v1. It is thePLWIF2join records plus agatewayevent per probe (result, gateway IPv4, time). A loss carries its cause:0association,2lease,3gateway. Kind 7dnsand loss cause1belong to the retired DNS check and are never reused (probes). An intentional restart first logskind=leavewithwifi_disconnect'sresult,value1 if still associated after 1 s, andms(below). The normal firmware also logshold until=startup max_ms=180000once and keeps the radio off until the firmware reportsstartup_done, or 180 s pass. Bank 0 waits the same way, since it runs the firmware and its probes.PLFLT1. A builder overlay of the vendor handler printscfsr,hfsr,mmfar,bfar, the exceptionlrand whether the process stack was in use, before the vendor backtrace. CFSR's IMPRECISERR, or a peripheral address in BFAR, marks a bus fault the stacked PC does not point at.PLSUP1.open watchdog=… trial=…on its first step.stop …once when it stops feeding the watchdog (withrestart=1for an intentional restart), orstop watchdog_fault=…. At boot, before anything arms:lease_fault …(OTA design) andhang …after a silent watchdog reset (below).PLPNC1.panic file=… line=… col=…, with the path's last 95 bytes, printed after the outputs are stopped and before the halt that the watchdog resets. It goes to the UART only: no backup register is free to carry it across the reset.PLOTA1andPLWCH1. Policydevice-api-v1, fromota::update. At the stage's start:update store=… min_version=… attempt=…, thenbank0 bytes=… recorded=…orrefused reason=…. On bank 1:PLWCH1 offer=1 version=…andgive_way=1 version=… restart=1, orerror stage=give_way. On bank 0:manifest version=… bytes=…,progress sectors=N/TOTALevery 16 sectors,activated version=… bytes=… sectors=…,error stage=…(timeout, cancelled, order, image, write, hash, marker, store),key retired=…oradded=…, andrestart watchdog=1.
Records from retired probes (PLTMR1, PLWDG1, PLPRK1, PLRDS1, PLWRS1, PLJNL1, PLSET1, PLADC1, PLWIF1, PLWIF2, PLMQT1, PLOPT1) are described in bring-up probes.
Link liveness without internet DNS#
The feeder is a local device, so it judges its network link only by what the LAN shows: the association, the DHCP lease and the gateway that lease named. Nothing in the firmware looks up a name. The Wi-Fi shim calls no resolver. lwIP's resolver is still linked, because the vendor ping console command reaches lwip_gethostbyname, netconn_gethostbyname and dns_gethostbyname_addrtype, and it takes the DHCP server's address. But it sends only when asked, and no firmware path asks.
The Wi-Fi task in C samples the link every 500 ms and calls wellfed_wifi_watch(associated, leased, probe) in wellfed_hal::link. The answer is wellfed_core::liveness::Verdict::code: hold, probe now, settled, or leave with a cause. The state machine (Liveness) is pure and host-tested in wellfed-core/tests/liveness.rs. wellfed_wifi_outcome starts a fresh one after every successful join. The C task is outside the stack audit, as the select and outcome calls already were.
| Check | How | Loss |
|---|---|---|
| Association | wifi_is_connected_to_ap |
Leave at once, cause 0 |
| Lease | dhcp_supplied_address and a nonzero address |
Leave at once, cause 2 |
| Gateway | One ICMP echo every 5 s | Three unanswered in a row, cause 3 |
| Restart | A restart was requested | Leave, cause 4 (below) |
- Lease. lwIP renews and rebinds by itself. It gives the address up only when the whole lease ran out unanswered.
- Gateway. The echo goes over a raw socket (
LWIP_RAWis on in the SDK's options) with a 1 s timeout. The reply must match on source, type, identifier and sequence. Once the gateway has answered during this join, three unanswered probes in a row (11–16 s of silence) are a loss. That catches an association that looks up while nothing gets through, which the driver's beacon-loss detection misses. - A gateway that never answers this join is taken to refuse echo, since DHCP just worked over the same link. The link then rests on association and lease alone.
- Why not ARP. No router can refuse ARP, but lwIP 2.0.2 here runs without core locking, so a request would have to be posted to the stack's thread. Its table also cannot tell a fresh reply from a cached entry.
- Settled. Once per join, on the gateway's first answer or after three probes without one, the shim resets its rejoin backoff. So a network that drops the feeder right after DHCP backs off to 30 s instead of rejoining every second.
- Live.
net_status::Status::liveis written only by the watch: settled and, once the gateway has answered, its latest probe answered too. Trial confirmation waits for it. A leave clears it before the loss event is drained.
Records are kind=gateway events. result is 0 answered, -1 no gateway in the lease, -2 socket, -3 send, -4 timeout. Only a timeout counts as silence: a probe that could not be sent, as when the device API's sessions and mDNS hold every connection, is no observation. value is the gateway and ms the round trip. A loss is kind=lost with result 0, 2 or 3. The device API's dns_losses and the health record's "dns" keep their names on the wire and now count causes 2 and 3.
If your network has no internet route, that is fine: the watch never asks beyond the gateway.
Leaving before a restart#
Every intentional restart leaves the access point first. That covers an activation, a give-way, request_reset for a reboot the device API or setup asked for, and a trial variant's hang. Each is a watchdog reset. Without a leave, the access point still holds the old boot's PMF-protected association. The next boot's WPA3 SAE authentication then succeeds but the association is rejected with status 30. After that reject, the vendor's wifi_connect sometimes never returns, and the whole of KM4 stalls into a silent reset 4 s later. A trial boot right after an OTA restart nearly always meets status 30, so this could spend a good build's three trial boots.
How the leave works:
- Order. The runtime's
restart()first callswellfed_hal::supervision::stopping(). That setswellfed_hal::link's restart flag and marks the breadcrumb. Then it stops the watchdog feeder as before. The reset never waits for the leave: if the disconnect hangs or the task is busy, the reset comes when it always did. - Supervision stops leave too, once certain. A stop that
supervise::feeddecides, such as a trial's 10-minute deadline or a hung task, is not a latch. Feeding resumes if the condition turns true within the watchdog's 1 s progress gap, so a sub-second false costs nothing and must not take the network down.supervision::supervisedcallsstopping()only once the stop has lasted the whole gap, when the reset is certain. A refused checkpoint calls it at once. An unconfirmed trial boot then leaves, and its next trial boot does not meet status 30. - The Wi-Fi task. The shim checks
wellfed_wifi_restarting()every 50 ms of its watch wait. The nextwellfed_wifi_watchanswers leave with cause 4 (Cause::Restart) whatever the link shows. The task stops DHCP, callswifi_disconnect()from its own context (the driver queues a protected deauthentication), waits up to 1 s forwifi_is_connected_to_apto report the association gone, logskind=leaveand parks. It joins no more, between attempts or after a join's turn waited. - Inside an attempt it checks twice more. After the scan (up to 10 s), so a restart asked for meanwhile never calls
wifi_connect. And right after a successfulwifi_connect, so an association made while the restart was pending is left at once with the same disconnect, wait andkind=leaveevent, and DHCP never runs. - Callers. Every reboot the device API or setup asks for already waits for its reply to go out (
reboot_at,Setup::reboot_due). The update stage waits for its answer (LEAVE_MS). So disconnecting at the request loses no answer.
The timing is tight but fits:
- A requested restart. From the request to the end of the wait takes at most about 2 s: a gateway echo in flight (1 s), then the 1 s wait. The watchdog's shortest expiry less a feed period is 2.18 s at the clock's 54 kHz bound (
config::WATCHDOG), and about 3.75 s at the measured 4.0 s expiry. - A supervision stop. The decision comes on the feeder pass 1 s after the stop began, about 1.25 s after the last refresh (passes are 250 ms apart). That leaves about 2.75 s at the measured 4.0 s expiry, but only about 1.2 s at the 54 kHz bound (2.43 s shortest). A leave takes a 50 ms wake plus the disconnect, up to about 2 s with an echo in flight. It fits the measured expiry and may not fit the bound, where the disconnect is best effort.
If a restart comes while wifi_connect or LwIP_DHCP runs, the leave waits for that call to return. If the reset comes first, the association it was making may outlive the boot.
Silent watchdog resets#
A hang that stops the watchdog feeder task itself prints nothing: no PLSUP1 stop, just the ROM banner about 4 s later and reset=00100000. To explain such a reset, the FreeRTOS tick hook leaves a breadcrumb every tick in backup register 2, which survives the reset. The builder sets configUSE_TICK_HOOK, and the shim's vApplicationTickHook calls wellfed_tick_hook (wellfed_hal::supervision::crumb). The next boot prints the crumb once, before anything else runs:
PLSUP1 hang kind=spin task=wellfed-wif since_ms=3968 suspended=0
taskis the task the last tick interrupted. It prints as the name's first three characters in lower case, or the three after the sharedwellfed-orwellfed_prefix.wellfed-svcis the service task,wellfed-wdgthe watchdog feeder,wellfed-loothe feeder's loop,wellfed-wifthe Wi-Fi task andwellfedthe diagnostic task (a name of exactlywellfed-prints the same).tmris the timer service,idlidle,cmdandrtwthe Wi-Fi driver's threads, andnonemeans no task yet. The builder raisesconfigMAX_TASK_NAME_LENfrom the vendor's 10 to 16 so the suffix survives.since_msis how long since the watchdog feeder task last ran, in 128 ms steps. 8064 means that long or longer. The feeder runs every 250 ms.kindisspinwhensince_msis 2048 or more: the tick kept running without the feeder, so a task kept the CPU, andtasknames it (suspended=1if the scheduler was suspended). It ismaskedwhen less: the tick stopped while the feeder still ran, so interrupts were masked or the tick itself stopped, andtasknames what was running then. It isstoppedwhen the feeder had stopped feeding on purpose; the line says what ran until the reset.- A
spinnamingidlmeans the CPU was free. The feeder task was blocked, not starved, so look for what it waits on rather than a busy task.
Limits:
- Only after a KM4 watchdog reset (
BOOT_KM4_WATCHDOG). Any other boot clears the register silently, and a cold boot finds it zero. - Tickless idle. The SDK runs with
configUSE_TICKLESS_IDLE1. While every task sleeps, idle stops the tick and FreeRTOS steps the count afterwards without calling the hook. Suppressed ticks are not counted, sosince_mscan read short when the system was mostly idle. A spin keeps the CPU busy, so it never suppresses ticks. - Inside a flash lease no tick runs, because the lease masks interrupts. A hang there is reported by the lease fault's line instead.
- Cost. A few loads and one backup-register write per 1 ms tick.
Register 2 was free because the SDK leaves registers 1–5 to the application and the others are taken: 1 holds the flash attempt and update bits with the lease fault, 3 and 4 the two cores' boot counts (4 also the trial), and 5 KM0's heartbeat. Register 2 once held KM0's park-handler diagnostics, which nothing read, and KM0 no longer writes it. The crumb word is a 3-bit tag, the name in 6-bit characters, the two flags, since and a 2-bit check, so a stray value is rarely taken for a crumb.
Evidence#
- Startup report. Observed: an artifact-bound startup passed
wellfed-audit startupon the research unit. This does not establish long-term uptime, persistent boot counters or a verified physical reset cause. - Stopped outputs. Observed: exact GPIO readback, an artifact-bound normal boot and operator-reported no motion. Independent voltage and infrared-off measurements, repeated measured resets and transient checks belong to the electrical output verification (motor driver). The single direction-register enable passed 50 cold boots on the analyzer (observed, October 27).
- PB3 and SWD. Observed: before startup released SWD, PB3 was never driven, and its LOW readback was the pad's pull-down (optical inputs).
- Shared-pad power. Observed: a capture with the new order showed an intact
PLPWR1ahead of KM4 output withafter_pad=00000301, the LDO powered with a zero trim. A later build with the trim and per-step Port A sampling read the same word at every step. The removedtrace-stock-pad-power.py(in Git history) executed the stock initializer and LDO instructions against modeled registers and delays, and its fixtures distinguished LDO power from the pad-enable bit: recovered, not an electrical measurement. - Indicator pins and polarity. Observed: with the shared-pad prerequisite restored, the operator saw all four indicators blink twice and finish dark, establishing HIGH-on and LOW-off. The output-only build then passed the auditor on a single boot: intact
PLPWR1with trim 0x2A, PA0, PA2 and PA4status=output, PB22pass, terminal LOW. An earlier capture (HIGH latch with LOW Port A input, two operator boots, interleaved KM0 and KM4 reports) keeps its failing verdicts. - Radio hold. Observed (
fw40-diag-01,fw40-diag-03-wifi): the first WPA3 join starves lower-priority tasks for about 700 ms, past a startup probe's 300 ms owner lease. That is why the radio waits forstartup_done. - Link liveness. Recovered and modeled (October 25): static analysis and host tests; not yet run on hardware. On the bench hotspot, which has no forwarding, the old DNS check passed only because the hotspot answers DNS locally. The bench host's firewall accepts ICMP on every interface, so expect
kind=gateway result=0every 5 s and a hotspot outage to show as an association or gateway loss. A run with forwarding blocked is still to come. - Status 30 hangs. Observed: 24 of 441 status-30 rejects on our builds stalled KM4, 17 of 35 on the October 25 evening builds, and 8 of 8 on stock. Status 53 never did. The leave itself is recovered and modeled (October 25), not yet run on hardware.
- Silent-reset crumb. Recovered and modeled (October 25). The first hardware crumbs all read
task=wellfed-, because the vendor's 10-character name limit cut every runtime task's name; the builder raised it to 16 on October 26.