From 3d328d28f4a740384955b96071dc5488ac1d69c7 Mon Sep 17 00:00:00 2001 From: GlassOnTin Date: Thu, 2 Apr 2026 11:28:00 +0100 Subject: [PATCH] Add bubble level complication with EMA-filtered accelerometer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always-on ACCEL_PASSTHROUGH at 10Hz feeds an exponential moving average filter (α=0.15) for smooth, noise-resistant tilt sensing. Watch face shows a circular bubble level below the step counter: - Ring with crosshairs, moving dot shows tilt direction - Dot colour: green (<3°), amber (<15°), red (>15°) - Angle readout in degrees below the ring - Bubble position clamped to ring boundary Accel mapping: ax/ay normalized to 1g (4096 LSB), inverted for natural bubble behaviour (tilt left → bubble moves left). --- Gui.h | 88 ++++++++++++++++++++++++++++++++++++++++++++++ RNode_Firmware.ino | 18 ++++++++++ 2 files changed, 106 insertions(+) diff --git a/Gui.h b/Gui.h index 51356ec..6aa7984 100644 --- a/Gui.h +++ b/Gui.h @@ -107,6 +107,16 @@ static lv_obj_t *gui_gps_fix = NULL; static lv_obj_t *gui_gps_alt = NULL; static lv_obj_t *gui_gps_beacon = NULL; +// Bubble level widgets +static lv_obj_t *gui_level_ring = NULL; // outer circle +static lv_obj_t *gui_level_dot = NULL; // moving bubble +static lv_obj_t *gui_level_cross_h = NULL; // crosshair horizontal +static lv_obj_t *gui_level_cross_v = NULL; // crosshair vertical +static lv_obj_t *gui_level_angle = NULL; // tilt angle text +#define GUI_LEVEL_SIZE 140 // ring diameter +#define GUI_LEVEL_DOT 16 // bubble diameter +#define GUI_LEVEL_Y 340 // vertical position on watch face + // Touch input via function pointer (set by .ino after touch init) typedef bool (*gui_touch_fn_t)(int16_t *x, int16_t *y); static gui_touch_fn_t gui_touch_fn = NULL; @@ -171,6 +181,8 @@ extern uint32_t imu_log_samples; extern uint32_t imu_log_start_ms; // Forward declaration for touch logging (defined in IMULogger.h) void sensor_log_touch(int16_t x, int16_t y, bool pressed); +// Forward declarations for filtered accel (defined in .ino) +extern volatile float imu_ax_f, imu_ay_f, imu_az_f; #ifndef PMU_TEMP_MIN #define PMU_TEMP_MIN -30 #endif @@ -341,6 +353,50 @@ static void gui_create_watchface(lv_obj_t *parent) { lv_obj_set_width(gui_step_label, GUI_W); lv_obj_set_style_text_align(gui_step_label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_pos(gui_step_label, 0, GUI_RULE2_Y + 15); + + // Bubble level + int lx = (GUI_W - GUI_LEVEL_SIZE) / 2; + + // Outer ring + gui_level_ring = lv_obj_create(parent); + lv_obj_remove_style_all(gui_level_ring); + lv_obj_set_size(gui_level_ring, GUI_LEVEL_SIZE, GUI_LEVEL_SIZE); + lv_obj_set_pos(gui_level_ring, lx, GUI_LEVEL_Y); + lv_obj_set_style_radius(gui_level_ring, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_color(gui_level_ring, lv_color_hex(GUI_COL_DIM), 0); + lv_obj_set_style_border_width(gui_level_ring, 1, 0); + lv_obj_set_style_bg_opa(gui_level_ring, LV_OPA_TRANSP, 0); + lv_obj_clear_flag(gui_level_ring, LV_OBJ_FLAG_SCROLLABLE); + + // Crosshairs + static lv_point_precise_t ch_pts[] = {{0,0},{GUI_LEVEL_SIZE,0}}; + gui_level_cross_h = lv_line_create(gui_level_ring); + lv_line_set_points(gui_level_cross_h, ch_pts, 2); + lv_obj_set_style_line_color(gui_level_cross_h, lv_color_hex(0x202020), 0); + lv_obj_set_style_line_width(gui_level_cross_h, 1, 0); + lv_obj_center(gui_level_cross_h); + + static lv_point_precise_t cv_pts[] = {{0,0},{0,GUI_LEVEL_SIZE}}; + gui_level_cross_v = lv_line_create(gui_level_ring); + lv_line_set_points(gui_level_cross_v, cv_pts, 2); + lv_obj_set_style_line_color(gui_level_cross_v, lv_color_hex(0x202020), 0); + lv_obj_set_style_line_width(gui_level_cross_v, 1, 0); + lv_obj_center(gui_level_cross_v); + + // Bubble dot + gui_level_dot = lv_obj_create(gui_level_ring); + lv_obj_remove_style_all(gui_level_dot); + lv_obj_set_size(gui_level_dot, GUI_LEVEL_DOT, GUI_LEVEL_DOT); + lv_obj_set_style_radius(gui_level_dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(gui_level_dot, lv_color_hex(GUI_COL_GREEN), 0); + lv_obj_set_style_bg_opa(gui_level_dot, LV_OPA_COVER, 0); + lv_obj_center(gui_level_dot); + + // Angle text below ring + gui_level_angle = gui_label(parent, &lv_font_montserrat_14, GUI_COL_DIM, ""); + lv_obj_set_width(gui_level_angle, GUI_W); + lv_obj_set_style_text_align(gui_level_angle, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_pos(gui_level_angle, 0, GUI_LEVEL_Y + GUI_LEVEL_SIZE + 5); } // --------------------------------------------------------------------------- @@ -729,6 +785,38 @@ static void gui_update_data() { } } + // Bubble level — map filtered accel to dot position + if (gui_level_dot && imu_az_f != 0) { + // Tilt as fraction of 1g (4096 LSB = 1g) + float tx = imu_ax_f / 4096.0f; // roll + float ty = imu_ay_f / 4096.0f; // pitch + + // Map to pixel offset (half ring size = full tilt) + float max_r = (GUI_LEVEL_SIZE - GUI_LEVEL_DOT) / 2.0f; + float px = -tx * max_r; // invert for natural bubble feel + float py = -ty * max_r; + + // Clamp to ring boundary + float dist = sqrtf(px * px + py * py); + if (dist > max_r) { + px = px * max_r / dist; + py = py * max_r / dist; + } + + lv_obj_set_pos(gui_level_dot, + (int)(GUI_LEVEL_SIZE / 2 - GUI_LEVEL_DOT / 2 + px), + (int)(GUI_LEVEL_SIZE / 2 - GUI_LEVEL_DOT / 2 + py)); + + // Color: green when near level, amber when tilted, red when extreme + float tilt_deg = atan2f(sqrtf(tx*tx + ty*ty), fabsf(imu_az_f / 4096.0f)) * 57.2958f; + uint32_t dot_col = (tilt_deg < 3.0f) ? GUI_COL_GREEN : + (tilt_deg < 15.0f) ? GUI_COL_AMBER : GUI_COL_RED; + lv_obj_set_style_bg_color(gui_level_dot, lv_color_hex(dot_col), 0); + + lv_label_set_text_fmt(gui_level_angle, "%.1f\xC2\xB0", tilt_deg); + lv_obj_set_style_text_color(gui_level_angle, lv_color_hex(dot_col), 0); + } + // Battery complication — voltage and state if (battery_state == BATTERY_STATE_CHARGING) { lv_label_set_text_fmt(gui_batt_value, "%.2fV", battery_voltage); diff --git a/RNode_Firmware.ino b/RNode_Firmware.ino index e847151..f160257 100644 --- a/RNode_Firmware.ino +++ b/RNode_Firmware.ino @@ -33,6 +33,20 @@ volatile uint32_t imu_step_count = 0; volatile bool imu_wrist_tilt = false; + // Filtered accelerometer for bubble level (EMA, α=0.15) + volatile float imu_ax_f = 0, imu_ay_f = 0, imu_az_f = 4096; + #define IMU_EMA_ALPHA 0.15f + void imu_accel_live_cb(uint8_t sensor_id, uint8_t *data, uint32_t size, uint64_t *timestamp, void *user_data) { + if (size >= 6) { + float ax = (int16_t)(data[0] | (data[1] << 8)); + float ay = (int16_t)(data[2] | (data[3] << 8)); + float az = (int16_t)(data[4] | (data[5] << 8)); + imu_ax_f += IMU_EMA_ALPHA * (ax - imu_ax_f); + imu_ay_f += IMU_EMA_ALPHA * (ay - imu_ay_f); + imu_az_f += IMU_EMA_ALPHA * (az - imu_az_f); + } + } + // MAX98357A I2S speaker + SPM1423 PDM microphone #include "Speaker.h" #include "Microphone.h" @@ -2239,6 +2253,10 @@ void loop() { bhi260->configure(SensorBHI260AP::WRIST_TILT_GESTURE, 1.0, 0); bhi260->onResultEvent(SensorBHI260AP::WRIST_TILT_GESTURE, imu_wrist_tilt_cb); + // Always-on accelerometer at 10Hz for bubble level + bhi260->configure(SensorBHI260AP::ACCEL_PASSTHROUGH, 10.0, 0); + bhi260->onResultEvent(SensorBHI260AP::ACCEL_PASSTHROUGH, imu_accel_live_cb); + // Register IMU log toggle for remote debug #if HAS_SD && HAS_DISPLAY gui_log_toggle_fn = []() -> bool {