Skip to content

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.

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 into CUSTOM_MATRIX = lite); no row or column pins.
  • "features": {"console": true}: calibration messages and MAG_DUMP output.
  • "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_PINS does 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_US and MAG_ADC_SAMPLE_TIME depend 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:

  1. 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
    
  2. A via keymap with VIA_ENABLE = yes in its rules.mk. VIA's raw HID needs a USB endpoint; with the console also enabled, MCUs with few endpoints (STM32F411) need KEYBOARD_SHARED_EP = yes, which may not work in some BIOS setup screens (QMK configuration options). QMK keeps VIA keymaps out of the main repository, so qmk lint rejects it there.

  3. 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.json is 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_size must be a multiple of it. The flash's SPI and chip-select pins go in config.h (see QMK's flash and EEPROM driver pages).

  • Internal flash (legacy emulation 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:

  1. Calibrate one unit (MAG_CAL), pressing every key fully and long keys in the centre.
  2. 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}, ...},
        ...
    };
    
  3. 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.