diff --git a/components/badge23/captouch.c b/components/badge23/captouch.c
index a2b32f423c0ca69f72c6236cb874eb89940dd81e..ae3da7c263b7a590ed38f37f57984b6e7c34fb7f 100644
--- a/components/badge23/captouch.c
+++ b/components/badge23/captouch.c
@@ -1,613 +1,262 @@
-#include "include/badge23/captouch.h"
-#include <freertos/FreeRTOS.h>
-#include <freertos/atomic.h>
-#include <stdint.h>
+#include "badge23/captouch.h"
+
+#include "esp_err.h"
 #include "esp_log.h"
-#include "flow3r_bsp_i2c.h"
-
-#define PETAL_PAD_TIP 0
-#define PETAL_PAD_CCW 1
-#define PETAL_PAD_CW 2
-#define PETAL_PAD_BASE 3
-
-#define CIN CDC_NONE 0
-#define CIN_CDC_NEG 1
-#define CIN_CDC_POS 2
-#define CIN_BIAS 3
-
-#define AFE_INCR_CAP 1000
-
-static const uint8_t top_map[] = { 0, 0, 0, 2, 2, 2, 6, 6, 6, 4, 4, 4 };
-static const uint8_t top_stages = 12;
-static const uint8_t bot_map[] = { 1, 1, 3, 3, 5, 7, 7, 9, 9, 8, 8, 8 };
-static const uint8_t bot_stages = 12;
-static const uint8_t bot_stage_config[] = { 0, 1, 2, 3,  5,  6,
-                                            7, 8, 9, 10, 11, 12 };
-#define DEFAULT_THRES_TOP 8000
-#define DEFAULT_THRES_BOT 12000
-
-#if defined(CONFIG_FLOW3R_HW_GEN_P4)
-static const uint8_t top_segment_map[] = {
-    1, 3, 2, 2, 3, 1, 1, 3, 2, 1, 3, 2
-};  // PETAL_PAD_*
-static const uint8_t bot_segment_map[] = {
-    3, 0, 3, 0, 0, 0, 3, 0, 3, 1, 2, 3
-};  // PETAL_PAD_*
-#elif defined(CONFIG_FLOW3R_HW_GEN_P6)
-static const uint8_t top_segment_map[] = {
-    1, 3, 2, 2, 3, 1, 1, 3, 2, 1, 3, 2
-};  // PETAL_PAD_*
-static const uint8_t bot_segment_map[] = {
-    3, 0, 3, 0, 0, 0, 3, 0, 3, 1, 2, 3
-};  // PETAL_PAD_*
-#elif defined(CONFIG_FLOW3R_HW_GEN_P3)
-static const uint8_t top_segment_map[] = {
-    0, 1, 2, 2, 1, 0, 0, 1, 2, 2, 1, 0
-};  // PETAL_PAD_*
-static const uint8_t bot_segment_map[] = {
-    3, 0, 3, 0, 0, 0, 3, 0, 3, 0, 2, 1
-};  // PETAL_PAD_*
-#endif
-
-static const char *TAG = "captouch";
-
-#define AD7147_REG_PWR_CONTROL 0x00
-#define AD7147_REG_STAGE_CAL_EN 0x01
-#define AD7147_REG_STAGE_HIGH_INT_ENABLE 0x06
-#define AD7147_REG_DEVICE_ID 0x17
-
-#define TIMEOUT_MS 1000
-
-#define PETAL_PRESSED_DEBOUNCE 2
-
-static struct ad714x_chip *chip_top;
-static struct ad714x_chip *chip_bot;
 
-typedef struct {
-    uint16_t amb;
-    uint16_t cdc;
-    uint16_t thres;
-    uint8_t pressed;
-} petal_pad_t;
+#include "flow3r_bsp_captouch.h"
 
-typedef struct {
-    uint8_t config_mask;
-    petal_pad_t pads[4];  // ordered according to PETAL_PAD_*
-    uint8_t pressed;
-} petal_t;
-
-static petal_t petals[10];
-
-struct ad714x_chip {
-    const flow3r_i2c_address *addr;
-    uint8_t gpio;
-    int pos_afe_offsets[13];
-    int neg_afe_offsets[13];
-    int neg_afe_offset_swap;
-    int stages;
-};
-
-static struct ad714x_chip chip_top_rev5 = {
-    .addr = &flow3r_i2c_addresses.touch_top,
-    .gpio = 15,
-    .pos_afe_offsets = { 4, 2, 2, 2, 2, 3, 4, 2, 2, 2, 2, 0 },
-    .stages = top_stages,
-};
-
-static struct ad714x_chip chip_bot_rev5 = {
-    .addr = &flow3r_i2c_addresses.touch_bottom,
-    .gpio = 15,
-    .pos_afe_offsets = { 3, 2, 1, 1, 1, 1, 1, 1, 2, 3, 3, 3 },
-    .stages = bot_stages,
-};
-
-static esp_err_t ad714x_i2c_write(const struct ad714x_chip *chip,
-                                  const uint16_t reg, const uint16_t data) {
-    const uint8_t tx[] = { reg >> 8, reg & 0xFF, data >> 8, data & 0xFF };
-    ESP_LOGD(TAG, "AD7147 write reg %X-> %X", reg, data);
-    return flow3r_bsp_i2c_write_to_device(*chip->addr, tx, sizeof(tx),
-                                          TIMEOUT_MS / portTICK_PERIOD_MS);
-}
+#include "freertos/FreeRTOS.h"
+#include "freertos/semphr.h"
+#include "freertos/task.h"
 
-static esp_err_t ad714x_i2c_read(const struct ad714x_chip *chip,
-                                 const uint16_t reg, uint16_t *data,
-                                 const size_t len) {
-    const uint8_t tx[] = { reg >> 8, reg & 0xFF };
-    uint8_t rx[len * 2];
-    esp_err_t ret = flow3r_bsp_i2c_write_read_device(
-        *chip->addr, tx, sizeof(tx), rx, sizeof(rx),
-        TIMEOUT_MS / portTICK_PERIOD_MS);
-    for (int i = 0; i < len; i++) {
-        data[i] = (rx[i * 2] << 8) | rx[i * 2 + 1];
-    }
-    return ret;
-}
+#include <string.h>
 
-struct ad7147_stage_config {
-    unsigned int cinX_connection_setup[13];
-    unsigned int se_connection_setup : 2;
-    unsigned int neg_afe_offset_disable : 1;
-    unsigned int pos_afe_offset_disable : 1;
-    unsigned int neg_afe_offset : 6;
-    unsigned int neg_afe_offset_swap : 1;
-    unsigned int pos_afe_offset : 6;
-    unsigned int pos_afe_offset_swap : 1;
-    unsigned int neg_threshold_sensitivity : 4;
-    unsigned int neg_peak_detect : 3;
-    unsigned int pos_threshold_sensitivity : 4;
-    unsigned int pos_peak_detect : 3;
-};
-
-static const uint16_t bank2 = 0x80;
-
-static void ad714x_set_stage_config(const struct ad714x_chip *chip,
-                                    const uint8_t stage,
-                                    const struct ad7147_stage_config *config) {
-    const uint16_t connection_6_0 = (config->cinX_connection_setup[6] << 12) |
-                                    (config->cinX_connection_setup[5] << 10) |
-                                    (config->cinX_connection_setup[4] << 8) |
-                                    (config->cinX_connection_setup[3] << 6) |
-                                    (config->cinX_connection_setup[2] << 4) |
-                                    (config->cinX_connection_setup[1] << 2) |
-                                    (config->cinX_connection_setup[0] << 0);
-    const uint16_t connection_12_7 = (config->pos_afe_offset_disable << 15) |
-                                     (config->neg_afe_offset_disable << 14) |
-                                     (config->se_connection_setup << 12) |
-                                     (config->cinX_connection_setup[12] << 10) |
-                                     (config->cinX_connection_setup[11] << 8) |
-                                     (config->cinX_connection_setup[10] << 6) |
-                                     (config->cinX_connection_setup[9] << 4) |
-                                     (config->cinX_connection_setup[8] << 2) |
-                                     (config->cinX_connection_setup[7] << 0);
-    const uint16_t afe_offset =
-        (config->pos_afe_offset_swap << 15) | (config->pos_afe_offset << 8) |
-        (config->neg_afe_offset_swap << 7) | (config->neg_afe_offset << 0);
-    const uint16_t sensitivity = (config->pos_peak_detect << 12) |
-                                 (config->pos_threshold_sensitivity << 8) |
-                                 (config->neg_peak_detect << 4) |
-                                 (config->neg_threshold_sensitivity << 0);
-
-    // ESP_LOGI(TAG, "Stage %d config-> %X %X %X %X", stage, connection_6_0,
-    // connection_12_7, afe_offset, sensitivity); ESP_LOGI(TAG, "Config: %X %X
-    // %X %X %X %X %X %X %X", config->pos_afe_offset_disable,
-    // config->pos_afe_offset_disable, config->se_connection_setup,
-    // config->cinX_connection_setup[12], config->cinX_connection_setup[11],
-    // config->cinX_connection_setup[10], config->cinX_connection_setup[9],
-    // config->cinX_connection_setup[8], config->cinX_connection_setup[7]);
-
-    ad714x_i2c_write(chip, bank2 + stage * 8, connection_6_0);
-    ad714x_i2c_write(chip, bank2 + stage * 8 + 1, connection_12_7);
-    ad714x_i2c_write(chip, bank2 + stage * 8 + 2, afe_offset);
-    ad714x_i2c_write(chip, bank2 + stage * 8 + 3, sensitivity);
-}
+static const char *TAG = "st3m-captouch";
 
-struct ad7147_device_config {
-    unsigned int power_mode : 2;
-    unsigned int lp_conv_delay : 2;
-    unsigned int sequence_stage_num : 4;
-    unsigned int decimation : 2;
-    unsigned int sw_reset : 1;
-    unsigned int int_pol : 1;
-    unsigned int ext_source : 1;
-    unsigned int cdc_bias : 2;
-
-    unsigned int stage0_cal_en : 1;
-    unsigned int stage1_cal_en : 1;
-    unsigned int stage2_cal_en : 1;
-    unsigned int stage3_cal_en : 1;
-    unsigned int stage4_cal_en : 1;
-    unsigned int stage5_cal_en : 1;
-    unsigned int stage6_cal_en : 1;
-    unsigned int stage7_cal_en : 1;
-    unsigned int stage8_cal_en : 1;
-    unsigned int stage9_cal_en : 1;
-    unsigned int stage10_cal_en : 1;
-    unsigned int stage11_cal_en : 1;
-    unsigned int avg_fp_skip : 2;
-    unsigned int avg_lp_skip : 2;
-
-    unsigned int stage0_high_int_enable : 1;
-    unsigned int stage1_high_int_enable : 1;
-    unsigned int stage2_high_int_enable : 1;
-    unsigned int stage3_high_int_enable : 1;
-    unsigned int stage4_high_int_enable : 1;
-    unsigned int stage5_high_int_enable : 1;
-    unsigned int stage6_high_int_enable : 1;
-    unsigned int stage7_high_int_enable : 1;
-    unsigned int stage8_high_int_enable : 1;
-    unsigned int stage9_high_int_enable : 1;
-    unsigned int stage10_high_int_enable : 1;
-    unsigned int stage11_high_int_enable : 1;
-};
-
-static void ad714x_set_device_config(
-    const struct ad714x_chip *chip, const struct ad7147_device_config *config) {
-    const uint16_t pwr_control =
-        (config->cdc_bias << 14) | (config->ext_source << 12) |
-        (config->int_pol << 11) | (config->sw_reset << 10) |
-        (config->decimation << 8) | (config->sequence_stage_num << 4) |
-        (config->lp_conv_delay << 2) | (config->power_mode << 0);
-    const uint16_t stage_cal_en =
-        (config->avg_lp_skip << 14) | (config->avg_fp_skip << 12) |
-        (config->stage11_cal_en << 11) | (config->stage10_cal_en << 10) |
-        (config->stage9_cal_en << 9) | (config->stage8_cal_en << 8) |
-        (config->stage7_cal_en << 7) | (config->stage6_cal_en << 6) |
-        (config->stage5_cal_en << 5) | (config->stage4_cal_en << 4) |
-        (config->stage3_cal_en << 3) | (config->stage2_cal_en << 2) |
-        (config->stage1_cal_en << 1) | (config->stage0_cal_en << 0);
-    const uint16_t stage_high_int_enable =
-        (config->stage11_high_int_enable << 11) |
-        (config->stage10_high_int_enable << 10) |
-        (config->stage9_high_int_enable << 9) |
-        (config->stage8_high_int_enable << 8) |
-        (config->stage7_high_int_enable << 7) |
-        (config->stage6_high_int_enable << 6) |
-        (config->stage5_high_int_enable << 5) |
-        (config->stage4_high_int_enable << 4) |
-        (config->stage3_high_int_enable << 3) |
-        (config->stage2_high_int_enable << 2) |
-        (config->stage1_high_int_enable << 1) |
-        (config->stage0_high_int_enable << 0);
-
-    ad714x_i2c_write(chip, AD7147_REG_PWR_CONTROL, pwr_control);
-    ad714x_i2c_write(chip, AD7147_REG_STAGE_CAL_EN, stage_cal_en);
-    ad714x_i2c_write(chip, AD7147_REG_STAGE_HIGH_INT_ENABLE,
-                     stage_high_int_enable);
+// A simple, non-concurrent ringbuffer. I feel like I've already implemented
+// this once in this codebase.
+//
+// TODO(q3k): unify/expose as common st3m API.
+typedef struct {
+    size_t write_ix;
+    bool wrapped;
+    uint16_t buf[4];
+} ringbuffer_t;
+
+// Size of ringbuffer, in elements.
+static inline size_t ringbuffer_size(const ringbuffer_t *rb) {
+    return sizeof(rb->buf) / sizeof(uint16_t);
 }
 
-static struct ad7147_stage_config ad714x_default_config(void) {
-    return (struct ad7147_stage_config){
-        .cinX_connection_setup = { CIN_BIAS, CIN_BIAS, CIN_BIAS, CIN_BIAS,
-                                   CIN_BIAS, CIN_BIAS, CIN_BIAS, CIN_BIAS,
-                                   CIN_BIAS, CIN_BIAS, CIN_BIAS, CIN_BIAS },
-        .se_connection_setup = 0b01,
-        .pos_afe_offset = 0,
-    };
+// Write to ringbuffer.
+static void ringbuffer_write(ringbuffer_t *rb, uint16_t data) {
+    rb->buf[rb->write_ix] = data;
+    rb->write_ix++;
+    if (rb->write_ix >= ringbuffer_size(rb)) {
+        rb->write_ix = 0;
+        rb->wrapped = true;
+    }
 }
 
-static void captouch_configure_stage(struct ad714x_chip *chip, uint8_t stage) {
-    struct ad7147_stage_config stage_config;
-    stage_config = ad714x_default_config();
-    if (chip == chip_bot) {
-        stage_config.cinX_connection_setup[bot_stage_config[stage]] =
-            CIN_CDC_POS;
-    } else {
-        stage_config.cinX_connection_setup[stage] = CIN_CDC_POS;
+// Get ringbuffer average (mean), or 0 if no values have yet been inserted.
+static uint16_t ringbuffer_avg(const ringbuffer_t *rb) {
+    int32_t res = 0;
+    if (rb->wrapped) {
+        for (size_t i = 0; i < ringbuffer_size(rb); i++) {
+            res += rb->buf[i];
+        }
+        res /= ringbuffer_size(rb);
+        return res;
     }
-    stage_config.pos_afe_offset = chip->pos_afe_offsets[stage];
-    ad714x_set_stage_config(chip, stage, &stage_config);
+    if (rb->write_ix == 0) {
+        return 0;
+    }
+    for (size_t i = 0; i < rb->write_ix; i++) {
+        res += rb->buf[i];
+    }
+    res /= rb->write_ix;
+    return res;
 }
 
-static int8_t captouch_configure_stage_afe_offset(uint8_t top, uint8_t stage,
-                                                  int8_t delta_afe) {
-    int8_t sat = 0;
-    struct ad714x_chip *chip = chip_bot;
-    if (top) chip = chip_top;
-    int8_t afe = chip->pos_afe_offsets[stage] - chip->neg_afe_offsets[stage];
-    if ((afe >= 63) && (delta_afe > 0)) sat = 1;
-    if ((afe <= 63) && (delta_afe < 0)) sat = -1;
-    afe += delta_afe;
-    if (afe >= 63) afe = 63;
-    if (afe <= -63) afe = -63;
-
-    if (afe > 0) {
-        chip->pos_afe_offsets[stage] = afe;
-        chip->neg_afe_offsets[stage] = 0;
-    } else {
-        chip->pos_afe_offsets[stage] = 0;
-        chip->neg_afe_offsets[stage] = -afe;
+// Get last inserted value, or 0 if no value have yet been inserted.
+static uint16_t ringbuffer_last(const ringbuffer_t *rb) {
+    if (rb->write_ix == 0) {
+        if (rb->wrapped) {
+            return rb->buf[ringbuffer_size(rb) - 1];
+        }
+        return 0;
     }
-    captouch_configure_stage(chip, stage);
-    return sat;
+    return rb->buf[rb->write_ix - 1];
 }
 
-static void captouch_init_chip(
-    struct ad714x_chip *chip, const struct ad7147_device_config device_config) {
-    uint16_t data;
-    ad714x_i2c_read(chip, AD7147_REG_DEVICE_ID, &data, 1);
-    ESP_LOGI(TAG, "DEVICE ID = %X", data);
+// TODO(q3k): expose these as user structs?
 
-    ad714x_set_device_config(chip, &device_config);
+typedef struct {
+    ringbuffer_t rb;
+    bool pressed;
+} st3m_captouch_petal_pad_t;
 
-    for (int i = 0; i < chip->stages; i++) {
-        captouch_configure_stage(chip, i);
-    }
-}
+typedef struct {
+    st3m_captouch_petal_pad_t base;
+    st3m_captouch_petal_pad_t cw;
+    st3m_captouch_petal_pad_t ccw;
+    bool pressed;
+} st3m_captouch_petal_top_t;
 
-static void captouch_init_petals() {
-    for (int i = 0; i < 10; i++) {
-        for (int j = 0; j < 4; j++) {
-            petals[i].pads[j].amb = 0;
-            petals[i].pads[j].cdc = 0;
-            if (i % 2) {
-                petals[i].pads[j].thres = DEFAULT_THRES_BOT;
-            } else {
-                petals[i].pads[j].thres = DEFAULT_THRES_TOP;
-            }
-        }
-        petals[i].config_mask = 0;
-    }
-    for (int i = 0; i < bot_stages; i++) {
-        petals[bot_map[i]].config_mask |= 1 << bot_segment_map[i];
-    }
-    for (int i = 0; i < top_stages; i++) {
-        petals[top_map[i]].config_mask |= 1 << top_segment_map[i];
-    }
+typedef struct {
+    st3m_captouch_petal_pad_t base;
+    st3m_captouch_petal_pad_t tip;
+    bool pressed;
+} st3m_captouch_petal_bottom_t;
+
+typedef struct {
+    flow3r_bsp_captouch_state_t raw;
+
+    st3m_captouch_petal_top_t top[5];
+    st3m_captouch_petal_bottom_t bottom[5];
+} st3m_captouch_state_t;
+
+static SemaphoreHandle_t _mu = NULL;
+static st3m_captouch_state_t _state = {};
+static bool _request_calibration = false;
+static bool _calibrating = false;
+
+static inline void _pad_feed(st3m_captouch_petal_pad_t *pad, uint16_t data,
+                             bool top) {
+    ringbuffer_write(&pad->rb, data);
+    int32_t thres = top ? 8000 : 12000;
+    pad->pressed = data > thres;
 }
 
-int32_t captouch_get_petal_rad(uint8_t petal) {
-    if (petal > 9) petal = 9;
-    uint8_t cf = petals[petal].config_mask;
-    if (cf == 0b1110) {  // CCW, CW, BASE
-        int32_t left = petals[petal].pads[PETAL_PAD_CCW].cdc;
-        left -= petals[petal].pads[PETAL_PAD_CCW].amb;
-        int32_t right = petals[petal].pads[PETAL_PAD_CW].cdc;
-        right -= petals[petal].pads[PETAL_PAD_CW].amb;
-        int32_t base = petals[petal].pads[PETAL_PAD_BASE].cdc;
-        base -= petals[petal].pads[PETAL_PAD_BASE].amb;
-        return (left + right) / 2 - base;
+static void _on_data(const flow3r_bsp_captouch_state_t *st) {
+    xSemaphoreTake(_mu, portMAX_DELAY);
+    memcpy(&_state, st, sizeof(flow3r_bsp_captouch_state_t));
+    for (size_t i = 0; i < 5; i++) {
+        _pad_feed(&_state.top[i].base, _state.raw.petals[i * 2].base.raw, true);
+        _pad_feed(&_state.top[i].cw, _state.raw.petals[i * 2].cw.raw, true);
+        _pad_feed(&_state.top[i].ccw, _state.raw.petals[i * 2].ccw.raw, true);
+        _state.top[i].pressed = _state.top[i].base.pressed ||
+                                _state.top[i].cw.pressed ||
+                                _state.top[i].ccw.pressed;
     }
-    if (cf == 0b111) {  // CCW, CW, TIP
-        int32_t left = petals[petal].pads[PETAL_PAD_CCW].cdc;
-        left -= petals[petal].pads[PETAL_PAD_CCW].amb;
-        int32_t right = petals[petal].pads[PETAL_PAD_CW].cdc;
-        right -= petals[petal].pads[PETAL_PAD_CW].amb;
-        int32_t tip = petals[petal].pads[PETAL_PAD_TIP].cdc;
-        tip -= petals[petal].pads[PETAL_PAD_TIP].amb;
-        return (-left - right) / 2 + tip;
+    for (size_t i = 0; i < 5; i++) {
+        _pad_feed(&_state.bottom[i].base, _state.raw.petals[i * 2 + 1].base.raw,
+                  false);
+        _pad_feed(&_state.bottom[i].tip, _state.raw.petals[i * 2 + 1].tip.raw,
+                  false);
+        _state.bottom[i].pressed =
+            _state.bottom[i].base.pressed || _state.bottom[i].tip.pressed;
     }
-    if (cf == 0b1001) {  // TIP, BASE
-        int32_t tip = petals[petal].pads[PETAL_PAD_TIP].cdc;
-        tip -= petals[petal].pads[PETAL_PAD_TIP].amb;
-        int32_t base = petals[petal].pads[PETAL_PAD_BASE].cdc;
-        base -= petals[petal].pads[PETAL_PAD_BASE].amb;
-        return tip - base;
-    }
-    if (cf == 0b1) {  // TIP
-        int32_t tip = petals[petal].pads[PETAL_PAD_TIP].cdc;
-        tip -= petals[petal].pads[PETAL_PAD_TIP].amb;
-        return tip;
+    if (_request_calibration) {
+        _request_calibration = false;
+        flow3r_bsp_captouch_calibrate();
     }
-    return 0;
-}
-
-int32_t captouch_get_petal_phi(uint8_t petal) {
-    if (petal > 9) petal = 9;
-    uint8_t cf = petals[petal].config_mask;
-    if ((cf == 0b1110) || (cf == 0b110) || (cf == 0b111)) {  // CCW, CW, (BASE)
-        int32_t left = petals[petal].pads[PETAL_PAD_CCW].cdc;
-        left -= petals[petal].pads[PETAL_PAD_CCW].amb;
-        int32_t right = petals[petal].pads[PETAL_PAD_CW].cdc;
-        right -= petals[petal].pads[PETAL_PAD_CW].amb;
-        return left - right;
-    }
-    return 0;
+    _calibrating = flow3r_bsp_captouch_calibrating();
+    xSemaphoreGive(_mu);
 }
 
 void captouch_init(void) {
-    captouch_init_petals();
-    chip_top = &chip_top_rev5;
-    chip_bot = &chip_bot_rev5;
-
-    captouch_init_chip(chip_top, (struct ad7147_device_config){
-                                     .sequence_stage_num = 11,
-                                     .decimation = 1,
-                                 });
-
-    captouch_init_chip(chip_bot, (struct ad7147_device_config){
-                                     .sequence_stage_num = 11,
-                                     .decimation = 1,
-                                 });
-}
+    assert(_mu == NULL);
+    _mu = xSemaphoreCreateMutex();
+    assert(_mu != NULL);
 
-uint16_t read_captouch() {
-    uint16_t bin_petals = 0;
-    for (int i = 0; i < 10; i++) {
-        if (petals[i].pressed) {
-            bin_petals |= (1 << i);
-        }
+    esp_err_t ret = flow3r_bsp_captouch_init(_on_data);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "Captouch init failed: %s", esp_err_to_name(ret));
     }
-    return bin_petals;
 }
 
-void read_captouch_ex(captouch_state_t *state) {
-    for (int i = 0; i < 10; i++) {
-        state->petals[i].pressed = petals[i].pressed > 0;
-        state->petals[i].pads.base_pressed =
-            petals[i].pads[PETAL_PAD_BASE].pressed > 0;
-        state->petals[i].pads.tip_pressed =
-            petals[i].pads[PETAL_PAD_TIP].pressed > 0;
-        state->petals[i].pads.cw_pressed =
-            petals[i].pads[PETAL_PAD_CW].pressed > 0;
-        state->petals[i].pads.ccw_pressed =
-            petals[i].pads[PETAL_PAD_CCW].pressed > 0;
-    }
+void captouch_print_debug_info(void) {
+    // Deprecated no-op, will be removed.
 }
 
-uint16_t cdc_data[2][12] = {
-    0,
-};
-uint16_t cdc_ambient[2][12] = {
-    0,
-};
+void captouch_read_cycle(void) {
+    // Deprecated no-op, will be removed.
+    // (now handled by interrupt)
+}
 
-static volatile uint32_t calib_active = 0;
+void captouch_set_calibration_afe_target(uint16_t target) {
+    // Deprecated no-op, will be removed.
+}
 
-static uint8_t calib_cycles = 0;
 void captouch_force_calibration() {
-    if (!calib_cycles) {    // last calib has finished
-        calib_cycles = 16;  // goal cycles, can be argument someday
-        Atomic_Increment_u32(&calib_active);
-    }
+    xSemaphoreTake(_mu, portMAX_DELAY);
+    _request_calibration = true;
+    xSemaphoreGive(_mu);
 }
 
 uint8_t captouch_calibration_active() {
-    return Atomic_CompareAndSwap_u32(&calib_active, 0, 0) ==
-           ATOMIC_COMPARE_AND_SWAP_FAILURE;
+    xSemaphoreTake(_mu, portMAX_DELAY);
+    bool res = _calibrating || _request_calibration;
+    xSemaphoreGive(_mu);
+    return res;
 }
 
-void check_petals_pressed() {
-    for (int i = 0; i < 10; i++) {
-        bool petal_pressed = false;
-        for (int j = 0; j < 4; j++) {
-            bool pad_pressed = false;
-            if ((petals[i].pads[j].amb + petals[i].pads[j].thres) <
-                petals[i].pads[j].cdc) {
-                petal_pressed = true;
-                pad_pressed = true;
-            }
-
-            if (pad_pressed) {
-                petals[i].pads[j].pressed = PETAL_PRESSED_DEBOUNCE;
-            } else if (petals[i].pads[j].pressed) {
-                petals[i].pads[j].pressed--;
-            }
-        }
-        if (petal_pressed) {
-            petals[i].pressed = PETAL_PRESSED_DEBOUNCE;
-        } else if (petals[i].pressed) {
-            petals[i].pressed--;
-        }
+void read_captouch_ex(captouch_state_t *state) {
+    memset(state, 0, sizeof(captouch_state_t));
+    xSemaphoreTake(_mu, portMAX_DELAY);
+    for (size_t i = 0; i < 5; i++) {
+        bool base = _state.top[i].base.pressed;
+        bool cw = _state.top[i].cw.pressed;
+        bool ccw = _state.top[i].ccw.pressed;
+        state->petals[i * 2].pads.base_pressed = base;
+        state->petals[i * 2].pads.cw_pressed = cw;
+        state->petals[i * 2].pads.ccw_pressed = ccw;
+        state->petals[i * 2].pressed = base || cw || ccw;
     }
-}
-
-void cdc_to_petal(bool bot, bool amb, uint16_t cdc_data[],
-                  uint8_t cdc_data_length) {
-    for (int i = 0; i < cdc_data_length; i++) {
-        size_t petal_index = bot ? bot_map[i] : top_map[i];
-        size_t pad_index = bot ? bot_segment_map[i] : top_segment_map[i];
-        petal_pad_t *pad = &petals[petal_index].pads[pad_index];
-        uint16_t *target = amb ? &pad->amb : &pad->cdc;
-        *target = cdc_data[i];
+    for (size_t i = 0; i < 5; i++) {
+        bool base = _state.bottom[i].base.pressed;
+        bool tip = _state.bottom[i].tip.pressed;
+        state->petals[i * 2 + 1].pads.base_pressed = base;
+        state->petals[i * 2 + 1].pads.tip_pressed = tip;
+        state->petals[i * 2 + 1].pressed = base || tip;
     }
+    xSemaphoreGive(_mu);
 }
 
-uint16_t captouch_get_petal_pad_raw(uint8_t petal, uint8_t pad) {
-    if (petal > 9) petal = 9;
-    if (pad > 3) pad = 3;
-    return petals[petal].pads[pad].cdc;
-}
-uint16_t captouch_get_petal_pad_calib_ref(uint8_t petal, uint8_t pad) {
-    if (petal > 9) petal = 9;
-    if (pad > 3) pad = 3;
-    return petals[petal].pads[pad].amb;
-}
-uint16_t captouch_get_petal_pad(uint8_t petal, uint8_t pad) {
-    if (petal > 9) petal = 9;
-    if (pad > 3) pad = 3;
-    if (petals[petal].pads[pad].amb < petals[petal].pads[pad].cdc) {
-        return petals[petal].pads[pad].cdc - petals[petal].pads[pad].amb;
+uint16_t read_captouch(void) {
+    xSemaphoreTake(_mu, portMAX_DELAY);
+    uint16_t res = 0;
+    for (size_t i = 0; i < 5; i++) {
+        if (_state.top[i].pressed) res |= (1 << (i * 2));
     }
-    return 0;
+    for (size_t i = 0; i < 5; i++) {
+        if (_state.bottom[i].pressed) res |= (1 << (i * 2 + 1));
+    }
+    xSemaphoreGive(_mu);
+    return res;
 }
 
 void captouch_set_petal_pad_threshold(uint8_t petal, uint8_t pad,
                                       uint16_t thres) {
-    if (petal > 9) petal = 9;
-    if (pad > 3) pad = 3;
-    petals[petal].pads[pad].thres = thres;
+    // Deprecated no-op, will be removed.
 }
 
-static int32_t calib_target = 6000;
-
-void captouch_set_calibration_afe_target(uint16_t target) {
-    calib_target = target;
+uint16_t captouch_get_petal_pad_raw(uint8_t petal, uint8_t pad) {
+    // Deprecated no-op, will be removed.
+    return 0;
 }
 
-void captouch_read_cycle() {
-    static uint8_t calib_cycle = 0;
-    static uint8_t calib_div = 1;
-    static uint32_t ambient_acc[2][12] = { {
-                                               0,
-                                           },
-                                           {
-                                               0,
-                                           } };
-    if (calib_cycles) {
-        if (calib_cycle == 0) {  // last cycle has finished, setup new
-            calib_cycle = calib_cycles;
-            calib_div = calib_cycles;
-            for (int j = 0; j < 12; j++) {
-                ambient_acc[0][j] = 0;
-                ambient_acc[1][j] = 0;
-            }
-        }
+uint16_t captouch_get_petal_pad_calib_ref(uint8_t petal, uint8_t pad) {
+    // Deprecated no-op, will be removed.
+    return 0;
+}
 
-        ad714x_i2c_read(chip_top, 0xB, cdc_ambient[0], chip_top->stages);
-        ad714x_i2c_read(chip_bot, 0xB, cdc_ambient[1], chip_bot->stages);
-        for (int j = 0; j < 12; j++) {
-            ambient_acc[0][j] += cdc_ambient[0][j];
-            ambient_acc[1][j] += cdc_ambient[1][j];
-        }
+uint16_t captouch_get_petal_pad(uint8_t petal, uint8_t pad) {
+    // Deprecated no-op, will be removed.
+    return 0;
+}
 
-        // TODO: use median instead of average
-        calib_cycle--;
-        if (!calib_cycle) {  // calib cycle is complete
-            for (int i = 0; i < 12; i++) {
-                cdc_ambient[0][i] = ambient_acc[0][i] / calib_div;
-                cdc_ambient[1][i] = ambient_acc[1][i] / calib_div;
-            }
-            cdc_to_petal(0, 1, cdc_ambient[0], 12);
-            cdc_to_petal(1, 1, cdc_ambient[1], 12);
-            calib_cycles = 0;
-
-            uint8_t recalib = 0;
-            for (int i = 0; i < 12; i++) {
-                for (int j = 0; j < 2; j++) {
-                    int32_t diff = ((int32_t)cdc_ambient[j][i]) - calib_target;
-                    int8_t steps = diff / (AFE_INCR_CAP);
-                    if ((steps > 1) || (steps < -1)) {
-                        if (!captouch_configure_stage_afe_offset(1 - j, i,
-                                                                 steps)) {
-                            recalib = 1;
-                        }
-                    }
-                }
-            }
-            if (recalib) {
-                calib_cycles = 16;  // do another round
-            } else {
-                Atomic_Decrement_u32(&calib_active);
-            }
-        }
+int32_t captouch_get_petal_phi(uint8_t petal) {
+    bool top = (petal % 2) == 0;
+    if (top) {
+        size_t ix = petal / 2;
+        xSemaphoreTake(_mu, portMAX_DELAY);
+        int32_t left = ringbuffer_avg(&_state.top[ix].ccw.rb);
+        int32_t right = ringbuffer_avg(&_state.top[ix].cw.rb);
+        xSemaphoreGive(_mu);
+        return left - right;
     } else {
-        ad714x_i2c_read(chip_top, 0xB, cdc_data[0], chip_top->stages);
-        cdc_to_petal(0, 0, cdc_data[0], 12);
-
-        ad714x_i2c_read(chip_bot, 0xB, cdc_data[1], chip_bot->stages);
-        cdc_to_petal(1, 0, cdc_data[1], 12);
-
-        check_petals_pressed();
+        return 0;
     }
 }
 
-static void captouch_print_debug_info_chip(const struct ad714x_chip *chip) {
-    uint16_t *data;
-    uint16_t *ambient;
-    const int stages = chip->stages;
-
-    if (chip == chip_top) {
-        data = cdc_data[0];
-        ambient = cdc_ambient[0];
+int32_t captouch_get_petal_rad(uint8_t petal) {
+    bool top = (petal % 2) == 0;
+    if (top) {
+        size_t ix = petal / 2;
+        xSemaphoreTake(_mu, portMAX_DELAY);
+        int32_t left = ringbuffer_avg(&_state.top[ix].ccw.rb);
+        int32_t right = ringbuffer_avg(&_state.top[ix].cw.rb);
+        int32_t base = ringbuffer_avg(&_state.top[ix].base.rb);
+        xSemaphoreGive(_mu);
+        return (left + right) / 2 - base;
     } else {
-        data = cdc_data[1];
-        ambient = cdc_ambient[1];
-    }
-
-    // Appease clang-tidy.
-    (void)data;
-    (void)ambient;
-    ESP_LOGI(TAG, "CDC results: %X %X %X %X %X %X %X %X %X %X %X %X", data[0],
-             data[1], data[2], data[3], data[4], data[5], data[6], data[7],
-             data[8], data[9], data[10], data[11]);
-
-    for (int stage = 0; stage < stages; stage++) {
-        ESP_LOGI(TAG, "stage %d ambient: %X diff: %d", stage, ambient[stage],
-                 data[stage] - ambient[stage]);
+        size_t ix = (petal - 1) / 2;
+        xSemaphoreTake(_mu, portMAX_DELAY);
+        int32_t tip = ringbuffer_avg(&_state.bottom[ix].tip.rb);
+        int32_t base = ringbuffer_avg(&_state.bottom[ix].base.rb);
+        xSemaphoreGive(_mu);
+        return tip - base;
     }
 }
-
-void captouch_print_debug_info(void) {
-    captouch_print_debug_info_chip(chip_top);
-    captouch_print_debug_info_chip(chip_bot);
-}
diff --git a/components/flow3r_bsp/CMakeLists.txt b/components/flow3r_bsp/CMakeLists.txt
index 60ecf0f064c22c517352f00e55fc2630226a3e40..3c16ce246b6c2846cd2068ec35b6f2a0f8e3394e 100644
--- a/components/flow3r_bsp/CMakeLists.txt
+++ b/components/flow3r_bsp/CMakeLists.txt
@@ -10,6 +10,9 @@ idf_component_register(
 		flow3r_bsp_leds.c
 		flow3r_bsp_rmtled.c
 		flow3r_bsp_spio.c
+		flow3r_bsp_captouch.c
+		flow3r_bsp_ad7147.c
+		flow3r_bsp_ad7147_hw.c
     INCLUDE_DIRS
 		.
     REQUIRES
diff --git a/components/flow3r_bsp/flow3r_bsp_ad7147.c b/components/flow3r_bsp/flow3r_bsp_ad7147.c
new file mode 100644
index 0000000000000000000000000000000000000000..8bae4631a1e3a78469b9e508812096b6590ec44e
--- /dev/null
+++ b/components/flow3r_bsp/flow3r_bsp_ad7147.c
@@ -0,0 +1,259 @@
+#include "flow3r_bsp_ad7147.h"
+#include "flow3r_bsp_ad7147_hw.h"
+#include "flow3r_bsp_captouch.h"
+
+#include "esp_err.h"
+#include "esp_log.h"
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <string.h>
+
+static const char *TAG = "flow3r-bsp-ad7147";
+static const int32_t _calib_target = 6000;
+static const int32_t _calib_incr_cap = 1000;
+
+#define COMPLAIN(c, ...)                \
+    do {                                \
+        if (!c->failed) {               \
+            ESP_LOGE(TAG, __VA_ARGS__); \
+            c->failed = true;           \
+        }                               \
+    } while (0)
+
+// Length of sequence, assuming sequence is right-padded with -1.
+static size_t _captouch_sequence_length(int8_t *sequence) {
+    for (size_t i = 0; i < 12; i++) {
+        if (sequence[i] == -1) {
+            return i;
+        }
+    }
+    return 12;
+}
+
+// Request current sequence from captouch chip.
+static esp_err_t _sequence_request(ad7147_chip_t *chip, bool reprogram) {
+    int8_t *seq = chip->sequences[chip->seq_position];
+    ad7147_sequence_t seq_out = {
+        .len = _captouch_sequence_length(seq),
+    };
+    for (size_t i = 0; i < seq_out.len; i++) {
+        int8_t channel = seq[i];
+        int8_t offset = chip->channels[channel].afe_offset;
+        seq_out.channels[i] = channel;
+        seq_out.pos_afe_offsets[i] = offset;
+    }
+
+    esp_err_t ret;
+    if ((ret = ad7147_hw_configure_stages(&chip->dev, &seq_out, reprogram)) !=
+        ESP_OK) {
+        return ret;
+    }
+    return ESP_OK;
+}
+
+// Advance internal sequence pointer to next sequence. Returns true if advance
+// occurred (in other words, false when there is only one sequence).
+static bool _sequence_advance(ad7147_chip_t *chip) {
+    // Advance to next sequence.
+    size_t start = chip->seq_position;
+    chip->seq_position++;
+    if (chip->seq_position >= _AD7147_SEQ_MAX) {
+        chip->seq_position = 0;
+    } else {
+        if (_captouch_sequence_length(chip->sequences[chip->seq_position]) ==
+            0) {
+            chip->seq_position = 0;
+        }
+    }
+    return start != chip->seq_position;
+}
+
+// Queue a calibration request until the next _chip_process call picks it up.
+static void _calibration_request(ad7147_chip_t *chip) {
+    chip->calibration_pending = true;
+}
+
+static int _uint16_sort(const void *va, const void *vb) {
+    uint16_t a = *((uint16_t *)va);
+    uint16_t b = *((uint16_t *)vb);
+    if (a < b) {
+        return -1;
+    }
+    if (a > b) {
+        return 1;
+    }
+    return 0;
+}
+
+// Calculate median of 16 measurements.
+static uint16_t _average_calib_measurements(uint16_t *measurements) {
+    qsort(measurements, _AD7147_CALIB_CYCLES, sizeof(uint16_t), _uint16_sort);
+    size_t ix = _AD7147_CALIB_CYCLES / 2;
+    return measurements[ix];
+}
+
+static size_t _channel_from_readout(const ad7147_chip_t *chip, size_t ix) {
+    size_t six = chip->seq_position;
+    const int8_t *seq = chip->sequences[six];
+    assert(seq[ix] >= 0);
+    return seq[ix];
+}
+
+// Check if a channel's AFE offset can be tweaked to reach a wanted amb value.
+// True is returned if a tweak was performed, false otherwise.
+static bool _channel_afe_tweak(ad7147_chip_t *chip, size_t cix) {
+    int32_t cur = chip->channels[cix].amb;
+    int32_t target = _calib_target;
+    int32_t diff = (cur - target) / _calib_incr_cap;
+    if (diff < 1 && diff > -1) {
+        // Close enough.
+        return false;
+    }
+    int32_t offset = chip->channels[cix].afe_offset;
+    if (offset <= 0 && diff < 0) {
+        // Saturated, can't do anything.
+        return false;
+    }
+    if (offset >= 63 && diff > 0) {
+        // Saturated, can't do anything.
+        return false;
+    }
+
+    offset += diff;
+    if (offset < 0) {
+        offset = 0;
+    }
+    if (offset > 63) {
+        offset = 63;
+    }
+    chip->channels[cix].afe_offset = offset;
+    return true;
+}
+
+// Called when a sequence is completed by the low-level layer.
+static void _on_data(void *user, uint16_t *data, size_t len) {
+    ad7147_chip_t *chip = (ad7147_chip_t *)user;
+
+    if (chip->calibration_cycles > 0) {
+        // We're doing a calibration cycle on our channels. Instead of writing
+        // the data to channel->cdc, write it to channel->amb_meas.
+        size_t j = chip->calibration_cycles - 1;
+        for (size_t i = 0; i < len; i++) {
+            chip->channels[_channel_from_readout(chip, i)].amb_meas[j] =
+                data[i];
+        }
+    } else {
+        // Normal measurement, apply to channel->cdc.
+        for (size_t i = 0; i < len; i++) {
+            chip->channels[_channel_from_readout(chip, i)].cdc = data[i];
+        }
+    }
+
+    bool reprogram = _sequence_advance(chip);
+
+    // Synchronize on beginning of sequence for calibration logic.
+    if (chip->seq_position == 0) {
+        // Deal with calibration pending flag, possibly starting calibration.
+        if (chip->calibration_pending) {
+            if (chip->calibration_cycles == 0) {
+                ESP_LOGI(TAG, "%s: calibration starting...", chip->name);
+                chip->calibration_cycles = _AD7147_CALIB_CYCLES;
+            }
+            chip->calibration_pending = false;
+        }
+
+        if (chip->calibration_cycles > 0) {
+            // Deal with active calibration.
+            chip->calibration_cycles--;
+            if (chip->calibration_cycles == 0) {
+                // Calibration measurements done. Calculate average amb data for
+                // each channel.
+                for (size_t i = 0; i < chip->nchannels; i++) {
+                    uint16_t avg =
+                        _average_calib_measurements(chip->channels[i].amb_meas);
+                    chip->channels[i].amb = avg;
+                }
+
+                char msg[256];
+                char *wr = msg;
+                for (size_t i = 0; i < chip->nchannels; i++) {
+                    if (wr != msg) {
+                        wr += snprintf(wr, 256 - (wr - msg), ", ");
+                    }
+                    wr += snprintf(wr, 256 - (wr - msg), "%04d/%02d",
+                                   chip->channels[i].amb,
+                                   chip->channels[i].afe_offset);
+                }
+                ESP_LOGD(TAG, "%s: calibration: %s.", chip->name, msg);
+
+                // Can we tweak the AFE to get a better measurement?
+                uint16_t rerun = 0;
+                for (size_t i = 0; i < chip->nchannels; i++) {
+                    bool tweak = _channel_afe_tweak(chip, i);
+                    if (tweak) {
+                        rerun |= (1 << i);
+                    }
+                }
+
+                if (rerun != 0) {
+                    // Rerun calibration again,
+                    ESP_LOGI(TAG,
+                             "%s: calibration done, but can do better (%04x). "
+                             "Retrying.",
+                             chip->name, rerun);
+                    chip->calibration_cycles = _AD7147_CALIB_CYCLES;
+                } else {
+                    ESP_LOGI(TAG, "%s: calibration done.", chip->name);
+                }
+            }
+        } else {
+            // Submit data to higher level for processing.
+            if (chip->callback != NULL) {
+                uint16_t val[13];
+                for (size_t i = 0; i < chip->nchannels; i++) {
+                    int32_t cdc = chip->channels[i].cdc;
+                    int32_t amb = chip->channels[i].amb;
+                    int32_t diff = cdc - amb;
+                    if (diff < 0) {
+                        val[i] = 0;
+                    } else if (diff > 65535) {
+                        val[i] = 65535;
+                    } else {
+                        val[i] = diff;
+                    }
+                }
+                chip->callback(chip->user, val, chip->nchannels);
+            }
+        }
+    }
+
+    // BUG(q3k): we shouldn't need to do this every cycle, but otherwise we get
+    // some weird results on positional output. Needs to be investigated.
+    esp_err_t ret;
+    if ((ret = _sequence_request(chip, reprogram)) != ESP_OK) {
+        COMPLAIN(chip, "%s: requesting next sequence failed: %s", chip->name,
+                 esp_err_to_name(ret));
+    }
+}
+
+esp_err_t flow3r_bsp_ad7147_chip_init(ad7147_chip_t *chip,
+                                      flow3r_i2c_address address) {
+    esp_err_t ret;
+    for (size_t i = 0; i < chip->nchannels; i++) {
+        chip->channels[i].amb = 0;
+    }
+    if ((ret = ad7147_hw_init(&chip->dev, address, _on_data, chip)) != ESP_OK) {
+        return ret;
+    }
+    _calibration_request(chip);
+    if ((ret = _sequence_request(chip, false)) != ESP_OK) {
+        return ret;
+    }
+    return ESP_OK;
+}
+
+esp_err_t flow3r_bsp_ad7147_chip_process(ad7147_chip_t *chip) {
+    return ad7147_hw_process(&chip->dev);
+}
\ No newline at end of file
diff --git a/components/flow3r_bsp/flow3r_bsp_ad7147.h b/components/flow3r_bsp/flow3r_bsp_ad7147.h
new file mode 100644
index 0000000000000000000000000000000000000000..5082a87d066d51f87bb3a2541958c5330ed79b96
--- /dev/null
+++ b/components/flow3r_bsp/flow3r_bsp_ad7147.h
@@ -0,0 +1,78 @@
+#pragma once
+
+// High-level AD7147 captouch functions. Includes support for switching
+// sequences and has basic software calibration routines.
+//
+// Only takes care of one captouch controller at once. Both captouch controllers
+// are put together into a single view in flow3r_bsp_captouch.
+
+// The AD7147 captouch chip is weird.
+//
+// It has 13 input channels, and can perform arbitrarily sequenced reads from
+// these channels (then report the results of that sequence at once), but the
+// maximum sequence length is 12 stages.
+//
+// That means getting 13 independent captouch channel readouts is tricky, as you
+// need to change these sequences around to get all channels.
+
+#include "flow3r_bsp_ad7147_hw.h"
+
+#define _AD7147_SEQ_MAX 2
+#define _AD7147_CALIB_CYCLES 16
+
+// State of an AD7147 channel. Each AD7147 has 13 channels, but can only access
+// 12 of them at once in a single sequence.
+typedef struct {
+    // Positive AFE offset currently programmed. [0,64).
+    int8_t afe_offset;
+    // Last measurement.
+    uint16_t cdc;
+
+    // Ambient value used for offset when checking for touch presence. Written
+    // by calibration, and attempts to reach a preset calibration setpoint.
+    uint16_t amb;
+    // Calibration samples gathered during the calibraiton process.
+    uint16_t amb_meas[_AD7147_CALIB_CYCLES];
+} ad7147_channel_t;
+
+// State and configuration of an AD7147 chip. Wraps the low-level structure in
+// everything required to manage multiple sequences and perform calibration.
+typedef struct {
+    // Opaque name used to prefix log messages.
+    const char *name;
+
+    // [0, n_channels) are the expected connected channels to the inputs of the
+    // chip.
+    size_t nchannels;
+    ad7147_channel_t channels[13];
+
+    // Sequences to be handled by this chip. Each sequence is a -1 right-padded
+    // list of channel numbers that the sequence will be programmed to. If a
+    // sequence is all -1, it will be skipped.
+    int8_t sequences[_AD7147_SEQ_MAX][12];
+
+    // Current position within the sequences list.
+    size_t seq_position;
+
+    // Called when all sequences have scanned through.
+    ad7147_data_callback_t callback;
+    void *user;
+
+    ad7147_hw_t dev;
+    bool failed;
+
+    bool calibration_pending;
+    size_t calibration_cycles;
+} ad7147_chip_t;
+
+// Call to initialize the chip at a given address. Structure must be zeroed, and
+// callback must be configured.
+//
+// The chip will be configured to pull its interrupt line low when a sequence
+// has finished and thus when _chip_process should be called.
+esp_err_t flow3r_bsp_ad7147_chip_init(ad7147_chip_t *chip,
+                                      flow3r_i2c_address address);
+
+// Call to poll the chip and perform any necessary actions. Can be called from
+// an interrupt.
+esp_err_t flow3r_bsp_ad7147_chip_process(ad7147_chip_t *chip);
\ No newline at end of file
diff --git a/components/flow3r_bsp/flow3r_bsp_ad7147_hw.c b/components/flow3r_bsp/flow3r_bsp_ad7147_hw.c
new file mode 100644
index 0000000000000000000000000000000000000000..b7237c5e17b4718ae607d9360a0abb9b9616b246
--- /dev/null
+++ b/components/flow3r_bsp/flow3r_bsp_ad7147_hw.c
@@ -0,0 +1,313 @@
+#include "flow3r_bsp_ad7147_hw.h"
+
+#include "esp_err.h"
+#include "esp_log.h"
+
+#include <string.h>
+
+#define TIMEOUT_MS 1000
+
+#define CIN CDC_NONE 0
+#define CIN_CDC_NEG 1
+#define CIN_CDC_POS 2
+#define CIN_BIAS 3
+
+#define AD7147_REG_PWR_CONTROL 0x00
+#define AD7147_REG_STAGE_CAL_EN 0x01
+#define AD7147_REG_AMB_COMP_CTRL0 0x02
+#define AD7147_REG_STAGE_HIGH_INT_ENABLE 0x06
+#define AD7147_REG_STAGE_COMPLETE_INT_ENABLE 0x07
+#define AD7147_REG_STAGE_COMPLETE_INT_STATUS 0x0A
+#define AD7147_REG_CDC_RESULT_S0 0x0B
+#define AD7147_REG_DEVICE_ID 0x17
+#define AD7147_REG_STAGE0_CONNECTION 0x80
+
+// Write single register at `reg`.
+static esp_err_t _i2c_write(const ad7147_hw_t *dev, uint16_t reg,
+                            uint16_t data) {
+    const uint8_t tx[] = { reg >> 8, reg & 0xFF, data >> 8, data & 0xFF };
+    return flow3r_bsp_i2c_write_to_device(dev->addr, tx, sizeof(tx),
+                                          TIMEOUT_MS / portTICK_PERIOD_MS);
+}
+
+// Write continuous `len`-long register range starting at `reg`.
+static esp_err_t _i2c_write_multiple(const ad7147_hw_t *dev, uint16_t reg,
+                                     const uint16_t *data, size_t len) {
+    uint8_t *tx = malloc(len * 2 + 2);
+    assert(tx != NULL);
+    tx[0] = reg >> 8;
+    tx[1] = reg & 0xff;
+    for (size_t i = 0; i < len; i++) {
+        tx[2 + i * 2] = data[i] >> 8;
+        tx[2 + i * 2 + 1] = data[i] & 0xff;
+    }
+    esp_err_t ret = flow3r_bsp_i2c_write_to_device(
+        dev->addr, tx, len * 2 + 2, TIMEOUT_MS / portTICK_PERIOD_MS);
+    free(tx);
+    return ret;
+}
+
+// Read continuous `len`-long register range starting at `reg`.
+static esp_err_t _i2c_read(const ad7147_hw_t *dev, const uint16_t reg,
+                           uint16_t *data, const size_t len) {
+    const uint8_t tx[] = { reg >> 8, reg & 0xFF };
+    uint8_t rx[len * 2];
+    esp_err_t ret = flow3r_bsp_i2c_write_read_device(
+        dev->addr, tx, sizeof(tx), rx, sizeof(rx),
+        TIMEOUT_MS / portTICK_PERIOD_MS);
+    for (int i = 0; i < len; i++) {
+        data[i] = (rx[i * 2] << 8) | rx[i * 2 + 1];
+    }
+    return ret;
+}
+
+// Configure device's PWR_CONTROL register based on dev_config.
+static esp_err_t _configure_pwr_control(const ad7147_hw_t *dev) {
+    const ad7147_device_config_t *config = &dev->dev_config;
+    const uint16_t val =
+        (config->cdc_bias << 14) | (config->ext_source << 12) |
+        (config->int_pol << 11) | (config->sw_reset << 10) |
+        (config->decimation << 8) | (config->sequence_stage_num << 4) |
+        (config->lp_conv_delay << 2) | (config->power_mode << 0);
+    return _i2c_write(dev, AD7147_REG_PWR_CONTROL, val);
+}
+
+// Configure device's STAGE_CAL_EN register based on dev_config.
+static esp_err_t _configure_stage_cal_en(const ad7147_hw_t *dev) {
+    const ad7147_device_config_t *config = &dev->dev_config;
+    const uint16_t val =
+        (config->avg_lp_skip << 14) | (config->avg_fp_skip << 12) |
+        (config->stage11_cal_en << 11) | (config->stage10_cal_en << 10) |
+        (config->stage9_cal_en << 9) | (config->stage8_cal_en << 8) |
+        (config->stage7_cal_en << 7) | (config->stage6_cal_en << 6) |
+        (config->stage5_cal_en << 5) | (config->stage4_cal_en << 4) |
+        (config->stage3_cal_en << 3) | (config->stage2_cal_en << 2) |
+        (config->stage1_cal_en << 1) | (config->stage0_cal_en << 0);
+
+    return _i2c_write(dev, AD7147_REG_STAGE_CAL_EN, val);
+}
+
+// Configure device's STAGE_HIGH_INT_ENANBLE register based on dev_config.
+static esp_err_t _configure_high_int_en(const ad7147_hw_t *dev) {
+    const ad7147_device_config_t *config = &dev->dev_config;
+    const uint16_t val = (config->stage11_high_int_enable << 11) |
+                         (config->stage10_high_int_enable << 10) |
+                         (config->stage9_high_int_enable << 9) |
+                         (config->stage8_high_int_enable << 8) |
+                         (config->stage7_high_int_enable << 7) |
+                         (config->stage6_high_int_enable << 6) |
+                         (config->stage5_high_int_enable << 5) |
+                         (config->stage4_high_int_enable << 4) |
+                         (config->stage3_high_int_enable << 3) |
+                         (config->stage2_high_int_enable << 2) |
+                         (config->stage1_high_int_enable << 1) |
+                         (config->stage0_high_int_enable << 0);
+
+    return _i2c_write(dev, AD7147_REG_STAGE_HIGH_INT_ENABLE, val);
+}
+
+// Configure device's STAGE_COMPLETE_INT_ENABLE register based on dev_config.
+static esp_err_t _configure_complete_int_en(const ad7147_hw_t *dev) {
+    const ad7147_device_config_t *config = &dev->dev_config;
+    const uint16_t val =
+        ((config->stageX_complete_int_enable[0] ? 1 : 0) << 11) |
+        ((config->stageX_complete_int_enable[1] ? 1 : 0) << 10) |
+        ((config->stageX_complete_int_enable[2] ? 1 : 0) << 9) |
+        ((config->stageX_complete_int_enable[3] ? 1 : 0) << 8) |
+        ((config->stageX_complete_int_enable[4] ? 1 : 0) << 7) |
+        ((config->stageX_complete_int_enable[5] ? 1 : 0) << 6) |
+        ((config->stageX_complete_int_enable[6] ? 1 : 0) << 5) |
+        ((config->stageX_complete_int_enable[6] ? 1 : 0) << 4) |
+        ((config->stageX_complete_int_enable[8] ? 1 : 0) << 3) |
+        ((config->stageX_complete_int_enable[9] ? 1 : 0) << 2) |
+        ((config->stageX_complete_int_enable[10] ? 1 : 0) << 1) |
+        ((config->stageX_complete_int_enable[11] ? 1 : 0) << 0);
+
+    return _i2c_write(dev, AD7147_REG_STAGE_COMPLETE_INT_ENABLE, val);
+}
+
+// Configure stage per stage_config.
+static esp_err_t _configure_stage(const ad7147_hw_t *dev, uint8_t stage) {
+    const ad7147_stage_config_t *config = &dev->stage_config[stage];
+
+    const uint16_t connection_6_0 = (config->cinX_connection_setup[6] << 12) |
+                                    (config->cinX_connection_setup[5] << 10) |
+                                    (config->cinX_connection_setup[4] << 8) |
+                                    (config->cinX_connection_setup[3] << 6) |
+                                    (config->cinX_connection_setup[2] << 4) |
+                                    (config->cinX_connection_setup[1] << 2) |
+                                    (config->cinX_connection_setup[0] << 0);
+    const uint16_t connection_12_7 = (config->pos_afe_offset_disable << 15) |
+                                     (config->neg_afe_offset_disable << 14) |
+                                     (config->se_connection_setup << 12) |
+                                     (config->cinX_connection_setup[12] << 10) |
+                                     (config->cinX_connection_setup[11] << 8) |
+                                     (config->cinX_connection_setup[10] << 6) |
+                                     (config->cinX_connection_setup[9] << 4) |
+                                     (config->cinX_connection_setup[8] << 2) |
+                                     (config->cinX_connection_setup[7] << 0);
+    const uint16_t afe_offset =
+        (config->pos_afe_offset_swap << 15) | (config->pos_afe_offset << 8) |
+        (config->neg_afe_offset_swap << 7) | (config->neg_afe_offset << 0);
+    const uint16_t sensitivity = (config->pos_peak_detect << 12) |
+                                 (config->pos_threshold_sensitivity << 8) |
+                                 (config->neg_peak_detect << 4) |
+                                 (config->neg_threshold_sensitivity << 0);
+
+    const uint8_t reg = AD7147_REG_STAGE0_CONNECTION + stage * 8;
+    uint16_t tx[4] = {
+        connection_6_0,
+        connection_12_7,
+        afe_offset,
+        sensitivity,
+    };
+    return _i2c_write_multiple(dev, reg, tx, 4);
+}
+
+// Configure entire device per stage_config and dev_config.
+static esp_err_t _configure_full(const ad7147_hw_t *dev) {
+    esp_err_t ret;
+    if ((ret = _configure_pwr_control(dev)) != ESP_OK) {
+        return ret;
+    }
+    if ((ret = _configure_stage_cal_en(dev)) != ESP_OK) {
+        return ret;
+    }
+    if ((ret = _configure_high_int_en(dev)) != ESP_OK) {
+        return ret;
+    }
+    if ((ret = _configure_complete_int_en(dev)) != ESP_OK) {
+        return ret;
+    }
+    for (uint8_t i = 0; i < 12; i++) {
+        if ((ret = _configure_stage(dev, i)) != ESP_OK) {
+            return ret;
+        }
+    }
+    return ESP_OK;
+}
+
+// Force stage sequencer to reset. Glitchy.
+static esp_err_t _reset_sequencer(const ad7147_hw_t *dev) {
+    uint16_t val = (0x0 << 0) |   // FF_SKIP_CNT
+                   (0xf << 4) |   // FP_PROXIMITY_CNT
+                   (0xf << 8) |   // LP_PROXIMITY_CNT
+                   (0x0 << 12) |  // PWR_DOWN_TIMEOUT
+                   (0x0 << 14) |  // FORCED_CAL
+                   (0x1 << 15);   // CONV_RESET
+    esp_err_t res;
+    if ((res = _i2c_write(dev, AD7147_REG_AMB_COMP_CTRL0, val)) != ESP_OK) {
+        return res;
+    }
+    return ESP_OK;
+}
+
+// Read completed conversion data and call user callback on it.
+static esp_err_t _process_complete(ad7147_hw_t *device) {
+    uint16_t data[12];
+    size_t count = device->num_stages;
+    esp_err_t res;
+    if ((res = _i2c_read(device, AD7147_REG_CDC_RESULT_S0, data, count)) !=
+        ESP_OK) {
+        return res;
+    }
+    if (device->callback != NULL) {
+        device->callback(device->user, data, count);
+    }
+
+    return ESP_OK;
+}
+
+esp_err_t ad7147_hw_process(ad7147_hw_t *device) {
+    // Read complete status register. This acknowledges interrupts.
+    uint16_t st = 0;
+    esp_err_t res =
+        _i2c_read(device, AD7147_REG_STAGE_COMPLETE_INT_STATUS, &st, 1);
+    if (res != ESP_OK) {
+        return res;
+    }
+
+    // Nothing to do if no stages are expected to be read.
+    if (device->num_stages < 1) {
+        return ESP_OK;
+    }
+
+    // Bit indicating the conversion has been complete for the requested number
+    // of stages.
+    uint16_t complete_bit = (1 << (device->num_stages - 1));
+    if (st & complete_bit) {
+        res = _process_complete(device);
+        if (res != ESP_OK) {
+            return res;
+        }
+    } else {
+        // Spurious hw_process call, nothing to do...
+    }
+
+    return ESP_OK;
+}
+
+esp_err_t ad7147_hw_init(ad7147_hw_t *device, flow3r_i2c_address addr,
+                         ad7147_data_callback_t callback, void *user) {
+    memset(device, 0, sizeof(ad7147_hw_t));
+    device->addr = addr;
+    device->callback = callback;
+    device->user = user;
+
+    // device->dev_config.decimation = 0b10; // Decimation: 64, lowest possible.
+    device->dev_config.decimation = 0b01;
+
+    for (size_t i = 0; i < 12; i++) {
+        for (size_t j = 0; j < 13; j++) {
+            device->stage_config[i].cinX_connection_setup[j] = CIN_BIAS;
+        }
+        device->stage_config[i].se_connection_setup = 0b01;
+    }
+    return _configure_full(device);
+}
+
+esp_err_t ad7147_hw_configure_stages(ad7147_hw_t *device,
+                                     const ad7147_sequence_t *seq,
+                                     bool reprogram) {
+    // Reset all stage/channel configuration.
+    for (size_t i = 0; i < 12; i++) {
+        for (int8_t j = 0; j < 13; j++) {
+            device->stage_config[i].cinX_connection_setup[j] = CIN_BIAS;
+        }
+        device->dev_config.stageX_complete_int_enable[i] = false;
+    }
+
+    // Configure stages as requested.
+    for (size_t i = 0; i < seq->len; i++) {
+        int8_t channel = seq->channels[i];
+        int8_t offset = seq->pos_afe_offsets[i];
+        device->stage_config[i].cinX_connection_setup[channel] = CIN_CDC_POS;
+        unsigned int pos_offset = offset < 0 ? 0 : (offset > 63 ? 63 : offset);
+        device->stage_config[i].pos_afe_offset = pos_offset;
+    }
+    device->dev_config.sequence_stage_num = seq->len - 1;
+    device->dev_config.stageX_complete_int_enable[seq->len - 1] = true;
+
+    // For our own record (more precisely, for the interrupt handler).
+    device->num_stages = seq->len;
+
+    // Submit changes over I2C.
+    esp_err_t ret;
+    if ((ret = _configure_pwr_control(device)) != ESP_OK) {
+        return ret;
+    }
+    for (uint8_t i = 0; i < 12; i++) {
+        if ((ret = _configure_stage(device, i)) != ESP_OK) {
+            return ret;
+        }
+    }
+    if ((ret = _configure_complete_int_en(device)) != ESP_OK) {
+        return ret;
+    }
+    if (reprogram) {
+        if ((ret = _reset_sequencer(device)) != ESP_OK) {
+            return ret;
+        }
+    }
+    return ESP_OK;
+}
diff --git a/components/flow3r_bsp/flow3r_bsp_ad7147_hw.h b/components/flow3r_bsp/flow3r_bsp_ad7147_hw.h
new file mode 100644
index 0000000000000000000000000000000000000000..5cdfd63b34142dba4000d08a47245d0b37af5536
--- /dev/null
+++ b/components/flow3r_bsp/flow3r_bsp_ad7147_hw.h
@@ -0,0 +1,115 @@
+#pragma once
+
+// Low-level AD7147 (captouch controller) interfacing functions. Currently only
+// implements sequences where each channel is connected to the positive CDC
+// input, and only the raw data is read out.
+
+#include "esp_err.h"
+#include "flow3r_bsp_i2c.h"
+
+#include <stdint.h>
+
+// 'Global' configuration for the AD7147 captouch controller.
+typedef struct ad7147_device_config {
+    unsigned int power_mode : 2;
+    unsigned int lp_conv_delay : 2;
+    unsigned int sequence_stage_num : 4;
+    unsigned int decimation : 2;
+    unsigned int sw_reset : 1;
+    unsigned int int_pol : 1;
+    unsigned int ext_source : 1;
+    unsigned int cdc_bias : 2;
+
+    unsigned int stage0_cal_en : 1;
+    unsigned int stage1_cal_en : 1;
+    unsigned int stage2_cal_en : 1;
+    unsigned int stage3_cal_en : 1;
+    unsigned int stage4_cal_en : 1;
+    unsigned int stage5_cal_en : 1;
+    unsigned int stage6_cal_en : 1;
+    unsigned int stage7_cal_en : 1;
+    unsigned int stage8_cal_en : 1;
+    unsigned int stage9_cal_en : 1;
+    unsigned int stage10_cal_en : 1;
+    unsigned int stage11_cal_en : 1;
+    unsigned int avg_fp_skip : 2;
+    unsigned int avg_lp_skip : 2;
+
+    unsigned int stage0_high_int_enable : 1;
+    unsigned int stage1_high_int_enable : 1;
+    unsigned int stage2_high_int_enable : 1;
+    unsigned int stage3_high_int_enable : 1;
+    unsigned int stage4_high_int_enable : 1;
+    unsigned int stage5_high_int_enable : 1;
+    unsigned int stage6_high_int_enable : 1;
+    unsigned int stage7_high_int_enable : 1;
+    unsigned int stage8_high_int_enable : 1;
+    unsigned int stage9_high_int_enable : 1;
+    unsigned int stage10_high_int_enable : 1;
+    unsigned int stage11_high_int_enable : 1;
+
+    bool stageX_complete_int_enable[12];
+} ad7147_device_config_t;
+
+// Per sequencer stage configuration.
+typedef struct {
+    unsigned int cinX_connection_setup[13];
+    unsigned int se_connection_setup : 2;
+    unsigned int neg_afe_offset_disable : 1;
+    unsigned int pos_afe_offset_disable : 1;
+    unsigned int neg_afe_offset : 6;
+    unsigned int neg_afe_offset_swap : 1;
+    unsigned int pos_afe_offset : 6;
+    unsigned int pos_afe_offset_swap : 1;
+    unsigned int neg_threshold_sensitivity : 4;
+    unsigned int neg_peak_detect : 3;
+    unsigned int pos_threshold_sensitivity : 4;
+    unsigned int pos_peak_detect : 3;
+} ad7147_stage_config_t;
+
+typedef void (*ad7147_data_callback_t)(void *user, uint16_t *data, size_t len);
+
+// AD7147 low level configuration/access structure. Doesn't know anything about
+// calibration or high-level sequencing, just talks to a chip to configure
+// stages and can be called to poll the chip for new CDC data.
+typedef struct {
+    // I2C address of chip.
+    flow3r_i2c_address addr;
+
+    // Function and user-controlled argument that will be called when the
+    // sequence has finished and new data is available.
+    ad7147_data_callback_t callback;
+    void *user;
+
+    ad7147_stage_config_t stage_config[12];
+    ad7147_device_config_t dev_config;
+    uint8_t num_stages;
+} ad7147_hw_t;
+
+// Initialize the AD7147 captouch controller with a given callback. This
+// callback will be called when new data has been acquired.
+esp_err_t ad7147_hw_init(ad7147_hw_t *device, flow3r_i2c_address addr,
+                         ad7147_data_callback_t callback, void *user);
+
+// Sequencer configuration, high-level.
+typedef struct {
+    // Number of sequencer stages, [1, 12].
+    size_t len;
+    // For each sequencer stage, channel number that this stage should sample.
+    int8_t channels[12];
+    // For each sequencer stage, AFE offset that this stage should use when
+    // sampling the configured channel.
+    int8_t pos_afe_offsets[12];
+} ad7147_sequence_t;
+
+// Configure sequencer stages.
+//
+// If reprogram is true, the sequencer will be restarted. This should be true if
+// the sequence channels/lengths changed from the previous call.
+esp_err_t ad7147_hw_configure_stages(ad7147_hw_t *device,
+                                     const ad7147_sequence_t *seq,
+                                     bool reprogram);
+
+// Polls sequencer status from the chip and calls the user callback if new data
+// is available / the sequence finished.
+esp_err_t ad7147_hw_process(ad7147_hw_t *device);
\ No newline at end of file
diff --git a/components/flow3r_bsp/flow3r_bsp_captouch.c b/components/flow3r_bsp/flow3r_bsp_captouch.c
new file mode 100644
index 0000000000000000000000000000000000000000..1246bebbb27d5766c8136caead1d2af024b76797
--- /dev/null
+++ b/components/flow3r_bsp/flow3r_bsp_captouch.c
@@ -0,0 +1,319 @@
+#include "flow3r_bsp_captouch.h"
+#include "flow3r_bsp_ad7147.h"
+#include "flow3r_bsp_i2c.h"
+
+#include "freertos/FreeRTOS.h"
+#include "freertos/queue.h"
+#include "freertos/task.h"
+
+#include "driver/gpio.h"
+
+#include "esp_log.h"
+
+static const char *TAG = "flow3r-bsp-captouch";
+
+typedef struct {
+    size_t petal_number;
+    petal_kind_t pad_kind;
+} pad_mapping_t;
+
+static const pad_mapping_t _map_top[12] = {
+    { 0, petal_pad_ccw },   // 0
+    { 0, petal_pad_base },  // 1
+    { 0, petal_pad_cw },    // 2
+    { 2, petal_pad_cw },    // 3
+    { 2, petal_pad_base },  // 4
+    { 2, petal_pad_ccw },   // 5
+    { 6, petal_pad_ccw },   // 6
+    { 6, petal_pad_base },  // 7
+    { 6, petal_pad_cw },    // 8
+    { 4, petal_pad_ccw },   // 9
+    { 4, petal_pad_base },  // 10
+    { 4, petal_pad_cw },    // 11
+};
+
+static ad7147_chip_t _top = {
+    .name = "top",
+    .nchannels = 12,
+    .sequences = {
+        {
+            0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
+        },
+        {
+            -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1,
+        },
+    },
+};
+
+static const pad_mapping_t _map_bot[13] = {
+    { 1, petal_pad_base },  // 0
+    { 1, petal_pad_tip },   // 1
+
+    { 3, petal_pad_base },  // 2
+    { 3, petal_pad_tip },   // 3
+
+    { 5, petal_pad_base },  // 4
+    { 5, petal_pad_tip },   // 5
+
+    { 7, petal_pad_tip },   // 6
+    { 7, petal_pad_base },  // 7
+
+    { 9, petal_pad_tip },   // 8
+    { 9, petal_pad_base },  // 9
+
+    { 8, petal_pad_ccw },   // 10
+    { 8, petal_pad_cw },    // 11
+    { 8, petal_pad_base },  // 12
+};
+
+static ad7147_chip_t _bot = {
+    .name = "bot",
+    .nchannels = 13,
+    .sequences = {
+        /// This is the ideal sequence we want. First, all the bottom sensors.
+        /// Then the top petal.
+
+        //{
+        //     0,  1,  2,  3,  4,  5,
+        //     6,  7,  8,  9, -1, -1,
+        //},
+        //{
+        //    10, 11, 12, -1, -1, -1,
+        //    -1, -1, -1, -1, -1, -1
+        //},
+        
+        /// However, that causes extreme glitches. This seems to make it
+        /// slightly better:
+
+        //{
+        //     0,  1,  2,  3,  4,  5,
+        //     9, -1, -1, -1, -1, -1,
+        //},
+        //{
+        //    10, 11, 12,  6,  7,  8,
+        //     9, -1, -1, -1, -1, -1
+        //},
+
+        /// However again, that's still too glitchy for my taste. So we end up
+        /// just ignoring one of the bottom petal pads and hope to figure this
+        /// out later (tm).
+        /// BUG(q3k): whyyyyyyyyyyyyyyy
+        {
+            0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12
+        },
+        {
+            -1, -1, -1, -1, -1, -1,
+            -1, -1, -1, -1, -1, -1,
+        },
+    },
+};
+
+static gpio_num_t _interrupt_gpio_top = GPIO_NUM_15;
+static gpio_num_t _interrupt_gpio_bot = GPIO_NUM_16;
+
+static flow3r_bsp_captouch_callback_t _callback = NULL;
+
+static flow3r_bsp_captouch_state_t _state = {};
+
+static bool _processed = false;
+
+static void _on_chip_data(void *user, uint16_t *data, size_t len) {
+    ad7147_chip_t *chip = (ad7147_chip_t *)user;
+    assert(chip == &_top || chip == &_bot);
+    bool top = chip == &_top;
+    const pad_mapping_t *map = top ? _map_top : _map_bot;
+
+    for (size_t i = 0; i < len; i++) {
+        flow3r_bsp_captouch_petal_state_t *petal =
+            &_state.petals[map[i].petal_number];
+        flow3r_bsp_captouch_petal_pad_state_t *pad =
+            flow3r_bsp_captouch_pad_for_petal(petal, map[i].pad_kind);
+        pad->raw = data[i];
+    }
+
+    _processed = true;
+}
+
+static QueueHandle_t _q = NULL;
+
+static void _bot_isr(void *data) {
+    (void)data;
+    bool bot = true;
+    xQueueSendFromISR(_q, &bot, NULL);
+}
+
+static void _top_isr(void *data) {
+    (void)data;
+    bool bot = false;
+    xQueueSendFromISR(_q, &bot, NULL);
+}
+
+static void _kickstart(void) {
+    bool bot = false;
+    xQueueSend(_q, &bot, portMAX_DELAY);
+    bot = true;
+    xQueueSend(_q, &bot, portMAX_DELAY);
+}
+
+static void _task(void *data) {
+    (void)data;
+
+    bool top_ok = true;
+    bool bot_ok = true;
+    esp_err_t ret;
+    for (;;) {
+        bool bot = false;
+        if (xQueueReceive(_q, &bot, portMAX_DELAY) == pdFALSE) {
+            ESP_LOGE(TAG, "Queue receive failed");
+            return;
+        }
+
+        if (bot) {
+            if ((ret = flow3r_bsp_ad7147_chip_process(&_bot)) != ESP_OK) {
+                if (bot_ok) {
+                    ESP_LOGE(TAG,
+                             "Bottom captouch processing failed: %s (silencing "
+                             "future warnings)",
+                             esp_err_to_name(ret));
+                    bot_ok = false;
+                }
+            } else {
+                bot_ok = true;
+            }
+        } else {
+            if ((ret = flow3r_bsp_ad7147_chip_process(&_top)) != ESP_OK) {
+                if (top_ok) {
+                    ESP_LOGE(TAG,
+                             "Top captouch processing failed: %s (silencing "
+                             "future warnings)",
+                             esp_err_to_name(ret));
+                    top_ok = false;
+                }
+            } else {
+                top_ok = true;
+            }
+        }
+
+        _callback(&_state);
+    }
+}
+
+esp_err_t _gpio_interrupt_setup(gpio_num_t num, gpio_isr_t isr) {
+    esp_err_t ret;
+
+    gpio_config_t io_conf = {
+        .intr_type = GPIO_INTR_NEGEDGE,
+        .mode = GPIO_MODE_INPUT,
+        .pull_up_en = true,
+        .pin_bit_mask = (1 << num),
+    };
+    if ((ret = gpio_config(&io_conf)) != ESP_OK) {
+        return ret;
+    }
+    if ((ret = gpio_isr_handler_add(num, isr, NULL)) != ESP_OK) {
+        return ret;
+    }
+    return ESP_OK;
+}
+
+esp_err_t flow3r_bsp_captouch_init(flow3r_bsp_captouch_callback_t callback) {
+    assert(_callback == NULL);
+    assert(callback != NULL);
+    _callback = callback;
+
+    _q = xQueueCreate(2, sizeof(bool));
+    assert(_q != NULL);
+
+    esp_err_t ret;
+
+    for (size_t i = 0; i < 10; i++) {
+        bool top = (i % 2) == 0;
+        _state.petals[i].kind = top ? petal_top : petal_bottom;
+        _state.petals[i].ccw.kind = petal_pad_ccw;
+        _state.petals[i].ccw.threshold = top ? 8000 : 12000;
+        _state.petals[i].cw.kind = petal_pad_cw;
+        _state.petals[i].cw.threshold = top ? 8000 : 12000;
+        _state.petals[i].tip.kind = petal_pad_tip;
+        _state.petals[i].tip.threshold = top ? 8000 : 12000;
+        _state.petals[i].base.kind = petal_pad_base;
+        _state.petals[i].base.threshold = top ? 8000 : 12000;
+    }
+
+    _top.callback = _on_chip_data;
+    _top.user = &_top;
+    _bot.callback = _on_chip_data;
+    _bot.user = &_bot;
+
+    if ((ret = flow3r_bsp_ad7147_chip_init(
+             &_top, flow3r_i2c_addresses.touch_top)) != ESP_OK) {
+        return ret;
+    }
+    if ((ret = flow3r_bsp_ad7147_chip_init(
+             &_bot, flow3r_i2c_addresses.touch_bottom)) != ESP_OK) {
+        return ret;
+    }
+    ESP_LOGI(TAG, "Captouch initialized");
+
+    if ((ret = gpio_install_isr_service(ESP_INTR_FLAG_SHARED |
+                                        ESP_INTR_FLAG_LOWMED)) != ESP_OK) {
+        ESP_LOGE(TAG, "Failed to install GPIO ISR service");
+        return ret;
+    }
+    if ((ret = _gpio_interrupt_setup(_interrupt_gpio_bot, _bot_isr)) !=
+        ESP_OK) {
+        ESP_LOGE(TAG, "Failed to add bottom captouch ISR");
+        return ret;
+    }
+    if ((ret = _gpio_interrupt_setup(_interrupt_gpio_top, _top_isr)) !=
+        ESP_OK) {
+        ESP_LOGE(TAG, "Failed to add top captouch ISR");
+        return ret;
+    }
+
+    xTaskCreate(&_task, "captouch", 4096, NULL, configMAX_PRIORITIES - 1, NULL);
+    _kickstart();
+    return ESP_OK;
+}
+
+const flow3r_bsp_captouch_petal_pad_state_t *
+flow3r_bsp_captouch_pad_for_petal_const(
+    const flow3r_bsp_captouch_petal_state_t *petal, petal_pad_kind_t kind) {
+    switch (kind) {
+        case petal_pad_tip:
+            return &petal->tip;
+        case petal_pad_base:
+            return &petal->base;
+        case petal_pad_cw:
+            return &petal->cw;
+        case petal_pad_ccw:
+            return &petal->ccw;
+    }
+    assert(0);
+}
+
+flow3r_bsp_captouch_petal_pad_state_t *flow3r_bsp_captouch_pad_for_petal(
+    flow3r_bsp_captouch_petal_state_t *petal, petal_pad_kind_t kind) {
+    switch (kind) {
+        case petal_pad_tip:
+            return &petal->tip;
+        case petal_pad_base:
+            return &petal->base;
+        case petal_pad_cw:
+            return &petal->cw;
+        case petal_pad_ccw:
+            return &petal->ccw;
+    }
+    assert(0);
+}
+
+void flow3r_bsp_captouch_calibrate() {
+    _bot.calibration_pending = true;
+    _top.calibration_pending = true;
+}
+
+bool flow3r_bsp_captouch_calibrating() {
+    bool bot = _bot.calibration_pending || _bot.calibration_cycles > 0;
+    bool top = _top.calibration_pending || _top.calibration_cycles > 0;
+    return bot || top;
+}
\ No newline at end of file
diff --git a/components/flow3r_bsp/flow3r_bsp_captouch.h b/components/flow3r_bsp/flow3r_bsp_captouch.h
new file mode 100644
index 0000000000000000000000000000000000000000..65a752c30c92d4222dd569ee0a7cedbccc44e449
--- /dev/null
+++ b/components/flow3r_bsp/flow3r_bsp_captouch.h
@@ -0,0 +1,88 @@
+#pragma once
+
+// Highest-level driver for captouch on the flow3r badge. Uses 2x AD7147.
+
+// The flow3r has 10 touch petals. 5 petals on the top layer, 5 petals on the
+// bottom layer.
+//
+// Top petals have three capacitive pads. Bottom petals have two capacitive
+// pads.
+//
+// The petals are numbered from 0 to 9 (inclusive). Petal 0 is next to the USB
+// port, and is a top petal. Petal 1 is a bottom petal to its left. Petal 2 is a
+// top petal to its left, and the rest continue counter-clockwise accordingly.
+
+#include "esp_err.h"
+
+#include <stdbool.h>
+
+// One of the four possible touch points (pads) on a petal. Top petals have
+// base/cw/ccw. Bottom petals have base/tip.
+typedef enum {
+    // Pad away from centre of badge.
+    petal_pad_tip = 0,
+    // Pad going counter-clockwise around the badge.
+    petal_pad_ccw = 1,
+    // Pad going clockwise around the badge.
+    petal_pad_cw = 2,
+    // Pad going towards the centre of the badge.
+    petal_pad_base = 3,
+} petal_pad_kind_t;
+
+// Each petal can be either top or bottom.
+typedef enum {
+    // Petal on the top layer. Has base, cw, ccw pads.
+    petal_top = 0,
+    // petal on the bottom layer. Has base and tip fields.
+    petal_bottom = 1,
+} petal_kind_t;
+
+// State of a petal's pad.
+typedef struct {
+    // Is it a top or bottom petal?
+    petal_pad_kind_t kind;
+    // Raw value, compensated for ambient value.
+    uint16_t raw;
+    // Configured threshold for touch detection.
+    uint16_t threshold;
+} flow3r_bsp_captouch_petal_pad_state_t;
+
+// State of a petal. Only the fields relevant to the petal kind (tip/base or
+// base/cw/ccw) are present.
+typedef struct {
+    petal_kind_t kind;
+    flow3r_bsp_captouch_petal_pad_state_t tip;
+    flow3r_bsp_captouch_petal_pad_state_t ccw;
+    flow3r_bsp_captouch_petal_pad_state_t cw;
+    flow3r_bsp_captouch_petal_pad_state_t base;
+} flow3r_bsp_captouch_petal_state_t;
+
+// State of all petals of the badge.
+typedef struct {
+    flow3r_bsp_captouch_petal_state_t petals[10];
+} flow3r_bsp_captouch_state_t;
+
+typedef void (*flow3r_bsp_captouch_callback_t)(
+    const flow3r_bsp_captouch_state_t *state);
+
+// Initialize captouch subsystem with a given callback. This callback will be
+// called any time new captouch data is available. The given data will be valid
+// for the lifetime of the function, so should be copied by users.
+//
+// An interrupt and task will be set up to handle the data processing.
+esp_err_t flow3r_bsp_captouch_init(flow3r_bsp_captouch_callback_t callback);
+
+// Get a given petal's pad data for a given petal kind.
+const flow3r_bsp_captouch_petal_pad_state_t *
+flow3r_bsp_captouch_pad_for_petal_const(
+    const flow3r_bsp_captouch_petal_state_t *petal, petal_pad_kind_t kind);
+flow3r_bsp_captouch_petal_pad_state_t *flow3r_bsp_captouch_pad_for_petal(
+    flow3r_bsp_captouch_petal_state_t *petal, petal_pad_kind_t kind);
+
+// Request captouch calibration.
+void flow3r_bsp_captouch_calibrate();
+
+// Returns true if captouch is currently calibrating.
+//
+// TODO(q3k): this seems glitchy, investigate.
+bool flow3r_bsp_captouch_calibrating();
\ No newline at end of file