Skip to content
 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

80 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

DC34 console

This is the console app for the DC34 badge.

To upload images to the badge, see this repo: https://github.com/bunnie/dc34-image

Accelerometer configuration

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.

Wearing the badge keeps the screen on

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:

  1. INT1_CFG switches 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, while 0x3F (AOI 0, 6D 0) is the OR of the six high/low events and fires when acceleration simply exceeds INT1_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_REG2 enables 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.

  2. I1_IA1 is routed while awake, not just asleep. The engine cannot reach the pin otherwise.

  3. The MotionIrq handler refreshes last_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.

Shared sample source

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".

Accelerometer data collection

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.

Commands

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

Wire format

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.

Host capture

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.

Why the logger never configures the sensor

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 than Lis2dh12::new(), because new() re-runs the driver's init_defaults and would wipe the power manager's configuration. Running test 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 reads INT1_SRC or INT2_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_THS are in LSB units that depend on the full scale and INT1_DURATION/INT2_DURATION count 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.

Sample rate

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.

Aliasing

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.

On-device motion classifier

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.

Turning it on and off

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_notification blocks 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 stop from 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.

The start/stop lock has to be inside start and stop

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.

On-screen output: the vault draws it, not this crate

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.

Screen layout

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 by dc34-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.

The class icons

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.

Sizing the label

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 current model

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.

Train and deploy

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.

Why there is a golden-vector gate

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.

The model is sample-rate dependent

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.

Coexistence with the power manager

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.

Reading the gap counters

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.

Thread stacks

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.

About

DC34 console

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages