diff --git a/components/flow3r_bsp/CMakeLists.txt b/components/flow3r_bsp/CMakeLists.txt
index 047b8c722014e6a69d5a87825195c6b15d09a490..11bc24a3266a684441199f7fd7545d4dca48c359 100644
--- a/components/flow3r_bsp/CMakeLists.txt
+++ b/components/flow3r_bsp/CMakeLists.txt
@@ -21,4 +21,5 @@ idf_component_register(
 		vfs
 		bmi270
 		bmp581
+        esp_timer
 )
diff --git a/components/flow3r_bsp/flow3r_bsp_ad7147.c b/components/flow3r_bsp/flow3r_bsp_ad7147.c
index ecce90c52e377ae2e5fd292852b3681f0a640550..387cb257299a7e7e2cb9b7d2631d66e29e83bf77 100644
--- a/components/flow3r_bsp/flow3r_bsp_ad7147.c
+++ b/components/flow3r_bsp/flow3r_bsp_ad7147.c
@@ -1,13 +1,3 @@
-// note: this code is kind of frankenstein'd out of the
-// remains of a refactor that introduced a lot of complexity
-// that wasn't needed anymore.
-//
-// it was an entire thing and we were exhausted and we did not
-// clean this up properly. it works, and that's enough for now.
-//
-// also there is still a major state machine missing so we
-// don't see good reason to improve structure at this point.
-
 #include "flow3r_bsp_ad7147.h"
 #include "flow3r_bsp_ad7147_hw.h"
 #include "flow3r_bsp_captouch.h"
@@ -29,33 +19,115 @@
 #include "freertos/task.h"
 
 #include "driver/gpio.h"
+#include "esp_timer.h"
 
-// #define CAPTOUCH_PROFILING
+// #define FLOW3R_BSP_CAPTOUCH_DEBUG_PROFILING
+// #define FLOW3R_BSP_CAPTOUCH_DEBUG_CURSED_PROFILING
+// #define FLOW3R_BSP_CAPTOUCH_DEBUG_SEQUENCING
+// #define FLOW3R_BSP_CAPTOUCH_DEBUG_CALIBRATION
+// #define FLOW3R_BSP_CAPTOUCH_DEBUG_PETAL 2
 
-#ifdef CAPTOUCH_PROFILING
-// not available for building recovery
-#include "esp_timer.h"
-#endif
+/*
+時折誰かが問う
+いつまでどこまで向かう気かと
+BABYどこまででも
+*/
+
+static ad7147_chip_t _top = {
+    .name = "top",
+    .is_bot = false,
+    .num_petals = 4,
+    .petals = { 0, 4, 6, 8 },
+};
+
+static ad7147_chip_t _bot = {
+    .name = "bot",
+    .is_bot = true,
+    .num_petals = 6,
+    .petals = { 1, 2, 3, 5, 7, 9 },
+};
+
+// callback function for the logging feature
+static flow3r_bsp_data_callback_t _on_data = NULL;
+void flow3r_bsp_ad7147_set_data_callback(flow3r_bsp_data_callback_t fun) {
+    _on_data = fun;
+}
+
+// modes that the petals are supposed to be in (excl. calibration and
+// transients)
+static flow3r_bsp_captouch_petal_mode_t _petal_modes[10] = {
+    PETAL_MODE_2D, PETAL_MODE_1D, PETAL_MODE_2D, PETAL_MODE_1D, PETAL_MODE_2D,
+    PETAL_MODE_1D, PETAL_MODE_2D, PETAL_MODE_1D, PETAL_MODE_2D, PETAL_MODE_1D,
+};
 
 static const char *TAG = "flow3r-bsp-ad7147";
 
 // output data that gets continuously written to and
 // memcpy'd when the user requests.
 static flow3r_bsp_captouch_data_t captouch_data;
-// lock for captouch_data
-static SemaphoreHandle_t captouch_output_lock = NULL;
-// task that generates captouch_data
-static TaskHandle_t _captouch_task_handle = NULL;
-// helper task for the bottom chip
-static TaskHandle_t _cursed_task_handle = NULL;
-// container for unprocessed petal data and its lock.
+
+// user-facing calibration data
+static ad7147_petal_calib_t calibration_data[10];
+// lock for calibration_data
+static SemaphoreHandle_t calibration_lock = NULL;
+// calibration data used internally by the chip tasks
+static ad7147_petal_calib_t _chip_calibration_data[10];
+
+// if you want both locks it's important to always take/free them in the
+// same order to avoid deadlock. use these functions to avoid such bugs.
+// xTicksToWait is implemented very halfassed and is the wait time for each
+// lock, i.e. you might wait twice as long
+static BaseType_t xSemaphoreTakeCaptouchOutput(TickType_t xTicksToWait) {
+    if (xSemaphoreTake(_top.output_lock, xTicksToWait) == pdFALSE)
+        return pdFALSE;
+    if (xSemaphoreTake(_bot.output_lock, xTicksToWait) == pdTRUE) return pdTRUE;
+    xSemaphoreGive(_top.output_lock);
+    return pdFALSE;
+}
+static BaseType_t xSemaphoreGiveCaptouchOutput() {
+    bool ret = true;
+    ret = (xSemaphoreGive(_bot.output_lock) == pdTRUE) && ret;
+    ret = (xSemaphoreGive(_top.output_lock) == pdTRUE) && ret;
+    return ret ? pdTRUE : pdFALSE;
+}
+
+// we just want a function with that name really
+static BaseType_t xSemaphoreBonk(SemaphoreHandle_t handle,
+                                 TickType_t xTicksToWait) {
+    if (xSemaphoreTake(handle, xTicksToWait) == pdFALSE) return pdFALSE;
+    return xSemaphoreGive(handle);
+}
+
+// tasks that generate captouch_data
+static TaskHandle_t _top_task_handle = NULL;
+static TaskHandle_t _bot_task_handle = NULL;
+
+// notifications for _*_task_handle
+// chip gpio interrupt
+#define NOTIF_GPIO 1
+// send this if you have written new target modes to ad7147_chip_t.user_modes to
+// apply them.
+#define NOTIF_MODE_CHANGE 2
+// send this to start a calibration.
+#define NOTIF_CALIB_START 4
+// send this to stop a calibration. note: this does not restore previous
+// calibration data. use this only for restarting calibration or applying
+// external calibration, else you might end up with poor performance.
+#define NOTIF_CALIB_STOP 8
+// send this to if you have written new data to calibration_data to apply it.
+#define NOTIF_CALIB_CHANGE 16
+// used internally to synchronize calibration stages between chips for
+// consistent EMI, don't send externally.
+#define NOTIF_INTERNAL_CALIB_NEXT_STAGE 32
+// used internally to iterate over calibrations.
+#define NOTIF_INTERNAL_CALIB_CHANGE 64
+
+#define NOTIF_CLEAR_MASK 127
+
+// container for unprocessed petal data.
 // 10 petals, 4 potential pad positions according to
 // petal_kind_t. some fields are never used.
 static uint16_t raw_petals[10][4];
-// lock for parts of raw_petals: only petal indices
-// that are served by the bottom chip (all uneven ones
-// and 2).
-static SemaphoreHandle_t raw_petal_bot_chip_lock = NULL;
 
 typedef struct {
     size_t petal_number;
@@ -69,7 +141,22 @@ typedef struct {
 
 static press_latch_t latches[10];
 
-static inline void petal_process(uint8_t index);
+static void petal_process(int index, uint32_t timestamp, int mode,
+                          SemaphoreHandle_t lock);
+
+static void petals_process(ad7147_chip_t *chip, uint32_t timestamp) {
+    for (int i = 0; i < chip->num_petals; i++) {
+        int petal = chip->petals[i];
+        petal_process(petal, timestamp, chip->modes[petal], chip->output_lock);
+    }
+}
+
+static void petals_clear(ad7147_chip_t *chip, uint32_t timestamp) {
+    for (int i = 0; i < chip->num_petals; i++) {
+        int petal = chip->petals[i];
+        petal_process(petal, timestamp, 0, chip->output_lock);
+    }
+}
 
 // DATASHEET VIOLATION 1
 // target value that we ideally wanna see from an idle petal.
@@ -175,28 +262,163 @@ static bool _interrupt_shared = false;
 #error "captouch not implemented for this badge generation"
 #endif
 
-static ad7147_chip_t _top = {
-    .name = "top",
-    .is_bot = false,
-    .nchannels = 12,
-    .sequence = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, -1 },
-};
+static uint8_t get_petal_pad_ch(uint8_t petal, petal_kind_t kind) {
+    // returns the channel but not the chip. defaults to channel 0.
+    // pretty bad function but does the trick for now.
+    static uint8_t cache[40] = {
+        0,
+    };
+    int index = kind + 4 * petal;
+    if (cache[index]) return cache[index] - 1;
+    int len;
+    len = sizeof(_map_bot) / sizeof(pad_mapping_t);
+    for (int i = 0; i < len; i++) {
+        if (_map_bot[i].petal_number == petal && _map_bot[i].pad_kind == kind) {
+            cache[index] = i + 1;
+            return i;
+        }
+    }
+    len = sizeof(_map_top) / sizeof(pad_mapping_t);
+    for (int i = 0; i < len; i++) {
+        if (_map_top[i].petal_number == petal && _map_top[i].pad_kind == kind) {
+            cache[index] = i + 1;
+            return i;
+        }
+    }
+    return 0;  // should never be reached
+}
 
-static ad7147_chip_t _bot = {
-    .name = "bot",
-    .is_bot = true,
-    // don't change this field pls there's a bunch of hardcoded stuff around :3
-    .nchannels = 13,
-    // first and last must be >= 7 bc i2c golf reasons
-    .sequence = { 8, 0, 1, 2, 3, 4, 5, 6, 7, 10, 11, 12, 9 },
-};
+void _write_petal_mode_seq(uint16_t **write_head, uint8_t petal,
+                           flow3r_bsp_captouch_petal_mode_t mode) {
+    if (petal & 1) {
+        switch (mode) {
+            case PETAL_MODE_OFF:  // c is a clown language
+                break;
+            case PETAL_MODE_0D:
+                **write_head = 1 << get_petal_pad_ch(petal, petal_pad_base);
+                **write_head |= 1 << get_petal_pad_ch(petal, petal_pad_tip);
+                *write_head += 1;
+                break;
+            case PETAL_MODE_1D:
+            case PETAL_MODE_2D:
+                **write_head = 1 << get_petal_pad_ch(petal, petal_pad_base);
+                *write_head += 1;  // the * is its little clown nose
+                **write_head = 1 << get_petal_pad_ch(petal, petal_pad_tip);
+                *write_head += 1;
+                break;
+        }
+    } else {
+        switch (mode) {
+            case PETAL_MODE_OFF:
+                break;
+            case PETAL_MODE_0D:
+                **write_head = 1 << get_petal_pad_ch(petal, petal_pad_base);
+                **write_head |= 1 << get_petal_pad_ch(petal, petal_pad_cw);
+                **write_head |= 1 << get_petal_pad_ch(petal, petal_pad_ccw);
+                *write_head += 1;
+                break;  // it's good for when you want to
+            case PETAL_MODE_1D:
+                **write_head = 1 << get_petal_pad_ch(petal, petal_pad_base);
+                *write_head += 1;
+                **write_head = 1 << get_petal_pad_ch(petal, petal_pad_cw);
+                **write_head |= 1 << get_petal_pad_ch(petal, petal_pad_ccw);
+                *write_head += 1;
+                break;
+            case PETAL_MODE_2D:  // be silly
+                **write_head = 1 << get_petal_pad_ch(petal, petal_pad_base);
+                *write_head += 1;
+                **write_head = 1 << get_petal_pad_ch(petal, petal_pad_cw);
+                *write_head += 1;
+                **write_head = 1 << get_petal_pad_ch(petal, petal_pad_ccw);
+                *write_head += 1;
+                break;
+        }
+    }
+}
+
+// hardcoding this one.
+// first and last must be >= 7 bc i2c golf reasons.
+// keeping petal 2 away from the swap helps w noise and/or our ocd a bit.
+static const uint16_t _cursed_seq[] = { 1 << 8, 1 << 0,  1 << 1,  1 << 2,
+                                        1 << 3, 1 << 10, 1 << 11, 1 << 12,
+                                        1 << 4, 1 << 5,  1 << 6,  1 << 7,
+                                        1 << 9 };
+
+static int _petal_modes_to_sequence(ad7147_chip_t *chip) {
+    uint16_t *write_head = chip->sequence;
+    for (int j = 0; j < chip->num_petals; j++) {  // go thru bot chip petals
+        int petal = chip->petals[j];
+        _write_petal_mode_seq(&write_head, petal, chip->modes[petal]);
+    }
+    int len = write_head - chip->sequence;
+    assert(len < 14 || len >= 0);
+    memset(write_head, 0, (13 - len) * sizeof(uint16_t));
+
+    chip->cursed_active = len == 13;
+    if (chip->cursed_active) {
+        memcpy(chip->sequence, _cursed_seq, 13 * sizeof(uint16_t));
+    }
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_SEQUENCING
+    write_head = chip->sequence;
+    printf("%s: applying %d-sequence: ", chip->name, len);
+    while ((write_head - chip->sequence) < 13)
+        printf("%d ", (int)(*write_head)), write_head++;
+    if (chip->cursed_active) printf(" (cursed sequence)");
+    printf("\n");
+#endif
+    return len;
+}
 
 static ad7147_sequence_stage_t _cursed_swap_stage;
 
+static inline int _mode_num_channels(flow3r_bsp_captouch_petal_mode_t mode) {
+    switch (mode) {
+        case PETAL_MODE_0D:
+            return 1;
+        case PETAL_MODE_1D:
+            return 2;
+        case PETAL_MODE_2D:
+            return 3;
+        default:
+            return 0;
+    }
+}
+
+static int _get_calib_data_index(ad7147_chip_t *chip, int i, int *petal_ret,
+                                 int *index_ret) {
+    assert(chip == &_top || chip == &_bot);
+    bool top = chip == &_top;
+    const pad_mapping_t *map = top ? _map_top : _map_bot;
+    int step = chip->sequence[i];
+    for (int j = 0; j < 13; j++) {
+        if (step & (1 << j)) {
+            int petal = map[j].petal_number;
+            int calib_index = _mode_num_channels(chip->modes[petal]) - 1;
+            if (calib_index < 0) continue;
+            if (calib_index) {
+                int offset = map[j].pad_kind;
+                if (petal & 1) {
+                    if (offset == 3) offset = 1;
+                } else {  // treat tip and base as same bc bw compat
+                    if (offset == 3) offset = 0;
+                }
+                if (calib_index == 1 && offset > 1) offset = 1;
+                if (calib_index == 2) calib_index = 3;
+                calib_index += offset;
+            }
+            *petal_ret = petal;
+            *index_ret = calib_index;
+            return true;
+        }
+    }
+    return false;
+}
+
 void get_stage_hw_config(ad7147_chip_t *chip, ad7147_sequence_t *seq_out,
-                         uint8_t i, uint8_t channel) {
-    int8_t offset = chip->channels[channel].afe_offset;
-    seq_out->stages[i].channel_mask = 1 << channel;
+                         uint8_t i, uint16_t channel_mask) {
+    int offset = chip->stages[i].afe_offset;
+    offset = offset > 126 ? 126 : offset;
+    seq_out->stages[i].channel_mask = channel_mask;
     // DATASHEET VIOLATION 2
     // the datasheet recommends to connect captouch pads to the internal bias
     // while they are not being measured. here's why we're not doing that:
@@ -236,11 +458,37 @@ void get_stage_hw_config(ad7147_chip_t *chip, ad7147_sequence_t *seq_out,
     }
 }
 
+static void _notify_from_isr(uint32_t mask, TaskHandle_t handle) {
+    if (handle == NULL) return;
+    BaseType_t xHigherPriorityTaskWoken;
+    xTaskNotifyFromISR(handle, mask, eSetBits, &xHigherPriorityTaskWoken);
+    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
+}
+
+static void _notify(uint32_t mask, TaskHandle_t handle) {
+    if (handle == NULL) return;
+    xTaskNotify(handle, mask, eSetBits);
+    portYIELD();
+}
+
+static void _notify_both(uint32_t mask) {
+    _notify(mask, _top.task);
+    _notify(mask, _bot.task);
+}
+
 // bad hack, see below
-static esp_err_t _cursed_sequence_request(ad7147_chip_t *chip, bool init,
-                                          bool recalib) {
-    int8_t *seq = chip->sequence;
+static esp_err_t _cursed_sequence_request(ad7147_chip_t *chip) {
+    uint16_t *seq = chip->sequence;
     ad7147_sequence_t seq_out;
+    int petal, calib_index;
+    if (_get_calib_data_index(chip, 12, &petal, &calib_index)) {
+        chip->stages[12].amb = _chip_calibration_data[petal].amb[calib_index];
+        chip->stages[12].afe_offset =
+            _chip_calibration_data[petal].afe[calib_index];
+        chip->stages[0].amb = _chip_calibration_data[petal].amb[calib_index];
+        chip->stages[0].afe_offset =
+            _chip_calibration_data[petal].afe[calib_index];
+    }
     get_stage_hw_config(chip, &seq_out, 0, seq[12]);
     memcpy(&_cursed_swap_stage, &(seq_out.stages[0]),
            sizeof(ad7147_sequence_stage_t));
@@ -253,35 +501,60 @@ static esp_err_t _cursed_sequence_request(ad7147_chip_t *chip, bool init,
 // In case of the bottom chip we request a 13-sequence, so that's
 // not possible, so we use a hardcoded hack to save the 13th
 // sequence step configuration data for later use.
-static esp_err_t _sequence_request(ad7147_chip_t *chip, bool init,
-                                   bool recalib) {
-    if (chip->is_bot) {
-        _cursed_sequence_request(chip, init, recalib);
+static void _sequence_request(ad7147_chip_t *chip, bool new_modes) {
+    if (new_modes) {
+        chip->len_sequence = _petal_modes_to_sequence(chip);
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_PROFILING
+    } else {
+        uint16_t prev_seq[13];
+        memcpy(prev_seq, chip->sequence, sizeof(chip->sequence));
+        _petal_modes_to_sequence(chip);
+        if (memcmp(prev_seq, chip->sequence, sizeof(chip->sequence))) {
+            printf("sequence mismatch!\n");
+        }
+#endif
     }
-    int8_t *seq = chip->sequence;
+    int len = chip->len_sequence;
+    uint16_t *seq = chip->sequence;
+
+    if (chip->cursed_active) {
+        _cursed_sequence_request(chip);
+        len = 12;
+    }
+
     ad7147_sequence_t seq_out = {
-        .len = chip->nchannels,
+        .len = len,
+        .interrupt_stage = 0,
     };
-    seq_out.len = seq_out.len > 12 ? 12 : seq_out.len;
+    for (int i = 0; i < len; i++) {
+        int petal, calib_index;
+        assert(_get_calib_data_index(chip, i, &petal, &calib_index));
+        chip->stages[i].amb = _chip_calibration_data[petal].amb[calib_index];
+        chip->stages[i].afe_offset =
+            _chip_calibration_data[petal].afe[calib_index];
+    }
     for (size_t i = 0; i < seq_out.len; i++) {
         get_stage_hw_config(chip, &seq_out, i, seq[i]);
     }
 
     esp_err_t ret;
     if ((ret = ad7147_hw_configure_stages(&chip->dev, &seq_out)) != ESP_OK) {
-        return ret;
+        ESP_LOGE(TAG, "%s: requesting next sequence failed: %s", chip->name,
+                 esp_err_to_name(ret));
+    }
+    if (!len) {
+        petals_clear(chip, esp_timer_get_time());
     }
-    return ESP_OK;
 }
 
-static bool _channel_afe_tweak(ad7147_chip_t *chip, size_t cix) {
-    int32_t cur = chip->channels[cix].amb;
+static bool _stage_afe_tweak(ad7147_chip_t *chip, size_t cix) {
+    int32_t cur = chip->stages[cix].amb;
     int32_t target = _calib_target;
     int32_t diff = (cur - target) / _calib_incr_cap;
     if (diff < 1 && diff > -1) {
         return false;
     }
-    int32_t offset = chip->channels[cix].afe_offset;
+    int32_t offset = chip->stages[cix].afe_offset;
     if (offset <= 0 && diff < 0) {
         return false;
     }
@@ -295,7 +568,7 @@ static bool _channel_afe_tweak(ad7147_chip_t *chip, size_t cix) {
     if (offset > 126) {
         offset = 126;
     }
-    chip->channels[cix].afe_offset = offset;
+    chip->stages[cix].afe_offset = offset;
     return true;
 }
 
@@ -306,192 +579,170 @@ static bool _channel_afe_tweak(ad7147_chip_t *chip, size_t cix) {
 // Should maybe get a proper name someday. Sorry. Only used by
 // raw_data_to_petal_pads atm, maybe it should just absorb it, it's
 // not that big.
-static void _on_chip_data(ad7147_chip_t *chip, uint16_t *values, size_t len) {
+static void _on_chip_data(ad7147_chip_t *chip, uint16_t *values, int len) {
     assert(chip == &_top || chip == &_bot);
     bool top = chip == &_top;
     const pad_mapping_t *map = top ? _map_top : _map_bot;
 
-    for (uint8_t i = 0; i < len; i++) {
-        raw_petals[map[i].petal_number][map[i].pad_kind] = values[i];
-    }
-}
-
-// Generates raw data and sends it to raw_petals[][].
-// Yes, raw data is processed. Hah. You have been lied to.
-// A summary of lies:
-// - While a calibration is running, data output isn't updated. This means
-//   we don't need to waste our time with updating raw_petals either. Not so
-//   bad right?
-// - Calibration is a composition of "coarse" hardware biasing (that AFE thing
-//   that  keeps popping up), plus a residual "fine" software offset.
-//   Since they both work in tandem, we decided to consider raw data whatever
-//   is left after both of them are applied, so raw_petals doesn't hold the
-//   raw readings, but already has the fine offset applied. This makes it
-//   considerably harder to forget to apply it.
-static void raw_data_to_petal_pads(ad7147_chip_t *chip, uint16_t *data,
-                                   size_t len) {
-    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.
-        int8_t j = chip->calibration_cycles - 1;
-        if (j < _AD7147_CALIB_CYCLES) {  // throw away first few datapoints
-            for (int8_t i = 0; i < len; i++) {
-                uint8_t k = chip->sequence[i];
-                chip->channels[k].amb_meas[j] = data[i];
+    for (int i = 0; i < len; i++) {
+        int step = chip->sequence[i];
+        int num_fields = 0;
+        int fields[3];
+        for (int j = 0; j < (top ? 12 : 13); j++) {
+            if (step & (1 << j)) {
+                fields[num_fields] = j;
+                num_fields++;
+                if (num_fields == 3) break;
             }
         }
-    } else {
-        // Normal measurement, apply to channel->cdc.
-        for (size_t i = 0; i < len; i++) {
-            uint8_t k = chip->sequence[i];
-            chip->channels[k].cdc = data[i];
+        if (!num_fields) continue;
+        int value = values[i] / num_fields;
+        for (int k = 0; k < num_fields; k++) {
+            int petal = map[fields[k]].petal_number;
+            int pad = map[fields[k]].pad_kind;
+            if (petal >= 10 || petal < 0 || pad >= 4 || pad < 0) {
+                ESP_LOGE(TAG, "out of bounds at petal %d pad %d stage %d\n",
+                         petal, pad, i);
+            } else {
+                raw_petals[petal][pad] = value;
+            }
         }
     }
+}
 
-    bool recalib = chip->calibration_external;
+// lazy copypasta code :3
+static void _on_chip_calib_data(ad7147_chip_t *chip, uint16_t *amb,
+                                uint16_t *afe, size_t len) {
+    for (int i = 0; i < len; i++) {
+        int petal, calib_index;
+        assert(_get_calib_data_index(chip, i, &petal, &calib_index));
+        _chip_calibration_data[petal].amb[calib_index] = amb[i];
+        _chip_calibration_data[petal].afe[calib_index] = afe[i];
+    }
+}
 
-    // Deal with calibration pending flag, possibly starting calibration.
-    if (chip->calibration_pending) {
-        if (!chip->calibration_active) {
-            ESP_LOGI(TAG, "%s: calibration starting...", chip->name);
-            chip->calibration_cycles = _AD7147_CALIB_CYCLES + 2;
-            chip->calibration_active = true;
+static void raw_data_to_calib(ad7147_chip_t *chip, uint16_t *data, size_t len) {
+    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.
+        int cycle = chip->calibration_cycles - 1;
+        if (cycle < _AD7147_CALIB_CYCLES) {  // throw away first few points
+            for (int i = 0; i < len; i++) {
+                chip->stages[i].amb_meas[cycle] = data[i];
+            }
         }
-        chip->calibration_pending = false;
     }
 
-    if (chip->calibration_active) {
-        // 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++) {
-                uint32_t avg = 0;
-                for (uint8_t j = 0; j < _AD7147_CALIB_CYCLES; j++) {
-                    avg += chip->channels[i].amb_meas[j];
-                }
-                chip->channels[i].amb = avg / _AD7147_CALIB_CYCLES;
+    // Deal with active calibration.
+    chip->calibration_cycles--;
+    if (chip->calibration_cycles <= 0) {
+        // Calibration measurements done. Calculate average amb data for
+        // each channel.
+        uint16_t amb[13];
+        uint16_t afe[13];
+        uint16_t rerun = 0;
+        for (size_t i = 0; i < len; i++) {
+            uint32_t avg = 0;
+            for (uint8_t j = 0; j < _AD7147_CALIB_CYCLES; j++) {
+                avg += chip->stages[i].amb_meas[j];
             }
-
+            chip->stages[i].amb = avg / _AD7147_CALIB_CYCLES;
             // Can we tweak the AFE to get a better measurement?
-            uint16_t rerun = 0;
-            for (size_t i = 0; i < chip->nchannels; i++) {
-                if (_channel_afe_tweak(chip, i)) {
-                    rerun |= (1 << i);
+            if (_stage_afe_tweak(chip, i)) {
+                rerun |= (1 << i);
+            }
+            amb[i] = chip->stages[i].amb;
+            afe[i] = chip->stages[i].afe_offset;
+        }
+        _on_chip_calib_data(chip, amb, afe, len);
+        if (rerun != 0) {
+            chip->calibration_cycles = _AD7147_CALIB_CYCLES + 5;
+            _notify(NOTIF_INTERNAL_CALIB_CHANGE, chip->task);
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_CALIBRATION
+            printf("%s calibration stage %d: repeating ", chip->name,
+                   chip->calibration_stage);
+            for (int i = 0; i < 13; i++) {
+                if (rerun & 1 << i) {
+                    printf("%d, ", i);
                 }
             }
-            if (rerun != 0) {
-                chip->calibration_cycles = _AD7147_CALIB_CYCLES + 2;
-                recalib = true;
-            } else {
-                chip->calibration_active = false;
-                ESP_LOGI(TAG, "%s: calibration done.", chip->name);
+            printf(" (%d, %d)\n", (int)rerun, (int)len);
+            for (int i = 0; i < 13; i++) {
+                if (rerun & 1 << i) {
+                    printf("  %s stage %d: amb %d, afe %d\n", chip->name, i,
+                           (int)chip->stages[i].amb,
+                           (int)chip->stages[i].afe_offset);
+                }
             }
-        }
-    } else {
-        // Submit data to higher level for processing.
-        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;
-            val[i] = diff < 0 ? 0 : (diff > 65535 ? 65535 : diff);
-        }
-        _on_chip_data(chip, val, chip->nchannels);
-    }
+#endif
+        } else {
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_CALIBRATION
+            printf("%s calibration stage %d: done\n", chip->name,
+                   chip->calibration_stage);
+            for (int i = 0; i < len; i++) {
+                printf("  %s stage %d: amb %d, afe %d\n", chip->name, i,
+                       (int)chip->stages[i].amb,
+                       (int)chip->stages[i].afe_offset);
+            }
+#endif
 
-    if (recalib) {
-        esp_err_t ret;
-        if ((ret = _sequence_request(chip, false, recalib)) != ESP_OK) {
-            ESP_LOGE(TAG, "%s: requesting next sequence failed: %s", chip->name,
-                     esp_err_to_name(ret));
-        }
-        if (chip->calibration_external) {
-            chip->calibration_external = false;
-            ESP_LOGI(TAG, "%s: captouch calibration updated", chip->name);
+            chip->calibration_stage_active = false;
+
+            ad7147_chip_t *other = chip == &_top ? &_bot : &_top;
+            _notify(NOTIF_INTERNAL_CALIB_NEXT_STAGE, other->task);
+
+            ESP_LOGI(TAG, "%s: calibration stage done.", chip->name);
         }
     }
 }
 
-// could probably delete half of this but meh who cares
-esp_err_t flow3r_bsp_ad7147_chip_init(ad7147_chip_t *chip) {
-    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)) != ESP_OK) {
-        return ret;
+static void raw_data_to_petal_pads(ad7147_chip_t *chip, uint16_t *data,
+                                   size_t len) {
+    // Normal measurement, apply to channel->cdc.
+    for (size_t i = 0; i < len; i++) {
+        chip->stages[i].cdc = data[i];
     }
-    chip->calibration_pending = true;
-    if ((ret = _sequence_request(chip, true, true)) != ESP_OK) {
-        return ret;
+    // Submit data to higher level for processing.
+    uint16_t val[13];
+    for (size_t i = 0; i < len; i++) {
+        int32_t cdc = chip->stages[i].cdc;
+        int32_t amb = chip->stages[i].amb;
+        int32_t diff = cdc - amb;
+        val[i] = diff < 0 ? 0 : (diff > 65535 ? 65535 : diff);
     }
-    return ESP_OK;
+    _on_chip_data(chip, val, len);
 }
 
-static bool _chip_process(ad7147_chip_t *chip) {
-    ad7147_hw_t *device = &chip->dev;
-    // Read complete status register. This acknowledges interrupts.
-    uint16_t st = 0;
-    if (!ad7147_hw_get_and_clear_completed(device, &st)) {
-        return false;
-    }
-
-    // Nothing to do if no stages are expected to be read.
-    if (device->num_stages < 1) {
-        return false;
-    }
-
-    // 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)) return false;
-
-    uint16_t data[12];
-    size_t count = device->num_stages;
-    if (!ad7147_hw_get_cdc_data(device, data, count)) {
-        return false;
-    }
-    raw_data_to_petal_pads(chip, data, count);
-    return true;
+// (sunshine emoji) tell the captouch task that top chip data is available :D
+static void _top_isr(void *data) {
+    _notify_from_isr(NOTIF_GPIO, _top_task_handle);
 }
 
-static void _notify_from_isr(uint32_t mask, TaskHandle_t handle) {
-    if (handle == NULL) return;
-    BaseType_t xHigherPriorityTaskWoken;
-    xTaskNotifyFromISR(handle, mask, eSetBits, &xHigherPriorityTaskWoken);
-    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
+// (dark cloud emoji) tell the cursed task that it is time for THE PROCEDURE
+static void _bot_isr(void *data) {
+    _notify_from_isr(NOTIF_GPIO, _bot_task_handle);
 }
 
-static void _notify(uint32_t mask, TaskHandle_t handle) {
-    if (handle == NULL) return;
-    xTaskNotify(handle, mask, eSetBits);
-    portYIELD();
+typedef struct {
+    uint16_t buffer[13];
+    uint16_t buffer_len;
+    uint16_t reject;
+    // normal process only
+    uint16_t previously_filled;
+    uint16_t interrupt_stage;
+    // cursed process only
+    uint16_t step;
+} _chip_process_data_t;
+
+static void _chip_process_reset(_chip_process_data_t *process_data) {
+    process_data->previously_filled = 0;
+    process_data->interrupt_stage = 0;
+    process_data->step = 0;
+    process_data->buffer_len = 0;
+    process_data->reject = 2;
 }
 
-// (sunshine emoji) tell the captouch task that top chip data is available :D
-static void _top_isr(void *data) { _notify_from_isr(2, _captouch_task_handle); }
-
-// (dark cloud emoji) tell the cursed task that it is time for THE PROCEDURE
-static void _bot_isr(void *data) { _notify_from_isr(1, _cursed_task_handle); }
-
-// So here's a thing to generally keep in mind with this driver:
-// It runs on interrupts. Which come from a hardware. Which
-// needs to know that the interrupt has been read in order to
-// be able to send another. So each time u receive an interrupt
-// you need to clear it else the machinery will just halt.
-// Unless maybe you put a timeout somewhere but that would be too
-// reasonable.
-// Not sure why we mention this here.
-static void _kickstart(void) {
-    if (_captouch_task_handle == NULL) return;
-    ulTaskNotifyValueClear(_captouch_task_handle, 0xffffffff);
-    _notify(2, _captouch_task_handle);
-    _notify(1, _cursed_task_handle);
-}
-
-#ifdef CAPTOUCH_PROFILING
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_CURSED_PROFILING
 static int64_t _cursed_step_time = 0;
 static uint32_t _cursed_step_to_step_time[4];
 static uint32_t _cursed_step_execution_time[4];
@@ -504,11 +755,11 @@ static uint32_t _cursed_step_execution_time[4];
 // poking the main captouch task when data is ready.
 // this is kinda sensitive and overall weird so we keep our lil crappy profiler
 // hooked up for now, we're sure we'll need it in the future again.
-static bool _cursed_chip_process(ad7147_chip_t *chip, uint8_t *step,
-                                 uint16_t *data) {
+static bool _cursed_chip_process(ad7147_chip_t *chip, uint32_t *timestamp,
+                                 _chip_process_data_t *process_data) {
     ad7147_hw_t *device = &chip->dev;
     uint16_t st = 0;
-#ifdef CAPTOUCH_PROFILING
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_CURSED_PROFILING
     int64_t time = esp_timer_get_time();
 #endif
     // this boi clears the interrupt flag of the captouch chip so it must
@@ -517,31 +768,17 @@ static bool _cursed_chip_process(ad7147_chip_t *chip, uint8_t *step,
     if (!ad7147_hw_get_and_clear_completed(device, &st)) {
         return false;
     }
-    // since we can only reset the chip sequencer to stage 0, for the 13th
-    // stage the hardware interrupt should trigger when state 0 is completed.
-    // this is okay for all steps of the prodecure, so to keep i2c traffic low
-    // we have it hardcoded to that at all times.
-    if (!(st & 1)) {
-#ifdef CAPTOUCH_PROFILING
-        // if u trigger this it's not bad, it just means that u generate more
-        // i2c traffic than absolutely necessary. remove bofh
-        // xTaskNotifyStateClear below for a demonstrations. with stock config
-        // this rarely triggers on step 1. don't really care to look into it.
-        ESP_LOGE(TAG, "cursed chip isr clear fail (step %u, mask %u\n)",
-                 (uint16_t)(*step), st);
-#endif
-        return false;
-    }
     // let's pretend for fun that channel0 is read by stage0 and so forth.
     // makes describing this a lot easier.
     // also i2c traffic functions are highly golfed, pls don't tear them apart
     // it saves like 1% cpu so be cool~
-    switch (*step) {
+    switch (process_data->step) {
         case 0:
             // read all 12 "regular" channels into the array
-            if (!ad7147_hw_get_cdc_data(device, data, 12)) {
+            if (!ad7147_hw_get_cdc_data(device, process_data->buffer, 12)) {
                 return false;
             }
+            process_data->buffer_len = 12;
             // reconfigures stage0 to channel 13 config, set up sequencer
             // to loop only stage0 (<-actually a lie but dw abt it~)
             if (!ad7147_hw_modulate_stage0_and_reset(device,
@@ -559,9 +796,11 @@ static bool _cursed_chip_process(ad7147_chip_t *chip, uint8_t *step,
         //         so we wait for another cycle.
         case 2:
             // grab data for channel 13
-            if (!ad7147_hw_get_cdc_data(device, &(data[12]), 1)) {
+            if (!ad7147_hw_get_cdc_data(device, &(process_data->buffer[12]),
+                                        1)) {
                 return false;
             }
+            *timestamp = esp_timer_get_time();
             // reconfigure stage0 to channel 0 config, set up sequencer
             // to loop all stages
             if (!ad7147_hw_modulate_stage0_and_reset(device, NULL)) {
@@ -573,135 +812,436 @@ static bool _cursed_chip_process(ad7147_chip_t *chip, uint8_t *step,
             if (!ad7147_hw_get_and_clear_completed(device, &st)) {
                 return false;
             }
-            // processing data here.
-            xSemaphoreTake(raw_petal_bot_chip_lock, portMAX_DELAY);
-            raw_data_to_petal_pads(chip, data, 13);
-            xSemaphoreGive(raw_petal_bot_chip_lock);
-            // notify the main captouch task that data is ready
-            _notify(1, _captouch_task_handle);
+            if (process_data->buffer_len != 12) {
+                process_data->step = 0;
+                return false;
+            }
+            process_data->buffer_len = 13;
             break;
-            // case 3: "whoa whoa whoa if you do the same here as with case 1
-            //         you'd wait for the whole 12-sequence without any good
-            //         reason!"
+            // case 3:
+            //  "whoa whoa whoa if you do the same here as with case 1
+            //  you'd wait for the whole 12-sequence without any good
+            //  reason!"
             // observant, but there's a hidden trick! both case2->case3 and
             // case 3->case0 have the sequencer configure to 12 steps but
             // consider: case 2 resets to stage0 (only stage we can reset to,
             // remember?), and the next interrupt is triggered once that is
             // complete! that is the magic of ISR trigger reconf laziness!
     }
-#ifdef CAPTOUCH_PROFILING
-    _cursed_step_to_step_time[*step] = time - _cursed_step_time;
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_CURSED_PROFILING
+    _cursed_step_to_step_time[process_data->step] = time - _cursed_step_time;
     _cursed_step_time = esp_timer_get_time();
-    _cursed_step_execution_time[*step] = _cursed_step_time - time;
+    _cursed_step_execution_time[process_data->step] = _cursed_step_time - time;
 #endif
-    *step = ((*step) + 1) % 4;
+    process_data->step = (process_data->step + 1) % 4;
+    return !process_data->step;
+}
+
+static bool _chip_process(ad7147_chip_t *chip, uint32_t *timestamp,
+                          _chip_process_data_t *process_data) {
+    if (chip->cursed_active)
+        return _cursed_chip_process(chip, timestamp, process_data);
+    ad7147_hw_t *device = &chip->dev;
+    // Read complete status register. This acknowledges interrupts.
+    uint16_t st = 0;
+    if (!ad7147_hw_get_and_clear_completed(device, &st)) {
+        return false;
+    }
+
+    // Nothing to do if no stages are expected to be read.
+    if (device->num_stages < 1) {
+        return false;
+    }
+
+    int count = device->num_stages;
+    process_data->previously_filled |= st;
+    if (process_data->previously_filled < (1 << count) - 1) {
+        return false;
+    }
+    if (!ad7147_hw_get_cdc_data(device, process_data->buffer, count)) {
+        return false;
+    }
+    *timestamp = esp_timer_get_time();
+    // so here's another curveball: we can't really lock on the output register
+    // of the captouch driver, it just keeps writing to it, so we get FUTURE
+    // DATA, whoa! this is actually bad because you may receive the same NO MORE
+    // FUTURE BUT NOW PRESENT DATA in the next round, because if you do any sort
+    // of velocity calculation this will bite u ^w^.
+    // we'd love to use the reset trick from the cursed sequence here, but turns
+    // out the jank behaves slightly different and procudes twitches on the
+    // 11th/last channel. so we're chasing st instead by moving around the
+    // interrupt stage. fun :3. this is actually faster than the reset sequence.
+    // and yeeeah for some applications future data is fine, and then we're
+    // sacrificing precious cycles here. but it's typically less than a ms. it's
+    // okay. it's the better default. also it's silly code :3
+    if (!ad7147_hw_get_and_clear_completed(device, &st)) return false;
+    st = st & ((1 << count) - 1);  // sanitize
+    if (st) {
+        int new_interrupt_stage = -1;
+        for (int i = 0; i < count; i++) {
+            int stage = (count + process_data->interrupt_stage - i) % count;
+            if (st & 1 << stage) {
+                new_interrupt_stage = stage;
+                break;
+            }
+        }
+        if (new_interrupt_stage < 0) {
+            ESP_LOGE(TAG, "bad stage vector");
+        }
+        // there is a question to be asked if we gain anything by not setting
+        // the interrupt to "slice apart" petals. due to random timing between
+        // isr receive and us grabbing the data it's a bit unpredictable, but
+        // we'd definitely catch much less ~positional data temporal
+        // dyslocation~ phenomena :3. but it'd be slower. and probably not super
+        // duper reliable. we think we wanna be lazy here and argue that we care
+        // most about the minimum data quality, which does not change from
+        // adding this. we get some datarate++ in return. yay datarate!
+        if (new_interrupt_stage != process_data->interrupt_stage) {
+            if (!ad7147_hw_override_interrupt_stage(
+                    device, process_data->interrupt_stage))
+                return false;
+            process_data->interrupt_stage = new_interrupt_stage;
+        }
+        if (!ad7147_hw_get_and_clear_completed(device, &st)) return false;
+        if (st >= (1 << count) - 1) _notify(NOTIF_GPIO, chip->task);
+    }
+    process_data->buffer_len = count;
+    process_data->previously_filled = st;
     return true;
 }
 
-// wrapper/data storage for the above.
-static void _cursed_task(void *data) {
-    uint8_t step = 0;
-    uint16_t buffer[13];
-    for (;;) {
-        uint32_t notif;  // ignoring this
-        if (xTaskNotifyWait(0, 3, &notif, portMAX_DELAY) == pdFALSE) {
-            ESP_LOGE(TAG, "Notification receive failed: cursed task");
-            continue;
+static bool _chip_process_kickstart(ad7147_chip_t *chip) {
+    ad7147_hw_t *device = &chip->dev;
+    uint16_t st = 0;
+    if (!ad7147_hw_get_and_clear_completed(device, &st)) {
+        ESP_LOGE(TAG, "kickstart failed");
+        return false;
+    }
+    return true;
+}
+
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_PROFILING
+typedef struct {
+    uint32_t timestamps[100];
+    uint32_t index;
+    ad7147_chip_t *chip;
+} _profiler_t;
+
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_CURSED_PROFILING
+static void _cursed_profiler_run() {
+    for (uint16_t i = 0; i < 4; i++) {
+        uint16_t k = (i + 3) % 4;
+        printf("last bot step %u to step %u time: %luus\n", k, i,
+               _cursed_step_to_step_time[i]);
+    }
+    for (uint16_t i = 0; i < 4; i++) {
+        printf("last bot step %u execution time: %luus\n", i,
+               _cursed_step_execution_time[i]);
+    }
+}
+#endif
+
+static void _profiler_run(_profiler_t *self, uint32_t timestamp) {
+    if (self->index < 100) {
+        self->timestamps[self->index] = timestamp;
+        self->index++;
+    } else {
+        uint32_t avg = self->timestamps[99] - self->timestamps[3];
+        avg /= (99 - 3);
+        uint32_t min = UINT32_MAX;
+        uint32_t max = 0;
+        for (int i = 3; i < 99; i++) {
+            uint32_t time = self->timestamps[i + 1] - self->timestamps[i];
+            if (time > max) max = time;
+            if (time < min) min = time;
+        }
+        if (chip->is_bot) {
+            printf(
+                "cycle time bot [us]:                       |  %5lu  %5lu  "
+                "%5lu\n",
+                min, avg, max);
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_CURSED_PROFILING
+            if (chip->cursed_active) {
+                _cursed_profiler_run();
+            }
+#endif
+        } else {
+            printf("cycle time top [us]:  %5lu  %5lu  %5lu  |\n", min, avg,
+                   max);
         }
-        _cursed_chip_process(&_bot, &step, buffer);
+        self->index = 0;
     }
 }
+#endif
 
-static void _task(void *data) {
-    (void)data;
+static void _calibration_init_next_stage(ad7147_chip_t *chip,
+                                         _chip_process_data_t *process_data) {
+    chip->calibration_proceed = false;
+    int stage = chip->calibration_stage - 1;
+    if (stage > 0) {
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_CALIBRATION
+        printf("%s: calibration stage %d\n", chip->name, stage);
+#endif
+        chip->calibration_active = true;
+        flow3r_bsp_captouch_petal_mode_t modes[10];
+        for (int i = 0; i < 10; i++) {
+            int limit = 3 - (i & 1);
+            modes[i] = stage > limit ? limit : stage;
+        }
+        chip->calibration_stage = stage;
+        chip->calibration_stage_active = true;
+        chip->calibration_cycles = _AD7147_CALIB_CYCLES + 5;
+
+        memcpy(chip->modes, modes, sizeof(chip->modes));
+        _sequence_request(chip, true);
+        _chip_process_reset(process_data);
+        _chip_process_kickstart(chip);
+    } else {
+        if (stage < 0) {
+            // restore previous calibration
+            _notify(NOTIF_CALIB_CHANGE, chip->task);
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_CALIBRATION
+            printf("%s: calibration stopped\n", chip->name);
+#endif
+        } else {
+            // need to hold the lock for checking notifs
+            xSemaphoreTake(calibration_lock, portMAX_DELAY);
+            uint32_t notif;
+            // it's okay, we're not clearing any and make it pending later with
+            // NOTIF_MODE_CHANGE
+            if ((xTaskNotifyWait(0, 0, &notif, 0) == pdTRUE) &&
+                (notif & NOTIF_CALIB_CHANGE)) {
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_CALIBRATION
+                printf(
+                    "%s: calibration data ignored because of pending user "
+                    "data\n",
+                    chip->name);
+#endif
+            } else {
+                memcpy(&calibration_data, &_chip_calibration_data,
+                       sizeof(ad7147_petal_calib_t) * 10);
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_CALIBRATION
+                printf("%s: calibration done\n", chip->name);
+#endif
+            }
+            xSemaphoreGive(calibration_lock);
+        }
+
+        chip->calibration_stage = 0;
+        chip->calibration_stage_active = false;
+        chip->calibration_cycles = 0;
+        chip->calibration_active = false;
+        _chip_process_reset(process_data);
+        _notify(NOTIF_MODE_CHANGE, chip->task);
+    }
+}
 
-#ifdef CAPTOUCH_PROFILING
-    int64_t top_timer[100];
-    uint8_t top_timer_index = 0;
-    int64_t bot_timer[100];
-    uint8_t bot_timer_index = 0;
+static void _task(ad7147_chip_t *chip) {
+    assert(chip == &_bot || chip == &_top);
+    uint32_t timestamp = 0;
+    _chip_process_data_t process_data;
+    _chip_process_reset(&process_data);
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_PROFILING
+    _profiler_t profiler = { index = 0, chip = chip };
 #endif
     for (;;) {
-#if defined(CONFIG_FLOW3R_HW_GEN_P4)
-        bool top = true, bot = true;
-        vTaskDelay(10 / portTICK_PERIOD_MS);
-#else
         uint32_t notif;
-        if (xTaskNotifyWait(0, 3, &notif, portMAX_DELAY) == pdFALSE) {
-            ESP_LOGE(TAG, "Notification receive failed");
+        if (xTaskNotifyWait(0, NOTIF_CLEAR_MASK, &notif, portMAX_DELAY) ==
+            pdFALSE) {
+            ESP_LOGE(TAG, "%s: Notification receive failed", chip->name);
             continue;
         }
-        notif = notif & 3;
-        if (!notif) continue;
-        bool bot = notif & 1;
-        bool top = notif & 2;
-
-        if (_interrupt_shared) {
-            // No way to know which captouch chip triggered the interrupt, so
-            // process both.
-            top = true;
-            bot = true;
+        if (notif & NOTIF_CALIB_CHANGE) {
+            xSemaphoreTake(calibration_lock, portMAX_DELAY);
+            memcpy(&_chip_calibration_data, &calibration_data,
+                   sizeof(ad7147_petal_calib_t) * 10);
+            xSemaphoreGive(calibration_lock);
         }
+        if (notif & (NOTIF_CALIB_STOP | NOTIF_CALIB_CHANGE)) {
+            if (chip->calibration_active) {
+                chip->calibration_stage = -1;
+                _calibration_init_next_stage(chip, &process_data);
+            }
+            // drop NOTIF_CALIB_START if _STOP or _CHANGE have been received in
+            // the same notif
+        } else if (notif & NOTIF_CALIB_START) {
+            chip->calibration_active = true;
+            // we just wanna bonk the lock to make sure nobody is holding it
+            xSemaphoreBonk(calibration_lock, portMAX_DELAY);
+            petals_clear(chip, esp_timer_get_time());
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_CALIBRATION
+            printf("%s: starting calibration stage\n", chip->name);
 #endif
-        if (top) {
-            uint8_t top_chip_petals[] = { 0, 4, 6, 8 };
-            // _chip_process grabs data from i2c and writes it to
-            // the respective raw petal pad
-            if (_chip_process(&_top)) {
-                xSemaphoreTake(captouch_output_lock, portMAX_DELAY);
-                // this one processes the raw data to user-facing
-                // parameters (mostly "pressed" and "position")
-                for (uint8_t i = 0; i < 4; i++) {
-                    petal_process(top_chip_petals[i]);
+            chip->calibration_stage = 4;
+            _calibration_init_next_stage(chip, &process_data);
+            // drop _NEXT_STAGE if (see above)
+        } else if (notif & NOTIF_INTERNAL_CALIB_NEXT_STAGE) {
+            chip->calibration_proceed = true;
+        }
+        if (chip->calibration_proceed && (!chip->calibration_stage_active)) {
+            _calibration_init_next_stage(chip, &process_data);
+        }
+        if (notif & NOTIF_GPIO) {
+            bool new_data = _chip_process(chip, &timestamp, &process_data);
+            if (new_data) {
+                if (process_data.reject > 0) {
+                    process_data.reject -= 1;
+                    new_data = false;
                 }
-                xSemaphoreGive(captouch_output_lock);
-            }
-#ifdef CAPTOUCH_PROFILING
-            if (top_timer_index < 100) {
-                top_timer[top_timer_index] = esp_timer_get_time();
-                top_timer_index++;
-            } else {
-                int32_t avg = top_timer[99] - top_timer[3];
-                avg /= 1000 * (99 - 3);
-                printf("average top captouch cycle time: %ldms\n", avg);
-                top_timer_index = 0;
             }
+            if (new_data) {
+                if (chip->calibration_stage_active) {
+                    raw_data_to_calib(chip, process_data.buffer,
+                                      process_data.buffer_len);
+                }
+                if (!chip->calibration_active) {
+                    raw_data_to_petal_pads(chip, process_data.buffer,
+                                           process_data.buffer_len);
+                    petals_process(chip, timestamp);
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_PROFILING
+                    // not running this during calibration bc it's annoying
+                    _profiler_run(&profiler, timestamp);
 #endif
+                }
+                process_data.buffer_len = 0;
+            }
         }
-        if (bot) {
-            uint8_t bot_chip_petals[] = { 1, 2, 3, 5, 7, 9 };
-            // same as top, but _chip_process has been done already by
-            // the helper task - we do need to grab an extra lock tho
-            xSemaphoreTake(captouch_output_lock, portMAX_DELAY);
-            // grab this one l8r bc higher prio
-            xSemaphoreTake(raw_petal_bot_chip_lock, portMAX_DELAY);
-            for (uint8_t i = 0; i < 6; i++) {
-                petal_process(bot_chip_petals[i]);
+        if (notif & (NOTIF_MODE_CHANGE | NOTIF_CALIB_CHANGE |
+                     NOTIF_INTERNAL_CALIB_CHANGE)) {
+            bool new_modes = notif & NOTIF_MODE_CHANGE;
+            if (new_modes) {
+                xSemaphoreTake(chip->user_lock, portMAX_DELAY);
+                new_modes =
+                    memcmp(chip->modes, chip->user_modes, sizeof(chip->modes));
+                if (new_modes) {
+                    memcpy(chip->modes, chip->user_modes, sizeof(chip->modes));
+                }
+                xSemaphoreGive(chip->user_lock);
             }
-            xSemaphoreGive(raw_petal_bot_chip_lock);
-            xSemaphoreGive(captouch_output_lock);
-#ifdef CAPTOUCH_PROFILING
-            if (bot_timer_index < 100) {
-                bot_timer[bot_timer_index] = esp_timer_get_time();
-                bot_timer_index++;
+            _sequence_request(chip, new_modes);
+            _chip_process_reset(&process_data);
+            _chip_process_kickstart(chip);
+        }
+    }
+}
+
+static void _bot_task(void *data) {
+    (void)data;
+    _task(&_bot);
+}
+
+static void _top_task(void *data) {
+    (void)data;
+    _task(&_top);
+}
+
+void flow3r_bsp_ad7147_get_petal_modes(flow3r_bsp_captouch_petal_mode_t *data) {
+    memcpy(data, _petal_modes, sizeof(_petal_modes));
+}
+
+static void _set_petal_modes(flow3r_bsp_captouch_petal_mode_t *data) {
+    for (int i = 0; i < 2; i++) {
+        ad7147_chip_t *chip = i ? &_bot : &_top;
+        xSemaphoreTake(chip->user_lock, portMAX_DELAY);
+        memcpy(chip->user_modes, data, sizeof(chip->user_modes));
+        xSemaphoreGive(chip->user_lock);
+        _notify(NOTIF_MODE_CHANGE, chip->task);
+    }
+}
+
+void flow3r_bsp_ad7147_set_petal_modes(flow3r_bsp_captouch_petal_mode_t *data) {
+    memcpy(_petal_modes, data, sizeof(_petal_modes));
+    _set_petal_modes(_petal_modes);
+}
+
+void flow3r_bsp_ad7147_calibrate() {
+    // the notification would set them as soon as it is processed
+    // but we do want those flags earlier
+    // would be nice if we could just read the notification vector
+    _bot.calibration_active = true;
+    _top.calibration_active = true;
+    _notify_both(NOTIF_CALIB_START);
+}
+
+int flow3r_bsp_ad7147_calibrating() {
+    if (_bot.calibration_active || _top.calibration_active) {
+        return _bot.calibration_stage > _top.calibration_stage
+                   ? _bot.calibration_stage
+                   : _top.calibration_stage;
+    }
+    return 0;
+}
+
+bool flow3r_bsp_ad7147_get_calibration_data(int32_t *data) {
+    if (_top.calibration_active || _bot.calibration_active) return false;
+    xSemaphoreTake(calibration_lock, portMAX_DELAY);
+    for (int petal = 0; petal < 10; petal++) {
+        for (int calib_index = 0; calib_index < 6; calib_index++) {
+            int data_index = petal * 12 + calib_index * 2;
+            if ((petal & 1) && calib_index >= 3) {
+                data[data_index] = 0;
+                data[data_index + 1] = 0;
             } else {
-                int32_t avg = bot_timer[99] - bot_timer[3];
-                avg /= 1000 * (99 - 3);
-                printf("average bot captouch cycle time: %ldms\n", avg);
-                bot_timer_index = 0;
-                for (uint16_t i = 0; i < 4; i++) {
-                    uint16_t k = (i + 3) % 4;
-                    printf("last bot step %u to step %u time: %luus\n", k, i,
-                           _cursed_step_to_step_time[i]);
-                }
-                for (uint16_t i = 0; i < 4; i++) {
-                    printf("last bot step %u execution time: %luus\n", i,
-                           _cursed_step_execution_time[i]);
-                }
+                data[data_index] = calibration_data[petal].amb[calib_index];
+                data[data_index + 1] = calibration_data[petal].afe[calib_index];
             }
-#endif
         }
     }
+    xSemaphoreGive(calibration_lock);
+    return true;
+}
+
+static uint16_t amb_limit(int32_t data) {
+    return data > 65535 ? 65535 : (data < 0 ? 0 : data);
+}
+
+static uint8_t afe_limit(int32_t data) {
+    return data > 126 ? 126 : (data < 0 ? 0 : data);
+}
+
+void flow3r_bsp_ad7147_set_calibration_data(int32_t *data) {
+    xSemaphoreTake(calibration_lock, portMAX_DELAY);
+    for (int petal = 0; petal < 10; petal++) {
+        for (int calib_index = 0; calib_index < 6; calib_index++) {
+            int data_index = petal * 12 + calib_index * 2;
+            if (!((petal & 1) && calib_index >= 3)) {
+                calibration_data[petal].amb[calib_index] =
+                    amb_limit(data[data_index]);
+                calibration_data[petal].afe[calib_index] =
+                    afe_limit(data[data_index + 1]);
+            }
+        }
+    }
+    // we must notify while holding the lock so if the calibration has
+    // not stopped yet it will not write its results to calibration_data
+    _notify_both(NOTIF_CALIB_CHANGE);
+    xSemaphoreGive(calibration_lock);
+}
+
+void flow3r_bsp_ad7147_get(flow3r_bsp_captouch_data_t *dest) {
+    xSemaphoreTakeCaptouchOutput(portMAX_DELAY);
+    memcpy(dest, &captouch_data, sizeof(captouch_data));
+    xSemaphoreGiveCaptouchOutput();
+}
+
+void flow3r_bsp_ad7147_refresh_events() {
+    xSemaphoreTakeCaptouchOutput(portMAX_DELAY);
+    for (uint8_t i = 0; i < 10; i++) {
+        captouch_data.petals[i].press_event = latches[i].press_event_new;
+        latches[i].fresh = true;
+    }
+    xSemaphoreGiveCaptouchOutput();
+}
+
+esp_err_t flow3r_bsp_ad7147_chip_init(ad7147_chip_t *chip) {
+    esp_err_t ret;
+    for (size_t i = 0; i < 13; i++) {
+        chip->stages[i].amb = 0;
+    }
+    if ((ret = ad7147_hw_init(&chip->dev)) != ESP_OK) {
+        return ret;
+    }
+    return ESP_OK;
 }
 
 esp_err_t _gpio_interrupt_setup(gpio_num_t num, gpio_isr_t isr) {
@@ -722,33 +1262,46 @@ esp_err_t _gpio_interrupt_setup(gpio_num_t num, gpio_isr_t isr) {
     return ESP_OK;
 }
 
+void assertSemaphoreCreateMutex(SemaphoreHandle_t *target) {
+    assert(*target == NULL);
+    *target = xSemaphoreCreateMutex();
+    assert(*target != NULL);
+}
+
 esp_err_t flow3r_bsp_ad7147_init() {
-    assert(captouch_output_lock == NULL);
-    captouch_output_lock = xSemaphoreCreateMutex();
-    assert(captouch_output_lock != NULL);
-    assert(raw_petal_bot_chip_lock == NULL);
-    raw_petal_bot_chip_lock = xSemaphoreCreateMutex();
-    assert(raw_petal_bot_chip_lock != NULL);
+    assertSemaphoreCreateMutex(&calibration_lock);
+
+    for (int i = 0; i < 2; i++) {
+        ad7147_chip_t *chip = i ? &_top : &_bot;
+        assertSemaphoreCreateMutex(&chip->output_lock);
+        assertSemaphoreCreateMutex(&chip->user_lock);
+        chip->dev.dev_config.decimation = 0b10;
+        memcpy(chip->user_modes, _petal_modes, sizeof(chip->user_modes));
+    }
 
     esp_err_t ret;
 
-    _top.dev.dev_config.decimation = 0b01;
+    for (int i = 0; i < 10; i++) {
+        captouch_data.petals[i].index = i;
+    }
+
     _top.dev.addr = flow3r_i2c_addresses.touch_top;
-    _bot.dev.dev_config.decimation = 0b10;
     _bot.dev.addr = flow3r_i2c_addresses.touch_bottom;
 
+    xTaskCreate(&_top_task, "captouch-top", 4096, NULL,
+                configMAX_PRIORITIES - 2, &_top_task_handle);
+    xTaskCreate(&_bot_task, "captouch-bot", 4096, NULL,
+                configMAX_PRIORITIES - 2, &_bot_task_handle);
+
+    _top.task = _top_task_handle;
+    _bot.task = _bot_task_handle;
+
     if ((ret = flow3r_bsp_ad7147_chip_init(&_top)) != ESP_OK) {
         return ret;
     }
     if ((ret = flow3r_bsp_ad7147_chip_init(&_bot)) != ESP_OK) {
         return ret;
     }
-    ESP_LOGI(TAG, "Captouch initialized");
-
-    xTaskCreate(&_task, "captouch", 4096, NULL, configMAX_PRIORITIES - 2,
-                &_captouch_task_handle);
-    xTaskCreate(&_cursed_task, "ad7147", 4096, NULL, configMAX_PRIORITIES - 1,
-                &_cursed_task_handle);
 
     if ((ret = gpio_install_isr_service(ESP_INTR_FLAG_SHARED |
                                         ESP_INTR_FLAG_LOWMED)) != ESP_OK) {
@@ -770,131 +1323,285 @@ esp_err_t flow3r_bsp_ad7147_init() {
         }
     }
 
-    _kickstart();
+    flow3r_bsp_ad7147_calibrate();
+    ESP_LOGI(TAG, "Captouch initialized");
     return ESP_OK;
 }
 
-void flow3r_bsp_ad7147_calibrate() {
-    _bot.calibration_pending = true;
-    _top.calibration_pending = true;
-}
+// roughly matches the behavior of the legacy api. someday we should have more
+// meaningful output units.
 
-bool flow3r_bsp_ad7147_calibrating() {
-    bool bot = _bot.calibration_pending || _bot.calibration_active;
-    bool top = _top.calibration_pending || _top.calibration_active;
-    return bot || top;
+#define TOP_PETAL_THRESHOLD 8000
+#define BOTTOM_PETAL_THRESHOLD 12000
+#define PETAL_HYSTERESIS 1000
+
+#define PETAL_TOP_GAIN 5300
+#define PETAL_TOP_2_GAIN 6000
+#define PETAL_BOT_GAIN 6500
+
+static inline int16_t clip_to_int16(int32_t num) {
+    return (num > 32767 ? 32767 : (num < -32768 ? -32768 : num));
 }
 
-void flow3r_bsp_ad7147_get_calibration_data(int32_t *data) {
-    while (flow3r_bsp_captouch_calibrating()) {
-    };
-    for (uint8_t i = 0; i < 13; i++) {
-        if (i < 12) {
-            data[2 * i] = _top.channels[i].afe_offset;
-            data[2 * i + 1] = _top.channels[i].amb;
+static int32_t top_petal_filter[10];
+
+static int32_t petal_pos_gain[10] = {
+    PETAL_TOP_GAIN, PETAL_BOT_GAIN, PETAL_TOP_2_GAIN, PETAL_BOT_GAIN,
+    PETAL_TOP_GAIN, PETAL_BOT_GAIN, PETAL_TOP_GAIN,   PETAL_BOT_GAIN,
+    PETAL_TOP_GAIN, PETAL_BOT_GAIN,
+};
+
+static inline void pos_calc_top(int index, int mode, int *rad, int *phi,
+                                int *raw_sum, uint16_t *raw_petal) {
+    int32_t cw = raw_petal[petal_pad_cw];
+    int32_t ccw = raw_petal[petal_pad_ccw];
+    int32_t base = raw_petal[petal_pad_base];
+    int32_t tip = ((cw + ccw) * 3) >> 2;
+    *raw_sum = base + ccw + cw;
+    if (mode > 1) {
+        int rad_calc = tip - base;
+        rad_calc *= petal_pos_gain[index];
+        rad_calc /= ((tip + base) >> 2) + 1;
+        if (mode > 2) {
+            int phi_calc = cw - ccw;
+            phi_calc *= petal_pos_gain[index];
+            phi_calc /= ((cw + ccw) >> 2) + 1;
+            phi_calc = (phi_calc * 10) / 6;
+
+            int phi_sq = (phi_calc * phi_calc) >> 15;
+            int phi_qb = (phi_sq * phi_calc) >> 15;
+            int blend = (rad_calc + (18 << 10)) >> 3;
+            phi_calc = (phi_calc * blend + phi_qb * ((1 << 12) - blend)) >> 12;
+
+            phi_calc = (phi_calc * 6) / 10;
+
+            *phi = phi_calc;
+        } else {
+            *phi = 0;
         }
-        data[2 * i + 24] = _bot.channels[i].afe_offset;
-        data[2 * i + 25] = _bot.channels[i].amb;
+        rad_calc -= 4096;
+        rad_calc += rad_calc >> 3;
+        *rad = rad_calc;
+    } else {
+        *rad = 0;
     }
 }
 
-static uint16_t amb_limit(int32_t data) {
-    return data > 65535 ? 65535 : (data < 0 ? 0 : data);
-}
-
-static uint8_t afe_limit(int32_t data) {
-    return data > 126 ? 126 : (data < 0 ? 0 : data);
+static inline void pos_calc_bot(int index, int mode, int *rad, int *raw_sum,
+                                uint16_t *raw_petal) {
+    int32_t tip = raw_petal[petal_pad_tip];
+    int32_t base = raw_petal[petal_pad_base];
+    *raw_sum = base + tip;
+    if (mode > 1) {
+        base += ((base * 3) >> 2);  // tiny gain correction
+        int rad_calc = tip - base;
+        rad_calc *= petal_pos_gain[index];
+        rad_calc /= ((tip + base) >> 2) + 1;
+        *rad = rad_calc;
+    } else {
+        *rad = 0;
+    }
 }
 
-void flow3r_bsp_ad7147_set_calibration_data(int32_t *data) {
-    while (flow3r_bsp_captouch_calibrating()) {
-    };
-    for (uint8_t i = 0; i < 13; i++) {
-        if (i < 12) {
-            _top.channels[i].afe_offset = afe_limit(data[2 * i]);
-            _top.channels[i].amb = amb_limit(data[2 * i + 1]);
+#ifdef FLOW3R_BSP_CAPTOUCH_SECONDARY_OUTPUT
+static int32_t top_petal_filter2[10];
+
+static inline void pos_calc_top2(int index, int mode, int *rad, int *phi,
+                                 int *raw_sum, uint16_t *raw_petal) {
+    int32_t cw = raw_petal[petal_pad_cw];
+    int32_t ccw = raw_petal[petal_pad_ccw];
+    int32_t base = raw_petal[petal_pad_base];
+    int32_t tip = (ccw + cw) >> 1;
+    *raw_sum = base + ccw + cw;
+    if (mode > 1) {
+        int rad_calc = tip - base;
+        rad_calc *= petal_pos_gain[index];
+        rad_calc /= ((tip + base) >> 2) + 1;
+        *rad = rad_calc;
+        if (mode > 2) {
+            int phi_calc = cw - ccw;
+            phi_calc *= petal_pos_gain[index];
+            phi_calc /= ((cw + ccw) >> 2) + 1;
+            *phi = phi_calc;
+        } else {
+            *phi = 0;
         }
-        _bot.channels[i].afe_offset = afe_limit(data[2 * i + 24]);
-        _bot.channels[i].amb = amb_limit(data[2 * i + 25]);
+    } else {
+        *rad = 0;
     }
-    _top.calibration_external = true;
-    _bot.calibration_external = true;
 }
 
-void flow3r_bsp_ad7147_get(flow3r_bsp_captouch_data_t *dest) {
-    xSemaphoreTake(captouch_output_lock, portMAX_DELAY);
-    memcpy(dest, &captouch_data, sizeof(captouch_data));
-    xSemaphoreGive(captouch_output_lock);
+static void pos_calc_bot2(int index, int mode, int *rad, int *raw_sum,
+                          uint16_t *raw_petal) {
+    pos_calc_bot(index, mode, rad, raw_sum, raw_petal);
 }
+#endif
 
-void flow3r_bsp_ad7147_refresh_events() {
-    xSemaphoreTake(captouch_output_lock, portMAX_DELAY);
-    for (uint8_t i = 0; i < 10; i++) {
-        captouch_data.petals[i].press_event = latches[i].press_event_new;
-        latches[i].fresh = true;
+static void petal_process(int index, uint32_t timestamp, int mode,
+                          SemaphoreHandle_t lock) {
+    // lock only locks multiple readers and one writer.
+    // for multiple writers you need to lock around this function.
+    flow3r_bsp_captouch_petal_data_t *petal = &(captouch_data.petals[index]);
+    if (!mode) {
+        if (!petal->mode) return;
+        if (lock) xSemaphoreTake(lock, portMAX_DELAY);
+        petal->mode = 0;
+        petal->pressed = false;
+        petal->coverage = 0;
+        petal->rad = 0;
+        petal->phi = 0;
+#ifdef FLOW3R_BSP_CAPTOUCH_SECONDARY_OUTPUT
+        petal->rad2 = 0;
+        petal->phi2 = 0;
+#endif
+        if (lock) xSemaphoreGive(lock);
+        return;
     }
-    xSemaphoreGive(captouch_output_lock);
-}
+    bool top = (index % 2) == 0;
 
-// roughly matches the behavior of the legacy api. someday we should have more
-// meaningful output units.
+    int rad = 0;
+    int phi = 0;
+    int raw_sum = 0;
 
-#define TOP_PETAL_THRESHOLD 8000
-#define BOTTOM_PETAL_THRESHOLD 12000
-#define PETAL_HYSTERESIS 1000
+    if (top) {
+        pos_calc_top(index, mode, &rad, &phi, &raw_sum, raw_petals[index]);
+#if defined(CONFIG_FLOW3R_HW_GEN_P3)
+        rad = -rad;
+#endif
+        phi = clip_to_int16(phi);
+    } else {
+        pos_calc_bot(index, mode, &rad, &raw_sum, raw_petals[index]);
+    }
+    rad = clip_to_int16(rad);
 
-#define POS_AMPLITUDE 40000
-#define POS_AMPLITUDE_SHIFT 2
-#define POS_DIV_MIN 1000
+    raw_sum = (raw_sum * 1024) /
+              (top ? (TOP_PETAL_THRESHOLD) : (BOTTOM_PETAL_THRESHOLD));
 
-static inline void petal_process(uint8_t index) {
-    flow3r_bsp_captouch_petal_data_t *petal = &(captouch_data.petals[index]);
-    int32_t tip = raw_petals[index][petal_pad_tip];
-    int32_t cw = raw_petals[index][petal_pad_cw];
-    int32_t ccw = raw_petals[index][petal_pad_ccw];
-    int32_t base = raw_petals[index][petal_pad_base];
-    bool top = (index % 2) == 0;
-    int32_t thres = top ? (TOP_PETAL_THRESHOLD) : (BOTTOM_PETAL_THRESHOLD);
-    thres =
-        petal->pressed ? thres - (PETAL_HYSTERESIS) : thres;  // some hysteresis
-    int32_t distance;
-    int32_t angle;
-    int32_t raw_sum;
-    int8_t div;
+    bool pressed_prev = petal->pressed;
+    bool pressed = raw_sum > (pressed_prev ? 896 : 1024);
+    bool reset_filters = pressed && (!pressed_prev) && (mode > 1);
+
+    if ((!latches[index].press_event_new) || latches[index].fresh) {
+        latches[index].press_event_new = pressed;
+        latches[index].fresh = false;
+    }
+    raw_sum = raw_sum > 65535 ? 65535 : raw_sum;
+
+#ifdef FLOW3R_BSP_CAPTOUCH_SECONDARY_OUTPUT
+    int rad2 = rad;
+    int phi2 = phi;
+    int raw_sum2 = 0;
     if (top) {
-        raw_sum = base + ccw + cw;
-        div = 3;
-        tip = (ccw + cw) >> 1;
-        angle = cw - ccw;
-        angle *= (POS_AMPLITUDE) >> (POS_AMPLITUDE_SHIFT);
-        angle /= ((cw + ccw) >> (POS_AMPLITUDE_SHIFT)) + (POS_DIV_MIN);
+        pos_calc_top2(index, mode, &rad2, &phi2, &raw_sum2, raw_petals[index]);
+        phi2 = clip_to_int16(phi2);
     } else {
-        raw_sum = base + tip;
-        div = 2;
-        angle = 0;
+        pos_calc_bot2(index, mode, &rad2, &raw_sum2, raw_petals[index]);
     }
-    distance = tip - base;
-    distance *= (POS_AMPLITUDE) >> (POS_AMPLITUDE_SHIFT);
-    distance /= ((tip + base) >> (POS_AMPLITUDE_SHIFT)) + (POS_DIV_MIN);
-#if defined(CONFIG_FLOW3R_HW_GEN_P3)
-    if (top) distance = -distance;
+    rad2 = clip_to_int16(rad2);
 #endif
-    petal->pressed = raw_sum > thres;
-    if (petal->pressed) {  // backwards compat hack for the few ppl who use
-        petal->raw_coverage = raw_sum / div;  // it as a "pressed" proxy
-    } else {                                  // by comparing it to
-        petal->raw_coverage = 0;              // 0
-    }                                         // TODO: undo
 
-    if ((!latches[index].press_event_new) || latches[index].fresh) {
-        latches[index].press_event_new = petal->pressed;
-        latches[index].fresh = false;
+#ifdef FLOW3R_BSP_CAPTOUCH_DEBUG_PETAL
+    static int print_petal_counter = 0;
+    int base = raw_petals[index][petal_pad_base];
+    int tip = raw_petals[index][petal_pad_tip];
+    int cw = raw_petals[index][petal_pad_cw];
+    int ccw = raw_petals[index][petal_pad_ccw];
+    if (index == (FLOW3R_BSP_CAPTOUCH_DEBUG_PETAL)) {
+        if (pressed) {
+            if (!pressed_prev)
+                printf("data = [\n    # cw, ccw/tip, base, rad, phi\n");
+            if (!print_petal_counter) {
+                if (top) {
+                    printf("    (%d, %d, ", cw, ccw);
+                } else {
+                    printf("    (0, %d, ", tip);
+                }
+                printf("%d, %d, %d", base, rad, phi);
+#ifdef FLOW3R_BSP_CAPTOUCH_SECONDARY_OUTPUT
+                printf(", %d, %d", rad2, phi2);
+#endif
+                printf("),\n");
+            }
+            print_petal_counter++;
+            print_petal_counter %= 6;
+        } else if (pressed_prev) {
+            printf("]\n");
+            print_petal_counter = 0;
+        }
+    }
+#endif
+
+    if (_on_data) _on_data(index, mode, pressed, timestamp, rad, phi, raw_sum);
+
+    if (mode > 1) {
+        // filter settings
+        const int32_t f_div_pow = 4;
+        const int32_t f_mult_old = 9;
+        const int32_t f_mult_new = (1 << f_div_pow) - f_mult_old;
+
+        if (reset_filters) {
+            if (top) {
+                top_petal_filter[index] = rad;
+                top_petal_filter[index + 1] = phi;
+            }
+        } else {
+            if (top) {
+                top_petal_filter[index] =
+                    (f_mult_old * top_petal_filter[index] + f_mult_new * rad) >>
+                    f_div_pow;
+                top_petal_filter[index + 1] =
+                    (f_mult_old * top_petal_filter[index + 1] +
+                     f_mult_new * phi) >>
+                    f_div_pow;
+                rad = (f_mult_old * petal->rad +
+                       f_mult_new * top_petal_filter[index]) >>
+                      f_div_pow;
+                phi = (f_mult_old * petal->phi +
+                       f_mult_new * top_petal_filter[index + 1]) >>
+                      f_div_pow;
+            } else {
+                rad = (f_mult_old * petal->rad + f_mult_new * rad) >> f_div_pow;
+            }
+        }
+
+#ifdef FLOW3R_BSP_CAPTOUCH_SECONDARY_OUTPUT
+        if (reset_filters) {
+            if (top) {
+                top_petal_filter2[index] = rad2;
+                top_petal_filter2[index + 1] = phi2;
+            }
+        } else {
+            if (top) {
+                top_petal_filter2[index] =
+                    (f_mult_old * top_petal_filter2[index] +
+                     f_mult_new * rad2) >>
+                    f_div_pow;
+                top_petal_filter2[index + 1] =
+                    (f_mult_old * top_petal_filter2[index + 1] +
+                     f_mult_new * phi2) >>
+                    f_div_pow;
+                rad2 = (f_mult_old * petal->rad2 +
+                        f_mult_new * top_petal_filter2[index]) >>
+                       f_div_pow;
+                phi2 = (f_mult_old * petal->phi2 +
+                        f_mult_new * top_petal_filter2[index + 1]) >>
+                       f_div_pow;
+            } else {
+                rad2 =
+                    (f_mult_old * petal->rad2 + f_mult_new * rad2) >> f_div_pow;
+            }
+        }
+#endif
     }
-    int8_t f_div_pow = 4;
-    int8_t f_mult_old = 9;
-    int8_t f_mult_new = (1 << f_div_pow) - f_mult_old;
-    petal->pos_distance =
-        (f_mult_old * petal->pos_distance + f_mult_new * distance) >> f_div_pow;
-    petal->pos_angle =
-        (f_mult_old * petal->pos_angle + f_mult_new * angle) >> f_div_pow;
+    if (lock) xSemaphoreTake(lock, portMAX_DELAY);
+    petal->mode = mode;
+    petal->pressed = pressed;
+    petal->coverage = raw_sum;
+    petal->rad = rad;
+    petal->phi = phi;
+#ifdef FLOW3R_BSP_CAPTOUCH_SECONDARY_OUTPUT
+    petal->rad2 = rad2;
+    petal->phi2 = phi2;
+#endif
+    if (lock) xSemaphoreGive(lock);
 }
diff --git a/components/flow3r_bsp/flow3r_bsp_ad7147.h b/components/flow3r_bsp/flow3r_bsp_ad7147.h
index 61402e068f7c69c58f086cf3a3dd34b628f629e3..44faac3ed3ed3874f32f1ae609443daa2a8f4527 100644
--- a/components/flow3r_bsp/flow3r_bsp_ad7147.h
+++ b/components/flow3r_bsp/flow3r_bsp_ad7147.h
@@ -35,8 +35,16 @@
 #include "flow3r_bsp_ad7147_hw.h"
 #include "flow3r_bsp_captouch.h"
 
+#include "freertos/FreeRTOS.h"
+#include "freertos/semphr.h"
+
 #define _AD7147_CALIB_CYCLES 16
 
+typedef struct {
+    uint16_t amb[6];
+    uint16_t afe[6];
+} ad7147_petal_calib_t;
+
 // 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 {
@@ -50,7 +58,7 @@ typedef struct {
     volatile uint16_t amb;
     // Calibration samples gathered during the calibraiton process.
     uint16_t amb_meas[_AD7147_CALIB_CYCLES];
-} ad7147_channel_t;
+} ad7147_stage_t;
 
 // State and configuration of an AD7147 chip. Wraps the low-level structure in
 // everything required to manage multiple sequences and perform calibration.
@@ -62,23 +70,41 @@ typedef struct {
 
     // [0, n_channels) are the expected connected channels to the inputs of the
     // chip.
-    size_t nchannels;
-    ad7147_channel_t channels[13];
+    ad7147_stage_t stages[13];
+
+    // modes that the petals are supposed to be in
+    flow3r_bsp_captouch_petal_mode_t user_modes[10];
+    // take this lock to change user_modes, then send a NOTIF_MODE
+    SemaphoreHandle_t user_lock;
+
+    // actual modes that the petals are in.
+    flow3r_bsp_captouch_petal_mode_t modes[10];
+    // Sequence to be handled by this chip as a 0 right-padded
+    // list of channel bitmasks that the chip will read
+    uint16_t sequence[13];
+    int len_sequence;
+    // Whether or not the cursed sequence is active.
+    bool cursed_active;
+
+    // Task that processes the chip
+    TaskHandle_t task;
+    // underlying hardware device
+    ad7147_hw_t dev;
+    // number of petals this chip supplies
+    size_t num_petals;
+    // list of petal indices, num_petals long
+    uint8_t petals[6];
 
-    // Sequence to be handled by this chip as a -1 right-padded
-    // list of channel numbers that the chip will read
-    int8_t sequence[13];
+    // True if calibration is running
+    bool calibration_active;
 
-    ad7147_hw_t dev;
-    bool failed;
-
-    // Request applying external calibration
-    volatile bool calibration_pending;
-    // True if calibration is running or pending
-    volatile bool calibration_active;
-    // Set true if external calibration is to be written to hw
-    volatile bool calibration_external;
-    int8_t calibration_cycles;
+    // internal flags used for calibration
+    bool calibration_stage_active;
+    bool calibration_proceed;
+    int calibration_stage;
+    int calibration_cycles;
+
+    SemaphoreHandle_t output_lock;
 } ad7147_chip_t;
 
 // One of the four possible touch points (pads) on a petal. Top petals have
@@ -138,13 +164,17 @@ flow3r_bsp_ad7147_petal_pad_state_t *flow3r_bsp_ad7147_pad_for_petal(
 void flow3r_bsp_ad7147_calibrate();
 
 // Returns true if captouch is currently calibrating.
-bool flow3r_bsp_ad7147_calibrating();
+int flow3r_bsp_ad7147_calibrating();
 
-// Set/get calibration data. data[] should be at least 50 entries long.
-void flow3r_bsp_ad7147_get_calibration_data(int32_t *data);
+// Set/get calibration data. data[] should be at least 120 entries long.
+bool flow3r_bsp_ad7147_get_calibration_data(int32_t *data);
 void flow3r_bsp_ad7147_set_calibration_data(int32_t *data);
 #pragma once
 
 void flow3r_bsp_ad7147_get(flow3r_bsp_captouch_data_t *dest);
 void flow3r_bsp_ad7147_refresh_events();
 esp_err_t flow3r_bsp_ad7147_init();
+
+void flow3r_bsp_ad7147_set_data_callback(flow3r_bsp_data_callback_t fun);
+void flow3r_bsp_ad7147_set_petal_modes(flow3r_bsp_captouch_petal_mode_t *data);
+void flow3r_bsp_ad7147_get_petal_modes(flow3r_bsp_captouch_petal_mode_t *data);
diff --git a/components/flow3r_bsp/flow3r_bsp_ad7147_hw.c b/components/flow3r_bsp/flow3r_bsp_ad7147_hw.c
index f7ca12bb35e0fc53f4f2980f5bf6f380d3a79aca..738fc0c08cda9c3c29decbe1de455bce0a868fa9 100644
--- a/components/flow3r_bsp/flow3r_bsp_ad7147_hw.c
+++ b/components/flow3r_bsp/flow3r_bsp_ad7147_hw.c
@@ -209,8 +209,6 @@ static esp_err_t _reset_sequencer(const ad7147_hw_t *dev) {
 }
 
 esp_err_t ad7147_hw_init(ad7147_hw_t *device) {
-    // ;w; y tho
-    // memset(device, 0, sizeof(ad7147_hw_t));
     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;
@@ -251,8 +249,14 @@ esp_err_t ad7147_hw_configure_stages(ad7147_hw_t *device,
         device->stage_config[i].neg_afe_offset_swap = true;
         device->stage_config[i].pos_afe_offset_swap = false;
     }
-    device->dev_config.sequence_stage_num = seq->len - 1;
-    device->dev_config.stageX_complete_int_enable[0] = true;
+    if (seq->len) {
+        device->dev_config.sequence_stage_num = seq->len - 1;
+        device->dev_config.stageX_complete_int_enable[seq->interrupt_stage] =
+            true;
+        device->dev_config.power_mode = 0;
+    } else {
+        device->dev_config.power_mode = 1;  // shutdown
+    }
 
     // For our own record (more precisely, for the interrupt handler).
     device->num_stages = seq->len;
@@ -315,6 +319,18 @@ bool ad7147_hw_reset_sequencer(ad7147_hw_t *device) {
 // namespace/duplicating/whatevs and we feel like putting it here is the better
 // of two bad options. anyways:
 
+bool ad7147_hw_override_interrupt_stage(ad7147_hw_t *device,
+                                        uint8_t interrupt_stage) {
+    const uint16_t val = 1 << interrupt_stage;
+    esp_err_t res =
+        _i2c_write(device, AD7147_REG_STAGE_COMPLETE_INT_ENABLE, val);
+    if (res != ESP_OK) {
+        ESP_LOGE(TAG, "captouch i2c failed: %s", esp_err_to_name(res));
+        return false;
+    }
+    return true;
+}
+
 // Configure stage. Defaults to stage_config if config is NULL.
 static esp_err_t _i2c_golf_override_stage(ad7147_hw_t *dev, uint8_t stage,
                                           ad7147_stage_config_t *config) {
@@ -348,9 +364,9 @@ static esp_err_t _i2c_golf_override_stage(ad7147_hw_t *dev, uint8_t stage,
 
 // helper function for ad7147_hw_modulate_stage0_and_reset
 static bool _i2c_golf_override_lower_registers(ad7147_hw_t *dev,
-                                               uint8_t num_stages) {
+                                               int num_stages) {
     esp_err_t res;
-    dev->dev_config.sequence_stage_num = num_stages;
+    if (num_stages >= 0) dev->dev_config.sequence_stage_num = num_stages;
 #if 0
     // writing to 0x00: AD7147_REG_PWR_CONTROL
     if ((res = _configure_pwr_control(dev)) != ESP_OK) {
@@ -414,12 +430,7 @@ bool ad7147_hw_modulate_stage0_and_reset(ad7147_hw_t *dev,
         num_stages = 11;
         res = _i2c_golf_override_stage(dev, 0, NULL);
     } else {
-        // this should be 0 for max performance, but we're deliberately
-        // throttling it to match the old 18ms performance in order to not eat
-        // too much CPU. this can be modified once we upgrade to espidf5.2 and
-        // this thing uses non-blocking i2c ops, or if there is an api that lets
-        // users configure performance.
-        num_stages = 5;  // 0;
+        num_stages = 0;
         ad7147_stage_config_t config = { 0 };
         for (uint8_t j = 0; j < 13; j++) {
             if (s_conf->channel_mask & (1 << j)) {
diff --git a/components/flow3r_bsp/flow3r_bsp_ad7147_hw.h b/components/flow3r_bsp/flow3r_bsp_ad7147_hw.h
index 3def723a4da26e2f36c6e9a0d22ed3f2b243b6da..aabfad4fac439606539c4e5427b2354f669c8f69 100644
--- a/components/flow3r_bsp/flow3r_bsp_ad7147_hw.h
+++ b/components/flow3r_bsp/flow3r_bsp_ad7147_hw.h
@@ -7,6 +7,9 @@
 #include "esp_err.h"
 #include "flow3r_bsp_i2c.h"
 
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+
 #include <stdint.h>
 
 // 'Global' configuration for the AD7147 captouch controller.
@@ -96,7 +99,9 @@ typedef struct {
 } ad7147_sequence_stage_t;
 
 typedef struct {
-    uint8_t len;  // Number of sequencer stages, [1, 12].
+    uint16_t interrupt_stage;  // at which stage's completion the interrupt is
+                               // to be sent, [0..11]
+    uint8_t len;               // Number of sequencer stages, [1, 12].
     ad7147_sequence_stage_t stages[13];
 } ad7147_sequence_t;
 
@@ -115,3 +120,5 @@ bool ad7147_hw_get_cdc_data(ad7147_hw_t *device, uint16_t *data,
 bool ad7147_hw_reset_sequencer(ad7147_hw_t *device);
 bool ad7147_hw_modulate_stage0_and_reset(ad7147_hw_t *dev,
                                          ad7147_sequence_stage_t *s_conf);
+bool ad7147_hw_override_interrupt_stage(ad7147_hw_t *device,
+                                        uint8_t interrupt_stage);
diff --git a/components/flow3r_bsp/flow3r_bsp_captouch.c b/components/flow3r_bsp/flow3r_bsp_captouch.c
index ef52e10e8dd2793f6bbd6aee16198a3a905f3b45..c5576169e4897f34945cbb1e02f6f886676e02ec 100644
--- a/components/flow3r_bsp/flow3r_bsp_captouch.c
+++ b/components/flow3r_bsp/flow3r_bsp_captouch.c
@@ -1,4 +1,5 @@
 #include "flow3r_bsp_captouch.h"
+#include <math.h>
 #include "esp_err.h"
 #include "esp_log.h"
 
@@ -12,10 +13,10 @@ static const char *TAG = "flow3r-bsp-captouch";
 void flow3r_bsp_captouch_set_calibration_data(int32_t *data) {
     flow3r_bsp_ad7147_set_calibration_data(data);
 }
-void flow3r_bsp_captouch_get_calibration_data(int32_t *data) {
-    flow3r_bsp_ad7147_get_calibration_data(data);
+bool flow3r_bsp_captouch_get_calibration_data(int32_t *data) {
+    return flow3r_bsp_ad7147_get_calibration_data(data);
 }
-bool flow3r_bsp_captouch_calibrating() {
+int flow3r_bsp_captouch_calibrating() {
     return flow3r_bsp_ad7147_calibrating();
 }
 void flow3r_bsp_captouch_calibration_request() {
@@ -33,4 +34,15 @@ void flow3r_bsp_captouch_init() {
         ESP_LOGE(TAG, "Captouch init failed: %s", esp_err_to_name(ret));
     }
 }
+void flow3r_bsp_captouch_set_data_callback(flow3r_bsp_data_callback_t fun) {
+    flow3r_bsp_ad7147_set_data_callback(fun);
+};
+void flow3r_bsp_captouch_set_petal_modes(
+    flow3r_bsp_captouch_petal_mode_t *data) {
+    flow3r_bsp_ad7147_set_petal_modes(data);
+};
+void flow3r_bsp_captouch_get_petal_modes(
+    flow3r_bsp_captouch_petal_mode_t *data) {
+    flow3r_bsp_ad7147_get_petal_modes(data);
+};
 #endif
diff --git a/components/flow3r_bsp/flow3r_bsp_captouch.h b/components/flow3r_bsp/flow3r_bsp_captouch.h
index c006e4d4a9d30121145b6cd808d78a451e9b20ce..a04d6748eab43810117fc0a7dee453b28feb07f4 100644
--- a/components/flow3r_bsp/flow3r_bsp_captouch.h
+++ b/components/flow3r_bsp/flow3r_bsp_captouch.h
@@ -15,13 +15,26 @@
 #include <stdbool.h>
 #include <stdint.h>
 
+#define FLOW3R_BSP_CAPTOUCH_RAD_UNIT (16384.)
+#define FLOW3R_BSP_CAPTOUCH_PHI_UNIT (16384.)
+#define FLOW3R_BSP_CAPTOUCH_COVERAGE_UNIT (1024.)
+
+// allows to compare two different positional algorithms.
+// unlocks the "._pos2" attribute on regular captouch output (but not on log
+// frames) #define FLOW3R_BSP_CAPTOUCH_SECONDARY_OUTPUT
+
 typedef struct {
-    bool pressed;
-    bool press_event;
-    uint16_t raw_coverage;
-    // legacy
-    int32_t pos_distance;
-    int32_t pos_angle;
+    int16_t rad;
+    int16_t phi;
+#ifdef FLOW3R_BSP_CAPTOUCH_SECONDARY_OUTPUT
+    int16_t rad2;
+    int16_t phi2;
+#endif
+    uint16_t coverage;
+    uint8_t index : 4;
+    uint8_t mode : 2;
+    uint8_t press_event : 1;
+    uint8_t pressed : 1;
 } flow3r_bsp_captouch_petal_data_t;
 
 typedef struct {
@@ -38,8 +51,39 @@ void flow3r_bsp_captouch_init();
 void flow3r_bsp_captouch_calibration_request();
 
 // Returns true if captouch is currently calibrating.
-bool flow3r_bsp_captouch_calibrating();
+int flow3r_bsp_captouch_calibrating();
 
-// Set/get calibration data. data[] should be at least 52 entries long.
-void flow3r_bsp_captouch_get_calibration_data(int32_t *data);
+// Set/get calibration data. data[] should be at least 120 entries long.
+bool flow3r_bsp_captouch_get_calibration_data(int32_t *data);
 void flow3r_bsp_captouch_set_calibration_data(int32_t *data);
+
+// experiments
+float flow3r_bsp_captouch_get_rad(flow3r_bsp_captouch_petal_data_t *petal,
+                                  uint8_t smooth, uint8_t drop_first,
+                                  uint8_t drop_last);
+float flow3r_bsp_captouch_get_phi(flow3r_bsp_captouch_petal_data_t *petal,
+                                  uint8_t smooth, uint8_t drop_first,
+                                  uint8_t drop_last);
+
+void flow3r_bsp_captouch_get_rad_raw(flow3r_bsp_captouch_petal_data_t *petal,
+                                     float *ret);
+void flow3r_bsp_captouch_get_phi_raw(flow3r_bsp_captouch_petal_data_t *petal,
+                                     float *ret);
+
+// uint8_t petal, bool pressed, uint32_t time_us, int32_t rad, int32_t phi,
+// int32_t coverage
+typedef void (*flow3r_bsp_data_callback_t)(uint8_t, uint8_t, bool, uint32_t,
+                                           int32_t, int32_t, int32_t);
+void flow3r_bsp_captouch_set_data_callback(flow3r_bsp_data_callback_t fun);
+
+typedef enum {
+    PETAL_MODE_OFF = 0,
+    PETAL_MODE_0D = 1,
+    PETAL_MODE_1D = 2,
+    PETAL_MODE_2D = 3,
+} flow3r_bsp_captouch_petal_mode_t;
+
+void flow3r_bsp_captouch_set_petal_modes(
+    flow3r_bsp_captouch_petal_mode_t *data);
+void flow3r_bsp_captouch_get_petal_modes(
+    flow3r_bsp_captouch_petal_mode_t *data);
diff --git a/components/micropython/usermodule/mp_captouch.c b/components/micropython/usermodule/mp_captouch.c
index 86f0543cae7a8d84235968012280dd4a8c86d317..73e2f9821e070c22bdcfd8e44fe82173559f1c42 100644
--- a/components/micropython/usermodule/mp_captouch.c
+++ b/components/micropython/usermodule/mp_captouch.c
@@ -1,26 +1,220 @@
 #include "py/builtin.h"
+#include "py/objstr.h"
+#include "py/parsenum.h"
 #include "py/runtime.h"
+#include "py/smallint.h"
 
+#include "esp_timer.h"
 #include "flow3r_bsp_captouch.h"
 
+#include <math.h>
 #include <string.h>
 
-STATIC mp_obj_t mp_captouch_calibration_active(void) {
-    return mp_obj_new_int(flow3r_bsp_captouch_calibrating());
+// :/
+typedef struct _mp_obj_complex_t {
+    mp_obj_base_t base;
+    mp_float_t real;
+    mp_float_t imag;
+} mp_obj_complex_t;
+
+#define LIST_MIN_ALLOC 4
+
+// this looks like the most useless optimization, this is not about caching tho:
+// we wanna output data to micropython in floats but!! if we do the conversion
+// in the cap touch driver task it can no longer jump between cores and that
+// would make us very sad
+// so we put the floating point mafh in the micropython task :D
+// also we n-e-v-e-r get to use unions and they're so cool :3
+typedef union {
+    float post;
+    int32_t pre;
+} processable;
+
+typedef struct {
+    processable rad;
+    processable phi;
+    processable coverage;
+    mp_uint_t timestamp;
+    uint8_t petal : 4;
+    uint8_t mode : 4;
+    bool pressed : 1;
+    bool processed : 1;
+} log_frame_data_t;
+
+typedef struct _log_frame_data_ll_t {
+    log_frame_data_t frame;
+    struct _log_frame_data_ll_t *next;
+} log_frame_data_ll_t;
+
+typedef struct {
+    mp_obj_base_t base;
+    log_frame_data_t data;
+} mp_captouch_petal_log_frame_t;
+
+const mp_obj_type_t mp_type_captouch_petal_log_frame;
+
+static bool calib_trusted = false;
+
+// argument: the fastest we can go is like 0.75ms per frame if each chip only
+// scans a single pad. we wanna avoid overflowing between thinks and garbage
+// collection can be like 50ms (overflowing is somewhat graceish tho we're just
+// dropping data) so a total worst case buffer length of 75ms seems reasonable
+#define LOG_MAX_LEN 100
+
+typedef struct {
+    log_frame_data_ll_t *start;  // pointer to first (earliest) element in ll
+    log_frame_data_ll_t *end;    // pointer to last (latest) element in ll
+    int len;                     // number of elements in log
+    SemaphoreHandle_t lock;      // lock for log
+
+    // when we kick the latch with refresh_events we buffer the log slice for
+    // the next captouch_read here:
+    log_frame_data_ll_t *latch_buffer_start;
+    int latch_buffer_len;
+    SemaphoreHandle_t latch_buffer_lock;
+} petal_log_buffer_t;
+
+static uint32_t refresh_time;
+static petal_log_buffer_t petal_log[10] = {
+    0,
+};
+static bool petal_log_initialized = false;
+static bool petal_log_active[10];
+
+static void _add_entry_to_log(uint8_t petal, uint8_t mode, bool pressed,
+                              uint32_t timestamp, int32_t rad, int32_t phi,
+                              int32_t coverage) {
+    if (petal >= 10) return;
+    if (!petal_log_active[petal]) return;
+    // TODO: can't m_new_obj here naively bc different task, fails in
+    // gc_alloc/mp_thread_get_state there's gotta be a lock, else _threads
+    // wouldn't work, will look into this sometime soon.
+    log_frame_data_ll_t *frame_ll = malloc(sizeof(log_frame_data_ll_t));
+    if (frame_ll == NULL) {
+        printf("captouch frame init error");
+        return;
+    }
+    frame_ll->next = NULL;
+
+    log_frame_data_t *frame = &(frame_ll->frame);
+    frame->mode = mode;
+    frame->petal = petal;
+    frame->processed = false;
+    frame->pressed = pressed;
+    frame->timestamp = timestamp & (MICROPY_PY_UTIME_TICKS_PERIOD - 1);
+    frame->rad.pre = rad;
+    frame->phi.pre = phi;
+    frame->coverage.pre = coverage;
+
+    // insert into linked list
+    petal_log_buffer_t *log = &(petal_log[petal]);
+    log_frame_data_ll_t *runaway = NULL;
+    xSemaphoreTake(log->lock, portMAX_DELAY);
+
+    if (log->len >= LOG_MAX_LEN) {
+        runaway = log->start;
+        log->start = log->start->next;
+        log->len--;
+    }
+
+    if (log->len == 0) {
+        assert(log->start == NULL);
+        assert(log->end == NULL);
+        log->start = frame_ll;
+        log->end = frame_ll;
+    } else {
+        log->end->next = frame_ll;
+    }
+    log->len++;
+    log->end = frame_ll;
+
+    xSemaphoreGive(log->lock);
+    free(runaway);
 }
-STATIC MP_DEFINE_CONST_FUN_OBJ_0(mp_captouch_calibration_active_obj,
-                                 mp_captouch_calibration_active);
 
-STATIC mp_obj_t mp_captouch_calibration_request(void) {
-    flow3r_bsp_captouch_calibration_request();
-    return mp_const_none;
+static void _kick_log_latch(uint8_t petal) {
+    petal_log_buffer_t *log = &(petal_log[petal]);
+    if (log->lock == NULL) return;
+
+    xSemaphoreTake(log->lock, portMAX_DELAY);
+    int len = log->len;
+    log_frame_data_ll_t *start = log->start;
+    log->len = 0;
+    log->start = NULL;
+    log->end = NULL;
+    xSemaphoreGive(log->lock);
+
+    xSemaphoreTake(log->latch_buffer_lock, portMAX_DELAY);
+    log_frame_data_ll_t *runaway = log->latch_buffer_start;
+    log->latch_buffer_len = len;
+    log->latch_buffer_start = start;
+    xSemaphoreGive(log->latch_buffer_lock);
+    while (runaway) {
+        log_frame_data_ll_t *runaway_next = runaway->next;
+        free(runaway);
+        runaway = runaway_next;
+    }
+}
+
+static mp_obj_t _get_list_from_log(uint8_t petal) {
+    petal_log_buffer_t *log = &(petal_log[petal]);
+
+    mp_obj_list_t *o = m_new_obj(mp_obj_list_t);
+    if (log->lock == NULL) {
+        mp_obj_list_init(o, 0);
+        return MP_OBJ_FROM_PTR(o);
+    }
+
+    xSemaphoreTake(log->latch_buffer_lock, portMAX_DELAY);
+
+    const int len = log->latch_buffer_len;  // len 0 ok
+    log_frame_data_ll_t *frame_ll = log->latch_buffer_start;
+
+    mp_obj_list_init(o, len);
+    log_frame_data_ll_t *del[len];
+    mp_captouch_petal_log_frame_t *frames[len];
+
+    for (int i = 0; i < len; i++) {
+        assert(frame_ll);
+        frames[i] = m_new(mp_captouch_petal_log_frame_t, 1);
+        frames[i]->base.type = &mp_type_captouch_petal_log_frame;
+        memcpy(&(frames[i]->data), &(frame_ll->frame),
+               sizeof(log_frame_data_t));
+        del[i] = frame_ll;
+        frame_ll = frame_ll->next;
+    }
+    assert(!frame_ll);
+
+    log->latch_buffer_start = NULL;
+    log->latch_buffer_len = 0;
+    xSemaphoreGive(log->latch_buffer_lock);
+
+    for (int i = 0; i < len; i++) {
+        o->items[i] = MP_OBJ_FROM_PTR(frames[i]);
+        free(del[i]);
+    }
+    return MP_OBJ_FROM_PTR(o);
+}
+
+static void _initialize_log() {
+    if (petal_log_initialized) return;
+    for (size_t i = 0; i < 10; i++) {
+        petal_log_buffer_t *log = &(petal_log[i]);
+        assert(log->lock == NULL);
+        assert(log->latch_buffer_lock == NULL);
+        log->lock = xSemaphoreCreateMutex();
+        assert(log->lock != NULL);
+        log->latch_buffer_lock = xSemaphoreCreateMutex();
+        assert(log->latch_buffer_lock != NULL);
+    }
+    flow3r_bsp_captouch_set_data_callback(_add_entry_to_log);
+    petal_log_initialized = true;
 }
-STATIC MP_DEFINE_CONST_FUN_OBJ_0(mp_captouch_calibration_request_obj,
-                                 mp_captouch_calibration_request);
 
 typedef struct {
     mp_obj_base_t base;
     mp_obj_t captouch;
+    mp_obj_t log;
     size_t ix;
 } mp_captouch_petal_state_t;
 
@@ -29,11 +223,97 @@ const mp_obj_type_t captouch_petal_state_type;
 typedef struct {
     mp_obj_base_t base;
     mp_obj_t petals;
+    uint32_t timestamp;
     flow3r_bsp_captouch_data_t underlying;
 } mp_captouch_state_t;
 
 const mp_obj_type_t captouch_state_type;
 
+static inline void log_frame_process_data(log_frame_data_t *data) {
+    if (!data->processed) {
+        data->rad.post = data->rad.pre / FLOW3R_BSP_CAPTOUCH_RAD_UNIT;
+        data->phi.post = data->phi.pre / FLOW3R_BSP_CAPTOUCH_PHI_UNIT;
+        data->coverage.post =
+            data->coverage.pre / FLOW3R_BSP_CAPTOUCH_COVERAGE_UNIT;
+        data->processed = true;
+    }
+}
+
+STATIC void mp_captouch_petal_log_frame_attr(mp_obj_t self_in, qstr attr,
+                                             mp_obj_t *dest) {
+    mp_captouch_petal_log_frame_t *self = MP_OBJ_TO_PTR(self_in);
+    if (dest[0] != MP_OBJ_NULL) {
+        return;
+    }
+    log_frame_data_t *data = &self->data;
+    log_frame_process_data(data);
+
+    switch (attr) {
+        case MP_QSTR_raw_pos:
+            dest[0] = mp_obj_new_complex(data->rad.post, data->phi.post);
+            break;
+        case MP_QSTR_pos:
+            if (data->pressed && data->mode > 1) {
+                dest[0] = mp_obj_new_complex(data->rad.post, data->phi.post);
+            } else {
+                dest[0] = mp_const_none;
+            }
+            break;
+        case MP_QSTR_raw_cap:
+            dest[0] = mp_obj_new_float(data->coverage.post);
+            break;
+        case MP_QSTR_pressed:
+            dest[0] = mp_obj_new_bool(data->pressed);
+            break;
+        case MP_QSTR_mode:
+            dest[0] = mp_obj_new_int(data->mode);
+            break;
+        case MP_QSTR_ticks_us:
+            dest[0] = MP_OBJ_NEW_SMALL_INT(data->timestamp);
+            break;
+    }
+}
+
+STATIC mp_obj_t mp_captouch_petal_log_frame_make_new(
+    const mp_obj_type_t *type_in, size_t n_args, size_t n_kw,
+    const mp_obj_t *args) {
+    mp_arg_check_num(n_args, n_kw, 2, 4, false);
+    // args: pressed, position, timestamp (optional, fallback: present time),
+    // coverage (optional, fallback: 5 if pressed else 0)
+
+    mp_captouch_petal_log_frame_t *self =
+        m_new_obj(mp_captouch_petal_log_frame_t);
+    self->base.type = type_in;
+    log_frame_data_t *data = &self->data;
+    data->processed = true;
+
+    data->pressed = mp_obj_is_true(args[0]);
+
+    if (mp_obj_is_type(args[1], &mp_type_complex)) {
+        mp_obj_complex_t *position = MP_OBJ_TO_PTR(args[1]);
+        data->rad.post = position->real;
+        data->phi.post = position->imag;
+    } else {
+        data->rad.post = mp_obj_get_float(args[1]);
+        data->phi.post = 0;
+    }
+
+    if (n_args > 2) {
+        data->timestamp = MP_OBJ_SMALL_INT_VALUE(args[2]);
+    } else {
+        data->timestamp =
+            esp_timer_get_time() & (MICROPY_PY_UTIME_TICKS_PERIOD - 1);
+    }
+
+    if (n_args > 3) {
+        data->coverage.post = mp_obj_get_float(args[3]);
+    } else {
+        data->coverage.post = data->pressed ? 5. : 0.;
+    }
+
+    return MP_OBJ_FROM_PTR(self);
+}
+
 STATIC void mp_captouch_petal_state_attr(mp_obj_t self_in, qstr attr,
                                          mp_obj_t *dest) {
     mp_captouch_petal_state_t *self = MP_OBJ_TO_PTR(self_in);
@@ -48,6 +328,10 @@ STATIC void mp_captouch_petal_state_attr(mp_obj_t self_in, qstr attr,
     bool top = (self->ix % 2) == 0;
 
     switch (attr) {
+        case MP_QSTR_log: {
+            mp_obj_list_t *log = MP_OBJ_TO_PTR(self->log);
+            dest[0] = mp_obj_new_list(log->len, log->items);
+        } break;
         case MP_QSTR_top:
             dest[0] = mp_obj_new_bool(top);
             break;
@@ -57,17 +341,64 @@ STATIC void mp_captouch_petal_state_attr(mp_obj_t self_in, qstr attr,
         case MP_QSTR_pressed:
             dest[0] = mp_obj_new_bool(state->press_event);
             break;
+        case MP_QSTR_raw_cap:
+            dest[0] = mp_obj_new_float(state->coverage /
+                                       FLOW3R_BSP_CAPTOUCH_COVERAGE_UNIT);
+            break;
+        case MP_QSTR_raw_pos:
+            dest[0] =
+                mp_obj_new_complex(state->rad / FLOW3R_BSP_CAPTOUCH_RAD_UNIT,
+                                   state->phi / FLOW3R_BSP_CAPTOUCH_PHI_UNIT);
+            break;
+        case MP_QSTR_pos:
+            if (state->press_event && state->mode > 1) {
+                dest[0] = mp_obj_new_complex(
+                    state->rad / FLOW3R_BSP_CAPTOUCH_RAD_UNIT,
+                    state->phi / FLOW3R_BSP_CAPTOUCH_PHI_UNIT);
+            } else {
+                dest[0] = mp_const_none;
+            }
+            break;
+#ifdef FLOW3R_BSP_CAPTOUCH_SECONDARY_OUTPUT
+        case MP_QSTR__pos2:
+            if (state->press_event && state->mode > 1) {
+                dest[0] = mp_obj_new_complex(
+                    state->rad2 / FLOW3R_BSP_CAPTOUCH_RAD_UNIT,
+                    state->phi2 / FLOW3R_BSP_CAPTOUCH_PHI_UNIT);
+            } else {
+                dest[0] = mp_const_none;
+            }
+            break;
+#endif
         case MP_QSTR_pressure:
-            dest[0] = mp_obj_new_int(state->raw_coverage);
+            // legacy
+            if (state->press_event) {
+                dest[0] = mp_obj_new_int(state->coverage);
+            } else {
+                dest[0] = mp_obj_new_int(0);
+            }
             break;
-        case MP_QSTR_position: {
-            mp_obj_t items[2] = {
-                mp_obj_new_int(state->pos_distance),
-                mp_obj_new_int(state->pos_angle),
-            };
-            dest[0] = mp_obj_new_tuple(2, items);
+        case MP_QSTR_position:
+            // legacy
+            {
+                int32_t ret[2];
+                ret[0] = state->rad;
+                ret[1] = state->phi;
+                mp_obj_t items[2];
+                // bitshift better than division here due to uneven spacing when
+                // crossing 0 fix manually when changing RAD_UNIT/PHI_UNIT
+                if (top) {
+                    ret[0] = (int32_t)((35000 * ret[0]) >> 14);
+                    ret[1] = (int32_t)((35000 * ret[1]) >> 14);
+                } else {
+                    ret[0] = (int32_t)(((25000 * ret[0]) >> 14) + 5000);
+                    ret[1] = 0;
+                }
+                items[0] = mp_obj_new_int(ret[0]);
+                items[1] = mp_obj_new_int(ret[1]);
+                dest[0] = mp_obj_new_tuple(2, items);
+            }
             break;
-        }
     }
 }
 
@@ -82,9 +413,17 @@ STATIC void mp_captouch_state_attr(mp_obj_t self_in, qstr attr,
         case MP_QSTR_petals:
             dest[0] = self->petals;
             break;
+        case MP_QSTR_ticks_us:
+            dest[0] = MP_OBJ_NEW_SMALL_INT(self->timestamp);
+            break;
     }
 }
 
+MP_DEFINE_CONST_OBJ_TYPE(mp_type_captouch_petal_log_frame,
+                         MP_QSTR_PetalLogFrame, MP_TYPE_FLAG_NONE, attr,
+                         mp_captouch_petal_log_frame_attr, make_new,
+                         mp_captouch_petal_log_frame_make_new);
+
 MP_DEFINE_CONST_OBJ_TYPE(captouch_petal_state_type, MP_QSTR_CaptouchPetalState,
                          MP_TYPE_FLAG_NONE, attr, mp_captouch_petal_state_attr);
 
@@ -94,6 +433,7 @@ MP_DEFINE_CONST_OBJ_TYPE(captouch_state_type, MP_QSTR_CaptouchState,
 STATIC mp_obj_t
 mp_captouch_state_new(const flow3r_bsp_captouch_data_t *underlying) {
     mp_captouch_state_t *captouch = m_new_obj(mp_captouch_state_t);
+    captouch->timestamp = refresh_time;
     captouch->base.type = &captouch_state_type;
     memcpy(&captouch->underlying, underlying,
            sizeof(flow3r_bsp_captouch_data_t));
@@ -104,7 +444,7 @@ mp_captouch_state_new(const flow3r_bsp_captouch_data_t *underlying) {
         petal->base.type = &captouch_petal_state_type;
         petal->captouch = MP_OBJ_FROM_PTR(captouch);
         petal->ix = i;
-
+        petal->log = _get_list_from_log(i);
         mp_obj_list_append(captouch->petals, MP_OBJ_FROM_PTR(petal));
     }
 
@@ -120,6 +460,10 @@ STATIC mp_obj_t mp_captouch_read(void) {
 STATIC MP_DEFINE_CONST_FUN_OBJ_0(mp_captouch_read_obj, mp_captouch_read);
 
 STATIC mp_obj_t mp_captouch_refresh_events(void) {
+    for (int i = 0; i < 10; i++) {
+        _kick_log_latch(i);
+    }
+    refresh_time = esp_timer_get_time() & (MICROPY_PY_UTIME_TICKS_PERIOD - 1);
     flow3r_bsp_captouch_refresh_events();
     return mp_const_none;
 }
@@ -127,50 +471,788 @@ STATIC mp_obj_t mp_captouch_refresh_events(void) {
 STATIC MP_DEFINE_CONST_FUN_OBJ_0(mp_captouch_refresh_events_obj,
                                  mp_captouch_refresh_events);
 
+STATIC mp_obj_t mp_captouch_calibration_get_trusted(void) {
+    return mp_obj_new_bool(calib_trusted);
+}
+STATIC MP_DEFINE_CONST_FUN_OBJ_0(mp_captouch_calibration_get_trusted_obj,
+                                 mp_captouch_calibration_get_trusted);
+
+STATIC mp_obj_t mp_captouch_calibration_set_trusted(mp_obj_t mp_data) {
+    calib_trusted = mp_obj_is_true(mp_data);
+    return mp_const_none;
+}
+STATIC MP_DEFINE_CONST_FUN_OBJ_1(mp_captouch_calibration_set_trusted_obj,
+                                 mp_captouch_calibration_set_trusted);
+
+STATIC mp_obj_t mp_captouch_calibration_active(void) {
+    return mp_obj_new_int(flow3r_bsp_captouch_calibrating());
+}
+STATIC MP_DEFINE_CONST_FUN_OBJ_0(mp_captouch_calibration_active_obj,
+                                 mp_captouch_calibration_active);
+
+STATIC mp_obj_t mp_captouch_calibration_request(void) {
+    flow3r_bsp_captouch_calibration_request();
+    return mp_const_none;
+}
+STATIC MP_DEFINE_CONST_FUN_OBJ_0(mp_captouch_calibration_request_obj,
+                                 mp_captouch_calibration_request);
+
+static int get_calibration_index(int petal, int num_dimensions,
+                                 int dimension_index) {
+    int index = petal * 6;
+    index += ((num_dimensions + 1) * num_dimensions) / 2;
+    index += dimension_index;
+    index *= 2;
+    if (index >= 120)
+        mp_raise_ValueError(
+            MP_ERROR_TEXT("target index out of range (our fault)"));
+    return index;
+}
+
 STATIC mp_obj_t mp_captouch_calibration_get_data(void) {
-    int32_t data[50];
-    flow3r_bsp_captouch_get_calibration_data(data);
-    mp_obj_t items[50];
-    for (uint8_t i = 0; i < 50; i++) {
-        items[i] = mp_obj_new_int(data[i]);
+    int32_t data[120];
+    if (!flow3r_bsp_captouch_get_calibration_data(data)) return mp_const_none;
+
+    mp_obj_t petal_dicts[10];
+    mp_obj_t items[3];
+    mp_obj_t strings[4] = { mp_obj_new_str("0d", 2), mp_obj_new_str("1d", 2),
+                            mp_obj_new_str("2d", 2),
+                            mp_obj_new_str("petal", 5) };
+
+    for (int petal = 0; petal < 10; petal++) {
+        petal_dicts[petal] =
+            mp_obj_dict_make_new(&mp_type_ordereddict, 0, 0, 0);
+        mp_obj_dict_store(petal_dicts[petal], strings[3],
+                          mp_obj_new_int(petal));
+        for (int ndim = 0; ndim < (3 - (petal & 1)); ndim++) {
+            for (int dim = 0; dim <= ndim; dim++) {
+                int index = get_calibration_index(petal, ndim, dim);
+                char buf[25];  // max len of 32bit int decimal is 11 chars
+                int trunc = snprintf(buf, 25, "%d/%d", (int)data[index],
+                                     (int)data[index + 1]);
+                assert(trunc < 25);
+                items[dim] = mp_obj_new_str(buf, trunc);
+            }
+            mp_obj_dict_store(petal_dicts[petal], strings[ndim],
+                              mp_obj_new_tuple(ndim + 1, items));
+        }
     }
-    return mp_obj_new_tuple(50, items);
+    mp_obj_t ret = mp_obj_dict_make_new(&mp_type_ordereddict, 0, 0, 0);
+    mp_obj_dict_store(ret, mp_obj_new_str("ad7147", 6),
+                      mp_obj_new_tuple(10, petal_dicts));
+    return ret;
 }
 
 STATIC MP_DEFINE_CONST_FUN_OBJ_0(mp_captouch_calibration_get_data_obj,
                                  mp_captouch_calibration_get_data);
 
+static bool parse_calibration_dict(mp_obj_t mp_data, int32_t *data) {
+    if (!mp_obj_is_dict_or_ordereddict(mp_data)) return false;
+    mp_map_elem_t *elem;
+    mp_obj_t len;
+    mp_obj_dict_t *calib_dict = MP_OBJ_TO_PTR(mp_data);
+    elem = mp_map_lookup(&calib_dict->map, mp_obj_new_str("ad7147", 6),
+                         MP_MAP_LOOKUP);
+    if (!elem) return false;
+    mp_obj_t bias_tuple = elem->value;
+    len = mp_obj_len_maybe(bias_tuple);
+    if ((len == MP_OBJ_NULL) || (mp_obj_get_int(len) != 10)) return false;
+    mp_obj_t strings[4] = { mp_obj_new_str("0d", 2), mp_obj_new_str("1d", 2),
+                            mp_obj_new_str("2d", 2),
+                            mp_obj_new_str("petal", 5) };
+
+    uint16_t petals_scanned = 0;
+    for (int index = 0; index < 10; index++) {
+        mp_obj_t petal_obj =
+            mp_obj_subscr(bias_tuple, mp_obj_new_int(index), MP_OBJ_SENTINEL);
+        if (!mp_obj_is_dict_or_ordereddict(petal_obj)) return false;
+        mp_obj_dict_t *petal_dict = MP_OBJ_TO_PTR(petal_obj);
+        elem = mp_map_lookup(&petal_dict->map, strings[3], MP_MAP_LOOKUP);
+        int petal = elem ? mp_obj_get_int(elem->value) : index;
+        if (petal > 9 || petal < 0)
+            mp_raise_ValueError(MP_ERROR_TEXT("petal index out of range"));
+        if (petals_scanned & 1 << petal)
+            mp_raise_ValueError(MP_ERROR_TEXT("petal index duplicate"));
+        petals_scanned |= 1 << petal;
+
+        for (int ndim = 0; ndim < (3 - (petal & 1)); ndim++) {
+            elem =
+                mp_map_lookup(&petal_dict->map, strings[ndim], MP_MAP_LOOKUP);
+            if (!elem) return false;
+            mp_obj_t calib_tuple = elem->value;
+            len = mp_obj_len_maybe(calib_tuple);
+            if ((len == MP_OBJ_NULL) || (mp_obj_get_int(len) != ndim + 1))
+                return false;
+            for (int dim = 0; dim <= ndim; dim++) {
+                int index = get_calibration_index(petal, ndim, dim);
+                mp_obj_t calib_str[2] = { mp_obj_subscr(calib_tuple,
+                                                        mp_obj_new_int(dim),
+                                                        MP_OBJ_SENTINEL),
+                                          mp_obj_new_str("/", 1) };
+                mp_obj_t calib_pt = mp_obj_str_split(2, calib_str);
+                for (int data_index = 0; data_index < 2; data_index++) {
+                    size_t l;
+                    const char *s = mp_obj_str_get_data(
+                        mp_obj_subscr(calib_pt, mp_obj_new_int(data_index),
+                                      MP_OBJ_SENTINEL),
+                        &l);
+                    data[index + data_index] =
+                        mp_obj_get_int(mp_parse_num_integer(s, l, 0, NULL));
+                }
+                if (data[index] > UINT16_MAX || data[index] < 0 ||
+                    data[index + 1] > 126 || data[index + 1] < 0) {
+                    mp_raise_ValueError(
+                        MP_ERROR_TEXT("calibration data out of range"));
+                    return false;  // never reached bc __attribute__((noreturn))
+                                   // but leaving it to make clear it doesn't
+                                   // return true
+                }
+            }
+        }
+    }
+    return true;
+}
+
 STATIC mp_obj_t mp_captouch_calibration_set_data(mp_obj_t mp_data) {
-    int32_t data[50];
-    mp_obj_iter_buf_t iter_buf;
-    mp_obj_t iterable = mp_getiter(mp_data, &iter_buf);
-    mp_obj_t item;
-    uint8_t i = 0;
-    while ((item = mp_iternext(iterable)) != MP_OBJ_STOP_ITERATION) {
-        data[i] = mp_obj_get_int(item);
-        i++;
-        if (i == 50) break;
-    }
-    // if(i != 50) TODO: Throw error? Maybe?
-    flow3r_bsp_captouch_set_calibration_data(data);
-    return mp_const_none;
+    int32_t data[120];
+    if (parse_calibration_dict(mp_data, data)) {
+        flow3r_bsp_captouch_set_calibration_data(data);
+        return mp_const_none;
+    }
+    mp_raise_TypeError(MP_ERROR_TEXT("calibration data malformed"));
 }
 
 STATIC MP_DEFINE_CONST_FUN_OBJ_1(mp_captouch_calibration_set_data_obj,
                                  mp_captouch_calibration_set_data);
 
-STATIC const mp_rom_map_elem_t globals_table[] = {
+STATIC const MP_DEFINE_STR_OBJ(mp_captouch_calibration_path_obj,
+                               "/flash/captouch_calib.json");
+
+STATIC const mp_rom_map_elem_t sys_globals_table[] = {
     { MP_ROM_QSTR(MP_QSTR_read), MP_ROM_PTR(&mp_captouch_read_obj) },
     { MP_ROM_QSTR(MP_QSTR_refresh_events),
       MP_ROM_PTR(&mp_captouch_refresh_events_obj) },
-    { MP_ROM_QSTR(MP_QSTR_calibration_active),
-      MP_ROM_PTR(&mp_captouch_calibration_active_obj) },
+    { MP_ROM_QSTR(MP_QSTR_calibration_path),
+      MP_ROM_PTR(&mp_captouch_calibration_path_obj) },
     { MP_ROM_QSTR(MP_QSTR_calibration_request),
       MP_ROM_PTR(&mp_captouch_calibration_request_obj) },
-    { MP_ROM_QSTR(MP_QSTR_calibration_get_data),
-      MP_ROM_PTR(&mp_captouch_calibration_get_data_obj) },
+    { MP_ROM_QSTR(MP_QSTR_calibration_active),
+      MP_ROM_PTR(&mp_captouch_calibration_active_obj) },
+    { MP_ROM_QSTR(MP_QSTR_calibration_set_trusted),
+      MP_ROM_PTR(&mp_captouch_calibration_set_trusted_obj) },
+    { MP_ROM_QSTR(MP_QSTR_calibration_get_trusted),
+      MP_ROM_PTR(&mp_captouch_calibration_get_trusted_obj) },
     { MP_ROM_QSTR(MP_QSTR_calibration_set_data),
       MP_ROM_PTR(&mp_captouch_calibration_set_data_obj) },
+    { MP_ROM_QSTR(MP_QSTR_calibration_get_data),
+      MP_ROM_PTR(&mp_captouch_calibration_get_data_obj) },
+};
+
+STATIC MP_DEFINE_CONST_DICT(sys_globals, sys_globals_table);
+
+const mp_obj_module_t mp_module_sys_captouch_user_cmodule = {
+    .base = { &mp_type_module },
+    .globals = (mp_obj_dict_t *)&sys_globals,
+};
+
+MP_REGISTER_MODULE(MP_QSTR_sys_captouch, mp_module_sys_captouch_user_cmodule);
+
+typedef struct {
+    mp_obj_base_t base;
+    int8_t index;
+    int8_t mode;
+    int8_t logging;
+} captouch_petal_config_obj_t;
+
+typedef struct {
+    mp_obj_base_t base;
+    mp_obj_t petals;
+} captouch_config_obj_t;
+
+static int _limit_petal_mode(int mode, int petal) {
+    int limit = 3 - (petal & 1);
+    return mode > limit ? limit : (mode < 0 ? 0 : mode);
+}
+
+STATIC void captouch_petal_config_attr(mp_obj_t self_in, qstr attr,
+                                       mp_obj_t *dest) {
+    captouch_petal_config_obj_t *self = MP_OBJ_TO_PTR(self_in);
+    if (dest[0] != MP_OBJ_NULL) {
+        if (attr == MP_QSTR_mode) {
+            self->mode =
+                _limit_petal_mode(mp_obj_get_int(dest[1]), self->index);
+            dest[0] = MP_OBJ_NULL;
+        } else if (attr == MP_QSTR_logging) {
+            self->logging = mp_obj_is_true(dest[1]);
+            dest[0] = MP_OBJ_NULL;
+        }
+    } else {
+        if (attr == MP_QSTR_mode) {
+            dest[0] = mp_obj_new_int(self->mode);
+        } else if (attr == MP_QSTR_logging) {
+            dest[0] = self->logging ? mp_const_true : mp_const_false;
+        } else {
+            dest[1] = MP_OBJ_SENTINEL;
+        }
+    }
+}
+
+STATIC mp_obj_t captouch_petal_config_set_default(mp_obj_t self_in) {
+    captouch_petal_config_obj_t *self = MP_OBJ_TO_PTR(self_in);
+    self->mode = _limit_petal_mode(3, self->index);
+    self->logging = false;
+    return mp_const_none;
+}
+MP_DEFINE_CONST_FUN_OBJ_1(captouch_petal_config_set_default_obj,
+                          captouch_petal_config_set_default);
+
+STATIC mp_obj_t captouch_petal_config_clear(mp_obj_t self_in) {
+    captouch_petal_config_obj_t *self = MP_OBJ_TO_PTR(self_in);
+    self->mode = 0;
+    self->logging = false;
+    return mp_const_none;
+}
+MP_DEFINE_CONST_FUN_OBJ_1(captouch_petal_config_clear_obj,
+                          captouch_petal_config_clear);
+
+STATIC mp_obj_t captouch_petal_config_set_min_mode(mp_obj_t self_in,
+                                                   mp_obj_t mp_mode) {
+    captouch_petal_config_obj_t *self = MP_OBJ_TO_PTR(self_in);
+    int mode = _limit_petal_mode(mp_obj_get_int(mp_mode), self->index);
+    self->mode = mode > self->mode ? mode : self->mode;
+    return mp_const_none;
+}
+MP_DEFINE_CONST_FUN_OBJ_2(captouch_petal_config_set_min_mode_obj,
+                          captouch_petal_config_set_min_mode);
+
+STATIC const mp_rom_map_elem_t captouch_petal_config_locals_dict_table[] = {
+    { MP_ROM_QSTR(MP_QSTR_set_default),
+      MP_ROM_PTR(&captouch_petal_config_set_default_obj) },
+    { MP_ROM_QSTR(MP_QSTR_clear),
+      MP_ROM_PTR(&captouch_petal_config_clear_obj) },
+    { MP_ROM_QSTR(MP_QSTR_set_min_mode),
+      MP_ROM_PTR(&captouch_petal_config_set_min_mode_obj) },
+};
+
+STATIC MP_DEFINE_CONST_DICT(captouch_petal_config_locals_dict,
+                            captouch_petal_config_locals_dict_table);
+
+MP_DEFINE_CONST_OBJ_TYPE(mp_type_captouch_petal_config, MP_QSTR_PetalConfig,
+                         MP_TYPE_FLAG_NONE, locals_dict,
+                         &captouch_petal_config_locals_dict, attr,
+                         captouch_petal_config_attr);
+
+static mp_obj_t captouch_config_new(int mode, mp_obj_type_t *type) {
+    captouch_config_obj_t *self = m_new_obj(captouch_config_obj_t);
+    self->base.type = type;
+    mp_obj_t petals[10];
+
+    flow3r_bsp_captouch_petal_mode_t mode_data[10];
+    if (mode > 1) flow3r_bsp_captouch_get_petal_modes(mode_data);
+    for (int petal = 0; petal < 10; petal++) {
+        captouch_petal_config_obj_t *petalconf =
+            m_new_obj(captouch_petal_config_obj_t);
+        petalconf->base.type = &mp_type_captouch_petal_config;
+        petalconf->index = petal;
+        if (mode > 1) {
+            petalconf->logging = petal_log_active[petal];
+            petalconf->mode = mode_data[petal];
+        } else {
+            petalconf->logging = false;
+            petalconf->mode = mode ? 3 - (petal & 1) : 0;
+        }
+        petals[petal] = MP_OBJ_FROM_PTR(petalconf);
+    }
+    self->petals = mp_obj_new_list(10, petals);
+    return MP_OBJ_FROM_PTR(self);
+}
+
+STATIC mp_obj_t captouch_config_empty(mp_obj_t type_in) {
+    return captouch_config_new(0, MP_OBJ_TO_PTR(type_in));
+}
+STATIC MP_DEFINE_CONST_FUN_OBJ_1(captouch_config_empty_fun_obj,
+                                 captouch_config_empty);
+STATIC MP_DEFINE_CONST_CLASSMETHOD_OBJ(
+    captouch_config_empty_obj, MP_ROM_PTR(&captouch_config_empty_fun_obj));
+
+STATIC mp_obj_t captouch_config_default(mp_obj_t type_in) {
+    return captouch_config_new(1, MP_OBJ_TO_PTR(type_in));
+}
+STATIC MP_DEFINE_CONST_FUN_OBJ_1(captouch_config_default_fun_obj,
+                                 captouch_config_default);
+STATIC MP_DEFINE_CONST_CLASSMETHOD_OBJ(
+    captouch_config_default_obj, MP_ROM_PTR(&captouch_config_default_fun_obj));
+
+STATIC mp_obj_t captouch_config_current(mp_obj_t type_in) {
+    return captouch_config_new(2, MP_OBJ_TO_PTR(type_in));
+}
+STATIC MP_DEFINE_CONST_FUN_OBJ_1(captouch_config_current_fun_obj,
+                                 captouch_config_current);
+STATIC MP_DEFINE_CONST_CLASSMETHOD_OBJ(
+    captouch_config_current_obj, MP_ROM_PTR(&captouch_config_current_fun_obj));
+
+STATIC void captouch_config_attr(mp_obj_t self_in, qstr attr, mp_obj_t *dest) {
+    if (dest[0] != MP_OBJ_NULL) {
+        return;
+    }
+    if (attr == MP_QSTR_petals) {
+        captouch_config_obj_t *self = MP_OBJ_TO_PTR(self_in);
+        dest[0] = self->petals;
+    } else {
+        dest[1] = MP_OBJ_SENTINEL;
+    }
+}
+
+static void captouch_config_verify(captouch_config_obj_t *self) {
+    for (int petal = 0; petal < 10; petal++) {
+        mp_obj_t petalconf_obj =
+            mp_obj_subscr(self->petals, mp_obj_new_int(petal), MP_OBJ_SENTINEL);
+        if (!mp_obj_is_type(petalconf_obj, &mp_type_captouch_petal_config))
+            mp_raise_TypeError(MP_ERROR_TEXT("petal data malformed"));
+    }
+}
+
+STATIC mp_obj_t captouch_config_apply(mp_obj_t self_in) {
+    _initialize_log();
+    captouch_config_obj_t *self = MP_OBJ_TO_PTR(self_in);
+    captouch_config_verify(self);
+    flow3r_bsp_captouch_petal_mode_t mode_data[10];
+    bool logging_data[10];
+    for (int petal = 0; petal < 10; petal++) {
+        captouch_petal_config_obj_t *petalconf = MP_OBJ_TO_PTR(mp_obj_subscr(
+            self->petals, mp_obj_new_int(petal), MP_OBJ_SENTINEL));
+        mode_data[petal] = _limit_petal_mode(petalconf->mode, petal);
+        logging_data[petal] = petalconf->logging;
+    }
+    flow3r_bsp_captouch_set_petal_modes(mode_data);
+    memcpy(petal_log_active, logging_data, sizeof(petal_log_active));
+    return mp_const_none;
+}
+MP_DEFINE_CONST_FUN_OBJ_1(captouch_config_apply_obj, captouch_config_apply);
+
+STATIC mp_obj_t captouch_config_apply_default(mp_obj_t self_in) {
+    flow3r_bsp_captouch_petal_mode_t mode_data[10];
+    bool logging_data[10];
+    for (int petal = 0; petal < 10; petal++) {
+        mode_data[petal] = _limit_petal_mode(3, petal);
+        logging_data[petal] = false;
+    }
+    flow3r_bsp_captouch_set_petal_modes(mode_data);
+    memcpy(petal_log_active, logging_data, sizeof(petal_log_active));
+    return mp_const_none;
+}
+MP_DEFINE_CONST_FUN_OBJ_1(captouch_config_apply_default_obj,
+                          captouch_config_apply_default);
+
+// dirty hack: struct not exposed, internal to vendor/py/objcomplex.c but if we
+// wanna write to ROM this is probably for the better
+typedef struct {
+    mp_obj_base_t base;
+    mp_float_t real;
+    mp_float_t imag;
+} mp_obj_complex_theft;
+
+STATIC const mp_obj_complex_theft mp_const_petal_angle[10] = {
+    { { &mp_type_complex }, (mp_float_t)0., (mp_float_t)-1. },
+    { { &mp_type_complex },
+      (mp_float_t)0.5877852522924731,
+      (mp_float_t)-0.8090169943749475 },
+    { { &mp_type_complex },
+      (mp_float_t)0.9510565162951535,
+      (mp_float_t)-0.3090169943749474 },
+    { { &mp_type_complex },
+      (mp_float_t)0.9510565162951535,
+      (mp_float_t)0.3090169943749474 },
+    { { &mp_type_complex },
+      (mp_float_t)0.5877852522924731,
+      (mp_float_t)0.8090169943749475 },
+    { { &mp_type_complex }, (mp_float_t)0., (mp_float_t)1. },
+    { { &mp_type_complex },
+      (mp_float_t)-0.5877852522924731,
+      (mp_float_t)0.8090169943749475 },
+    { { &mp_type_complex },
+      (mp_float_t)-0.9510565162951535,
+      (mp_float_t)0.3090169943749474 },
+    { { &mp_type_complex },
+      (mp_float_t)-0.9510565162951535,
+      (mp_float_t)-0.3090169943749474 },
+    { { &mp_type_complex },
+      (mp_float_t)-0.5877852522924731,
+      (mp_float_t)-0.8090169943749475 },
+};
+
+STATIC const mp_rom_obj_tuple_t mp_petal_angles_tuple_obj = {
+    { &mp_type_tuple },
+    10,
+    {
+        MP_ROM_PTR(&mp_const_petal_angle[0]),
+        MP_ROM_PTR(&mp_const_petal_angle[1]),
+        MP_ROM_PTR(&mp_const_petal_angle[2]),
+        MP_ROM_PTR(&mp_const_petal_angle[3]),
+        MP_ROM_PTR(&mp_const_petal_angle[4]),
+        MP_ROM_PTR(&mp_const_petal_angle[5]),
+        MP_ROM_PTR(&mp_const_petal_angle[6]),
+        MP_ROM_PTR(&mp_const_petal_angle[7]),
+        MP_ROM_PTR(&mp_const_petal_angle[8]),
+        MP_ROM_PTR(&mp_const_petal_angle[9]),
+    },
+};
+
+STATIC const mp_rom_map_elem_t captouch_config_locals_dict_table[] = {
+    { MP_ROM_QSTR(MP_QSTR_empty), MP_ROM_PTR(&captouch_config_empty_obj) },
+    { MP_ROM_QSTR(MP_QSTR_default), MP_ROM_PTR(&captouch_config_default_obj) },
+    { MP_ROM_QSTR(MP_QSTR_current), MP_ROM_PTR(&captouch_config_current_obj) },
+    { MP_ROM_QSTR(MP_QSTR_apply), MP_ROM_PTR(&captouch_config_apply_obj) },
+    { MP_ROM_QSTR(MP_QSTR_apply_default),
+      MP_ROM_PTR(&captouch_config_apply_default_obj) },
+};
+
+STATIC MP_DEFINE_CONST_DICT(captouch_config_locals_dict,
+                            captouch_config_locals_dict_table);
+
+MP_DEFINE_CONST_OBJ_TYPE(mp_type_captouch_config, MP_QSTR_Config,
+                         MP_TYPE_FLAG_NONE, locals_dict,
+                         &captouch_config_locals_dict, attr,
+                         captouch_config_attr);
+
+typedef struct {
+    mp_obj_base_t base;
+    mp_obj_t frames;
+    int max_len;
+} captouch_petal_log_obj_t;
+
+#define PREALLOC_LIMIT 128
+
+static inline mp_int_t ticks_diff_inner(mp_uint_t end, mp_uint_t start) {
+    return (((end - start + MICROPY_PY_UTIME_TICKS_PERIOD / 2) &
+             (MICROPY_PY_UTIME_TICKS_PERIOD - 1)) -
+            MICROPY_PY_UTIME_TICKS_PERIOD / 2);
+}
+
+static int list_multidel_nodealloc(mp_obj_list_t *list, int num_del,
+                                   bool from_newest) {
+    if (num_del <= 0) return 0;
+    num_del = num_del > list->len ? list->len : num_del;
+    list->len -= num_del;
+    if (!from_newest)
+        memmove(list->items, list->items + num_del,
+                (list->len) * sizeof(mp_obj_t));
+    for (int i = 0; i < num_del; i++) {
+        list->items[list->len + i] = MP_OBJ_NULL;
+    }
+    mp_seq_clear(list->items, list->len + 1, list->alloc, sizeof(*list->items));
+    return num_del;
+}
+
+static mp_captouch_petal_log_frame_t *mp_obj_to_frame_ptr(mp_obj_t frame_obj) {
+    if (!mp_obj_is_type(frame_obj, &mp_type_captouch_petal_log_frame)) {
+        mp_raise_TypeError(MP_ERROR_TEXT("frame must be PetalLogFrame"));
+    }
+    return MP_OBJ_TO_PTR(frame_obj);
+}
+
+static void log_crop(mp_obj_list_t *list, int min_frames, float min_time_ms,
+                     bool from_newest) {
+    int len = list->len;
+    if (!len) return;
+    min_frames = min_frames < 0 ? 0 : min_frames;
+
+    if (min_time_ms > 0.) {
+        mp_uint_t min_time_us = min_time_ms * 1000;
+        mp_captouch_petal_log_frame_t *frame =
+            mp_obj_to_frame_ptr(list->items[from_newest ? len - 1 : 0]);
+        mp_uint_t ref_timestamp = frame->data.timestamp;
+
+        for (int i = min_frames + 1; i < len; i++) {
+            int index = from_newest ? len - 1 - i : i;
+            frame = mp_obj_to_frame_ptr(list->items[index]);
+            mp_uint_t start =
+                from_newest ? frame->data.timestamp : ref_timestamp;
+            mp_uint_t end = from_newest ? ref_timestamp : frame->data.timestamp;
+            if (ticks_diff_inner(end, start) >= min_time_us) {
+                min_frames = i - 1;
+                break;
+            }
+        }
+    }
+    list_multidel_nodealloc(list, min_frames, from_newest);
+}
+
+static inline void get_slice_index(mp_obj_t mp_index, int len, int *ret) {
+    if (mp_index == mp_const_none) return;
+    int index = mp_obj_get_int(mp_index);
+    if (index < 0) index += len;
+    if (index < 0) {
+        index = 0;
+    } else if (index > len) {
+        index = len;
+    }
+    *ret = index;
+}
+
+STATIC mp_obj_t captouch_petal_log_average(size_t n_args,
+                                           const mp_obj_t *args) {
+    captouch_petal_log_obj_t *self = MP_OBJ_TO_PTR(args[0]);
+    mp_obj_list_t *frames = MP_OBJ_TO_PTR(self->frames);
+    int start = 0;
+    if (n_args > 1) get_slice_index(args[1], frames->len, &start);
+    int stop = frames->len;
+    if (n_args > 2) get_slice_index(args[2], frames->len, &stop);
+
+    int len = stop - start;
+    if (len < 1) return mp_const_none;
+
+    float sum_real = 0;
+    float sum_imag = 0;
+    for (int i = start; i < stop; i++) {
+        mp_captouch_petal_log_frame_t *frame =
+            mp_obj_to_frame_ptr(frames->items[i]);
+        log_frame_data_t *data = &frame->data;
+        log_frame_process_data(data);
+        sum_real += data->rad.post;
+        sum_imag += data->phi.post;
+    }
+    return mp_obj_new_complex(sum_real / len, sum_imag / len);
+}
+MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(captouch_petal_log_average_obj, 1, 3,
+                                    captouch_petal_log_average);
+
+STATIC mp_obj_t captouch_petal_log_slope_per_ms(size_t n_args,
+                                                const mp_obj_t *args) {
+    captouch_petal_log_obj_t *self = MP_OBJ_TO_PTR(args[0]);
+    mp_obj_list_t *frames = MP_OBJ_TO_PTR(self->frames);
+    int start = 0;
+    if (n_args > 1) get_slice_index(args[1], frames->len, &start);
+    int stop = frames->len;
+    if (n_args > 2) get_slice_index(args[2], frames->len, &stop);
+
+    int len = stop - start;
+    if (len < 2) return mp_const_none;
+
+    mp_uint_t timestamp_start;
+    float sum_real = 0;
+    float sum_imag = 0;
+    float sum_t_real = 0;
+    float sum_t_imag = 0;
+    float sum_t_sq = 0;
+    float sum_t = 0;
+    for (int i = 0; i < len; i++) {
+        mp_captouch_petal_log_frame_t *frame =
+            mp_obj_to_frame_ptr(frames->items[i + start]);
+        log_frame_data_t *data = &frame->data;
+        log_frame_process_data(data);
+        sum_real += data->rad.post;
+        sum_imag += data->phi.post;
+        if (!i) {
+            timestamp_start = data->timestamp;
+            continue;
+        } else {
+            float timestamp =
+                ticks_diff_inner(data->timestamp, timestamp_start);
+            sum_t_real += data->rad.post * timestamp;
+            sum_t_imag += data->phi.post * timestamp;
+            sum_t_sq += timestamp * timestamp;
+            sum_t += timestamp;
+        }
+    }
+    float div = len * sum_t_sq - sum_t * sum_t;
+    if (!div) return mp_const_none;
+    float k_real = (len * sum_t_real - sum_real * sum_t) * 1000 / div;
+    float k_imag = (len * sum_t_imag - sum_imag * sum_t) * 1000 / div;
+    return mp_obj_new_complex(k_real, k_imag);
+}
+MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(captouch_petal_log_slope_per_ms_obj, 1, 3,
+                                    captouch_petal_log_slope_per_ms);
+
+STATIC mp_obj_t captouch_petal_log_length(mp_obj_t self_in) {
+    captouch_petal_log_obj_t *self = MP_OBJ_TO_PTR(self_in);
+    mp_obj_list_t *frames = MP_OBJ_TO_PTR(self->frames);
+    return mp_obj_new_int(frames->len);
+}
+MP_DEFINE_CONST_FUN_OBJ_1(captouch_petal_log_length_obj,
+                          captouch_petal_log_length);
+
+STATIC mp_obj_t captouch_petal_log_length_ms(size_t n_args,
+                                             const mp_obj_t *args) {
+    captouch_petal_log_obj_t *self = MP_OBJ_TO_PTR(args[0]);
+    mp_obj_list_t *frames = MP_OBJ_TO_PTR(self->frames);
+    int start = 0;
+    if (n_args > 1) get_slice_index(args[1], frames->len, &start);
+    int stop = frames->len;
+    if (n_args > 2) get_slice_index(args[2], frames->len, &stop);
+    if (stop - start < 2) return mp_obj_new_float(0.);
+    mp_captouch_petal_log_frame_t *oldest =
+        mp_obj_to_frame_ptr(frames->items[start]);
+    mp_captouch_petal_log_frame_t *newest =
+        mp_obj_to_frame_ptr(frames->items[stop - 1]);
+    float ret =
+        ticks_diff_inner(newest->data.timestamp, oldest->data.timestamp);
+    return mp_obj_new_float(ret / 1000);
+}
+MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(captouch_petal_log_length_ms_obj, 1, 3,
+                                    captouch_petal_log_length_ms);
+
+STATIC mp_obj_t captouch_petal_log_index_offset_ms(mp_obj_t self_in,
+                                                   mp_obj_t mp_index,
+                                                   mp_obj_t mp_min_offset_ms) {
+    captouch_petal_log_obj_t *self = MP_OBJ_TO_PTR(self_in);
+    mp_obj_list_t *frames = MP_OBJ_TO_PTR(self->frames);
+    int ref_index = mp_obj_get_int(mp_index);
+    if (ref_index < 0) ref_index += frames->len;
+    if (ref_index >= frames->len || ref_index < 0)
+        mp_raise_msg(&mp_type_IndexError, MP_ERROR_TEXT("index out of range"));
+    float min_offset_ms = mp_obj_get_float(mp_min_offset_ms);
+    mp_uint_t ref_timestamp =
+        mp_obj_to_frame_ptr(frames->items[ref_index])->data.timestamp;
+
+    if (!min_offset_ms) return mp_index;
+    if (min_offset_ms > 0) {
+        mp_int_t min_offset_us = min_offset_ms * 1000.;
+        // TODO: Make a smarter search that estimates based on length_ms/length
+        // and then expands linearily in whichever direction
+        for (int test_index = ref_index + 1; test_index < frames->len;
+             test_index++) {
+            mp_uint_t test_timestamp =
+                mp_obj_to_frame_ptr(frames->items[test_index])->data.timestamp;
+            if (ticks_diff_inner(test_timestamp, ref_timestamp) > min_offset_us)
+                return mp_obj_new_int(test_index);
+        }
+    } else {
+        mp_int_t min_offset_us = -min_offset_ms * 1000.;
+        for (int test_index = ref_index - 1; test_index >= 0; test_index--) {
+            mp_uint_t test_timestamp =
+                mp_obj_to_frame_ptr(frames->items[test_index])->data.timestamp;
+            if (ticks_diff_inner(ref_timestamp, test_timestamp) > min_offset_us)
+                return mp_obj_new_int(test_index);
+        }
+    }
+    return mp_const_none;
+}
+MP_DEFINE_CONST_FUN_OBJ_3(captouch_petal_log_index_offset_ms_obj,
+                          captouch_petal_log_index_offset_ms);
+
+STATIC mp_obj_t captouch_petal_log_crop(mp_obj_t self_in, mp_obj_t mp_index) {
+    if (mp_index == mp_const_none) return mp_obj_new_int(0);
+    captouch_petal_log_obj_t *self = MP_OBJ_TO_PTR(self_in);
+    mp_obj_list_t *frames = MP_OBJ_TO_PTR(self->frames);
+    int num_del = mp_obj_get_int(mp_index);
+    if (num_del < 0) num_del += frames->len;
+    num_del = list_multidel_nodealloc(frames, num_del, false);
+    return mp_obj_new_int(num_del);
+}
+MP_DEFINE_CONST_FUN_OBJ_2(captouch_petal_log_crop_obj, captouch_petal_log_crop);
+
+STATIC mp_obj_t captouch_petal_log_clear(mp_obj_t self_in) {
+    captouch_petal_log_obj_t *self = MP_OBJ_TO_PTR(self_in);
+    mp_obj_list_t *frames = MP_OBJ_TO_PTR(self->frames);
+    frames->len = 0;
+    frames->items =
+        m_renew(mp_obj_t, frames->items, frames->alloc, LIST_MIN_ALLOC);
+    frames->alloc = LIST_MIN_ALLOC;
+    mp_seq_clear(frames->items, 0, frames->alloc, sizeof(*frames->items));
+    return mp_const_none;
+}
+STATIC MP_DEFINE_CONST_FUN_OBJ_1(captouch_petal_log_clear_obj,
+                                 captouch_petal_log_clear);
+
+STATIC mp_obj_t captouch_petal_log_append(mp_obj_t self_in,
+                                          mp_obj_t frame_obj) {
+    if (!mp_obj_is_type(frame_obj, &mp_type_captouch_petal_log_frame)) {
+        mp_raise_TypeError(MP_ERROR_TEXT("frame must be PetalLogFrame"));
+    }
+    captouch_petal_log_obj_t *self = MP_OBJ_TO_PTR(self_in);
+    mp_obj_list_t *frames = MP_OBJ_TO_PTR(self->frames);
+    if (frames->len >= frames->alloc) {
+        int new_alloc = frames->alloc * 2;
+        if (self->max_len > new_alloc) new_alloc = self->max_len;
+        frames->items =
+            m_renew(mp_obj_t, frames->items, frames->alloc, new_alloc);
+        frames->alloc = new_alloc;
+        mp_seq_clear(frames->items, frames->len + 1, frames->alloc,
+                     sizeof(*frames->items));
+    }
+    frames->items[frames->len++] = frame_obj;
+    if (frames->len > self->max_len) {
+        self->max_len =
+            frames->len > (PREALLOC_LIMIT) ? (PREALLOC_LIMIT) : frames->len;
+    }
+    return mp_const_none;
+}
+STATIC MP_DEFINE_CONST_FUN_OBJ_2(captouch_petal_log_append_obj,
+                                 captouch_petal_log_append);
+
+STATIC void captouch_petal_log_attr(mp_obj_t self_in, qstr attr,
+                                    mp_obj_t *dest) {
+    captouch_petal_log_obj_t *self = MP_OBJ_TO_PTR(self_in);
+    if (dest[0] != MP_OBJ_NULL) {
+        if (attr == MP_QSTR_frames) {
+            if (!mp_obj_is_type(dest[1], &mp_type_list))
+                mp_raise_TypeError(MP_ERROR_TEXT("frames must be a list"));
+            self->frames = dest[1];
+            dest[0] = MP_OBJ_NULL;
+        }
+    } else {
+        if (attr == MP_QSTR_frames) {
+            dest[0] = self->frames;
+        } else {
+            dest[1] = MP_OBJ_SENTINEL;
+        }
+    }
+}
+
+STATIC mp_obj_t captouch_petal_log_make_new(const mp_obj_type_t *type_in,
+                                            size_t n_args, size_t n_kw,
+                                            const mp_obj_t *args) {
+    mp_arg_check_num(n_args, n_kw, 0, 1, false);
+    captouch_petal_log_obj_t *self = m_new_obj(captouch_petal_log_obj_t);
+    self->base.type = type_in;
+    if (n_args) {
+        if (!mp_obj_is_type(args[0], &mp_type_list))
+            mp_raise_TypeError(MP_ERROR_TEXT("frames must be a list"));
+        self->frames = args[0];
+        mp_obj_list_t *list = MP_OBJ_TO_PTR(self->frames);
+        self->max_len = list->len;
+    } else {
+        self->frames = mp_obj_new_list(0, NULL);
+        self->max_len = LIST_MIN_ALLOC;
+    }
+    return MP_OBJ_FROM_PTR(self);
+}
+
+STATIC const mp_rom_map_elem_t captouch_petal_log_locals_dict_table[] = {
+    { MP_ROM_QSTR(MP_QSTR_average),
+      MP_ROM_PTR(&captouch_petal_log_average_obj) },
+    { MP_ROM_QSTR(MP_QSTR_slope_per_ms),
+      MP_ROM_PTR(&captouch_petal_log_slope_per_ms_obj) },
+    { MP_ROM_QSTR(MP_QSTR_length), MP_ROM_PTR(&captouch_petal_log_length_obj) },
+    { MP_ROM_QSTR(MP_QSTR_length_ms),
+      MP_ROM_PTR(&captouch_petal_log_length_ms_obj) },
+    { MP_ROM_QSTR(MP_QSTR_clear), MP_ROM_PTR(&captouch_petal_log_clear_obj) },
+    { MP_ROM_QSTR(MP_QSTR_crop), MP_ROM_PTR(&captouch_petal_log_crop_obj) },
+    { MP_ROM_QSTR(MP_QSTR_append), MP_ROM_PTR(&captouch_petal_log_append_obj) },
+    { MP_ROM_QSTR(MP_QSTR_index_offset_ms),
+      MP_ROM_PTR(&captouch_petal_log_index_offset_ms_obj) },
+};
+
+STATIC MP_DEFINE_CONST_DICT(captouch_petal_log_locals_dict,
+                            captouch_petal_log_locals_dict_table);
+
+MP_DEFINE_CONST_OBJ_TYPE(mp_type_captouch_petal_log, MP_QSTR_PetalLog,
+                         MP_TYPE_FLAG_NONE, locals_dict,
+                         &captouch_petal_log_locals_dict, attr,
+                         captouch_petal_log_attr, make_new,
+                         captouch_petal_log_make_new);
+
+STATIC const mp_rom_map_elem_t globals_table[] = {
+    { MP_ROM_QSTR(MP_QSTR_Config), MP_ROM_PTR(&mp_type_captouch_config) },
+    { MP_ROM_QSTR(MP_QSTR_PetalLog), MP_ROM_PTR(&mp_type_captouch_petal_log) },
+    { MP_ROM_QSTR(MP_QSTR_PetalLogFrame),
+      MP_ROM_PTR(&mp_type_captouch_petal_log_frame) },
+    { MP_ROM_QSTR(MP_QSTR_PETAL_ANGLES),
+      MP_ROM_PTR(&mp_petal_angles_tuple_obj) },
+#ifndef FLOW3R_V2
+    { MP_ROM_QSTR(MP_QSTR_read), MP_ROM_PTR(&mp_captouch_read_obj) },
+#endif
 };
 
 STATIC MP_DEFINE_CONST_DICT(globals, globals_table);
diff --git a/docs/api/assets/captouch_petals.png b/docs/api/assets/captouch_petals.png
new file mode 100644
index 0000000000000000000000000000000000000000..615d765b75bf24bb86203bde0253aa447c00b332
Binary files /dev/null and b/docs/api/assets/captouch_petals.png differ
diff --git a/docs/api/captouch.rst b/docs/api/captouch.rst
index a93385143711944407f56269914dee3a6255e3e4..61cd39a738517322ff2660e4a427501f1549c1d5 100644
--- a/docs/api/captouch.rst
+++ b/docs/api/captouch.rst
@@ -1,7 +1,526 @@
 ``captouch`` module
 ===================
 
-.. automodule:: captouch
-   :members:
-   :undoc-members:
+Basic usage
+-----------
 
+In a flow3r application you receive a ``CaptouchState`` object in each ``think()`` cycle. Here's a simple example:
+
+.. code-block:: python
+
+    class App(Application):
+        def think(self, ins, delta_ms):
+            petal_0_is_pressed = ins.captouch.petals[0].pressed
+
+You cannot instantiate this object directly, but for REPL experiments there is a workaround listed below.
+
+.. py:class:: CaptouchState
+
+    .. py:attribute:: petals
+        :type: List[CaptouchPetalState]
+
+        State of individual petals.
+
+        Contains 10 elements, with the zeroeth element being the petal closest to
+        the USB port. Then, every other petal in a clockwise direction.
+
+        Even indices are top petals, odd indices are bottom petals.
+
+        The top petal indices are printed in roman numerals around the flow3r display,
+        with "X" corresponding to 0.
+
+    .. py:attribute:: ticks_us
+        :type: int
+
+        Timestamp of when the captouch data has been requested from the backend, i.e. when
+        the ``think()`` cycle started. Mostly useful for comparing for to the same attribute
+        of ``PetalLogFrame``. Behaves identical to the return type of ``time.ticks_us()``
+        and should only be used with ``time.ticks_diff()`` to avoid overflow issues. Overflow
+        occurs after ~10min.
+
+
+.. py:class:: CaptouchPetalState
+
+    .. py:attribute:: pressed
+        :type: bool
+
+        True if the petal has been touched during the last ``think()`` cycle.
+
+        May be affected by ``captouch.Config``.
+
+    .. py:attribute:: pos
+        :type: Optional(float)
+
+        Coordinates where this petal is touched or None if the petal isn't
+        touched or positional output is turned off via `captouch.Config``.
+
+        The coordinate system is rotated with the petal's orientation: The
+        real part corresponds to the axis going from the center of the screen
+        to the center of this petal, the imaginary part is perpendicular to
+        that so that it increases with clockwise motion.
+
+        Both real and imaginary part are centered around 0 and scaled to a
+        [-1..1] range. We try to guarantee that the output can span this full
+        range, but it may also go beyond.
+
+        May be affected by ``captouch.Config``.
+
+        See ``captouch.PETAL_ANGLES`` to align the output with the display
+        coordinate system.
+
+    .. py:attribute:: raw_pos
+        :type: float
+
+        Similar to ``.pos``, but never None. Will probably return garbage when
+        petal is not pressed. It is mostly useful for interpolating data between
+        petals.
+
+    .. py:attribute:: raw_cap
+        :type: float
+
+        Returns a the raw capacity reading from the petal in arbitrary units.
+        The value that kind-of-sort-of corresponds to how much the pad
+        is covered. Since the footprint of a finger expands when compressed
+        (what a sentence), this could in theory be roughly used for pressure,
+        but the data quality just doesn't cut it:
+
+        It's mostly okay when not compared against fixed values, but rather
+        some sort of floating average, but it's not really monotonic and also
+        it doesn't react until the finger is a few mm away from the pad so it's
+        kinda bad for proximity sensing too. It's tempting to use it for gating
+        away light touches, but that results in poor performance in some
+        environmental conditions. Test carefully, and best make nothing important
+        depend on it.
+
+        Normalized so that "1" corresponds to the upper hysteresis limit of the
+        ``pressed`` API.
+
+        May be affected by ``captouch.Config``.
+
+
+.. py:data:: PETAL_ANGLES
+   :type: list[complex]
+ 
+   List of constants that can be used to rotate the output of the `.pos` attribute of both `CaptouchPetalState` and
+   `PetalLogFrame` to align with the display (x, y) coordinates.
+
+    .. code-block:: python
+
+        # (in think)
+        for x in range(10):
+            pos = ins.captouch.petals[x].pos
+            pos *= 60 * captouch.PETAL_ANGLES[x]
+            self.display_coords[x] = (pos.real, pos.imag)
+
+        # (in draw)
+        for x in range(10):
+            ctx.move_to(* self.display_coords[x])
+            ctx.text(str(x))
+
+
+
+Speeding things up
+------------------
+
+The flow3r captouch driver is not the fastest, it takes at least 14ms to generate a full dataset with all channels running.
+For applications where speed is key it is possible to merge data channels to reduce scanning time. Each petal can be turned
+off entirely, act only as a button or be a 1D slider (also 2D for top petals). For example, if you turn off all all petals
+except for 2 and 8 for a "dual joystick" mode, you increase your frame rate to up to 2.3ms!
+
+.. code-block:: python
+
+    import captouch
+
+    class App(Application):
+        def __init__(self):
+            self.captouch_config = captouch.Config.empty()
+            # top petals are used as buttons, bottom petals not at all
+            for petal in range(0,10,2):
+                self.captouch_config.petals[petal].mode = 1
+
+        def on_enter(self, vm):
+            self.captouch_config.apply()
+
+.. py:class:: Config
+
+    .. py:method:: empty() -> Config:
+        :classmethod:
+
+        Initializer method that returns a config with everything disabled. Ideal
+        for ORing the requirements of different components together.
+
+    .. py:method:: default() -> Config:
+        :classmethod:
+
+        Initializer method that returns the default config, same as when entering an application.
+
+    .. py:method:: current() -> Config:
+        :classmethod:
+
+        Initializer method that returns the currently active config.
+
+    .. py:method:: apply() -> None:
+        
+        Apply this config to the driver.
+
+    .. py:method:: apply_default() -> None:
+
+        Convenience method to restore defaults. same as ``Config.default().apply()`` but mildly faster
+        if you already have one around.
+
+    .. py:attribute:: petals
+        :type: List[PetalConfig]
+
+        Config of individual petals, indexed as in the ``CaptouchState`` object.
+
+.. py:class:: Config.PetalConfig
+
+    .. py:attribute:: mode
+        :type: int
+
+        What kind of data should be collected for this petal.
+
+        0: No data at all
+
+        1: Button Mode: All pads combined, no positional output
+
+        2: 1D: Only radial position is provided
+
+        3: 2D: Full positional output. Only available for top petals.
+
+        Default: 3 (top petals), 2 (bottom petals)
+
+    .. py:attribute:: logging
+        :type: bool
+
+        Whether or not you want to collect the raw data log. This
+        eats some CPU time proportional to the ``think()`` cycle
+        time, use only when you actually do something with the data.
+
+        Default: False
+
+
+Gestures
+--------
+
+For many common applications we provide widgets that do whatever data processing is needed so you don't have to implement
+everything from scratch, see ``st3m.ui.widgets``. If whatever you want is already in there we recommend using these as
+future performance improvements will then directly benefit your application.
+
+If you do want to do your own signal processing you will probably want to use the logging feature: The positional data is
+fairly imperfect already, missing frames or needing to detect duplicates doesn't make it better, and the general-purpose
+filtering on the "primitive" positional output may be an issue for fast motion detection. Using the unprocessed log
+doesn't make postprocessing easy, but at least you get the best data quality the driver can offer.
+
+In order to use the logging feature effectively we also provide a ``PetalLog`` class that implements fast time-based
+cropping and data processing. This could all be done in raw python too, but e.g. for linear regression the C implementation
+runs around 40 times faster and creates less intermediate objects so that garbage collection triggers less. This is
+particularily important if the captouch driver is configured to run a specific petal very fast.
+
+.. py:class:: PetalLogFrame
+
+    .. py:attribute:: pressed
+        :type: bool
+
+        Identical to ``pressed`` of ``CaptouchPetalState``.
+
+    .. py:attribute:: pos
+        :type: Optional(float)
+
+        Identical to ``pos`` of ``CaptouchPetalState``.
+
+    .. py:attribute:: raw_pos
+        :type: float
+
+        Identical to ``raw_pos`` of ``CaptouchPetalState``.
+
+    .. py:attribute:: raw_cap
+        :type: float
+
+        Identical to ``raw_cap`` of ``CaptouchPetalState``.
+
+    .. py:attribute:: mode
+        :type: int
+
+        Config mode setting that was used for recording the frame (see ``captouch.Config``).
+
+    .. py:attribute:: ticks_us
+        :type: int
+
+        Timestamp that reflects the approximate time at which the data was captured (to
+        be exact, when the I2C transmission has completed). Behaves identical to the return
+        type of ``time.ticks_us()`` and should only be used with ``time.ticks_diff()``
+        to avoid overflow issues. Overflow occurs after ~10min.
+
+
+.. py:class:: PetalLog
+
+    .. py:attribute:: frames
+
+        List of PetalLogFrames. May be manipulated or replaced by user. We use the binary structure of micropython
+        ``list`` as well as ``PetalLogFrame``, so any duck typing may result in ``TypeError`` when the other attributes
+        and methods of this class are used.
+
+    .. py:method:: append(frame: PetalLogFrame):
+    
+        Appends frame to ``.frames``. There's a performance benefit when only modifying ``.frames`` with this method
+        alongside ``.crop()`` and ``.clear()``.
+
+    .. py:method:: crop(index: Optional(int)) -> int
+
+        Crops the oldest elements in ``.frames`` in-place and returns the number of cropped frames. The ``index``
+        parameter behaves slice-like, equivalent to ``.frames = .frames[index:]``, i.e. positive values remove
+        that amount of oldest frames, negative values limit the list at most ``-index`` frames and None does nothing.
+        Typically used together with ``index_offset_ms()`` to keep the length of ``frames`` in check.
+
+    .. py:method:: clear()
+
+        Clears ``.frames``.
+
+    .. py:method:: length() -> int
+
+        Returns ``len(.frames)`` but slightly faster.
+
+    .. py:method:: length_ms(start: Optional(int) = None, stop: Optional(int) = None, /) -> float
+
+        Returns difference in timestamp between newest and oldest frame in milliseconds or 0 if ``.frames`` is empty.
+        The optional ``start`` and ``stop`` parameters delimit which slice of ``.frames`` is used for computation,
+        equivalent to ``.frames[start:stop]``. Negative values behave as expected.
+
+    .. py:method:: index_offset_ms(index: int, min_offset_ms: float, /) -> Optional(int)
+
+        Returns the index of the frame that is at least ``min_offset_ms`` newer (or older for negative ``min_offset_ms``)
+        than the frame at ``index``, or ``None`` if no such frame exists. Negative ``index`` values are allowed and work
+        as expected, e.g. ``index = -1`` indicates the newest frame. Will raise ``IndexError`` if the index is out of range.
+
+    .. py:method:: average(start: Optional(int) = None, stop: Optional(int) = None, /) -> Optional(complex)
+
+        Returns the average position of elements in ``.frames``. Will return ``None`` if no frames are available.
+        The optional ``start`` and ``stop`` parameters delimit which slice of ``.frames`` is used for computation,
+        equivalent to ``.frames[start:stop]``. Negative values behave as expected.
+
+    .. py:method:: slope_per_ms(start: Optional(int) = None, stop: Optional(int) = None, /) -> Optional(complex)
+
+        Returns the ordinary least squares linear regression slope of the position of elements in ``.frames``. Uses
+        timestamp and disregards order of ``.frames``. Will return ``None`` if less than 2 frames are available or all
+        timestamps are equal.
+        The optional ``start`` and ``stop`` parameters delimit which slice of ``.frames`` is used for computation,
+        equivalent to ``.frames[start:stop]``. Negative values behave as expected.
+
+
+The nitty gritty
+----------------
+
+The flow3r captouch setup is not as good as a smartphone touchscreen. While a typical modern touchscreen receives data from
+a fine grid of wire intersections, flow3r just has 2 per bottom pad and 3 per top pad. Here's an illustration:
+
+.. image:: assets/captouch_petals.png
+
+On a grid type touch device you can infer rough position even with rather high noise levels as long as a "high" and "low"
+for each grid point is roughly represented. On a device like flow3r, unfortunately we do not have this luxury. This leads
+to higher noise sensitivity and some other unexpected behaviors that limit how captouch can be used:
+
+**Liftoff artifacts**
+
+In general, the positional output is dependent on pressure, finger size and environmental factors. For example, if you have a USB
+cable connected to the USB-C port and put it in your pants pocket without connecting it to anything, your finger will result in a
+different excitation than another person's finger who touches a different petal. This is not a super pratical scenario, but people
+have observed effects like this if flow3r has been on different surfaces (i.e. tables, couches). We tried our best to suppress
+these side effects in the ``.pressed`` and ``.pos`` outputs, but for example ``.raw_cap`` is heavily affected by it and there's
+little we can do about it.
+
+A more pratical side effect is that if you release a petal, the positional output will momentarily drift. This is bad for swipe
+gesture recognition, as this can easily be misread as a swipe. You might think that the ``.raw_cap`` channel may help suppressing
+this, but since ``.raw_cap`` also changes a lot during motion without liftoff, a trivial algorithm would suppress valid swipes.
+The current implementation of the ``Scroller`` widget does not use ``.raw_cap`` at all since any math we could come up with reasonable
+effort was situational at best, but typically detrimental to the feel.
+
+These liftoff artifacts (or lifton, for the counterpart at the beginning of a gesture) are a nuisance to many widgets in different
+forms. In general, we found it to be the be the best approach to ignore 20ms of the positional data from the beginning and/or end of
+each touch, depending on the use case. This pattern was found to be so common that the ``PetalLog`` class has been in great parts
+designed around facilitating the implementation of such rejection algorithms.
+
+Some users may instinctively use slow liftoff in order to make sure they don't accidentially introduce a motion, erroneously
+attributing these artifacts to their own performance rather than a shortcoming of the hardware. This is unfortunate, as these
+slow liftoffs are much harder to detect (we did some testing with ``.raw_cap`` but found no universally applicable pattern, there
+often is a visible kink in the data but it often occurs later than the artifacts, so if you investigate options like this make
+sure to exclude "red herrings" - we wasted a good few hours that could've been prevented by plotting *all* the data).
+
+The hardware is out there in the world, the best we can do at this point is to accept its performance, explain it to the user and
+then be **consistent** - if fast liftoffs are the most consistent way to work around these issues, we should go for them, even if
+for some they may be counterintuitive.
+
+**Data rates**
+
+As a rule of thumb, all (even) top petals are hooked up to one chip, all (odd) bottom petals to another, except for petal 2,
+which is connected to the "bottom" chip. This means for example that if you disable all bottom petals, petal 2 receives data
+much faster than the other top petals.
+
+Generally, each data point that you collect (i.e., the integer value of ``.mode`` for each petal) takes about 0.75ms, however due
+to a less-than-ideal peripheral protocol we typically run a bit slower than that, expect the full cycle to take 2-3ms on top.
+Higher priority tasks (audio rendering, WiFi) may make this worse. Also, if the bottom chip is fully utilized (13 datapoints, 2 from
+each bottom petal, 3 from petal 2) there is an additonal penalty resulting in a spin time of ~14ms typ.
+
+Since data rates are not constant, ``PetalLog`` doesn't simply assume a constant framerate and converts to indices based
+on that for a good reason, each datapoint may be a random amount of time apart from its predecessor, with only a lower limit given
+by the aforementioned 0.75ms \* (number of channels of the specific chip) rule. Depending on other hardware activity there may be
+drastic differences. Any references to data rates are meant in a stochastic sense - they are best measured in Becquerel, not Hertz.
+
+Making widgets that feel the same-ish with different data rates is difficult. We're trying hard to make the provided widget library
+perform satisfactory at all configurations, and fail at that thus far. But that is only of concern to us as we're trying to provide
+generics: For a specific application, there is no shame in hardcoding a widget to only work as expected for a given data rate. Since
+e.g. the top petals may spin at different rates however, you should take care in setting up the driver so that all petals which should
+have equal behavior actually run at the same number of chip channels. For example, the Violin application, which extracts rubbing motion
+from all top petals, activates bottom petal channels that it does not use in order to make sure that petal 2 runs at the same data rate.
+Feel free to not use the widget auto-configuration at all and create your own for the purpose manually, or modify the autogenerated one
+after it has been created. It is meant as a mere helper, you may find reasons to ignore or enhance it at times.
+
+You might feel tempted to dynamically switch configuration, for example to run petals as buttons very fast and only enable positional
+output once they are touched. This would be great in theory and make many applications a bit snappier, however the chips exhibit strange
+undocumented glitches when configurations are changed in certain ways. Our approach to configuration changes at this point is to try
+to guarantee the validity of all datasets that you receive, but since these glitches often rare and difficult to track down we are
+overshooting and throwing away more data than needed. Changing configuration at this point in time results in 3 datasets being thrown
+away, resulting in a significant (~50ms typ.) gap in the data logs. We may be able to improve on this in some specific transition types,
+(i.e., channel number remains constant), but it is unclear if we will ever find the effort to implement this justifiable.
+
+
+**Miscellaneous quirks**
+
+- The top petals have quite a large deadzone near the outer tip.
+
+- The top petals like to "zig zag" around the center. For 1D value input the bottom petals are plain better.
+
+- The bottom petals are less noisy. To compensate, the top petals use stronger filtering in the non-logged positional output,
+  making them a bit slower.
+
+- Faster spin times do not only affect the log but also the built-in filters on the non-logged outputs, making especially the top
+  petals much more responsive. 
+
+- ``.raw_cap`` is not monotonic with respect to how much of the petal you cover: In fact, if you cover an entire top petal with multiple
+  flat fingers, it fairly consistently outputs lower values compared to the flat of the thumb. The causes for this behavior are unknown.
+
+
+Annex 1: Basics of complex numbers
+----------------------------------
+
+You may have noticed that the positional output is complex-valued. We find that it enables very concise 2D operations,
+but not everyone is familiar with them. If you don't wanna deal with it at all and use linear algebra or the likes
+instead, you can simply convert it to a tuple like so:
+
+.. code-block:: python
+
+    # create complex number with real part 1 and imaginary part 3
+    pos = complex(1, 3)
+    tuple_data = [pos.real, pos.imag]
+
+If you do want to use them directly however, here's some basic operations. Translation and scaling work just as you'd expect:
+
+.. code-block:: python
+
+    # translate by 2 in the real direction and 4 in the imaginary direction
+    offset = complex(2, 4)
+    pos += offset
+
+    # scale both real and imaginary part by 2
+    pos += pos * 2
+
+Complex numbers have an important trick up their sleeve: Multiplication of two complex numbers adds their respective angles together,
+which can be used for rotation. That angle is the angle between the corresponding vector, as illustrated here:
+`wikipedia <https://en.wikipedia.org/wiki/Argument_(complex_analysis)>`_
+
+Of course, this can be used together with scaling in the same operation; the scaling factor is simply the length of the complex number.
+
+We can conveniently create numbers with a given length and angle or extract them from a given number like so:
+
+.. code-block:: python
+
+    import cmath
+
+    # create number with angle of 45 degrees (math.tau / 8 in radians) and length of 2:
+    rotator = 2 * cmath.exp(1j * math.tau / 8)
+    # alternative: programmer's choice
+    rotator = cmath.rect(2, math.tau / 8)
+    # apply the rotation
+    pos *= rotator
+
+    # get angle, in this case math.tau / 8
+    angle = cmath.phase(rotator)
+    # get length, in this case 2
+    length = abs(rotator)
+
+For rotating around a point other than the origin, simply translate and de-translate before and after the rotation:
+
+.. code-block:: python
+
+    pos += offset
+    pos *= rotator
+    pos -= offset
+
+As a practical example, you can set an RGB color like this (a ``Slider`` widget would do a better job and preventing artifacts
+but let's keep things simple):
+
+.. code-block:: python
+
+    # (in think)
+    petal = ins.captouch.petals[0]
+    if petal.pos is not None:
+        hue = cmath.phase(petal.pos)
+        sat = max(1-abs(petal.pos),0)
+        col = st3m.ui.colours.hsv_to_rgb(hue, sat, 1)
+        leds.set_all_rgb(*col)
+        leds.update()
+
+Annex 2: REPL workaround
+------------------------
+
+We've cornered ourselves there a little: some useful features of the captouch driver are synchronized to the ``think()`` cycle, but
+many early applications don't use the ``CaptouchState`` provided but instead create their own via ``captouch.read()`` (legacy, don't do
+this in new applications please). If this function were to trigger a reset in the ``.pressed`` attribute, half of the data would be
+thrown away and it would be easy to miss button presses. ``.log`` would drop frames in similar manner. You could do some sort of lazy
+evaluation of ``think()``'s object but that might just result in more subtle bugs if users aren't careful, we'd rather break loudly :D.
+Instead, the OS uses a special trigger function at the beginning of each ``think()``. To construct a proper ``CaptouchState`` in the
+repl we must call this function manually. Don't ever do it in applications tho, really.
+
+.. code-block:: python
+
+    import sys_captouch # in REPL only, never in applications!
+    sys_captouch.refresh_events() # your app will break in subtle and annoying ways
+    captouch_state = sys_captouch.read()
+
+
+Annex 3: Legacy API
+-------------------
+
+.. py:class:: CaptouchPetalState
+    :no-index:
+
+    .. py:attribute:: position
+        :type: Tuple(int, int)
+        
+        Similar to ``.raw_pos``, but not normalized. First element corresponds to real part, second element to imaginary.
+        For top petals about ``35000 * .raw_pos``, for bottom petals about ``25000 * .raw_pos + 5000`` (note that addition only
+        affects the real part, the imaginary part is always 0 for bottom petals).
+
+    .. py:attribute:: pressure
+        :type: int
+        
+        Similar to ``.raw_cap``, but not normalized. Depending on firmware version roughly about ``8000 * .raw_cap``, but may
+        or may not be always 0 if the petal is not pressed.
+
+
+.. py:function:: read() -> CaptouchState
+
+    Reads current captouch state from hardware and returns a snapshot in time.
+    ``.pressed`` and ``.log`` attributes are broken in the REPL.
+
+    Typically you'd want to use the captouch data provided by ``think()``, so this method for application purposes
+    is replaced with nothing. See workaround for reasoning.
+
+    What if you do *need* the captouch state outside of ``think()`` though? Well, chances are you don't, it just appears convenient:
+    We've seen this pattern a few times where ``think()`` requires a previous state, and the first such previous state is generated by
+    ``__init__()``, but this is an anti-pattern. Instead, set the previous state to ``None`` in ``on_enter()`` and handle that case
+    in ``think()``. The common consequence of doing otherwise is that after exiting and reentering an application the previous state
+    is very stale, which can lead to unintended behavior. Dedicated "first think" functionality really is the way to go in these cases.
+
+    Some example applications that ship with flow3r unfortunately use this pattern, and we should really clean that up, but we
+    didn't have time for this release yet. Apologies, IOU, will totally get around to it soon.
diff --git a/docs/index.rst b/docs/index.rst
index 3ee6a85bd16c203adbcc529ee03bc22b36cb16af..da20a0b8f7c74269a612ffe4a771961b9b3f6afd 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -49,10 +49,10 @@ User manual
 
    api/application.rst
    api/input.rst
+   api/captouch.rst
    api/audio.rst
    api/badgelink.rst
    api/badgenet.rst
-   api/captouch.rst
    api/ctx.rst
    api/leds.rst
    api/colours.rst
diff --git a/python_payload/apps/demo_cap_touch/__init__.py b/python_payload/apps/demo_cap_touch/__init__.py
index b6416de76984f7aa877218631559f1ebb793d751..77d50b4ba68cb049799ce042731dd6e704c22f5d 100644
--- a/python_payload/apps/demo_cap_touch/__init__.py
+++ b/python_payload/apps/demo_cap_touch/__init__.py
@@ -1,131 +1,55 @@
-from st3m import logging
-from st3m.application import Application, ApplicationContext
-from st3m.goose import List, Optional
+from st3m.application import Application
+from st3m.goose import List
 from st3m.input import InputState
 from st3m.ui.view import ViewManager
 from ctx import Context
 
-log = logging.Log(__name__, level=logging.INFO)
-log.info("import")
-
+import captouch
 import cmath
 import math
-import time
-import captouch
 
 
 class Dot:
-    def __init__(self, size: float, imag: float, real: float) -> None:
-        self.size = size
-        self.imag = imag
-        self.real = real
-
-    def draw(self, i: int, ctx: Context) -> None:
-        imag = self.imag
-        real = self.real
-        size = self.size
-
-        col = (1.0, 0.0, 1.0)
-        if i % 2:
-            col = (0.0, 0.8, 0.8)
-        ctx.rgb(*col).rectangle(
-            -int(imag - (size / 2)), -int(real - (size / 2)), size, size
-        ).fill()
+    size = None
+    pos = 0j
+    filled = True
+
+    def draw(self, ctx: Context):
+        if self.size is None or self.pos is None or self.filled is None:
+            return
+        ctx.save()
+        ctx.translate(self.pos.real, self.pos.imag)
+        ctx.rotate(cmath.phase(self.pos))
+        ctx.move_to(-self.size / 2, -self.size / 2)
+        ctx.rel_line_to(self.size, self.size / 2)
+        ctx.rel_line_to(-self.size, self.size / 2)
+        ctx.close_path()
+        ctx.fill() if self.filled else ctx.stroke()
+        ctx.restore()
 
 
 class CapTouchDemo(Application):
-    def __init__(self, app_ctx: ApplicationContext) -> None:
-        super().__init__(app_ctx)
-        self.dots: List[Dot] = []
-        self.last_calib = None
-        self.state = 0
-        self.timer = 0
-        self.button = None
+    def on_enter(self, vm: ViewManager):
+        super().on_enter(vm)
+        self.dots: List[Dot] = [Dot() for x in range(10)]
 
     def think(self, ins: InputState, delta_ms: int) -> None:
         super().think(ins, delta_ms)
-        if self.button is not None:
-            press_event = (self.button != ins.buttons.app) and ins.buttons.app
-        else:
-            press_event = False
-        self.button = int(ins.buttons.app)
-        if press_event:
-            print(self.button)
-        if self.state == 0:
-            if press_event:
-                self.state = 1
-            self.dots = []
-            for i in range(10):
-                petal = ins.captouch.petals[i]
-                (rad, phi) = petal.position
-                size = 4
-                if petal.pressed:
-                    size += 4
-                x = 70 + (rad / 1000) + 0j
-                x += ((-phi) / 600) * 1j
-                rot = cmath.exp(-2j * math.pi * i / 10)
-                x = x * rot
-                self.dots.append(Dot(size, x.imag, x.real))
-        elif self.state == 1:
-            if press_event:
-                if self.button == ins.buttons.PRESSED_DOWN:
-                    self.state = 2
-                    self.timer = 2999
-                else:
-                    self.state = 0
-        elif self.state == 2:
-            self.timer -= delta_ms
-            if self.timer < 0:
-                self.state = 3
-                captouch.calibration_request()
-        elif self.state == 3:
-            if not captouch.calibration_active():
-                self.state = 0
+        for i in range(10):
+            petal = ins.captouch.petals[i]
+            dot = self.dots[i]
+            pos = 0j if petal.pos is None else petal.pos * 30
+            dot.pos = (pos + 70) * captouch.PETAL_ANGLES[i]
+            dot.size = 5 + 8 * math.sqrt(petal.raw_cap)
+            dot.filled = not petal.pressed
 
     def draw(self, ctx: Context) -> None:
         ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
-        if self.state == 0:
-            for i, dot in enumerate(self.dots):
-                dot.draw(i, ctx)
-        else:
-            ctx.rgb(0, 0.8, 0.8)
-            ctx.text_align = ctx.CENTER
-            ctx.move_to(0, 0)
-            ctx.font = ctx.get_font_name(0)
-            ctx.font_size = 20
-            pos = -55
-            ctx.move_to(0, pos)
-            ctx.text("calibration mode")
-            pos += 30
-            ctx.move_to(0, pos)
-            ctx.text("do not touch the petals")
-            pos += 20
-            ctx.move_to(0, pos)
-            ctx.text("from the front during")
-            pos += 20
-            ctx.move_to(0, pos)
-            ctx.text("calibration")
-            pos += 40
-            ctx.move_to(0, pos)
-            if self.state == 1:
-                pos -= 10
-                ctx.move_to(0, pos)
-                ctx.text("down: start 3s countdown")
-                pos += 20
-                ctx.move_to(0, pos)
-                ctx.text("left/right: cancel")
-            elif self.state == 2:
-                ctx.text("calibrating in " + str(1 + int(self.timer / 1000)))
-            elif self.state == 3:
-                ctx.rgb(1.0, 0.5, 0.2)
-                ctx.text("calibrating...")
-
-    def on_enter(self, vm: Optional[ViewManager]) -> None:
-        super().on_enter(vm)
-        self.button = None
+        for i in range(10):
+            ctx.rgb(*((0.0, 0.8, 0.8) if i % 2 else (1.0, 0.0, 1.0)))
+            self.dots[i].draw(ctx)
 
 
-# For running with `mpremote run`:
 if __name__ == "__main__":
     import st3m.run
 
diff --git a/python_payload/mypystubs/captouch.pyi b/python_payload/mypystubs/captouch.pyi
index ea64b05c87458549bfda8db111aa63e100cda3cb..11607973a050b9c79058956018ebee5328d8841e 100644
--- a/python_payload/mypystubs/captouch.pyi
+++ b/python_payload/mypystubs/captouch.pyi
@@ -1,10 +1,53 @@
-from typing import Protocol, List, Tuple
+from typing import Protocol, List, Tuple, Union
+
+class PetalLogFrame:
+    @property
+    def pressed(self) -> bool:
+        """
+        Identical to ``pressed`` of ``CaptouchPetalState``.
+        """
+        ...
+    @property
+    def pos(self) -> Union[complex, None]:
+        """
+        Identical to ``pos`` of ``CaptouchPetalState``.
+        """
+        ...
+    @property
+    def raw_pos(self) -> complex:
+        """
+        Identical to ``raw_pos`` of ``CaptouchPetalState``.
+        """
+        ...
+    @property
+    def raw_cap(self) -> float:
+        """
+        Identical to ``raw_cap`` of ``CaptouchPetalState``.
+        """
+        ...
+    @property
+    def mode(self) -> float:
+        """
+        Config mode setting that was used for recording the frame (see ``captouch.Config``).
+        """
+        ...
+    @property
+    def ticks_us(self) -> int:
+        """
+        Timestamp that reflects the approximate time at which the data was captured (to
+        be exact, when the I2C transmission has completed). Behaves identical to the return
+        type of ``time.ticks_us()`` and should only be used with ``time.ticks_diff()``
+        to avoid overflow issues. Overflow occurs after ~10min.
+        """
+        ...
 
 class CaptouchPetalState(Protocol):
     @property
     def pressed(self) -> bool:
         """
-        True if the petal is being touched.
+        True if the petal has been touched during the last ``think()`` cycle.
+
+        May be affected by ``captouch.Config``.
         """
         ...
     @property
@@ -20,22 +63,83 @@ class CaptouchPetalState(Protocol):
         """
         ...
     @property
-    def position(self) -> Tuple[int, int]:
+    def pos(self) -> Union[complex, None]:
         """
-        Coordinates of touch on petal in the form of a (distance, angle)
-        tuple. The units are arbitrary, but centered around (0, 0).
+        Coordinates where this petal is touched or None if the petal isn't
+        touched or positional output is turned off via `captouch.Config``.
+
+        The coordinate system is rotated with the petal's orientation: The
+        real part corresponds to the axis going from the center of the screen
+        to the center of this petal, the imaginary part is perpendicular to
+        that so that it increases with clockwise motion.
 
-        These are approximately cartesian, but the axes are rotated to align
-        with the radial and angular components of the petal position relative
-        to the center of the badge, meaning:
+        Both real and imaginary part are centered around 0 and scaled to a
+        [-1..1] range. We try to guarantee that the output can span this full
+        range, but it may also go beyond.
+
+        May be affected by ``captouch.Config``.
+
+        See ``captouch.PETAL_ANGLES`` to align the output with the display
+        coordinate system.
+        """
+        ...
+    @property
+    def raw_pos(self) -> complex:
+        """
+        Similar to ``.pos``, but never None. Will probably return garbage when
+        petal is not pressed. It is mostly useful for interpolating data between
+        petals.
+        """
+        ...
+    @property
+    def raw_cap(self) -> float:
+        """
+        Returns a the raw capacity reading from the petal in arbitrary units.
+        The value that kind-of-sort-of corresponds to how much the pad
+        is covered. Since the footprint of a finger expands when compressed
+        (what a sentence), this could in theory be roughly used for pressure,
+        but the data quality just doesn't cut it:
 
-        An increase in distance means the touch is further away from the centre
-        of the badge.
+        It's mostly okay when not compared against fixed values, but rather
+        some sort of floating average, but it's not really monotonic and also
+        it doesn't react until the finger is a few mm away from the pad so it's
+        kinda bad for proximity sensing too. It's tempting to use it for gating
+        away light touches, but that results in poor performance in some
+        environmental conditions. Test carefully, and best make nothing important
+        depend on it.
 
-        An increase in angle means the touch is more clockwise.
+        Normalized so that "1" corresponds to the upper hysteresis limit of the
+        ``pressed`` API.
 
-        The hardware only provides angular positional data for the top petals,
-        the bottom petals always return an angle of 0.
+        May be affected by ``captouch.Config``.
+        """
+        ...
+    @property
+    def log(self) -> List[CaptouchPetalLogFrame]:
+        """
+        Raw frame output of the captouch driver. Must be enabled by ``captouch.Config``.
+
+        Since ``think()`` and the captouch driver are running asynchronously - oftentimes the
+        driver is even running asynchronously with _itself_! - the attributes provided in this
+        class are conflated from several frames for the purpose of not missing short presses,
+        hysteresis and filtering data. The filters are chosen to provide a good balance between
+        response time and noise, but it's easy to miss datapoints entirely, or accidentially
+        processing the same twice. For advanced captouch operation this is an issue.
+
+        Every ``think()`` cycle this log is filled with the frames that occurred since the last
+        capture. The lowest indices are the oldest frames, so that you could compile a complete log
+        (or one cropped to arbitrary length) simply by appending new data.
+
+        Is affected by REPL weirdness. # TODO
+        """
+        ...
+    @property
+    def position(self) -> Tuple[int, int]:
+        """
+        Legacy API
+
+        Similar to raw_pos, but not normalized.
+        # TODO: Add conversion formula
         """
         ...
 
@@ -49,7 +153,7 @@ class CaptouchState(Protocol):
         """
         State of individual petals.
 
-        Contains 10 elements, with the zeroth element being the petal closest to
+        Contains 10 elements, with the zeroeth element being the petal closest to
         the USB port. Then, every other petal in a clockwise direction.
 
         Petals 0, 2, 4, 6, 8 are Top petals.
@@ -57,22 +161,91 @@ class CaptouchState(Protocol):
         Petals 1, 3, 5, 7, 9 are Bottom petals.
         """
         ...
+    @property
+    def ticks_us(self) -> int:
+        """
+        Timestamp of when the captouch data has been requested from the backend, i.e. when
+        the ``think()`` cycle started. Mostly useful for comparing for to the same attribute
+        of ``CaptouchPetalLogFrame``. Behaves identical to the return type of
+        ``time.ticks_us()`` and should only be used with ``time.ticks_diff()`` to avoid
+        overflow issues. Overflow occurs after ~10min.
+        """
+        ...
 
 def read() -> CaptouchState:
     """
+    LEGACY API
+
     Reads current captouch state from hardware and returns a snapshot in time.
-    """
-    ...
+    ``.pressed`` and ``.log`` attributes are broken in the REPL.
 
-def calibration_active() -> bool:
-    """
-    Returns true if the captouch system is current recalibrating.
-    """
-    ...
+    Typically you'd want to use the captouch data provided by ``think()``, so this method
+    is replaced with nothing. See workaround for reasoning.
 
-def calibration_request() -> None:
-    """
-    Attempts to start calibration of captouch controllers. No-op if a
-    calibration is already active.
     """
     ...
+
+class Config:
+    class PetalConfig:
+        @property
+        def mode(self) -> int:
+            """
+            What kind of data should be collected for this petal.
+
+            0: No data at all
+
+            1: Button Mode: All pads combined, no positional output
+
+            2: 1D: Only radial position is provided
+
+            3: 2D: Full positional output. Only available for top petals.
+
+            Default: 3 (top petals), 2 (bottom petals)
+            """
+            ...
+        @property
+        def logging(self) -> bool:
+            """
+            Whether or not you want to collect the raw data log. This
+            eats some CPU time proportional to the ``think()`` cycle
+            time, use only when you actually do something with the data.
+
+            Default: False
+            """
+            ...
+    @property
+    def petals(self) -> List[PetalConfig]:
+        """
+        Config of individual petals, indexed as in the ``CaptouchState`` object.
+        """
+        ...
+    @classmethod
+    def empty(cls) -> "Config":
+        """
+        Initializer method that returns a config with everything disabled. Ideal
+        for ORing the requirements of different components together.
+        """
+        ...
+    @classmethod
+    def default(cls) -> "Config":
+        """
+        Initializer method that returns the default config, same as when entering an application.
+        """
+        ...
+    @classmethod
+    def current(cls) -> "Config":
+        """
+        Initializer method that returns the currently active config.
+        """
+        ...
+    def apply(self) -> None:
+        """
+        Apply this config to the driver.
+        """
+        ...
+    def apply_default(self) -> None:
+        """
+        Convenience method to restore defaults. same as ``Config.default().apply()`` but mildly faster
+        if you already have one around.
+        """
+        ...
diff --git a/python_payload/st3m/application.py b/python_payload/st3m/application.py
index 8d704e1eacf48fe1b6920a3e1c587145cdd20b5b..1eaa146a4529a1c2f0dc1a5822ef70e332321831 100644
--- a/python_payload/st3m/application.py
+++ b/python_payload/st3m/application.py
@@ -26,6 +26,7 @@ import sys
 import random
 import time
 import math
+import captouch
 
 log = Log(__name__)
 
@@ -62,6 +63,7 @@ def setup_for_app(app_ctx: Optional[ApplicationContext]) -> None:
         elif wifi_preference is False:
             st3m.wifi.disable()
     leds.set_slew_rate(settings.num_leds_speed.value)
+    captouch.Config.default().apply
 
 
 class Application(BaseView):
diff --git a/python_payload/st3m/main_menu.py b/python_payload/st3m/main_menu.py
index 010d80a238c8e73c197dc030aee0ed2befdaa977..201af8cff0dc226c927f87d0235f40f24ddae1f2 100644
--- a/python_payload/st3m/main_menu.py
+++ b/python_payload/st3m/main_menu.py
@@ -33,6 +33,7 @@ from st3m.ui.elements.menus import SimpleMenu, SunMenu
 from st3m.utils import rm_recursive, simplify_path
 from st3m import application_settings
 from st3m.ui import colours
+import captouch
 import bl00mbox
 
 
@@ -207,6 +208,7 @@ def restore_sys_defaults():
     sys_display.set_backlight(settings.num_display_brightness.value)
     led_patterns.set_menu_colors()
     bl00mbox.Sys.foreground_clear()
+    captouch.Config.default().apply()
     # media.stop()
 
 
diff --git a/python_payload/st3m/reactor.py b/python_payload/st3m/reactor.py
index 3f9f3342ac082bb6aff40096041b272b97dd047d..1c36b707e1d4e3de73c8bac42be18c8c64bd7a5d 100644
--- a/python_payload/st3m/reactor.py
+++ b/python_payload/st3m/reactor.py
@@ -7,7 +7,7 @@ from ctx import Context
 import time
 import sys_display
 import sys_kernel
-import captouch
+import sys_captouch
 
 import bl00mbox
 
@@ -141,7 +141,7 @@ class Reactor:
 
         # temp band aid against input dropping, will be cleaned up in
         # upcoming input api refactor
-        captouch.refresh_events()
+        sys_captouch.refresh_events()
 
         hr = InputState()
 
diff --git a/python_payload/st3m/run.py b/python_payload/st3m/run.py
index cc7534f2206c8e52713f125d0840e5d73ae316cc..ceb00e2b0867f0cbf15f6d4640c363b1666de29d 100644
--- a/python_payload/st3m/run.py
+++ b/python_payload/st3m/run.py
@@ -2,6 +2,7 @@ from st3m.reactor import Reactor, Responder
 from st3m.goose import Optional
 from st3m.ui.elements import overlays
 from st3m.ui.view import View, ViewManager, ViewTransitionBlend
+from st3m.ui.captouch_calibrator import CaptouchCalibrator
 from st3m.main_menu import MainMenu
 from st3m.application import (
     BundleManager,
@@ -14,7 +15,7 @@ from st3m.input import InputState
 import st3m.wifi
 import st3m.utils
 
-import captouch
+import sys_captouch
 import audio
 import leds
 import gc
@@ -23,6 +24,7 @@ import sys_display
 import sys_mode
 import bl00mbox
 import os
+import json
 
 import machine
 import network
@@ -125,6 +127,7 @@ def run_view(v: View, debug_vm=True) -> None:
 
 def run_app(klass, bundle_path=None):
     app_ctx = ApplicationContext(bundle_path)
+    CaptouchCalibrator.check_captouch()
     setup_for_app(app_ctx)
     run_view(klass(app_ctx), debug_vm=True)
 
@@ -141,9 +144,11 @@ def run_main() -> None:
     log.info(f"starting main")
     log.info(f"free memory: {gc.mem_free()}")
 
+    captouch_calibration_ok = CaptouchCalibrator.check_captouch()
+
     timer = None
     autostart_settings = application_settings.get_autostart()
-    autostart_slug = autostart_settings["slug"]
+    autostart_slug = autostart_settings["slug"] if captouch_calibration_ok else None
     if autostart_settings["slug"]:
         # crimes :>
         ctx = sys_display.ctx(sys_display.osd)
@@ -171,8 +176,6 @@ def run_main() -> None:
         timer = time.ticks_ms() + 2000
         sys_display.update(ctx)
 
-    captouch.calibration_request()
-
     audio.headphones_set_volume_dB(settings.num_headphones_startup_volume_db.value)
     audio.speaker_set_volume_dB(settings.num_speaker_startup_volume_db.value)
     audio.headphones_set_minimum_volume_dB(settings.num_headphones_min_db.value)
@@ -220,30 +223,34 @@ def run_main() -> None:
     application_settings.synchronize_apps(bundles.bundles.values())
 
     menu_main = MainMenu(bundles)
-    views = [menu_main]
-    while timer:
-        if time.ticks_ms() > timer:
-            timer = None
-    ins = InputState()
-    if autostart_slug is None:
-        pass
-    elif ins.buttons.os + ins.buttons.app:
-        log.info(f"Bypassing autostart")
-    elif autostart_slug == "++back_button":
-        ViewManager._ignore_press = True
-    else:
-        requested = [
-            b
-            for b in bundles.bundles.values()
-            if application_settings.is_autostart(b.path)
-        ]
-        if len(requested) > 1:
-            log.error(f"More than one bundle with slug {autostart_slug}")
-        elif len(requested) == 0:
-            log.error(f"Requested bundle {autostart_slug} not found")
+    if captouch_calibration_ok:
+        views = [menu_main]
+        while timer:
+            if time.ticks_ms() > timer:
+                timer = None
+        ins = InputState()
+        if autostart_slug is None:
+            pass
+        elif ins.buttons.os + ins.buttons.app:
+            log.info(f"Bypassing autostart")
+        elif autostart_slug == "++back_button":
+            ViewManager._ignore_press = True
         else:
-            log.info(f"Autostarting into {autostart_slug}")
-            views += [requested[0].load()]
+            requested = [
+                b
+                for b in bundles.bundles.values()
+                if application_settings.is_autostart(b.path)
+            ]
+            if len(requested) > 1:
+                log.error(f"More than one bundle with slug {autostart_slug}")
+            elif len(requested) == 0:
+                log.error(f"Requested bundle {autostart_slug} not found")
+            else:
+                log.info(f"Autostarting into {autostart_slug}")
+                views += [requested[0].load()]
+    else:
+        views = [CaptouchCalibrator(menu=menu_main)]
+
     run_views(views, debug_vm=False)
 
 
diff --git a/python_payload/st3m/settings_menu.py b/python_payload/st3m/settings_menu.py
index 6f802daaf1311c622ce7ecd85acbe38b09913cd9..f0906e6a583c3bbd43451f24949c3e47670b53fa 100644
--- a/python_payload/st3m/settings_menu.py
+++ b/python_payload/st3m/settings_menu.py
@@ -11,10 +11,16 @@ from st3m.goose import (
     Union,
     TYPE_CHECKING,
 )
-from st3m.ui.menu import MenuItem, MenuItemBack, MenuItemAction
+from st3m.ui.menu import (
+    MenuItem,
+    MenuItemBack,
+    MenuItemAction,
+    MenuItemLaunchPersistentView,
+)
 from st3m.application import BundleMetadata, MenuItemAppLaunch
 from st3m.ui.elements.menus import SimpleMenu
 from st3m.ui.view import ViewManager
+from st3m.ui.captouch_calibrator import CaptouchCalibrator
 from st3m.settings import *
 from ctx import Context
 
@@ -217,6 +223,7 @@ settings_menu_structure: "MenuStructure" = [
     MenuItemAppLaunch(BundleMetadata("/flash/sys/apps/audio_config")),
     MenuItemAppLaunch(BundleMetadata("/flash/sys/apps/appearance")),
     MenuItemAppLaunch(BundleMetadata("/flash/sys/apps/graphics_mode")),
+    MenuItemLaunchPersistentView("Captouch Calibrator", CaptouchCalibrator),
     onoff_wifi,
     onoff_wifi_preference,
     onoff_show_tray,
diff --git a/python_payload/st3m/ui/captouch_calibrator.py b/python_payload/st3m/ui/captouch_calibrator.py
new file mode 100644
index 0000000000000000000000000000000000000000..ec14763084da96d8479f45c67bb2ac7c27af0f2d
--- /dev/null
+++ b/python_payload/st3m/ui/captouch_calibrator.py
@@ -0,0 +1,387 @@
+import cmath
+import math
+import time
+import sys_captouch
+import captouch
+import json
+from st3m.ui.view import BaseView
+
+
+class CaptouchCalibrator(BaseView):
+    BASE_STATE = 0
+    PRE_COUNTDOWN_STATE = 1
+    COUNTDOWN_STATE = 2
+    CALIBRATION_STATE = 3
+    CALIBRATION_CHECK_STATE = 4
+    SAVE_STATE = 5
+    POST_SAVE_STATE = 6
+    FIRST_BOOT_HINT_STATE = 7
+
+    def __init__(self, menu=None):
+        super().__init__()
+        self.menu = menu
+        self.vm = None
+        self.timer = 0
+        self.button = None
+        self.calib_stage = -1
+        self.calib_ok = None
+        self.calib = None
+        self.save_ok = None
+        self.full_redraw = True
+        self._selection_index = 0
+        self.selections = []
+        self.app_is_left = None
+        self.calib_prev = None
+
+    @property
+    def state(self):
+        return self._state
+
+    @state.setter
+    def state(self, val):
+        if val == self.PRE_COUNTDOWN_STATE:
+            self.selections = ["start 3s countdown"]
+        elif val == self.CALIBRATION_CHECK_STATE:
+            self.selections = ["redo", "save"]
+        elif val == self.POST_SAVE_STATE:
+            self.selections = ["ok"]
+        elif val == self.FIRST_BOOT_HINT_STATE:
+            self.selections = ["exit"]
+        elif val == self.BASE_STATE:
+            self.selections = ["clear", "calibrate"]
+        else:
+            self.selections = []
+        self.selection_index = 0
+        self._state = val
+        self.pos = [None] * 10
+        self.pos_prev = [None] * 10
+        self.full_redraw = True
+
+    @property
+    def selection_index(self):
+        return self._selection_index
+
+    @selection_index.setter
+    def selection_index(self, val):
+        if self.selections:
+            self._selection_index = val % len(self.selections)
+        else:
+            self._selection_index = None
+
+    def restore_calib(self):
+        if self.calib_prev is not None:
+            sys_captouch.calibration_set_data(self.calib_prev)
+            self.calib_prev = None
+
+    def think(self, ins, delta_ms):
+        super().think(ins, delta_ms)
+        self.app_is_left = ins.buttons.app_is_left
+        if self.button is not None:
+            press_event = (self.button != ins.buttons.app) and ins.buttons.app
+        else:
+            press_event = False
+        self.button = int(ins.buttons.app)
+        self.draw_time += delta_ms
+
+        if self.state in [self.BASE_STATE, self.CALIBRATION_CHECK_STATE]:
+            for x in range(10):
+                self.pos[x] = ins.captouch.petals[x].pos
+
+        if self.state == self.BASE_STATE:
+            if press_event:
+                if self.button == ins.buttons.PRESSED_DOWN:
+                    if self.selection_index:
+                        self.state = self.PRE_COUNTDOWN_STATE
+                    else:
+                        self.state = self.BASE_STATE
+                else:
+                    self.selection_index += self.button
+        elif self.state == self.PRE_COUNTDOWN_STATE:
+            if press_event and self.button == ins.buttons.PRESSED_DOWN:
+                self.state = self.COUNTDOWN_STATE
+                self.timer = 2999
+        elif self.state == self.COUNTDOWN_STATE:
+            self.timer -= delta_ms
+            if self.timer < 0:
+                self.state = self.CALIBRATION_STATE
+                self.calib_prev = sys_captouch.calibration_get_data()
+                sys_captouch.calibration_request()
+                self.calib_stage = 3
+        elif self.state == self.CALIBRATION_STATE:
+            self.calib_stage = sys_captouch.calibration_active()
+            if not self.calib_stage:
+                self.calib = sys_captouch.calibration_get_data()
+                if self.calib:
+                    self.state = self.CALIBRATION_CHECK_STATE
+                    self.calib_stage = -1
+                    self.calib_ok = None
+        elif self.state == self.CALIBRATION_CHECK_STATE:
+            if self.calib_ok is None:
+                try:
+                    self.calib_ok = [True] * 10
+
+                    def check_entry(_arg):
+                        _vals = [int(x) for x in _arg.split("/")]
+                        return _vals[0] < 52000 and _vals[0]
+
+                    for petalbias in self.calib["ad7147"]:
+                        petal = petalbias["petal"]
+                        if petal % 2:
+                            labels = ["0d", "1d"]
+                        else:
+                            labels = ["0d", "1d", "2d"]
+                        for label in labels:
+                            entries = petalbias[label]
+                            for entry in entries:
+                                self.calib_ok[petal] = self.calib_ok[
+                                    petal
+                                ] and check_entry(entry)
+                except Exception as e:
+                    print(e)
+                    self.calib_ok = False
+                self.full_redraw = True
+            if press_event:
+                if self.button == ins.buttons.PRESSED_DOWN:
+                    if self.selection_index:
+                        self.state = self.SAVE_STATE
+                        self.calib_prev = None
+                    else:
+                        self.restore_calib()
+                        self.state = self.PRE_COUNTDOWN_STATE
+                else:
+                    self.selection_index += self.button
+        elif self.state == self.SAVE_STATE:
+            if not self.full_redraw:
+                self.save_ok = bool(self.calib)
+                if self.save_ok:
+                    try:
+                        with open(sys_captouch.calibration_path, "w") as f:
+                            json.dump(self.calib, f, indent=4)
+                    except:
+                        self.save_ok = False
+                if self.save_ok:
+                    sys_captouch.calibration_set_trusted(True)
+                self.state = self.POST_SAVE_STATE
+        elif self.state == self.POST_SAVE_STATE:
+            if press_event and self.button == ins.buttons.PRESSED_DOWN:
+                if self.menu:
+                    self.state = self.FIRST_BOOT_HINT_STATE
+                else:
+                    self.state = self.BASE_STATE
+        elif self.state == self.FIRST_BOOT_HINT_STATE:
+            if press_event and self.button == ins.buttons.PRESSED_DOWN:
+                if self.menu:
+                    if self.vm and not self.vm._history:
+                        self.vm.replace(self.menu)
+                    self.menu = None
+
+    def draw_selection(self, ctx, selections, selection_index):
+        ctx.save()
+        col_fg = (0, 1, 1)
+        col_bg = (0, 0, 0)
+        text_space = ctx.text_width("   ")
+        strings = ["["] + selections + ["]"]
+        text_widths = [ctx.text_width(x) + text_space for x in strings]
+
+        orig_x, orig_y = ctx.x, ctx.y
+        ctx.rgb(*col_bg).rectangle(-self._width / 2, ctx.y - 18, self._width, 26).fill()
+        ctx.move_to(orig_x, orig_y)
+
+        new_width = sum(text_widths)
+        if abs(new_width - self._width) < 0.1:
+            ctx.rel_move_to(-new_width / 2, 0)
+            for x, string in enumerate(strings):
+                orig_x, orig_y = ctx.x, ctx.y
+                ctx.rgb(*col_fg)
+                if selections and selection_index == x - 1:
+                    ctx.rectangle(
+                        ctx.x, ctx.y - ctx.font_size + 4, text_widths[x], ctx.font_size
+                    ).fill()
+                    ctx.move_to(orig_x, orig_y)
+                    ctx.rgb(*col_bg)
+                ctx.rel_move_to(text_widths[x] / 2, 0)
+                ctx.text(string)
+                ctx.move_to(orig_x + text_widths[x], orig_y)
+        else:
+            if self._width < new_width:
+                self._width += self.draw_time * 0.6
+                if self._width > new_width:
+                    self._width = new_width
+            elif self._width > new_width:
+                self._width -= self.draw_time * 0.6
+                if self._width < new_width:
+                    self._width = new_width
+            orig_y = ctx.y
+            ctx.rgb(*col_fg)
+            ctx.move_to(-self._width / 2 + text_widths[0] / 2, orig_y)
+            ctx.text(strings[0])
+            ctx.move_to(self._width / 2 - text_widths[-1] / 2, orig_y)
+            ctx.text(strings[-1])
+
+        ctx.restore()
+
+    def draw(self, ctx) -> None:
+        if self.always_full_redraw:
+            self.full_redraw = True
+        ctx.text_align = ctx.CENTER
+        if self.full_redraw:
+            ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
+            ctx.rgb(1, 0, 1)
+            ctx.move_to(0, 0)
+            ctx.font = ctx.get_font_name(4)
+            ctx.font_size = 22
+            ctx.move_to(0, -80)
+            ctx.text("Captouch")
+            ctx.move_to(0, -55)
+            ctx.text("Calibrator")
+        ctx.font = ctx.get_font_name(6)
+        ctx.font_size = 18
+        ctx.rgb(0, 1, 1)
+
+        pos = -20
+        ctx.move_to(0, pos)
+        if self.state in [
+            self.PRE_COUNTDOWN_STATE,
+            self.COUNTDOWN_STATE,
+            self.CALIBRATION_STATE,
+        ]:
+            if self.full_redraw:
+                ctx.move_to(0, pos)
+                ctx.text("do not touch the petals from")
+                ctx.move_to(0, pos + 20)
+                ctx.text("the front during calibration")
+            else:
+                ctx.rgb(0, 0, 0).rectangle(-120, pos + 35, 240, 20).fill().rgb(0, 1, 1)
+            ctx.move_to(0, pos + 50)
+            if self.state == self.COUNTDOWN_STATE:
+                ctx.text("calibrating in " + str(1 + int(self.timer / 1000)))
+            elif self.state == self.CALIBRATION_STATE:
+                ctx.rgb(1.0, 0.5, 0.2)
+                ctx.text("calibrating" + "." * (4 - self.calib_stage))
+        elif self.full_redraw:
+            if self.state == self.BASE_STATE:
+                if sys_captouch.calibration_get_trusted():
+                    ctx.text("trusted calibration found")
+                    ctx.move_to(0, pos + 20)
+                    ctx.text("you can draw on here to try it!")
+                    ctx.move_to(0, pos + 50)
+                    ctx.text("(top petals: 2D, bottom: 1D)")
+                else:
+                    ctx.rgb(1.0, 0, 0)
+                    ctx.text("no trusted calibration found")
+                    ctx.move_to(0, pos + 20)
+                    ctx.text("please perform a calibration")
+            elif self.state == self.CALIBRATION_CHECK_STATE:
+                if self.calib_ok is None:
+                    ctx.text("checking calibration...")
+                elif not self.calib_ok:
+                    ctx.rgb(1.0, 0, 0)
+                    ctx.text("bad calibration, driver error")
+                    ctx.move_to(0, pos + 20)
+                    ctx.text("you should never see this")
+                else:
+                    if all(self.calib_ok):
+                        ctx.text("calibration looks good!")
+                        ctx.move_to(0, pos + 20)
+                        ctx.text("you can draw on here to try it!")
+                        ctx.move_to(0, pos + 50)
+                        ctx.text("(top petals: 2D, bottom: 1D)")
+                    else:
+                        ctx.rgb(1.0, 0.5, 0.2)
+                        ctx.text("issues found on petals:")
+                        ctx.move_to(0, pos + 20)
+                        ctx.text(
+                            " ".join(
+                                [str(x) for x in range(10) if not self.calib_ok[x]]
+                            )
+                        )
+                        ctx.move_to(0, pos + 50)
+                        ctx.text("check for dirt")
+            elif self.state == self.SAVE_STATE:
+                ctx.move_to(0, pos + 20)
+                ctx.text("saving calibration...")
+            elif self.state == self.POST_SAVE_STATE:
+                ctx.move_to(0, pos + 20)
+                if self.save_ok:
+                    ctx.rgb(0, 1.0, 0)
+                    ctx.text("saved calibration!")
+                else:
+                    ctx.rgb(1.0, 0, 0)
+                    ctx.text("saving failed :/")
+            elif self.state == self.FIRST_BOOT_HINT_STATE:
+                ctx.move_to(0, pos)
+                ctx.text("you can redo this anytime")
+                ctx.move_to(0, pos + 20)
+                ctx.text("in System -> Settings")
+                ctx.move_to(0, pos + 40)
+                ctx.text("-> Captouch Calibration")
+
+        if self.state in [self.CALIBRATION_CHECK_STATE, self.BASE_STATE]:
+            for x in range(10):
+                if self.pos_prev[x] is not None:
+                    pos = ((self.pos_prev[x] * 27) + 73) * captouch.PETAL_ANGLES[x]
+                    # if abs(self.pos_prev[x].real) > 1 or abs(self.pos_prev[x].imag) > 1:
+                    if abs(self.pos_prev[x]) > 1:
+                        ctx.rgb(0.0, 0.0, 1.0)
+                    else:
+                        ctx.rgb(1.0, 1.0, 0.0)
+                    ctx.rectangle(pos.real - 2, pos.imag - 2, 4, 4).fill()
+                    self.pos_prev[x] = None
+            ctx.rgb(1.0, 0.0, 0.0)
+            for x in range(10):
+                if self.pos[x] is not None:
+                    pos = ((self.pos[x] * 27) + 73) * captouch.PETAL_ANGLES[x]
+                    ctx.rectangle(pos.real - 1.5, pos.imag - 1.5, 3, 3).fill()
+                    self.pos_prev[x] = self.pos[x]
+
+        pos = 60
+        ctx.move_to(0, pos)
+        self.draw_selection(ctx, self.selections, self.selection_index)
+        if (
+            self.full_redraw
+            and self.menu
+            and self.state in [self.PRE_COUNTDOWN_STATE, self.CALIBRATION_CHECK_STATE]
+            and self.app_is_left is not None
+        ):
+            ctx.move_to(0, pos + 25)
+            ctx.rgb(0.0, 0.7, 0.7)
+            ctx.font_size = 15
+            shoulder_side = "left" if self.app_is_left else "right"
+            ctx.text(f"({shoulder_side} shoulder button")
+            ctx.move_to(0, pos + 40)
+            if self.state == self.PRE_COUNTDOWN_STATE:
+                ctx.text("down to confirm)")
+            if self.state == self.CALIBRATION_CHECK_STATE:
+                ctx.text("left/right to select)")
+        self.draw_time = 0
+        self.full_redraw = self.app_is_left is None
+
+    def on_enter(self, vm) -> None:
+        super().on_enter(vm)
+        self.button = None
+        self.state = self.PRE_COUNTDOWN_STATE if self.menu else self.BASE_STATE
+        self._width = 0
+        self.draw_time = 0
+        self.always_full_redraw = True
+        self.calibration_trusted = sys_captouch.calibration_get_trusted()
+
+    def on_enter_done(self):
+        self.always_full_redraw = False
+
+    def on_exit(self):
+        self.always_full_redraw = True
+        self.restore_calib()
+
+    def on_exit_done(self):
+        self.always_full_redraw = False
+
+    @classmethod
+    def check_captouch(cls):
+        if not sys_captouch.calibration_get_trusted():
+            try:
+                with open(sys_captouch.calibration_path) as f:
+                    sys_captouch.calibration_set_data(json.load(f))
+                sys_captouch.calibration_set_trusted(True)
+            except:
+                pass
+        return sys_captouch.calibration_get_trusted()
diff --git a/sim/fakes/captouch.py b/sim/fakes/captouch.py
index 9194d98b79dd3f76666253662ef9073561198742..038fdf43aff363ad9b6451773e07d1fb6386284e 100644
--- a/sim/fakes/captouch.py
+++ b/sim/fakes/captouch.py
@@ -1,15 +1,57 @@
 from typing import List
 
 
+class PetalLogFrame:
+    def __init__(self, ix: int, pressed: bool):
+        self.mode = 2 if ix % 2 else 3
+        self.pos = 0j if pressed else None
+        self.raw_cap = 4 if pressed else 0
+        self.raw_pos = 0j
+        self.ticks_us = 0
+
+
+class PetalLog:
+    def __init__(self):
+        self.clear()
+
+    def append(self, frame: PetalLogFrame):
+        self.frames.append(frame)
+        self.length += 1
+        self.length_ms += 10
+
+    def clear(self):
+        self.frames = []
+        self.length = 0
+        self.length_ms = 0
+
+    def index_offset_ms(self, index: int, min_offset_ms: float):
+        if index < 0:
+            index += self.length
+        if index >= self.length:
+            index = self.length - 1
+        index += int(min_offset_ms / 10)
+        if index < 0:
+            index = 0
+        if index >= self.length:
+            index = self.length - 1
+        return index
+
+    def average(start: int = None, stop: int = None):
+        return 0
+
+    def slope_per_ms(start: int = None, stop: int = None):
+        return 0
+
+
 class CaptouchPetalState:
     def __init__(self, ix: int, pressed: bool):
-        self._pressed = pressed
+        self.pressed = pressed
         self._ix = ix
         self.position = (0, 0)
-
-    @property
-    def pressed(self) -> bool:
-        return self._pressed
+        self.pos = 0j if pressed else None
+        self.raw_cap = 4 if pressed else 0
+        self.raw_pos = 0j
+        self.log = [CaptouchPetalLogFrame(ix, pressed)]
 
     @property
     def top(self) -> bool:
@@ -20,6 +62,52 @@ class CaptouchPetalState:
         return not self.top
 
 
+_modes = [3 - (x % 2) for x in range(10)]
+_logging = [False for x in range(10)]
+
+
+class PetalConfig:
+    mode = 0
+    logging = False
+
+    def set_min_mode(self, mode):
+        self.mode = max(self.mode, mode)
+
+
+class Config:
+    def __init__(self, _token=None):
+        if _token is None:
+            raise ValueError("don't initialize directly")
+        self.petals = [PetalConfig() for x in range(10)]
+        for x in range(10):
+            if _token == 1:
+                self.petals[x].mode = 3 - (x % 2)
+            if _token == 2:
+                self.petals[x].mode = _modes[x]
+
+    def apply(self):
+        for x in range(10):
+            _modes[x] = self.petals[x].mode
+            _logging[x] = self.petals[x].logging
+
+    def apply_default(self):
+        for x in range(10):
+            _modes[x] = 3 - (x % 2)
+            _logging[x] = False
+
+    @classmethod
+    def current(cls):
+        return cls(_token=2)
+
+    @classmethod
+    def default(cls):
+        return cls(_token=1)
+
+    @classmethod
+    def empty(cls):
+        return cls(_token=0)
+
+
 class CaptouchState:
     def __init__(self, petals: List[CaptouchPetalState]):
         self._petals = petals
diff --git a/sim/fakes/sys_captouch.py b/sim/fakes/sys_captouch.py
new file mode 100644
index 0000000000000000000000000000000000000000..d7aba9131100f8f4b5925571a6453e29a03beba4
--- /dev/null
+++ b/sim/fakes/sys_captouch.py
@@ -0,0 +1,46 @@
+def calibration_get_data():
+    return [0] * 120
+
+
+def calibration_set_data(val):
+    pass
+
+
+_trusted = False
+
+
+def refresh_events():
+    pass
+
+
+def calibration_set_trusted(val):
+    global _trusted
+    _trusted = bool(val)
+
+
+def calibration_get_trusted():
+    return _trusted
+
+
+_modes = [3, 2] * 5
+
+
+def config_set_petal_modes(val):
+    global _modes
+    _modes = val
+
+
+def config_get_petal_modes():
+    return _modes
+
+
+_logging = [False] * 10
+
+
+def config_set_logging(val):
+    global _logging
+    _logging = val
+
+
+def config_get_logging():
+    return _logging