MAGDA (Magnetic Switches)
MAGDA (Magnetic AnaloG Distance Analyzer) is a library that turns per-key analog magnetic readings (linear Hall or TMR sensors) into QMK's key matrix, with calibration, an adjustable actuation point and rapid trigger. It is a QMK community module, acheron/mag, in the acheron_qmk_modules repository. This page is for porting a board to it: the files, the configuration and the code a board provides.
- Using such a keyboard (calibration, actuation, rapid trigger): Magnetic Switches and Magnetic Switches: Rapid Trigger.
- Designing the hardware, bringing a board up, testing and tuning it: the maker guide.
- The library's full specification (behaviour, algorithms, every
MAG_*setting and its default, data layout): MAGDA architecture. Where this page and the specification differ, the specification is right. - A complete worked port: Reference Design: Praxis HE (firmware in
keyboards/aeboards/praxis_he/).
Prerequisites
MAGDA makes one assumption about the hardware: each key gives one scalar reading that changes monotonically with key travel. Scale, offset and direction do not matter; they are learned per key by calibration. A stock driver (mux_adc) covers the common analog-multiplexer design on STM32F2/F4/F7; other hardware needs a small backend of its own (see Backend).
Readings. A backend delivers one frame per scan: a raw count for every matrix position, the number the ADC returns for that key's sensor (0–4095 for a 12-bit ADC; one count is the reference voltage ÷ 4096, about 0.81 mV at 3.3 V). MAGDA filters these, calibrates each key's rest and bottom in counts, and converts them to travel (0–1023). ADCs, counts, noise and travel from the basics: Readings: from a magnet to a number.
Directory setup
Add the module repository to the QMK tree (or to an external userspace) as modules/acheron:
git submodule add https://github.com/AcheronProject/acheron_qmk_modules.git modules/acheron
The board directory is an ordinary QMK keyboard, configured in keyboard.json and a post_rules.mk; it needs no rules.mk.
In keyboard.json:
"modules": ["acheron/mag"]: enables MAGDA for every keymap of the board."matrix_pins": {"custom_lite": true}: the matrix is MAGDA's (QMK turns this intoCUSTOM_MATRIX = lite); no row or column pins."features": {"console": true}: calibration messages andMAG_DUMPoutput."debounce": 0: MAGDA's hysteresis replaces debouncing."eeprom": a backing store large enough for everything stored (see Storage).- Bootmagic is of no use: keys may not register before calibration. Provide a hardware way into the bootloader.
In post_rules.mk:
MAG_DRIVER = mux_adc # omit if the board supplies its own backend
QMK builds the module from its own files: it adds the library sources, puts the module directory on the include path and sizes the module's datablock (the module's config.h); with MAG_DRIVER = mux_adc, the module's post_rules.mk compiles the driver and sets ANALOG_DRIVER_REQUIRED = yes (which also enables the ChibiOS ADC HAL). MAG_DRIVER belongs in the board's post_rules.mk: the module reads it in its own post_rules.mk, which QMK reads after the board's and before it sets up its drivers; a board's rules.mk is read too early, and QMK expects it to hold plain assignments only. Other build settings with no keyboard.json key go there too (Praxis: WS2812_DRIVER_REQUIRED = yes).
The keyboard's config.h holds the backend configuration and any MAG_* overrides; every MAG_* setting has a default in mag_config.h (#ifndef guarded), listed in the specification, section 3.4.
Backend
Stock driver: mux_adc
For boards with N analog multiplexers sharing their select lines (and optionally one enable), each multiplexer's common pin on its own ADC input. One select step converts all multiplexers at once, as one ADC1 conversion group, with DMA.
Macro (config.h) |
Required | Meaning |
|---|---|---|
MAG_MUX_SEL_PINS |
yes | { S0, S1, … }, least significant first |
MAG_MUX_ADC_PINS |
yes | { … }, one ADC pin per multiplexer, in multiplexer order; all on the same ADC, which for this driver is ADC1 |
MAG_SETTLE_US |
yes | delay after changing the select lines, µs |
MAG_ADC_SAMPLE_TIME |
yes | ADC sample time, in the platform's encoding (e.g. ADC_SAMPLE_28) |
MAG_MUX_EN_PIN |
no | shared enable pin |
MAG_MUX_EN_ACTIVE_LOW |
no | define if the enable is active low |
MAG_MUX_PER_ROW |
no | multiplexer index = matrix row, select value = column (otherwise transposed) |
- Wiring it assumes: multiplexer input k is matrix column k (with
MAG_MUX_PER_ROW), the identity mapping. Scrambled wiring needs a custom backend. - Checks: the build stops if a required macro is missing, if
MAG_MUX_ADC_PINSdoes not have one pin per multiplexer, if there are more than 16 multiplexers, or if the select pins cannot address every step. - Peripherals: ADC1 only; enable it in the board's
mcuconf.h(STM32_ADC_USE_ADC1). 12-bit readings. - Timing:
MAG_SETTLE_USandMAG_ADC_SAMPLE_TIMEdepend on the board (trace lengths, sensor output resistance). Start generous (Praxis started at 5 µs /ADC_SAMPLE_56) and tune them in bring-up (maker guide, step 7). Praxis uses 2 µs /ADC_SAMPLE_28.
Example (Praxis HE: five 16:1 multiplexers, one per row):
#define MAG_MUX_SEL_PINS {B14, B13, B10, B1}
#define MAG_MUX_EN_PIN B12
#define MAG_MUX_EN_ACTIVE_LOW
#define MAG_MUX_ADC_PINS {A0, A1, A2, A3, A4}
#define MAG_MUX_PER_ROW
#define MAG_SETTLE_US 2
#define MAG_ADC_SAMPLE_TIME ADC_SAMPLE_28
Own backend
Without MAG_DRIVER, the board supplies the backend contract (mag_hw.h):
void mag_hw_init(void); // set up the sensing hardware
void mag_hw_scan(uint16_t raw[MATRIX_ROWS][MATRIX_COLS]); // fill a full frame, blocking
const matrix_row_t mag_key_mask[MATRIX_ROWS] = { ... }; // bit set = key present
mag_hw_scan reads every position once per call; values at positions not in the key mask are ignored. Readings must be monotonic in travel, in any unit, in either direction. A backend that returns a constant frame is a useful first step in bring-up: the board then enumerates and flashes before any sensing works.
Travel curve
The field a sensor sees is strongly nonlinear in magnet distance, so MAGDA converts readings to physical travel with a travel curve:
| Setting | Default | Meaning |
|---|---|---|
MAG_TRAVEL_CURVE |
MAG_CURVE_POWER |
MAG_CURVE_POWER (sensor on the magnet's axis) or MAG_CURVE_LINEAR (no conversion) |
MAG_ZERO_FIELD |
2048 | MAG_CURVE_POWER: the sensor's output at zero field, raw counts (VDD/2 for a ratiometric sensor on the ADC reference rail) |
MAG_FIELD_EXPONENT |
3.0 | MAG_CURVE_POWER: field falloff exponent; fitted from readings at known depths |
Which curve suits which sensor type and placement, and how to fit the exponent: maker guide, steps 3 and 11. The model and how to add a curve for another geometry (e.g. off-axis TMR): specification, section 3.2.
Key mask
mag_key_mask has one bit per matrix position: set where a key (sensor) is fitted, clear where the multiplexer input is unused. Take it from the schematic: for each multiplexer (row, with MAG_MUX_PER_ROW), list the inputs that carry a sensor; bit c of row r is input c of multiplexer r. Unused inputs should be tied to ground on the PCB; they then read about 0, which bring-up checks.
Praxis HE (rows 0–2 full, row 3 without column 0, row 4 with columns 3, 4, 5, 6, 8, 10, 14):
const matrix_row_t mag_key_mask[MATRIX_ROWS] = {
0x7FFF, // row 0: cols 0-14
0x7FFF, // row 1: cols 0-14
0x7FFF, // row 2: cols 0-14
0x7FFE, // row 3: cols 1-14
0x4578, // row 4: cols 3,4,5,6,8,10,14
};
Integration calls
None for the library itself. QMK calls the module through its hooks (mag.c), each before the board's own *_kb function: keyboard_post_init_mag runs mag_init() (loads calibration and settings, captures rest levels), process_record_mag runs mag_process_record() (MAGDA keycodes, handled there), housekeeping_task_mag runs mag_task() (console streaming, delayed saves). The library never defines a board's *_kb functions, so the board keeps its own. Source files that use MAGDA's names (keycodes, hooks, mag_key_mask) include mag.h.
Optional hooks, weak no-ops in the library, for boards with indicators (the library assumes none):
void mag_calibration_mode_kb(bool active) {
// true at "Calibration mode started", false when calibration completes or times out
}
void mag_rapidtrigger_kb(bool active) {
// rapid trigger switched on or off; also called once from mag_init with the stored state
}
Praxis lights all three LEDs white during calibration and LED D4 while rapid trigger is on; a board without LEDs leaves both out. mag_init() runs before the board's keyboard_post_init_kb(), so the first mag_rapidtrigger_kb() call can arrive before an indicator initialised there is ready: record the state in the hook and show it once the indicator is initialised (Praxis: a leds_ready flag), or initialise the indicator in keyboard_pre_init_kb().
Keycodes. The library's keycodes start at QK_KB_0: MAG_CAL, MAG_DUMP, MAG_CAL_DUMP, MAG_ACT_UP, MAG_ACT_DN, MAG_RT_TOG. A board's own keycodes start at MAG_KC_LAST + 1. The default keymap should reach QK_BOOT, MAG_CAL, MAG_DUMP, MAG_CAL_DUMP and EE_CLR (on a layer); EE_CLR is the way out of a bad calibration, so place it away from keys used often on the same layer.
Configurator apps (optional)
MAGDA exposes its settings (actuation point, field exponent, rapid trigger and its distance) and its keycodes through QMK's VIA protocol (quantum/via.c), the interface that dynamic keymap and configurator apps such as VIA use. A board that wants app control wires it up; a board that does not leaves this out, and its users change settings with the keycodes. The value ids and packet handling: API reference. For VIA:
-
Forward the protocol's custom values to the library in the board's source:
#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 -
A
viakeymap withVIA_ENABLE = yesin itsrules.mk. VIA's raw HID needs a USB endpoint; with the console also enabled, MCUs with few endpoints (STM32F411) needKEYBOARD_SHARED_EP = yes, which may not work in some BIOS setup screens (QMK configuration options). QMK keeps VIA keymaps out of the main repository, soqmk lintrejects it there. - A VIA JSON (loaded through VIA's Design tab) with the board's layout, a Magnetic switches menu and the MAGDA custom keycodes in enum order. The menu items and value ids are listed in the specification, section 4.5; the Praxis
via.jsonis a complete example.
Storage
MAGDA stores each key's calibration and the global settings in its module datablock, sized from the matrix (4 bytes per matrix position plus 4, and a 4-byte version QMK keeps in front; Praxis HE: 304 + 4 bytes). The board's own keyboard datablock stays free. With VIA, the dynamic keymap and macros need room as well. The EEPROM's size must fit all of it: plan for about 2–3 KB (the specification, section 4.2, has the breakdown for Praxis and a full-size board).
Choose the backing store in keyboard.json:
-
External SPI NOR flash with wear-leveling (recommended): several KB, wear-leveled, untouched by reflashing the MCU, so calibration survives firmware updates. Praxis:
"eeprom": { "driver": "wear_leveling", "wear_leveling": {"driver": "spi_flash", "backing_size": 65536, "logical_size": 4096} }Wear-leveling keeps a RAM copy of
logical_size, so keep it modest;backing_sizemust be a multiple of it. The flash's SPI and chip-select pins go inconfig.h(see QMK's flash and EEPROM driver pages). -
Internal flash (
legacyemulation on STM32F4x1): 1 KB, enough for the datablock and QMK's settings, but not with a VIA keymap on larger boards.
Why external flash is recommended, for hardware designers: maker guide, step 1.
DMA streams
The sensor ADC, the external flash's SPI and an LED driver (PWM timer or SPI) each use DMA, and on STM32F4 no two of them may share a stream. Each peripheral can only use the streams its DMA request table allows (MCU reference manual); ChibiOS assigns defaults in mcuconf.h, and the WS2812 PWM driver takes its stream from config.h. Check all three together. Praxis HE:
| Peripheral | Use | DMA stream |
|---|---|---|
| ADC1 | sensors (mux_adc) |
DMA2 stream 4 |
| SPI1 | external flash (RX / TX) | DMA2 streams 0 / 3 |
| TIM1_UP | WS2812 PWM (PB15, TIM1_CH3N) | DMA2 stream 5, channel 6 |
Factory calibration table
A board can ship calibration values compiled into the firmware, so it works calibrated before its first calibration and after EE_CLR:
- Calibrate one unit (
MAG_CAL), pressing every key fully and long keys in the centre. -
Press
MAG_CAL_DUMP. Check that every key shows(stored). After the per-key lines, the console prints a ready-made initializer with the calibration in use:const mag_cal_t mag_default_cal[MATRIX_ROWS][MATRIX_COLS] = { {{2231, 3052}, {2054, 2869}, ...}, ... }; -
Paste it into the board's source; it replaces the library's all-zero default.
{0, 0}means "no factory value" for that position.
Stored calibration always takes precedence. Every unit flashed with the binary gets the same values; unit-to-unit differences are absorbed only by the rest level captured at each power-up and by slow drift tracking, so users should still calibrate their own board.
Testing and tuning
Bring-up (USB, raw scan, timing, noise, calibration, persistence), the noise-margin tests, tuning the analog parameters, rapid-trigger tests and fitting the field exponent are described step by step in the maker guide, steps 5–11, with the Praxis HE results as the example.
Worked example: Praxis HE
keyboards/aeboards/praxis_he/:
| File | What it shows |
|---|---|
post_rules.mk |
MAG_DRIVER = mux_adc, WS2812 driver required |
config.h |
mux_adc configuration, flash SPI, WS2812 PWM and DMA, board defaults for actuation and rapid-trigger distance |
keyboard.json |
the acheron/mag module, matrix, layout, custom matrix, console, debounce 0, wear-leveling on SPI flash, WS2812 PWM driver |
halconf.h, mcuconf.h |
PWM and SPI HAL; ADC1, SPI1, TIM1 |
praxis_he.c |
key mask, factory calibration, indicator hooks, VIA forwarding |
keymaps/default/ |
MAGDA keycodes on layer 1 |
The board's development record (every bring-up phase, its measurements, open issues) is ARCHITECTURE.md in the Praxis HE PCB repository; the design and port are the reference design.