MAGDA — analog magnetic switch library for QMK
Location: the QMK community module acheron/mag, directory mag/ of the acheron_qmk_modules repository, added to a QMK tree as modules/acheron (F13).
This page is the specification of the MAGDA (Magnetic AnaloG Distance Analyzer) library, for maintainers: design, algorithms, data layout, hardware findings and open issues. Using a magnetic-switch keyboard: Magnetic Switches; porting a board: MAGDA driver documentation and API reference.
This library turns per-key analog magnetic readings into QMK's boolean matrix. The core only assumes that each key gives one scalar reading that changes monotonically with key travel. It makes no assumption about the direction of change or the sensor type. That covers linear Hall and TMR sensors alike.
First port: Praxis HE (reference design; development record ARCHITECTURE.md in the Praxis HE PCB repository, cited below as Praxis ARCHITECTURE.md).
Ground rules for the implementer:
- The names in this document are implemented and final. Any new macro, type, function, file name or notation needs approval before it is used; add none beyond those listed without asking.
- Make minimal, targeted changes; confirm scope before editing files outside the module repository.
- Verify every QMK/ChibiOS API and build mechanism against the current QMK tree before relying on it.
- Sections 1–7 describe current behaviour. Hardware findings and design history are in section 8 (referenced as F1, F2, …), open questions in section 9.
- The module directory holds QMK's module files —
qmk_module.json,config.h,rules.mk,post_rules.mkandmag.c(section 5) — and nokeyboard.json.
1. Layering
| Layer | Files | Depends on | Role |
|---|---|---|---|
| Backend | board code, or drivers/ |
ChibiOS/QMK | produce one raw frame |
| Core | mag_calib, mag_actuation |
nothing (pure C) | raw → travel → pressed/released |
| Glue | mag_matrix, mag_store, mag_process, mag |
QMK | matrix entry points, persistence, keycodes, module hooks |
The core compiles and runs on the host, for unit tests with recorded frames.
The glue owns the QMK custom-matrix entry points and the community-module hooks (mag.c, section 5), which QMK calls before the board's own *_kb functions, so the library never collides with them.
2. Backend contract
The board supplies exactly two functions and one constant:
void mag_hw_init(void);
void mag_hw_scan(uint16_t raw[MATRIX_ROWS][MATRIX_COLS]); // fill a full frame, blocking
extern const matrix_row_t mag_key_mask[MATRIX_ROWS]; // bit set = physical key present
mag_hw_scanreturns raw values for every position. Values at positions not inmag_key_maskare ignored.- A raw value must be monotonic in key travel. Full scale, offset and direction are learned per key, so no scaling is required.
- Optional weak hook:
bool mag_hw_selftest(void). It returns false if the backend detects a hardware fault, for example grounded mux inputs reading nonzero. The default returns true. The library does not call it yet; when and what to do on failure is still to be decided.
2.1 Optional factory calibration table
A board may ship per-key calibration values compiled into the firmware, so a PCB works calibrated from first power-up:
typedef struct { uint16_t rest; uint16_t bottom; } mag_cal_t;
const mag_cal_t mag_default_cal[MATRIX_ROWS][MATRIX_COLS] = { ... };
restis the low level andbottomthe high level, both as raw readings, in the same units the console calibration prints.- Direction is derived from
sign(bottom − rest), so the table carries no polarity field. - An entry with
rest == bottommeans "no factory value" for that key, so partial tables are allowed. - The library provides a weak all-zero default, so boards without a table need no code.
- Entries go through the same validity rules as learned calibration (section 3.2).
The values come from a calibration run on real hardware. Because the table is compiled in, every unit flashed with the same binary gets the same values. Unit-to-unit sensor variation is therefore absorbed only by boot-time rest capture and drift tracking, unless each unit gets its own build.
A board either implements the contract itself or uses a stock driver.
2.2 Stock driver: drivers/mux_adc
This driver covers the common topology of N analog muxes that share their select lines, with each mux's common pin on its own ADC channel.
Scanning works like this:
- Write select value s (0 … number of select steps − 1; see Geometry below).
- Wait
MAG_SETTLE_US. - Convert all N ADC channels as one group, using DMA.
Configuration via config.h:
| Macro | Meaning |
|---|---|
MAG_MUX_SEL_PINS |
{ S0, S1, … }, LSB first |
MAG_MUX_EN_PIN |
shared enable pin (optional) |
MAG_MUX_EN_ACTIVE_LOW |
define if the enable is active low |
MAG_MUX_ADC_PINS |
{ … }, one per mux; all on the same ADC, one conversion group; the driver currently fixes it to ADC1 (ADCD1) and uses only the channel number from pinToMux(), not its ADC |
MAG_MUX_PER_ROW |
mux index = matrix row, select value = column (otherwise transposed) |
MAG_SETTLE_US |
delay after select change |
MAG_ADC_SAMPLE_TIME |
ADC sample time, in the platform's own encoding |
Required vs optional. MAG_MUX_SEL_PINS, MAG_MUX_ADC_PINS, MAG_SETTLE_US and MAG_ADC_SAMPLE_TIME are required and have no library defaults (the driver stops the build with #error if one is missing), since they depend on the board. MAG_MUX_EN_PIN, MAG_MUX_EN_ACTIVE_LOW and MAG_MUX_PER_ROW are optional.
Geometry. No separate input-count macro: with MAG_MUX_PER_ROW there are MATRIX_ROWS muxes and MATRIX_COLS select steps, otherwise the reverse. Static asserts check that MAG_MUX_ADC_PINS has one pin per mux, that there are at most 16 muxes (one conversion group), and that the select pins cover the steps.
Implementation. drivers/mux_adc.c, selected with MAG_DRIVER = mux_adc. It uses ADC1 only (ADCD1, enabled by the board's mcuconf.h), 12-bit. The module's post_rules.mk sets ANALOG_DRIVER_REQUIRED = yes so QMK's pinToMux() (platforms/chibios/drivers/analog.h) maps each ADC pin to its channel; the driver never calls QMK's analogReadPin/adc_read, so it alone starts and uses ADCD1. mag_hw_init sets select pins as outputs, asserts the enable, puts the ADC pins in analog mode and builds one conversion group (all mux channels in mux order, each at MAG_ADC_SAMPLE_TIME). mag_hw_scan does, per select step: write select lines, wait_us(MAG_SETTLE_US) (busy-wait), adcConvert (blocking, DMA).
Assumed wiring. Mux input k is matrix position k, the identity mapping. Boards with scrambled mux wiring either write their own backend, or the driver gains an input map later, by agreement.
Platform coverage. ADC group setup is STM32-family specific. The driver initially targets the STM32 ADCv2 peripheral (F2/F4/F7 series). Other families are added as separate implementations behind the same config.
3. Core
3.1 Data
Keys are addressed by a linear index, row * MATRIX_COLS + col, over the full matrix. Absent positions are skipped.
Each key stores:
| Group | Fields | Kept where |
|---|---|---|
| Calibration | rest, bottom, flags (valid, direction) |
persisted |
| Settings | mode, actuation, hysteresis, rapidtrigger_down, rapidtrigger_up |
global; actuation, mode (rapid trigger on/off) and the rapid-trigger distance (rapidtrigger_down = rapidtrigger_up) are persisted (section 4.2), hysteresis is the mag_config.h default. mag_act_set() accepts per-key values, but nothing sets or stores them yet |
| Runtime | filtered value, travel, pressed, rapid-trigger peak/trough | RAM only |
3.2 mag_calib
API:
void mag_calib_init(void); // all keys uncalibrated, no rest
void mag_calib_set(uint16_t idx, uint16_t rest, uint16_t bottom); // stored or factory values
void mag_calib_boot_sample(uint16_t idx, uint16_t raw); // boot rest capture, MAG_CAL_FRAMES times
void mag_calib_boot_done(uint16_t idx);
uint16_t mag_calib_update(uint16_t idx, uint16_t raw, bool pressed); // returns travel
bool mag_calib_valid(uint16_t idx);
uint16_t mag_calib_get_rest(uint16_t idx); // current rest (after boot capture and drift)
rest == bottom in mag_calib_set leaves the key uncalibrated. Direction and validity are derived there.
Filter. An exponential moving average on raw values, with strength set by MAG_FILTER_SHIFT. A shift of 0 means no filtering.
Travel. MAG_TRAVEL_MAX (default 1023) is full travel. With the travel curve (below), travel is the fraction of the physical rest-to-bottom distance. With MAG_CURVE_LINEAR, or where the curve does not fit a key, travel is linear in the reading, with \(T_\mathrm{max}\) = MAG_TRAVEL_MAX:
The direction is sign(bottom − rest), learned per key.
Rest level.
- At boot: rest is the average of
MAG_CAL_FRAMESinitial frames. If that average falls outsideMAG_REST_WINDOWof the stored rest, the key is assumed held and the stored rest is kept. An uncalibrated key always takes the average. - During use: rest follows slow drift at a rate set by
MAG_DRIFT_SHIFT, only while the key is released (pressedfalse) and stable (filtered value withinMAG_REST_WINDOWof rest). Drift keeps fractional bits, so small offsets are still followed.
Bottom-out. bottom is learned in the calibration procedure (section 4.4). That procedure also stores a fresh rest.
Invalid keys. A key is marked invalid, and never actuates, if either condition holds:
- its span
|bottom − rest|is belowMAG_MIN_SPAN; - its rest reading sits at a rail: within
MAG_RAIL_MARGINof 0 orMAG_RAW_MAX.
A key with no rest level yet (before boot capture) is at the 0 rail, so it is invalid. Invalid keys return travel 0.
Travel curve. Field strength is strongly nonlinear in magnet distance: the reading barely moves while the magnet is far from the sensor (top of the keystroke) and changes fast near the bottom. A fraction of the reading span therefore does not feel like the same fraction of the keypress (F3). A travel curve converts readings to physical travel.
Interface (mag_curve.h). A curve is a function position(reading): any coordinate proportional to the magnet's physical position, with arbitrary scale, offset and sign. mag_calib normalises it per key with that key's calibration:
clamped to 0 … \(T_\mathrm{max}\), where \(p\) is the curve's position(reading).
Everything a curve does not know — each key's offset, gain, direction and travel length — comes from rest and bottom. A curve implements three functions:
| Function | Role |
|---|---|
void mag_curve_init(void) |
called once from mag_calib_init(); precompute here (e.g. a lookup table) |
float mag_curve_position(uint16_t reading) |
the position coordinate; runs per key per scan, so it must be cheap |
bool mag_curve_fits(uint16_t rest, uint16_t bottom) |
whether the model applies to a key with this calibration; if not, that key stays linear |
Selection. In the board's config.h: #define MAG_TRAVEL_CURVE MAG_CURVE_POWER (the default) or MAG_CURVE_LINEAR. Each curve lives in curves/<name>.c, wrapped in #if MAG_TRAVEL_CURVE == MAG_CURVE_<NAME>; the module's rules.mk compiles all of them and only the selected one has content.
Per-key constants and evaluation (mag_calib.c). Whenever a key's rest or bottom changes (calibration loaded, boot rest capture, drift), mag_calib asks mag_curve_fits(rest, bottom); if it fits it stores p_rest = position(rest) and p_scale = 1 / (position(bottom) − position(rest)). Each scan it clamps the filtered reading to the calibrated interval between rest and bottom — so position() is only evaluated where the model was declared to fit, and readings beyond rest or bottom give 0 or full travel — then computes \(t = \big(p(\text{reading}) - p_\mathrm{rest}\big) \cdot p_\mathrm{scale}\). Keys where the curve does not fit, and uncalibrated keys, use the integer linear formula above.
Curve MAG_CURVE_LINEAR (curves/linear.c). No conversion: mag_curve_fits() returns false, so every key uses the linear formula.
Physical model. The field of a switch magnet, on and off the sensor's axis, and why the shape of the travel curve depends on where the sensor sits: maker guide, step 11.
Curve MAG_CURVE_POWER (curves/power.c), for a magnet on the sensor's axis. The reading is the sensor's zero-field output \(B_0\) (MAG_ZERO_FIELD) plus the magnet's field \(B\), and the field magnitude falls off as a power of the magnet-to-sensor distance \(z\), with exponent \(n\) (MAG_FIELD_EXPONENT):
Solving for distance:
The keystroke moves the magnet from \(z_\mathrm{rest}\) (key up) to \(z_\mathrm{bottom}\) (bottomed out), so the travel fraction for a reading with field \(B\) is
The factor \(K^{1/n}\) — magnet strength, sensor gain, ADC scale — and the travel length in millimetres cancel. So the curve's position is simply
(decreasing toward bottom; the sign is absorbed by the normalisation).
Where each value comes from:
| Quantity | Source |
|---|---|
| \(B_\mathrm{rest}\) (per key) | calibrated rest − MAG_ZERO_FIELD; rest from low-level sensing (average of MAG_CAL_FRAMES frames), refreshed by boot capture and drift |
| \(B_\mathrm{bottom}\) (per key) | calibrated bottom − MAG_ZERO_FIELD; bottom from high-level sensing (deepest filtered reading while the key was pressed) |
| \(B\) (per scan) | filtered reading − MAG_ZERO_FIELD |
\(B_0\) = MAG_ZERO_FIELD (per board) |
the sensor's output with no field: VDD/2 for a ratiometric sensor on the ADC reference rail = MAG_RAW_MAX / 2 + 1 (default, 2048); from the sensor datasheet otherwise |
\(n\) = MAG_FIELD_EXPONENT (per board) |
3 for an ideal magnetic dipole (default); really a tuning value, fitted from readings at known depths (below) |
| \(K\), travel length, sensor gain | not needed: they cancel |
Implementation: mag_curve_init() builds table[f] \(= 1/f^{1/n}\) for every field magnitude \(f\) from 0 to max(MAG_ZERO_FIELD, MAG_RAW_MAX − MAG_ZERO_FIELD) (powf once per entry at boot; 8 KB of RAM for 12-bit readings; powf per key per scan would cost a large share of the CPU). mag_curve_position() is one table lookup. mag_curve_fits() requires rest and bottom on the same side of MAG_ZERO_FIELD (either magnet orientation works, only \(\lvert B \rvert\) is used), \(\lvert B \rvert\) growing from rest to bottom, and \(\lvert B_\mathrm{rest} \rvert \geq 16\) counts (below that, the result hinges on the exact zero-field value).
Assumptions and limits:
- The power law is an approximation: n = 3 is a magnetic dipole, valid far from the magnet; a switch magnet close to the sensor deviates from it.
- An error in
MAG_ZERO_FIELDshifts every \(\lvert B \rvert\) and bends the curve most for keys whose rest field is small. - Reported working by feel on Praxis HE; not validated against measured key depths (section 9).
- A key whose rest is within 16 counts of
MAG_ZERO_FIELDdoes not fit and uses linear travel, so it actuates deeper than its neighbours at low levels (section 9).
Exponent: factory value and user value. Like calibration, the exponent has a firmware ("factory") value — MAG_FIELD_EXPONENT in the board's config.h — that every unit ships with. It is fitted from raw readings at known key depths against t from the formula above; the procedure is in the maker guide, step 11. A user exponent overrides the factory one: it is stored in the datablock as n × 100 (field exponent; 0 = use the factory value, so blocks written before the field existed read as factory, F8), set from VIA (section 4.5), and applied by mag_init before calibration is loaded (mag_curve_set_exponent(), then per-key constants). A runtime change calls mag_curve_set_exponent(n) (the power curve rebuilds its table, a few ms) and must be followed immediately by mag_calib_refresh_curve(); in between, keys' curve constants belong to the old curve and travel is invalid. EE_CLR returns to the factory value. MAG_CAL_DUMP shows the effective exponent and whether it is factory or user.
- Off-axis sensors (e.g. TMR sensors beside the magnet, not under it) see a different field-versus-travel relation, which is not a power law (maker guide, step 11); they need their own curve.
Adding a curve (e.g. off-axis TMR):
- Add an id in
mag_config.h:#define MAG_CURVE_<NAME> <next number>, with a one-line description, and any parameters as#ifndef-guarded defaults. - Add
curves/<name>.c, includingmag_curve.h, wrapped in#if MAG_TRAVEL_CURVE == MAG_CURVE_<NAME>, implementing the four functions ofmag_curve.h.position()must be monotonic in travel between any fitting rest and bottom; precompute anything expensive inmag_curve_init(). - Add the file to the curves
SRCline in the module'srules.mkand toCALIB_SRCintests/Makefile, and add a host test (liketests/test_curve.c). - Document the model, its inputs and its fit conditions in this section.
Noise margin. The power curve is steep near rest, so a travel step near the top of the keystroke is only a few raw counts. For a typical Praxis key (rest 2230, bottom 2950, n = 3), in raw counts above rest:
| Actuation level | Actuation point | Release point (− 64 travel units) | Hysteresis gap |
|---|---|---|---|
| 10 % | 25 | 9 | 16 |
| 20 % | 54 | 35 | 19 |
| 30 % | 89 | 67 | 22 |
| 50 % | 183 | 150 | 33 |
| 90 % | 553 | 470 | 83 |
Filtered idle noise is about 10–13 counts peak-to-peak (section 4.4 measurement, reduced by the filter). Keys whose rest is closest to MAG_ZERO_FIELD have the smallest margins; on Praxis (n = 3.5) the tightest key actuates at 10 % 12 counts above rest.
The drift and hysteresis defaults come from hardware testing at low levels (F9): drift must be much slower than a finger resting on a key (MAG_DRIFT_SHIFT 16, ≈ 33 s time constant at ≈ 2000 frames/s), because drift runs while the key is released and within MAG_REST_WINDOW, which contains low actuation points; and the hysteresis gap must exceed filtered noise on the tightest keys (MAG_HYSTERESIS_DEFAULT 64, 6 % of travel). If a board needs more, the alternatives are a hysteresis with a minimum in raw counts, a separate, narrower drift window (both need new names), or a higher floor for the actuation range. How to test and tune this on a board: the maker guide.
The reading-to-travel mapping for the same key:
| Reading (% of span) | 5 | 10 | 20 | 30 | 50 | 70 | 90 |
|---|---|---|---|---|---|---|---|
| Travel (% of distance) | 14 | 25 | 43 | 56 | 74 | 86 | 96 |
Tests: tests/test_curve.c builds with MAG_CURVE_POWER (endpoints, shape against the formula, monotonicity, magnet reversed, each fit condition); tests/test_calib.c builds with MAG_CURVE_LINEAR.
3.3 mag_actuation
API:
typedef enum { MAG_MODE_FIXED, MAG_MODE_RAPID } mag_act_mode_t;
typedef struct {
uint8_t mode; // mag_act_mode_t
uint16_t actuation, hysteresis, rapidtrigger_down, rapidtrigger_up; // travel units
} mag_act_settings_t;
void mag_act_init(void); // all keys: global defaults, fixed mode, released
void mag_act_set(uint16_t idx, const mag_act_settings_t *s); // per-key override; see below
bool mag_act_update(uint16_t idx, uint16_t travel); // returns the key state
mag_act_set clamps actuation to 1 … MAG_TRAVEL_MAX and rapidtrigger_down, rapidtrigger_up to at least 1. A released key takes new settings at once and is reset. A key that is down (pressed, or armed in rapid trigger) keeps its current settings and state until it is fully released and its travel is below the new settings' release point, then takes the new ones. Both conditions prevent the key that caused a change from re-triggering it (F4, F5). mag_act_init resets every key unconditionally.
- Fixed mode. Press at travel ≥
actuation, release at travel <actuation − hysteresis. Ifhysteresis ≥ actuation, release is at travel 0. - Rapid trigger.
- Arming: after first crossing
actuation, the key is armed. - Release: on a drop of
rapidtrigger_upbelow the running peak. - Re-press: on a rise of
rapidtrigger_downabove the running trough. - Reset: full reset (released, disarmed) at travel <
actuation − hysteresis, same release point as fixed mode.
- Arming: after first crossing
This hysteresis replaces debouncing, so boards set QMK debounce to 0.
Rapid-trigger settings in use (section 4.3). Rapid trigger is switched on and off globally at runtime. Its distance is one value for release and re-press (rapidtrigger_up = rapidtrigger_down), set by the user in percent of travel (1–20 %, default MAG_RT_UP_DEFAULT / MAG_RT_DOWN_DEFAULT = 61 ≈ 6 %). It is separate from hysteresis, which stays MAG_HYSTERESIS_DEFAULT and sets both the fixed-mode release point and the rapid-trigger reset point, so a small rapid-trigger distance cannot bring back fixed-mode chatter. Like the hysteresis, the distance spans the fewest raw counts near the top of the keystroke (section 3.2, noise margin): on the Praxis calibration, near the reset point, 3 % spans 3–9 counts and 5 % spans 6–15 (smallest key to median), against 10–13 counts of filtered noise. On Praxis, a sweep down to 1 % showed no extra events at 50 % or 20 % actuation, against the model's prediction (F12); the lower limit stays 1 %. Boards set their own default distance (Praxis: 10 %). User documentation: Rapid trigger.
3.4 Configuration defaults
All in mag_config.h, #ifndef guarded; a board overrides them in its config.h.
| Macro | Default | Unit / meaning |
|---|---|---|
MAG_FILTER_SHIFT |
2 | EMA α = 1/2^shift; 0 = off |
MAG_TRAVEL_MAX |
1023 | travel at bottom-out |
MAG_REST_WINDOW |
64 | raw counts |
MAG_DRIFT_SHIFT |
16 | drift rate 1/2^shift per frame (≈ 33 s time constant at 2000 frames/s; section 3.2, noise margin) |
MAG_TRAVEL_CURVE |
MAG_CURVE_POWER |
travel curve: MAG_CURVE_LINEAR or MAG_CURVE_POWER (section 3.2) |
MAG_ZERO_FIELD |
MAG_RAW_MAX / 2 + 1 (2048) |
MAG_CURVE_POWER: sensor output at zero field, raw counts |
MAG_FIELD_EXPONENT |
3.0f | MAG_CURVE_POWER: field falloff exponent n, > 0 |
MAG_MIN_SPAN |
100 | raw counts; also the provisional actuation threshold |
MAG_CAL_FRAMES |
64 | frames averaged for a rest level |
MAG_CAL_NOISE_BOUND |
64 | raw counts; max variation while averaging a rest level in calibration (2 × Praxis idle p-p p90) |
MAG_RAW_MAX |
4095 | full-scale raw reading |
MAG_RAIL_MARGIN |
32 | raw counts |
MAG_ACTUATION_DEFAULT |
512 | travel |
MAG_HYSTERESIS_DEFAULT |
64 | travel (section 3.2, noise margin) |
MAG_RT_DOWN_DEFAULT |
61 | travel; rapid-trigger re-press distance when none is stored (6 %) |
MAG_RT_UP_DEFAULT |
61 | travel; rapid-trigger release distance when none is stored (6 %) |
MAG_DUMP_INTERVAL_MS |
100 | ms between MAG_DUMP frames |
MAG_SAVE_DELAY_MS |
1000 | ms after the last settings change before it is written |
The default mode is fixed.
4. Glue
4.1 mag_matrix
Implements matrix_init_custom() and matrix_scan_custom(matrix_row_t current_matrix[]), plus mag_init() and mag_task().
Init order. QMK runs matrix_init() (and so matrix_init_custom) before quantum_init() sets up eeconfig and the datablock, and calls keyboard_post_init() last, which runs the module hook keyboard_post_init_mag() (and so mag_init()) before the board's keyboard_post_init_kb(). Stored calibration is therefore only readable in mag_init(), so boot work is split:
matrix_init_custom:mag_hw_init,mag_calib_init,mag_act_init.mag_init: load the datablock (mag_store_load()), apply a stored user exponent (section 3.2), load calibration per present key (stored datablock, elsemag_default_cal, else provisional; section 4.2), apply the stored global settings (actuation point, rapid trigger and its distance,mag_settings_apply(), section 4.3) and report rapid trigger to the board (mag_rapidtrigger_kb()), then runMAG_CAL_FRAMESblocking scans for boot rest capture (section 3.2), then enable scanning.
Scan. Until mag_init has run, matrix_scan_custom reports no keys. After that:
mag_hw_scan(raw).- For each present key:
mag_calib_update(with the key's current matrix bit aspressed), then, if the key is valid,mag_act_update. Invalid keys still pass throughmag_calibso filtering and drift stay current; they never set their bit. - Write the resulting bits.
- Return true if any bit changed.
The last raw and travel frames are kept for MAG_DUMP.
Dump. mag_task prints one frame every MAG_DUMP_INTERVAL_MS while MAG_DUMP streaming is on (CONSOLE_ENABLE builds only). Format, one raw and one trv line per row, then a blank line. raw covers every matrix position, including absent ones (grounded inputs should read about 0); trv shows absent positions as -:
raw 0: 2048 2051 ...
trv 0: 0 0 ...
Weak defaults defined here: mag_hw_selftest() (returns true) and mag_default_cal (all zero).
4.2 mag_store
Persists calibration and settings in the module's QMK datablock (EECONFIG_MODULE_MAG_DATA_SIZE), behind a version and layout tag.
- A tag mismatch discards stored data rather than misapplying it.
- Source of calibration at boot, first available per key: stored datablock, then
mag_default_cal, then provisional actuation (section 4.4). - The calibration procedure (section 4.4) always overrides the table and any stored calibration for all keys, and its result is stored. Recalibrating therefore never needs an EEPROM clear.
- Stored calibration survives reflashing (the datablock is not part of the firmware image), unless the datablock format changes: then the version tag no longer matches and the stored data is discarded.
- Discarding calibration (back to
mag_default_calor provisional) takes an EEPROM clear: QMK'sQK_CLEAR_EEPROM(EE_CLR) invalidates eeconfig and soft-resets; the next boot'seeconfig_init()zero-fills the datablock. This also resets QMK's own settings (and a VIA keymap). It is the recovery path when a bad calibration makesMAG_CALitself unreachable, so boards should placeEE_CLRin their default keymap, away from keys used often on the same layer (F10). - The block is the module's own; the keyboard datablock stays free for the board's persistent data. QMK places module blocks after its own settings and the keyboard and user datablocks, and VIA's region after them (
quantum/nvm/eeprom/nvm_eeprom_eeconfig_internal.h,nvm_eeprom_via_internal.h). - Moving to the module block (F13) changed where the block sits: firmware from before that change and firmware after it do not see each other's calibration and settings, and VIA's region moved with it. After upgrading, the board starts from
mag_default_cal(or provisional keys) and default settings, and a VIA keymap resets; recalibrate once. - Writes are coalesced: marked dirty, flushed from the housekeeping task after
MAG_SAVE_DELAY_MS. This avoids a flash write on every change. Exception: the calibration procedure saves immediately on completion, so itsValues saved.message is true when printed.
Implementation (mag_store.c/.h): a RAM copy of the block, loaded by mag_init before boot rest capture.
void mag_store_load(void);
mag_cal_t mag_store_get_cal(uint16_t idx);
void mag_store_put_cal(uint16_t idx, mag_cal_t cal);
uint16_t mag_store_get_actuation(void); // travel units; 0 = not set (use default)
void mag_store_put_actuation(uint16_t actuation);
uint16_t mag_store_get_exponent(void); // user exponent × 100; 0 = not set (use MAG_FIELD_EXPONENT)
void mag_store_put_exponent(uint16_t n100);
bool mag_store_get_rapidtrigger(void); // rapid trigger on
void mag_store_put_rapidtrigger(bool on);
uint8_t mag_store_get_rapidtrigger_distance(void); // percent of travel; 0 = not set (use MAG_RT_*_DEFAULT)
void mag_store_put_rapidtrigger_distance(uint8_t pct);
void mag_store_save(void); // write now
void mag_store_task(void); // from mag_task: write MAG_SAVE_DELAY_MS after the last change
Datablock layout, format 1 (packed):
| Offset | Field | Size |
|---|---|---|
| 0 | mag_cal_t cal[MATRIX_ROWS * MATRIX_COLS] (rest, bottom; rest == bottom = none) |
4 · rows · cols |
| 4 · rows · cols | actuation: bits 0–14 global actuation point, travel units, 0 = not set; bit 15 rapid trigger on |
2 |
| +2 | exponent: bits 0–9 user field exponent × 100 (power curve), 0 = firmware value (F8); bits 10–15 rapid-trigger distance, percent, 0 = default |
2 |
Praxis HE: 304 bytes. The module's config.h defines EECONFIG_MODULE_MAG_DATA_SIZE = 4 · rows · cols + 4 and EECONFIG_MODULE_MAG_DATA_VERSION = 0x4845 (a fixed tag; never change it, or every stored block is discarded) in the upper half, MAG_STORE_FORMAT in bits 12–15, the size in the low 12 bits. QMK stores the version in 4 bytes in front of the block. QMK's eeconfig.c needs both defines; QMK force-includes a module's config.h into every compile unit, before the keyboard's, so it holds only these defines. mag_store.c reads and writes the block with QMK's generated eeconfig_read_mag_datablock() / eeconfig_update_mag_datablock() (module API 1.1.3). mag_store.c static-asserts the struct size. On a version mismatch QMK zero-fills the block, which reads as no calibration and default actuation. Bump MAG_STORE_FORMAT whenever the layout changes.
Version history: format 1 — initial (build step 4b). The exponent (step 6) and the rapid-trigger bits (step 7) use bits that older blocks hold as 0, meaning "not set", so the format stayed 1 (F8, F11). Accessors return and write only their own bits.
EEPROM budget. The emulated EEPROM must hold QMK's own settings, the MAGDA datablock and, with VIA, the dynamic keymap — and QMK sizes each of these from the matrix, so the total grows with the board. Per matrix position n = rows × cols (sizes from this QMK tree):
| Item | Size | Praxis HE (n = 75) | Full-size board (6 × 18, n = 108) |
|---|---|---|---|
QMK core eeconfig (eeprom_core_t) |
37 B | 37 B | 37 B |
| MAGDA datablock, format 1, with QMK's 4-byte version | 4 n + 8 | 308 B | 440 B |
| MAGDA per-key settings (planned; ≈ 9 B per position, estimate) | ≈ 9 n | ≈ 675 B | ≈ 972 B |
| VIA header + dynamic keymap (4 layers × 2 B) | ≈ 4 + 8 n | 604 B | 868 B |
| VIA macros (16 macros) | uses whatever is left | ≥ 0.5 KB useful | ≥ 0.5 KB useful |
| Total, today without VIA | ≈ 0.35 KB | ≈ 0.5 KB | |
| Total, per-key settings + VIA + macros | ≈ 2.1 KB | ≈ 2.8 KB |
Consequences: the 1 KB legacy emulation of STM32F4x1 internal flash holds today's datablock but not per-key settings with VIA. A board should plan for about 2–3 KB of logical EEPROM; Praxis uses 4 KB (logical_size 4096) on external flash, which leaves room for growth. Wear-leveling keeps a RAM copy of the logical size, so 4 KB of EEPROM costs 4 KB of RAM.
Why external SPI flash is recommended. It provides the several KB the budget needs without competing with firmware for internal flash, its wear-leveled backing (64 KB here) absorbs repeated saves, and reflashing the MCU never touches it, so calibration survives firmware updates (a format change is detected by the version tag). Internal-flash boards stay within 1 KB and must go without per-key settings or VIA. The maker guide (step 1) explains the same trade-off for board designers.
Board choice: the backing store is selected in the board's keyboard.json (eeprom.driver and eeprom.wear_leveling), not here. The library only uses the datablock and never depends on the backing.
For STM32F4x1 boards using internal flash, QMK's embedded_flash wear-leveling driver does not yet work on that family. Those boards use legacy, which provides 1 KB of emulated EEPROM, and the MAGDA datablock must fit alongside QMK's own settings.
4.3 mag_process
Library keycodes occupy the first keyboard-range slots, declared in mag.h:
enum { MAG_CAL = QK_KB_0, MAG_DUMP, MAG_CAL_DUMP, MAG_ACT_UP, MAG_ACT_DN, MAG_RT_TOG, MAG_KC_LAST = MAG_RT_TOG };
MAG_KC_LAST is the last library keycode; boards start their own at MAG_KC_LAST + 1.
| Keycode | Action |
|---|---|
MAG_CAL |
start the calibration procedure (section 4.4); no effect while it runs |
MAG_DUMP |
toggle console streaming of raw and travel frames (section 4.1); no effect without CONSOLE_ENABLE |
MAG_CAL_DUMP |
print the calibration and settings in use on the console, with their source; no effect without CONSOLE_ENABLE |
mag_process_record returns false for all library keycodes (handled).
MAG_CAL_DUMP output (on press). It prints what is in use and where it comes from, so a board running on its factory table or on provisional keys shows that. First a header, then one line per present key, then a mag_default_cal initializer ready to paste into a board's source as its factory table (section 2.1):
Calibration (format 1), actuation 60% (614, default)
Travel curve power, zero field 2048, exponent 3.00 (factory)
Rapid trigger off, distance 10% (default)
Key (0,0) rest 2231 (now 2233) bottom 3052 span 821 (factory)
Key (0,1) rest 2054 (now 2054) bottom 2869 span 815 (stored)
Key (3,0) ... (absent positions are skipped)
Key (4,14) provisional, rest now 2158
const mag_cal_t mag_default_cal[MATRIX_ROWS][MATRIX_COLS] = {
{{2231, 3052}, {2054, 2869}, ...},
...
};
- Per key, the calibration in use is the one
mag_initloaded: stored (datablock), elsemag_default_cal(factory), else none (provisional).rest,bottomandspanare those calibrated values;nowis the current rest level frommag_calib_get_rest(), which differs from the calibrated one by boot rest capture and drift (section 3.2). A provisional key shows only its current rest. - The initializer prints the calibration in use (stored, else factory); provisional and absent positions are
{0, 0}.
actuation shows the global actuation point in use, as percent and travel units, marked default when none is stored (then MAG_ACTUATION_DEFAULT). The second line shows the travel curve and, for MAG_CURVE_POWER, its firmware parameters (MAG_ZERO_FIELD, MAG_FIELD_EXPONENT printed with two decimals — QMK's printf has no float formatting), so one dump holds every input needed to fit the exponent (section 3.2). The third line shows rapid trigger and its distance, (default) when none is stored. The streaming flag mag_dump_enabled lives in mag_process.c and is read by mag_task.
Global actuation adjustment.
| Keycode | Action |
|---|---|
MAG_ACT_UP |
raise the global actuation point by 5 % of travel, up to 90 % |
MAG_ACT_DN |
lower it by the same step, down to 10 % |
- On press: the current level (stored value, else
MAG_ACTUATION_DEFAULT) is converted to percent and rounded to the nearest 5 %, stepped, clamped to 10–90 %, converted back to travel units (p × MAG_TRAVEL_MAX / 100, rounded; e.g. 45 % = 460), stored withmag_store_put_actuation(), and applied to every key (mag_settings_apply(), below). Step and limits are local constants inmag_process.c. - The default 512 reads as 50 %, so the first press gives 55 % or 45 %.
-
Hysteresis stays
MAG_HYSTERESIS_DEFAULT(64 travel units) at every level. -
Range 10–90 %: above ~90 % a normal press may not reach it (bottom is the deepest reading seen during calibration). Low levels sit only a few raw counts above rest (section 3.2, noise margin) and feel very sensitive (section 9).
- Each press prints the new level on the console, e.g.
Actuation 45%(also at a limit). - The level is stored in the datablock (
mag_store) with the coalesced-write rule of section 4.2 (writtenMAG_SAVE_DELAY_MSafter the last press), and applied at boot bymag_init. MAG_CAL_DUMPshows it as e.g.actuation 45% (460).
Rapid trigger.
| Keycode | Action |
|---|---|
MAG_RT_TOG |
switch rapid trigger on or off for all keys (section 3.3) |
- On press: flips the stored flag (
mag_store_put_rapidtrigger(), delayed save), applies the settings to every key, printsRapid trigger on/Rapid trigger off, and callsmag_rapidtrigger_kb(). A key that is down keeps its mode until fully released (section 3.3). - The distance is set from VIA (section 4.5); each change prints e.g.
Rapid trigger distance 4%.
Applying settings. mag_settings_apply() (mag_process.c, also called by mag_init) builds one mag_act_settings_t from the store — mode from the rapid flag, actuation stored or MAG_ACTUATION_DEFAULT, hysteresis MAG_HYSTERESIS_DEFAULT, rapidtrigger_down = rapidtrigger_up = the stored distance (pct × MAG_TRAVEL_MAX / 100, rounded) or the defaults — and passes it to mag_act_set() for every key. The actuation keycodes, MAG_RT_TOG and VIA all store first, then call it.
Settings adjustment at runtime from the VIA app: section 4.5.
Weak hooks: void mag_calibration_mode_kb(bool active), so the board can indicate calibration mode, and void mag_rapidtrigger_kb(bool active), called once from mag_init with the stored state and on every change, so it can indicate rapid trigger; for example with LEDs.
4.4 Calibration procedure
The user starts calibration with MAG_CAL, with QMK Toolbox (or qmk console) already open to follow its progress.
Console output. Messages are printed with uprintf(), compiled only under CONSOLE_ENABLE. The board enables the console in its keyboard.json ("features": {"console": true}).
Running inside the scan. The procedure runs as a state machine inside matrix_scan_custom (mag_matrix.c), driven by raw frames. While it runs, the matrix reports no keys pressed, so nothing is sent to the host; every key still passes through mag_calib_update, so filtered values stay current (mag_calib_filtered() exposes them).
Trigger. mag_process_record sets the internal flag mag_cal_requested on the release of MAG_CAL; the next scan starts the procedure. Requests while it runs are ignored.
Steps:
- Start. Print
Calibration mode started. Callmag_calibration_mode_kb(true). TheMAG_CALkey is already released (release trigger). Then wait until no key is down as the normal pipeline sees it (calibration or provisional actuation, not reported to the host), at most 3 s — otherwise a key still held, such as the layer key used to reachMAG_CAL, would be sensed at its pressed level. After 3 s it continues anyway, so a key that reads as stuck (e.g. held at power-up) can still be calibrated. - Low level. Print
Low level sensing initiated. Please wait.For each present key in turn:- average
MAG_CAL_FRAMESframes to get itsrest(default 64, overridable in the board'sconfig.h); all keys are averaged in parallel over the same frames and each prints when its own window completes; - print
Key (row,col) low level sensed as value, for exampleKey (2,5) low level sensed as 2048; - if the raw reading varies (max − min) by more than
MAG_CAL_NOISE_BOUNDwhile averaging (the key is being touched), restart that key's window. The default is twice the 90th-percentile idle peak-to-peak noise measured on Praxis HE (F2); a board measures its own noise in its timing phase and overrides the bound inconfig.hif needed.
- average
- Transition. Print
Low level sensing done. Press keys for high level sensing. - High level.
- A key is being pressed when
|filtered − rest|exceedsMAG_MIN_SPAN(filtered =mag_calib_filtered(),rest= the value just sensed). - While it is pressed, track the reading furthest from
rest. - On release (back within
MAG_MIN_SPANofrest), record that reading as the candidatebottom, and printKey (row,col) high level sensed as value, for exampleKey (2,5) high level sensed as 3104. - Further presses of the same key update
bottomand print again, but only if the span grows.
- A key is being pressed when
- Completion. Calibration ends only when every present key has a
bottom. There is no manual exit. Then:- apply
restandbottomfor all keys withmag_calib_set()(direction is derived from them) and store them throughmag_store, written immediately (mag_store_save()); - print
Calibration done. Values saved.; - call
mag_calibration_mode_kb(false); - resume normal operation.
- apply
- Timeout. If the procedure is still running 10 min after
Calibration mode started(F1):- discard all partial results;
- keep the previously stored calibration;
- print
Calibration timed out. Previous calibration kept.; - call
mag_calibration_mode_kb(false); - resume normal operation.
Uncalibrated keys. A key with neither stored calibration nor a factory entry in mag_default_cal actuates provisionally when |raw − boot rest| exceeds MAG_MIN_SPAN. This makes MAG_CAL usable before any calibration exists. mag_calib_update implements this by returning MAG_TRAVEL_MAX above that threshold and 0 below it.
Output timing. Anything printed while no host is listening is lost.
4.5 Runtime configuration (VIA)
Users set the actuation point and the field exponent from the VIA app's custom menu, and can assign the MAGDA keycodes in VIA.
Protocol (verified in quantum/via.h / via.c). VIA sends raw-HID packets [command, channel, value_id, value…] and the keyboard answers in place. Commands used: id_custom_set_value (0x07), id_custom_get_value (0x08), id_custom_save (0x09); unhandled requests are answered with id_unhandled (0xFF). Custom values arrive on id_custom_channel (0) through the weak board hook via_custom_value_command_kb(uint8_t *data, uint8_t length).
Library entry. bool mag_via_command(uint8_t *data, uint8_t length) (mag.h, implemented in mag_process.c under #ifdef VIA_ENABLE to share the actuation code). Returns true if it handled the packet. The library does not define via_custom_value_command_kb itself (section 1: no *_kb collisions); the board does:
#ifdef VIA_ENABLE
# include "via.h"
void via_custom_value_command_kb(uint8_t *data, uint8_t length) {
if (!mag_via_command(data, length)) {
data[0] = id_unhandled;
}
}
#endif
VIA_ENABLE is set in a keymap's rules.mk. The VIA code is always compiled and guarded by the define, so it does not depend on the order in which QMK reads the build files.
Values (one byte each):
| Value id | Meaning | get | set |
|---|---|---|---|
MAG_VIA_ACTUATION = 1 |
actuation point, % of travel | current level (stored, else default), rounded to 5 % | same path as MAG_ACT_UP / MAG_ACT_DN: rounded to 5 %, clamped 10–90 %, applied to all keys (held keys take it on release, section 3.3), stored with the delayed save, Actuation NN% printed |
MAG_VIA_EXPONENT = 2 |
power-curve exponent × 10 (5–60 = 0.5–6.0) | effective exponent (user, else factory), rounded to 0.1; 0 when the curve is not MAG_CURVE_POWER |
clamped to 5–60, stored as n × 100, curve rebuilt, keys refreshed (section 3.2); ignored for other curves |
MAG_VIA_RAPID_TRIGGER = 3 |
rapid trigger off (0) / on (1) | stored flag | nonzero = on; if it changes: same path as MAG_RT_TOG (stored with the delayed save, applied, printed, mag_rapidtrigger_kb() called) |
MAG_VIA_RT_DISTANCE = 4 |
rapid-trigger distance, % of travel | stored value, else the default rounded (6) | clamped to 1–20 %, stored with the delayed save, applied to all keys (held keys take it on release), Rapid trigger distance NN% printed |
id_custom_save writes the datablock immediately (mag_store_save()); otherwise changes are written MAG_SAVE_DELAY_MS after the last one. VIA sends the save for a whole custom channel with no value id (packet 9 0), so the save is accepted regardless of value id (F6).
VIA definition. A board ships a VIA JSON (Praxis: keyboards/aeboards/praxis_he/via.json, loaded through VIA's Design tab) with its layout, a menu
{"label": "Magnetic switches", "content": [{"label": "Actuation", "content": [
{"label": "Actuation point", "type": "range", "options": [10, 90], "content": ["id_mag_actuation", 0, 1]},
{"label": "Field exponent", "type": "range", "options": [5, 60], "content": ["id_mag_exponent", 0, 2]},
{"label": "Rapid trigger", "type": "toggle", "content": ["id_mag_rapid_trigger", 0, 3]},
{"label": "Rapid trigger distance", "type": "range", "options": [1, 20], "content": ["id_mag_rt_distance", 0, 4]}]}]}
and customKeycodes in MAGDA enum order. The menu labels are deliberately plain ("Actuation point", "Field exponent"; F7): the slider values are percent (actuation) and exponent × 10 (30 = 3.0), and a percent is a fraction of travel only as far as the travel curve is accurate (section 3.2) — those details belong in the documentation, not the GUI. The custom keycodes (MAG_CAL, MAG_DUMP, MAG_CAL_DUMP, MAG_ACT_UP, MAG_ACT_DN, MAG_RT_TOG) are listed in that order because VIA maps them to QK_KB_0 onward. The JSON schema belongs to the VIA app, not QMK, and could not be checked in this tree; it follows VIA v3 conventions and is validated by loading it in VIA.
QMK behaviours to know. VIA's EEPROM validity is derived from the firmware build date (via.c): flashing a VIA build from a different day resets the VIA keymap. Its first-boot reset touches only VIA's own region, so calibration and MAGDA settings survive. VIA's keymap and macros count toward the EEPROM budget (section 4.2).
5. Board integration
The library provides:
void mag_init(void); // from keyboard_post_init_mag
bool mag_process_record(uint16_t keycode, keyrecord_t *rec); // from process_record_mag; false = handled
void mag_task(void); // from housekeeping_task_mag
The board does not call them. mag.c implements QMK's community-module hooks, which QMK calls before the board's *_kb functions (quantum/keyboard.c, quantum/quantum.c): keyboard_post_init_mag() calls mag_init(), process_record_mag() calls mag_process_record() and stops there if it returns false, housekeeping_task_mag() calls mag_task(). Each then calls the generated weak _mag_kb() hook (which calls _mag_user()). mag.c asserts module API 1.1.3 (ASSERT_COMMUNITY_MODULES_MIN_API_VERSION), the first with module datablocks.
The board may override:
void mag_calibration_mode_kb(bool active); // weak no-op; true at "Calibration mode started", false on completion or timeout
void mag_rapidtrigger_kb(bool active); // weak no-op; the stored state once from mag_init, then on every change
With VIA, the board also implements via_custom_value_command_kb calling mag_via_command (section 4.5).
These are the board's way to signal calibration mode and rapid trigger. The library assumes no indicator hardware: a board without LEDs leaves it undefined, others do whatever suits them (Praxis lights its three RGB LEDs white during calibration and D4 while rapid trigger is on, praxis_he.c). The first mag_rapidtrigger_kb() call comes from mag_init(), before the board's keyboard_post_init_kb(); Praxis records the state and shows it once its LEDs are initialised.
Build integration. The module repository sits at modules/acheron in the QMK tree (or an external userspace). The board's keyboard.json enables the module ("modules": ["acheron/mag"], for every keymap) and declares the custom matrix ("matrix_pins": {"custom_lite": true}, which QMK turns into CUSTOM_MATRIX = lite); its post_rules.mk selects the backend:
MAG_DRIVER = mux_adc # optional: the stock backend
QMK builds the module from its directory (lib/python/qmk/cli/generate/community_modules.py, builddefs/build_keyboard.mk): it adds the module directory to VPATH, which QMK uses both as the source search path and as an include directory, compiles mag.c, reads the module's rules.mk (library sources, travel curves by $(MODULE_PATH_MAG)), force-includes its config.h (datablock size and version) and reads its post_rules.mk. That last one handles MAG_DRIVER = mux_adc (ANALOG_DRIVER_REQUIRED = yes, drivers/mux_adc.c): QMK reads it after the board's post_rules.mk, where MAG_DRIVER is set, and before common_features.mk, so ANALOG_DRIVER_REQUIRED still takes effect. MAG_DRIVER goes in the board's post_rules.mk, not rules.mk: the module's rules.mk is read before the board's post_rules.mk but after its rules.mk, and QMK expects a keyboard's rules.mk to hold plain assignments only (qmk lint directs other code to post_rules.mk). A board needs no rules.mk. Omitting MAG_DRIVER means the board supplies its own backend. The library is standalone and does not depend on other vendors' code.
6. Directory layout
| Path | Purpose |
|---|---|
qmk_module.json |
module declaration (name, maintainer, license, URL) |
config.h |
datablock size and version for QMK (section 4.2), force-included by QMK |
rules.mk |
library and travel-curve sources |
post_rules.mk |
stock backend selection (MAG_DRIVER) |
mag.c |
community-module hooks (section 5) |
mag.h |
public API: section 5, keycodes, hooks |
mag_hw.h |
backend contract: section 2 |
mag_config.h |
defaults for all MAG_* macros (#ifndef guarded) |
mag_calib.c / .h |
core |
mag_curve.h |
travel curve interface (section 3.2) |
curves/linear.c, curves/power.c |
travel curves, one compiled in per MAG_TRAVEL_CURVE |
mag_actuation.c / .h |
core |
mag_matrix.c |
glue |
mag_store.c / .h |
glue |
mag_process.c |
glue |
drivers/mux_adc.c |
stock backend (STM32 ADCv2) |
tests/ |
host-side tests for mag_calib and mag_actuation (synthetic readings for now; recorded frames later) |
tests/Makefile |
builds and runs them (below) |
tools/pace_capture.py, tools/fit_marks.py |
host-side measurement tools: paced reading-versus-depth capture from a MAG_DUMP log, and the power-curve fit and plot (maker guide, step 11) |
Host tests
The core (mag_calib, mag_actuation) is plain C and is tested on the host: only a host compiler and make, no QMK build or hardware. From the module repository root (modules/acheron/ inside a QMK tree):
make -C mag/tests
This builds four test programs into tests/build/ and runs them:
| Program | Tests |
|---|---|
test_calib |
mag_calib with the default MAG_FILTER_SHIFT, travel curve MAG_CURVE_LINEAR (linear travel) |
test_calib_nofilter |
the same tests with MAG_FILTER_SHIFT=0 (filter passthrough) |
test_curve |
the travel curve MAG_CURVE_POWER with mag_calib (default MAG_ZERO_FIELD and MAG_FIELD_EXPONENT) |
test_actuation |
mag_actuation, fixed mode and rapid trigger |
Each prints its test names, then N checks, M failed. Failed checks print file, line and the values compared. make exits nonzero if any check fails.
Other targets and options:
make -C mag/tests cleanremovestests/build/.CC=clangselects another compiler.- The matrix is 5 × 15 (
-DMATRIX_ROWS=5 -DMATRIX_COLS=15), matching the Praxis HE. AnyMAG_*default can be overridden the same way, for exampleCFLAGS=-DMAG_MIN_SPAN=80(this replaces the default-O1 -g; warnings and the matrix size stay).
The tests use synthetic readings; frames recorded on hardware are an open issue (section 9).
7. Build order
Steps 1–8 are complete and verified on Praxis HE (Praxis ARCHITECTURE.md sections 3 and 4.10): 1 contract and core, 2 glue, 3 mux_adc driver, 4 storage and calibration (4a MAG_CAL flow, 4b mag_store), 5 actuation keycodes, 6 runtime configuration (VIA), 7 rapid trigger (distance floor 1 %, F12), 8 community module (the library as the QMK community module acheron/mag in its own repository, module hooks instead of board calls, the module datablock instead of the keyboard datablock; sections 4.2, 5, 6, F13). No step remains; open work is in section 9.
8. Findings
Hardware findings and design history, each with where it was found, what was observed, the cause (hypotheses marked) and what changed. Details and data: Praxis ARCHITECTURE.md section 4.
- F1. Calibration timeout (Praxis phase 4). The first full calibration hit the original 60 s timeout. Timeout set to 10 min (section 4.4).
- F2. Calibration noise bound (Praxis phase 3). Idle peak-to-peak noise at the chosen timing: median 26, 90th percentile 32, max 46 counts, about 3× the datasheet estimate.
MAG_CAL_NOISE_BOUND= 64, twice the 90th percentile (section 4.4). - F3. Linear travel felt wrong (Praxis phase 6). With travel linear in the reading, 10 % actuated around mid-travel and 90 % needed an almost full press: 10–90 % covered roughly the lower half of physical travel. Cause: field strength is strongly nonlinear in magnet distance. Added the pluggable travel curve with
MAG_CURVE_POWERas default (section 3.2). - F4. Resetting a held key (Praxis phase 6).
mag_act_set()originally reset every key at once, soMAG_ACT_UPstepped several times per keystroke: the held key crossed each new, higher actuation point again on its way down. Now a key that is down keeps its settings until fully released (section 3.3). - F5. Applying at the old release point (Praxis phase 6). After F4, pending settings were applied once the key dropped below its old release point, and one
MAG_ACT_DNkeystroke stepped 90 % → 65 %: on its way up the key was still above each new, lower actuation point and re-pressed. Now pending settings apply only below the new release point too (section 3.3). - F6. VIA channel save (Praxis phase 6). VIA sends the custom-menu save for a whole channel with no value id (packet
9 0); answering itid_unhandledmade VIA report "Receiving incorrect response for command". The save is now accepted regardless of value id (section 4.5). - F7. VIA menu labels (Praxis phase 6). After the first VIA test the menu labels were simplified to "Actuation point" / "Field exponent"; units and caveats belong in the documentation (section 4.5).
- F8. User exponent field (build step 6). The datablock's last two bytes were
reservedand always 0. They becameexponentwith 0 meaning "factory value", so existing blocks keep their meaning and the format stayed 1 (section 4.2). - F9. Noise margin at low levels (Praxis phase 6). At 20 % with
MAG_DRIFT_SHIFT8 andMAG_HYSTERESIS_DEFAULT32: a slow press registered deeper than normal and twice, and a key held at its actuation point did not register. Causes: rest drift (time constant 256 frames ≈ 0.13 s) followed a slow or resting finger; noise (filtered ≈ 10–13 counts peak-to-peak) crossed a 6–11 count hysteresis gap. Changed toMAG_DRIFT_SHIFT16 (≈ 33 s) andMAG_HYSTERESIS_DEFAULT64 (gap on the Praxis calibration, min / median: 7 / 17 counts at 10 %, 10 / 21 at 20 %, 24 / 37 at 50 %). All tests then passed at 10, 15 and 20 % (section 3.2). - F10.
EE_CLRnext toMAG_ACT_UP(Praxis phase 6). WithEE_CLRbesideMAG_ACT_UPon the same layer, accidental presses erased calibration, and the resulting behaviour was first mistaken for chatter.EE_CLRbelongs away from frequently used keys (section 4.2). - F11. Rapid-trigger storage (build step 7). Format 1 had no spare bytes, and a format change would discard stored calibration and move the VIA region. The stored actuation point and exponent use 10 of their 16 bits each, so the rapid-trigger flag (bit 15 of
actuation) and distance (bits 10–15 ofexponent) went there; older blocks read as off / default (section 4.2). - F12. Rapid-trigger distance floor (Praxis phase 7). A sweep from 20 % down to 1 % at 50 % and 20 % actuation showed no extra events (key bobbed deep, held still deep and just below the actuation point, hands off). The model gave 2–5 raw counts at 1 % near the reset point against an estimated 10–13 counts of filtered noise, so it predicted extra events below about 3 % (50 %) and 6 % (20 %). Cause of the difference unknown; hypotheses: filtered noise is smaller than estimated (the estimate scales idle peak-to-peak at 10 Hz sampling by the filter's white-noise factor), or 5 s holds are too short to show rare events. The lower limit stays 1 % (section 3.3).
- F13. Community module (build step 8). The library lived in the QMK tree (
keyboards/acheron/mag/, included by a board'spost_rules.mkthroughmag.mk), its datablock was QMK's keyboard datablock, sized by a headermag.mkforce-included into every compile unit, and the board calledmag_init,mag_process_recordandmag_taskfrom its*_kbfunctions. It became a QMK community module in its own repository: QMK builds it from the module's files, calls it through module hooks, and gives it a module datablock whose size comes from the module'sconfig.h, so the force-include went and the keyboard datablock is free for the board. The layout andMAG_STORE_FORMATstayed 1, but the block moved in the EEPROM, so calibration and settings stored by earlier firmware are not read after upgrading, and VIA's region moved too (section 4.2).
9. Open issues
- Self-test.
mag_hw_selftest()exists as a weak default but is never called; when to run it and what to do on failure is undecided (section 2). - Exponent validation. First measurement (Praxis R0C7, one key, Praxis
ARCHITECTURE.mdsection 4.9): readings versus caliper depth fit a power curve to about 0.07 mm rms, but not with n = 3 and zero field 2048 (0.35 mm rms, actuation up to about 0.4 mm deeper than set); n ≈ 1.0 with zero field 2048 and n ≈ 2.4 with zero field ≈ 2164 fit equally well, so the zero-field reading has to be measured (bare sensor) and more keys measured beforeMAG_FIELD_EXPONENT/MAG_ZERO_FIELDchange (section 3.2). Praxis ships withMAG_FIELD_EXPONENT1.0 on that basis; measurement procedure and tools (tools/pace_capture.py,tools/fit_marks.py): maker guide, step 11. - Zero-field measurement. The zero-field reading is a single compile-time value per board,
MAG_ZERO_FIELD(default half the supply, 2048), assumed rather than measured; it also sizes the power curve's lookup table (curves/power.c). It matters most for keys resting close to it, and it cannot be separated from the exponent by a depth sweep on one key (Praxis: n = 1 with 2048 and n ≈ 2.4 with ≈ 2164 fit equally well). Improvement: routines to measure zero-field levels and use them. (1) Measure: a procedure and tool that read a bare sensor (switch removed, or before switches are fitted) and average it, for the board as a whole or per key, the waytools/pace_capture.pyaverages a hold;tools/fit_marks.pycould also fit the zero field jointly with the exponent from sweeps of several keys. (2) Use: a measured board value as the factoryMAG_ZERO_FIELD; beyond that, a stored value (like the user exponent) or a per-key factory table (likemag_default_cal), applied in the curve as the reading's reference while keeping one shared lookup table (sized for the full range, since the reference would no longer be a compile-time constant). Open points: names to approve; storage (spare bits or a format change); whether per-key values are worth it given that most sensors share the supply and the ADC reference. - Low levels feel too sensitive. On Praxis, 10–20 % pass the noise tests but are uncomfortable to type on. Options: a higher floor than 10 %, or a fitted exponent, which moves where a percent sits physically (section 4.3).
- Keys near the zero field fall back to linear travel (section 3.2); on Praxis, R0C1.
- Real drift rate. How fast rest levels actually drift (e.g. with temperature) has not been measured, so the upper limit for
MAG_DRIFT_SHIFTis open. - Noise model vs rapid trigger (F12). The model predicted extra events below ~3–6 % distance; none were seen down to 1 %. Until the reason is known, the noise-margin tables in section 3.2 may be pessimistic for rapid trigger; check with
MAG_DUMPtraces. - ADC other than ADC1.
mux_adcis fixed toADCD1. A board whose multiplexer pins are all on ADC2 or ADC3 needs a driver change: a board macro selecting the ADC driver (name to approve), and a check at init that every pin'spinToMux(pin).adcmatches it. - Downgrading firmware. Firmware from before step 7 reads the rapid-trigger bits as part of the actuation point (> 1023, clamped to 100 %) and the exponent (a wrong curve). After such a downgrade,
EE_CLRand recalibrate. - Layout options (sensor positions without a switch). On a board with layout options, some sensors have no switch above them. Calibration completes only when every key in
mag_key_maskhas a bottom, so such a position blocks it until the 10 min timeout, which discards the run; an empty sensor next to another layout's switch may also register phantom presses (not measured). Rest level cannot identify empty positions (a fitted key can rest near the zero field). Likely direction: an active-key mask set from VIA layout options (via_get_layout_options(), hookvia_set_layout_options_kb(),quantum/via.c) through a board table, so calibration and actuation skip inactive positions. Open points: non-VIA builds, the layout choice resetting with VIA's EEPROM, a calibration exit gesture as a safety net. - Board-defined travel curves. Adding a curve today changes the library: a curve id in
mag_config.h,curves/<name>.c, the module'srules.mkand the test Makefile (procedure in section 3.2). Improvement: a curve id meaning "supplied by the board" (name to approve), with which the library compiles no curve and the board implements the curve interface in its own source, so a board can ship a curve fitted to its own sensor, magnet and geometry without touching the library. Entry points the board would implement (all exist inmag_curve.h):mag_curve_init()(once, frommag_calib_init; precompute here),mag_curve_position()(every key, every scan: must stay cheap),mag_curve_fits()(whenever a key's calibration changes),mag_curve_set_exponent()(user parameter from the configurator interface; may ignore it). Open points: weak defaults so a board overrides only what it needs; what the configurator's exponent value means for such a curve (a generic curve parameter, or hidden); host-testing a board curve with the library's tests; documenting it in the porter pages once it exists. - Recorded frames. The host tests use synthetic readings; frames recorded on hardware are still to be added (section 6).