This is the console app for the DC34 badge.
To upload images to the badge, see this repo: https://github.com/bunnie/dc34-image
setup_accel in src/power.rs owns the LIS2DH12 and configures it once at startup:
CTRL_REG1 = 0x67 200 Hz, LPen=0, XYZ enabled
CTRL_REG4 = 0xA8 BDU=1, +/-8g, HR=1 (12-bit)
12-bit high resolution is the lowest-noise mode this part has; there is no "low noise" control bit on the LIS2DH12, unlike some other ST accelerometers. At +/-8g in HR the sensitivity is 4 mg/digit, which is the same as +/-2g in Normal mode - so the range is four times wider at no cost in resolution.
The interrupt thresholds and durations are not in physical units, and depend on both settings above. Thresholds are in LSBs whose weight comes from the full scale (16 mg at +/-2g, 62 mg at +/-8g), and durations count ODR samples (5 ms each at 200 Hz). The current values and what they mean:
INT1_CFG = 0x7F 6D movement recognition } asleep
INT1_THS = 5 310 mg motion/wake amplitude }
INT1_DURATION = 24 120 ms how long it must be sustained
INT1_CFG = 0x3F OR of six events - plain threshold } awake
INT1_THS = 1 62 mg motion amplitude }
INT1_DURATION = 4 20 ms how long it must be sustained
INT2_THS = 12 744 mg orientation flip amplitude
INT2_DURATION = 64 320 ms flip debounce
INT1 has two settings because it does two different jobs, and the INT1_CFG mode matters more than
the threshold - see "Wearing the badge keeps the screen on" below. accel_enable_int loads whichever
set applies, and setup_accel writes the sleep set as the initial state.
If you change the ODR or the full scale, recompute all six. Leaving them alone silently retunes
the badge's behaviour rather than producing any error. As a concrete example, moving from +/-2g to
+/-8g without touching INT2_THS turns a 768 mg orientation threshold into 2976 mg, which gravity
cannot reach - a flip only swings about 1000 mg - so screen rotation stops working entirely.
On battery the idle screen asks for a 36-second timeout (SHORT_TIMEOUT in the vault's
update_power_state), after which the power manager blanks the screen and suspends. Only
PowerManagerOp::KeyPress used to refresh last_action_time_ms, and accel_enable_int(.., false) on
Boot left INT1 unrouted while awake, so a badge worn in a stable orientation looked completely idle no
matter how far it was carried. Orientation changes inject a 🔽/🔼 key and did count, which is why a
swinging lanyard stayed lit and a steady one went dark.
Three things had to change together, and any one alone does nothing:
-
INT1_CFGswitches to the threshold generator while awake. This is the one that actually mattered, and it is easy to miss because it looks like a tuning constant. Bit 7 is AOI and bit 6 is 6D, and together they select between different hardware functions:0x7F(AOI 0, 6D 1) is 6-direction movement recognition, which fires when the acceleration vector crosses between direction zones, while0x3F(AOI 0, 6D 0) is the OR of the six high/low events and fires when acceleration simply exceedsINT1_THS. A badge carried steadily in one orientation never changes direction zone, so under 6D it produces no interrupt however far it walks - and lowering the threshold does not help, because it only makes the wrong question more sensitive.6D also pairs badly with the high-pass filter
CTRL_REG2enables on this generator. 6D needs the raw gravity vector to know which zone it is in and the filter removes exactly that, leaving it responsive only to sharp transients. That is precisely why picking the badge up woke it while wearing it did not keep it awake. The threshold generator is what the filter is meant to pair with. -
I1_IA1is routed while awake, not just asleep. The engine cannot reach the pin otherwise. -
The
MotionIrqhandler refresheslast_action_time_ms. Otherwise the interrupt fires, gets acknowledged, and the idle timer keeps counting down regardless.
That refresh is gated on !wfi_awaiting_keypress. Once suspended, relighting the screen belongs to the
keypress handler, so extending the timer after sleep would hold the badge out of suspend with the
display dark - spending battery to show nothing.
The handler also pushes the RTC deep-sleep alarm, because that alarm is only refreshed by keypresses and by INT1; at 25 minutes on battery it would otherwise expire mid-walk and take the screen with it. It is rate-limited to once a minute, since setting it costs an I2C transaction on the bus the sampler is already driving at 50 Hz.
accel_pause_int's resume path restores both I1_IA1 and I1_IA2. Restoring only orientation - as
it originally did - means the first datalog capture silently ends the keep-awake behaviour for the rest
of the session, because a capture pauses and resumes around itself.
62 mg is already the finest step +/-8g offers, so if the screen still sleeps on you the knob is
INT1_DUR_AWAKE, not the threshold. In the other direction, every trigger costs the power manager
several I2C reads on the bus the 50 Hz sampler shares, so if the classifier degrades after this change
raise the duration: check motionclf status for a climbing gap_skips or a worst_delta well above
roughly 240 ms, which is what a sampler starved by interrupt traffic looks like.
src/accel_source.rs owns the single sampler thread and the read-only sensor discipline described
under "Why the logger never configures the sensor" below. Consumers register a SampleSink and each
receives every sample; the thread starts on the first registration and exits on the last, so an idle
badge runs no sampler.
There is deliberately one poller rather than one per consumer. Two would double I2C traffic and give the consumers timestamps that disagree, which would make a CSV capture and a live classification of the same motion impossible to line up.
Sample::t_ms is relative to when the source started, not to any consumer. A consumer that needs
its own zero point rebases against its first sample, which is what the CSV logger does so that
t_ms in a capture still means "milliseconds since this capture started".
src/accel_logger.rs streams labelled, timestamped accelerometer samples off the badge as CSV, for
building training datasets offline. It is collection only; inference is src/motion_clf.rs.
Build it in with --features datalog (which selects the USB sink by default). --features datalog-uart sends to UART2 instead, for a physical debug-header tap with no USB contention.
datalog label <0..65535> set the label applied to subsequent samples
datalog start emit the header and begin sampling
datalog stop drain, emit the trailer, stop
datalog status running state, label, and counters
One header line, then one line per sample:
# fmt=csv v2 odr=Hz200 period_ms=20 fs=8g mode=highres units=mg label=3 t0=123456
t_ms,x_mg,y_mg,z_mg,label
t_ms is milliseconds since the capture started, taken on the badge - host-side timing is useless
here because USB and tty buffering reorder arrival. Values are integer milli-g; there is no floating
point anywhere in the path.
The header reports the rate, full scale, and mode read back from the device, because the logger
does not choose them (see below) and must not claim settings it did not make. period_ms is the
sample period it settled on, which is not always implied by odr - see below. Always trust the
header over anything written here: the values above are an example, not a guarantee.
Marker lines report every form of loss, since a gap that is not marked will silently misalign labels during training:
# drop ring=<n> sampler could not store a record; egress fell behind
# drop tx=<n> transmit path refused bytes
# lag n=<n> sample deadlines missed
# end count=<n> dropped_ring=<n> dropped_tx=<n> lagged=<n>
A take is suspect when any of those counters is non-zero; the # end trailer always spells all
three out, so match on the value rather than the word. host/capture.py does this and prints "take
looks clean" when they are all zero.
cd host
./capture.py --list
./capture.py --label walking --seconds 60
Writes raw/<label>_<timestamp>.csv plus a .meta sidecar holding the header, every marker, the
measured rate, and a suspect flag. The label number-to-name map lives in the script, so classes can
be renamed or regrouped without reflashing.
The accelerometer belongs to the power manager in src/power.rs, which owns it, claims its interrupt
pin (PC15), and drives wake-on-motion and screen orientation from it. The logger is a second reader
of the same device and writes no sensor register:
- It attaches with
Lis2dh12::attach()rather thanLis2dh12::new(), becausenew()re-runs the driver'sinit_defaultsand would wipe the power manager's configuration. Runningtest accel, which does construct a second driver, demonstrates that failure: it zeroes CTRL_REG2/3/5 and drops the orientation routing until the next reboot. - It reads only
OUT_*and the scaling registers. It never readsINT1_SRCorINT2_SRC, because the power manager latches INT1 and reading a latched source register clears it, consuming a wake or orientation event. - It leaves the output data rate, full scale, and operating mode alone, and instead reads the rate
back and follows it. Reconfiguring any of those would silently retune the power manager's wake and
orientation behaviour, because
INT1_THS/INT2_THSare in LSB units that depend on the full scale andINT1_DURATION/INT2_DURATIONcount ODR samples rather than milliseconds.
Consequently there is no sensor state to restore when a capture ends. Capture does send the existing
PauseAccel opcode, the same one the vault uses, to stop interrupt churn from disturbing a take; that
opcode only masks CTRL_REG3 routing and re-arms INT2_CFG, and does not touch the data rate, full
scale, or power state.
The sample period is whichever is slower: the 50 Hz target, or the device's own output rate. Polling
faster than the part converts just returns the same reading twice, padding the dataset with duplicates
that misrepresent the sampling process, so the sampler never does it. period_ms in the header is the
period actually used.
setup_accel currently runs the part at 200 Hz, so captures land at the 50 Hz cap by taking every
fourth conversion. If the power manager's tuning changes the rate, the logger follows automatically
and the header reflects it - no firmware change needed here.
Sampling slower than the device converts is plain decimation with no anti-alias filter, so energy above half the sample rate folds into band. This is fine for human-activity sensing, where the signal is below roughly 10 Hz, but a sharp tap or structural vibration will alias. If that shows up in captured data, average the conversions within each sample window instead of taking one, at the cost of proportionally more I2C traffic.
src/motion_clf.rs classifies motion in real time from the same sample stream, using integer features
and a decision tree compiled into the firmware. Build it with --features motionclf; it can run
alongside datalog, since both are consumers of the shared source.
motionclf start begin classifying
motionclf stop stop, and report windows, gap skips and worst sample delta
motionclf status running state, class and margin, counters, live device settings
motionclf model what the compiled-in model was trained on
A window is 100 samples with a 50-sample hop, so at 20 ms per sample a fresh classification appears once per second over the trailing two seconds. The firmware only ever handles a class index, and that index is the same number the capture was labelled with, so classes can be renamed or regrouped host-side without touching firmware logic.
The configured classes are walking, talking, standing, sitting and off-human. walking and
off-human are easy to separate; talking versus standing is the hard pair, since both are "worn and
still" and differ only by the vibration speech puts through the torso.
The classifier starts by itself at boot, so a badge that is simply powered on shows a class without anyone typing anything. That auto-start waits for the power manager to register its name, because the power manager configures the sensor to ±8 g high-resolution as part of coming up, and starting before that would read the driver's ±2 g defaults and mis-scale every sample for the rest of the run.
Two ways to change the state afterwards:
- Badge menu → Motion Sensing toggles it. The label is fixed rather than showing the current state,
because the menu is built once at startup and would otherwise display whatever was true then. There is
deliberately no confirmation dialog:
show_notificationblocks the vault's main loop until a key arrives, and firing one at the moment the menu is closing is a worse failure than no confirmation. The icon appearing or vanishing on the idle screen is the feedback. motionclf start/motionclf stopfrom the serial shell, which do the same thing.
The menu route is PowerManagerOp::SetMotionClf, a blocking scalar taking 0, 1, or "anything else means
toggle" and replying (enabled, supported). Two details in that handler are deliberate. The console
resolves the toggle, rather than the vault reading the state and writing back the opposite, so two quick
presses cannot both read "off" and both turn it on. And the start or stop runs on a spawned thread rather
than in the server loop: starting registers a sink and then waits for the sampler to read the device and
publish its period, and that same loop services the accelerometer interrupt, so blocking there to wait for
the sampler would mean waiting on work this very thread has to perform. supported is 0 on a build
without motionclf, which is what lets the menu say the feature is absent instead of claiming a state it
cannot reach.
start and stop each take Control::transition for their whole duration. start marks itself running
before it has a sink id and stop unregisters whichever id it finds, so interleaving the two leaves a
sink registered while nothing is running: the sampler then holds a consumer it will never hear from
again and never exits.
An earlier version put that lock in set_enabled only. That is not enough, and the failure is worth
recording because it looks nothing like a locking bug from the outside. The shell commands and the boot
auto-start call start/stop directly, bypassing the wrapper, so a menu-driven start sitting in
wait_for_config could still race a shell-driven stop - which is exactly what happens when a host
capture script stops the classifier while someone is pressing the menu. Every race stranded another
WindowSink, and since the sampler walks every registered sink under one lock on every sample at 50 Hz,
the cost compounded until it missed its deadline and the badge froze. There was no watchdog rescue,
because the suspend path calls wdt.disable() before initiate_suspend().
set_enabled is now a thin delegate that deliberately does not take the lock: std::sync::Mutex is
not reentrant, so holding it and then calling start would deadlock on first use.
stop also detaches whatever sink id it finds even when running was already false, so a sink stranded
by an interrupted start self-heals instead of lingering for the life of the process.
Relatedly, accel_source's sampler clears its published config inside the same critical section that
clears running, not after the loop. Clearing it later races a restart: register sees running == false, spawns a fresh sampler that publishes a config, and the dying thread then wipes it. Nothing
republishes, so every later wait_for_config times out and the classifier can never be turned back on.
The class appears on the badge display, but dc34-console does not draw it. dc34-vault asks for the
current class over IPC during its own redraw and draws it itself.
That split is not incidental. Both are separate processes writing one framebuffer with no arbitration -
the last flush wins - and the vault repaints its idle screen every few seconds. A label drawn from this
crate appeared and was erased moments later, which is what a user sees as a blip. Repainting often enough
to look continuous also cost real samples: pushing the 128x128 buffer over SPI contended with the sampler
badly enough to raise the worst inter-sample delta from 47 ms to 139 ms and discard about one window in
eleven, with sh1107 timeout in draw warnings alongside. Having the owner of the screen draw during a
repaint it was doing anyway removes both problems for free.
The mechanism is PowerManagerOp::GetMotionClass, a blocking scalar on the existing power-manager server
returning (valid, class_index). valid is 0 when the classifier is not running or has not filled a
window yet, and the vault draws nothing in that case. When motionclf is not compiled in, the opcode
still exists and always answers invalid, so the vault needs no feature knowledge.
Class names come from dc34_api::motion_class_name(), generated into dc34-api by train.py from the
same run as the model. Putting them in the shared crate is what keeps the names the UI shows from drifting
away from the classes actually trained - the same failure mode as capture.py and train.py disagreeing
about what index 2 means.
The vault reads the class once per redraw and uses it for both the picture and the label. Asking twice would mean two blocking round trips to the power manager every frame, and the two answers could disagree - drawing one class's icon above another class's name.
In dc34-vault/src/ux.rs, the idle screen splits 128x128 with nothing wasted:
- Rows 0..95 - the picture. When a class is available this is its icon from
bitmaps::motion_icons; otherwise it is the logo,bitmaps::dc_logo_small, generated bydc34-vault/tools/shrink_logo.py. The logo's ink is 101x92 centred in that area with 2 px above and below, so it is actually larger than the original 93x85 while still freeing the bottom. The full-size logo is still used for the boot splash, where nothing overlaps it. - Rows 96..127 - the class label, 30 px tall (
GlyphStyle::ExtraLarge).
The generator's --area-bottom and the STATUS_BAND_TOP constant in ux.rs are a matched pair; change
one and the other must follow, or the picture and the text will overlap or leave a gap. Both
shrink_logo.py and gen_motion_icons.py take --area-bottom, so all three have to agree.
dc34-vault/tools/gen_motion_icons.py holds the art as editable ASCII grids and emits
src/bitmaps/motion_icons.rs, in the same [u32; 512] format shrink_logo.py produces - 4 words per
128-pixel row, bits LSB-first, and a set bit is background while a clear bit is ink, so a blank
screen is all-ones. --preview renders each icon at full resolution without writing anything.
Two rules govern whether these read at all on a 1-bit panel:
- Solid silhouettes, not stick figures. These follow the AIGA/DOT pedestrian pictograms, the copyright-free 1974 set behind every crossing signal: filled shapes with deliberate negative space between limb and torso. A one-pixel limb merges into the body at this size.
- Integer scaling only. Scaling 32 px art by 2.875x rounded some strokes to 3 pixels and others to 2, which is what made the first attempt look ragged. Everything now scales by exactly 3x via pixel replication. Keep the ink small enough that a whole-number multiple fits the area.
The lookup is for_class_name, keyed by name rather than index for the same reason as everything else
here: indices are regenerated with the model. A class with no art, or no classification at all, falls
back to the logo, so the screen is never blank while the classifier is off or filling its first window.
If a custom user image is loaded, that path only repaints on its 3-second alternation, so it also checks whether the class changed - otherwise the previous activity's icon lingers for up to three seconds after the badge has changed its mind.
There is no DEV MODE indicator any more - the class label took that space. BIO ACTIVE alternates with
the class in the same band rather than drawing over it, since they occupy the same rows.
class_glyph_style picks the largest style that fits, and it measures rather than estimates: widths
come from blitstr2's own per-character wide and kern, summed locally with no IPC. Measured widths on
this screen:
style Walking Talking Standing Off-Human
Bold 45 41 49 64
Tall 46 40 50 67
Large 57 53 66 91
ExtraLarge 69 65 80 107
So even the longest name fits at 30 px in a 128 px screen. Two earlier attempts at this went wrong and are
worth not repeating. Estimating width as a fraction of glyph height put Off-Human over budget and forced
a needlessly small font. Asking the graphics server to measure via bounds_compute_textview costs a round
trip per candidate whose client side ends in an unwrap on the returned buffer, and the build carrying
it did not boot. Reading the font metrics directly is both exact and free.
Labels are capitalized for display only (off-human renders as Off-Human), so the names stay lowercase
where they are typed and used as filenames.
margin is the tree depth reached, a coarse confidence proxy - more comparisons agreed. It is not a
probability. Real posteriors need softmax, and the core has no FPU.
The checked-in src/clf_model.rs is trained on real captures - four takes of five minutes each,
1196 windows, balanced at 299 per class. It is 15 nodes at depth 6 and uses 7 of the 37 features
(x_std, x_max, y_rms, z_min, z_max, z_rms, m_mean).
accuracy, time-split held-out: 0.972 <-- trust this one
accuracy, random held-out: 0.994 (optimistic: overlapping windows leak)
accuracy, training set: 0.998 (means nothing on its own)
Trust the time-split number. Windows overlap by half, so a random split puts near-duplicate windows on
both sides of the divide and scores far too well; train.py reports both and labels which is which.
With a single take per class even the time split is optimistic, because it cannot tell an activity apart
from the circumstances of that one recording.
walking, standing, off-human and sitting are trained. talking is named but has no data, so
the tree can never emit it - motionclf model marks it (untrained) rather than implying it is
predictable. talking versus standing is expected to be the hard pair, since both are "worn and
still" and differ only by the vibration speech puts through the torso.
If you retrain, note that --synthetic still exists and produces a corpus that proves the pipeline
end to end without recognising real gestures. Do not ship it.
New to this? Follow host/HOWTO.md, a step-by-step walkthrough. The short version:
cd host
./capture.py --label walking --seconds 60 # repeat per class
./train.py --data raw # or --synthetic
cd ../../.motionclf-check && cargo test # the gate
# then rebuild and flash
train.py writes two checked-in files: src/clf_model.rs (the tree as const arrays plus a pure
classify) and src/clf_golden.rs (golden vectors). Both are dependency-free.
Class indices come from the capture labels, not from the model's internal ordering, so CLASS_NAMES[i]
names class i even when the labels used are not contiguous; unused indices read unused. Keep the
LABELS map in capture.py in step with SYNTH_CLASSES in train.py, or a take labelled 2 will be
scored against a model that thinks 2 means something else.
For real captures, train.py reads the period, full scale and mode out of the .meta headers rather
than trusting its own defaults, and refuses to train on a corpus that mixes sample rates - jerk sums
and crossing rates are not comparable across periods.
The host trains on features it computes in Python; the badge computes them in Rust. If those two
disagree, the model is evaluated on different numbers than it was fitted to, and nothing reports an
error - accuracy is just quietly worse. So train.py emits real windows with their expected
features and class, and .motionclf-check/ asserts the device code reproduces all of it exactly.
This is not theoretical. Rust integer division truncates toward zero while Python's // floors toward
negative infinity, and they disagree for every negative value - which includes most means on a
gravity-loaded axis. When this was first measured, using // changed 9 of the 37 features in every one
of the 156 windows then in the corpus. Treat the gate as the acceptance criterion: "it looked right on
hardware" cannot detect a silent integer bug.
dc34-console is a binary crate cross-compiled for riscv32imac-unknown-xous-elf and its
dependencies do not build for the host, so cargo test cannot reach code inside it. That is why
clf_features.rs and clf_model.rs have no dependencies and .motionclf-check/ includes them by
#[path]. Adding a xous or bao1x_hal import to either file breaks the gate.
Jerk sums, crossing rates and variances all scale with the sample period, and the sampler follows
whatever rate the power manager set. So a retune in setup_accel would silently invalidate the model.
The artifact records TRAINED_PERIOD_MS and the classifier refuses to start on a mismatch, telling
you the period to retrain at. Full scale and mode only warn, since features are in milli-g and
therefore already scale-normalised - the effect there is quantisation noise, not a change of units.
Unlike datalog, the classifier does not send PauseAccel. It is meant to run continuously, and
pausing would leave wake-on-motion and orientation disabled for its whole run. It tolerates the
interrupt churn instead, which is the power manager's normal behaviour.
Windows that span a sampling gap are skipped rather than classified, because a discontinuity would produce a confident answer about a signal that never existed. Gaps are detected from the timestamps in the window itself, so a stalled classifier is caught as well as a lagging sampler.
A gap means an inter-sample delta above four times the period. That bound is empirical, and the history is worth knowing before anyone tightens it: at two periods (40 ms) the badge discarded 45 of 91 windows while the sampler was in fact keeping up, because measured jitter peaks around 47 ms. One sample arriving a period late is jitter, not missing data, and with a 100-sample window and 50% overlap a single such sample poisoned two full windows.
motionclf status therefore reports worst_delta next to gap_skips, so the two situations stay
distinguishable: skips with a worst delta near the threshold mean the threshold is too tight, whereas
skips with a much larger delta mean samples really were missed. Observed values on this badge are about
47 ms with the classifier alone (0% skipped) and up to 145 ms with a capture running at the same time
(a few percent skipped, which is roughly six genuinely missing samples and correctly discarded).
Note the sampler's own lagged counter can read 0 while these deltas are large, because it only checks
the deadline before each I2C read; a delay during the read escapes it. That is exactly why the
classifier measures gaps from the data instead of trusting a counter.
The classifier thread is spawned with an explicit 128 KiB stack and the sampler with 32 KiB, unlike
every other thread in this crate, which uses std::thread::spawn's default. clf_features::extract
alone puts 1.6 KiB of widened channel arrays on the stack, and several xous-core services (shellchat at
8 MiB, pddb, gam, modals, status and graphics-server at 1 MiB) set theirs explicitly rather than trust
the default - reason enough not to rely on it for the deepest stack user here.
Relatedly, SampleSink::on_sample must only buffer and return. The classifier's sink writes one sample
and bumps a counter; the window copy and all the arithmetic happen on the classifier thread. An earlier
version snapshotted the whole window inside on_sample, which meant 100 iterations and a 600-byte
struct built on the sampler thread while the global sink registry lock was held.