#include "oled.h" #include "oled_font.h" #include "bt.h" #include "slots.h" #include "audio.h" #include "config.h" #include "state_mgr.h" #include #include #include "hardware/spi.h" #include "hardware/gpio.h" #include "hardware/watchdog.h" #include "hardware/clocks.h" #include "hardware/vreg.h" #include "cmd.h" #include "pico/time.h" extern uint8_t interrupt_in_data[63]; // defined in main.cpp // Mic diagnostic counters (defined in main.cpp). extern uint32_t bt_31_packet_count(); extern uint32_t host_out02_total(); extern uint32_t host_out02_trig_allow(); extern uint32_t host_out02_to_bt(); extern uint32_t host_out02_trig_folded(); extern uint8_t bt_31_last_byte2(); extern uint8_t bt_31_b2_or_mask(); extern uint16_t bt_31_len_min(); extern uint16_t bt_31_len_max(); extern void bt_31_mic_prefix(uint8_t out[6]); extern bool spk_active; // main.cpp: true while host USB speaker stream is open // Global (not in the anon namespace below) so state_mgr.cpp can extern it: // true while an OLED lightbar mode or the charging pulse owns the LED, which // tells state_update() to ignore the host's AllowLedColor writes. bool g_lightbar_override = false; namespace { constexpr uint kPinDC = 8; constexpr uint kPinCS = 9; constexpr uint kPinCLK = 10; constexpr uint kPinMOSI = 11; constexpr uint kPinRST = 12; constexpr uint kPinKey0 = 15; constexpr uint kPinKey1 = 17; constexpr int kW = 128; constexpr int kH = 64; constexpr int kRowBytes = kW / 8; constexpr int kFbBytes = kRowBytes * kH; uint8_t fb[kFbBytes]; uint32_t last_render_us = 0; constexpr uint32_t kFrameUs = 100000; bool key0_prev = true; bool key1_prev = true; uint32_t key0_t_us = 0; uint32_t key1_t_us = 0; constexpr uint32_t kDebounceUs = 20000; // Single-press latch — armed on rising edge, fired on release. KEY0 was // previously a double-click reboot trigger; that gesture moved to the // KEY0+KEY1 chord below because rapid forward-navigation kept tripping it. bool key0_armed = false; // KEY1 long-press detection (for brightness cycling) uint32_t key1_press_us = 0; bool key1_was_pressed = false; constexpr uint32_t kLongPressUs = 1500000; // KEY0 + KEY1 simultaneous hold → watchdog_reboot. 1 s hold is long enough // to filter accidental two-button taps but short enough to feel responsive. uint32_t chord_held_since_us = 0; constexpr uint32_t kChordHoldUs = 1000000; // Brightness levels (SH1107 contrast register 0x81). User cycles via KEY1 long-press. constexpr uint8_t kBrightLevels[] = {0xFF, 0x7F, 0x3F, 0x10}; constexpr int kNumBrightLevels = sizeof(kBrightLevels) / sizeof(kBrightLevels[0]); int bright_idx = 0; uint8_t current_contrast = 0xFF; // Auto-dim / auto-off after idle. Tracks last button/input activity. // Tier 1: Active → full brightness (bright_idx). // Tier 2: idle > dim threshold → contrast drops to kDimContrast (deep dim). // Tier 3: idle > off threshold → SH1107 panel turned fully off (cmd 0xAE) // to prevent OLED burn-in on long unattended sits. // The two thresholds are user-configurable (Config_body.screen_dim_timeout / // screen_off_timeout, minutes; 0 = tier disabled) — issue #5. last_activity_us // is 64-bit µs so the full 0..250 min range is representable without the ~71 min // wrap of time_us_32(). // kDimContrast tuned by eye: 0x10 looked like only ~10% reduction on this // panel (contrast-vs-brightness is heavily non-linear near the bottom of // the register range). 0x02 is visibly dim while still legible up close. uint64_t last_activity_us = 0; uint32_t last_input_hash = 0; constexpr uint8_t kDimContrast = 0x01; enum OledPowerState { OLED_ACTIVE, OLED_DIM, OLED_OFF }; OledPowerState oled_power_state = OLED_ACTIVE; bool prev_bt_connected = false; // Screen ordering — single source of truth. Reorder by editing this block; // oled_loop's switch and handle_buttons' KEY1 contextual checks use these // names, so the indices can move without touching that code. constexpr int kScreenStatus = 0; constexpr int kScreenSlots = 1; constexpr int kScreenLightbar = 2; constexpr int kScreenTriggers = 3; constexpr int kScreenGyro = 4; constexpr int kScreenTouchpad = 5; constexpr int kScreenDiag = 6; constexpr int kScreenCpu = 7; constexpr int kScreenRssi = 8; constexpr int kScreenVU = 9; constexpr int kScreenSettings = 10; constexpr int kNumScreens = 11; int current_screen = 0; // Lightbar mode cycle: 0=LIVE, 1-4=FAV0-3, 5=BREATHING, 6=RAINBOW, 7=FADE, // 8=HOST (passthrough — let the host/game own the LED). HOST is the default so // the dongle doesn't hijack game player-indicator LEDs out of the box. Keep // this numbering in sync with Config_body::lightbar_mode (src/config.h). constexpr int kLbModeHost = 8; constexpr int kNumLbModes = 9; // Settings screen state constexpr int kNumSettingsItems = 17; // 8 fields + 3 auto-haptic + 2 screen-timeout + BT mic + Ctrl-wake + Reset + Wipe constexpr int kSettingsAutoHapEnaIdx = 8; constexpr int kSettingsAutoHapGainIdx = 9; constexpr int kSettingsAutoHapLpIdx = 10; constexpr int kSettingsScrDimIdx = 11; constexpr int kSettingsScrOffIdx = 12; constexpr int kSettingsBtMicIdx = 13; constexpr int kSettingsCtrlWakeIdx = 14; constexpr int kSettingsResetIdx = 15; constexpr int kSettingsWipeSlotsIdx = 16; Config_body settings_local{}; int settings_sel = 0; bool settings_dirty = false; bool settings_init_done = false; uint8_t settings_last_dpad = 8; // 8 = released uint8_t settings_last_face = 0; const char* settings_save_status = ""; // shown briefly after Triangle press // Factory-reset hold-Triangle-2s state. Borrowed from zurce/DS5Dongle-OLED's // "hold to wipe" UX pattern (https://github.com/zurce/DS5Dongle-OLED). uint32_t settings_tri_press_us = 0; bool settings_reset_triggered = false; constexpr uint32_t kResetHoldUs = 2000000; uint8_t lb_r = 0, lb_g = 0, lb_b = 0; // Lightbar mode + favorite slots: 0 = LIVE tilt preview; 1..4 = saved slots F0..F3. // These are seeded from flash (lightbar_load_config) at boot; the defaults here // only apply before that runs. lb_dirty tracks an unsaved mode/favorite change // so we persist once on leaving the Lightbar screen instead of per button press. int lb_mode = kLbModeHost; uint8_t lb_fav_r[4] = {255, 0, 0, 255}; // Red, Green, Blue, White defaults uint8_t lb_fav_g[4] = {0, 255, 0, 255}; uint8_t lb_fav_b[4] = {0, 0, 255, 255}; uint8_t lb_last_face = 0; bool lb_dirty = false; uint32_t rumble_off_at_us = 0; bool rumble_active = false; constexpr uint32_t kRumbleBurstUs = 250000; int trigger_preset = 0; const char* const kTrigPresetNames[] = {"Off", "Feedback", "Weapon", "Vibration", "Bow", "Gallop", "Machine"}; // Rising-edge trackers for the screens whose K1=cycle action moved to a // controller button. Trigger Test uses △ (byte 7 bit 7); Lightbar uses R1 // (byte 8 bit 1) because △ is already taken on Lightbar for "save current // RGB to favorite slot 0". uint8_t triggers_last_face = 0; uint8_t lb_last_buttons = 0; constexpr int kNumTrigPresets = 7; void cmd(uint8_t c) { gpio_put(kPinDC, 0); gpio_put(kPinCS, 0); spi_write_blocking(spi1, &c, 1); gpio_put(kPinCS, 1); } void data_byte(uint8_t d) { gpio_put(kPinDC, 1); gpio_put(kPinCS, 0); spi_write_blocking(spi1, &d, 1); gpio_put(kPinCS, 1); } uint8_t reverse_byte(uint8_t b) { b = ((b & 0x55) << 1) | ((b & 0xAA) >> 1); b = ((b & 0x33) << 2) | ((b & 0xCC) >> 2); b = ((b & 0x0F) << 4) | ((b & 0xF0) >> 4); return b; } void hw_reset() { gpio_put(kPinRST, 1); sleep_ms(100); gpio_put(kPinRST, 0); sleep_ms(100); gpio_put(kPinRST, 1); sleep_ms(100); } void sh1107_set_contrast(uint8_t value) { if (value == current_contrast) return; current_contrast = value; cmd(0x81); cmd(value); } void sh1107_init() { cmd(0xAE); cmd(0x00); cmd(0x10); cmd(0xB0); cmd(0xDC); cmd(0x00); cmd(0x81); cmd(0x6F); cmd(0x21); cmd(0xA0); cmd(0xC0); cmd(0xA4); cmd(0xA6); cmd(0xA8); cmd(0x3F); cmd(0xD3); cmd(0x60); cmd(0xD5); cmd(0x41); cmd(0xD9); cmd(0x22); cmd(0xDB); cmd(0x35); cmd(0xAD); cmd(0x8A); sleep_ms(50); cmd(0xAF); } // Forward-declared so flush_fb can paint the per-button arrows on top of // the rendered framebuffer just before SPI sends it to the OLED. Body // lives near the other text-drawing helpers below. void draw_button_chrome(); void flush_fb_raw() { cmd(0xB0); for (int j = 0; j < kH; j++) { const uint8_t col = kH - 1 - j; cmd(0x00 + (col & 0x0F)); cmd(0x10 + (col >> 4)); for (int i = 0; i < kRowBytes; i++) { data_byte(reverse_byte(fb[j * kRowBytes + i])); } } } void flush_fb() { draw_button_chrome(); flush_fb_raw(); } void fb_clear() { memset(fb, 0, sizeof(fb)); } void px(int x, int y, bool on) { if (x < 0 || x >= kW || y < 0 || y >= kH) return; uint8_t *p = &fb[y * kRowBytes + (x / 8)]; uint8_t m = 1 << (7 - (x % 8)); if (on) *p |= m; else *p &= ~m; } void rect_outline(int x, int y, int w, int h) { for (int i = 0; i < w; i++) { px(x + i, y, true); px(x + i, y + h - 1, true); } for (int i = 0; i < h; i++) { px(x, y + i, true); px(x + w - 1, y + i, true); } } void rect_filled(int x, int y, int w, int h) { for (int j = 0; j < h; j++) for (int i = 0; i < w; i++) px(x + i, y + j, true); } // XOR-invert every pixel in a region (used to flash a control "pressed"). void rect_invert(int x, int y, int w, int h) { for (int j = 0; j < h; j++) for (int i = 0; i < w; i++) { const int xx = x + i, yy = y + j; if (xx < 0 || xx >= kW || yy < 0 || yy >= kH) continue; fb[yy * kRowBytes + (xx / 8)] ^= 1 << (7 - (xx % 8)); } } void draw_char(int x, int y, char c) { if (c < 0x20 || c > 0x7E) return; const uint8_t *g = kFont5x7[c - 0x20]; for (int col = 0; col < kFontW; col++) { uint8_t bits = g[col]; for (int row = 0; row < kFontH; row++) { if (bits & (1 << row)) px(x + col, y + row, true); } } } void draw_text(int x, int y, const char *s) { while (*s) { draw_char(x, y, *s++); x += 6; } } // Button-chrome strip on the left edge of every screen. KEY0 (top button) // shows '>' at y=8; KEY1 (bottom button) shows '<' at y=49. Painted by // flush_fb() on top of the rendered framebuffer so it never gets clobbered. // Per-screen renderers reserve x ∈ [0..5] (5-wide glyph + 1 padding) and // start main content at kContentX. constexpr int kContentX = 6; void draw_button_chrome() { draw_char(0, 8, '>'); draw_char(0, 49, '<'); } // Pixel-art icon support. Visual approach inspired by zurce/DS5Dongle-OLED // (https://github.com/zurce/DS5Dongle-OLED) — credit to zurce for the idea // of decorating the OLED with small bitmaps instead of bare text/shapes. // Bitmap layout: row-major, MSB = leftmost pixel, ceil(w/8) bytes per row. void draw_icon(int x, int y, const uint8_t *bitmap, int w, int h) { const int row_bytes = (w + 7) / 8; for (int row = 0; row < h; row++) { for (int col = 0; col < w; col++) { const uint8_t byte = bitmap[row * row_bytes + (col / 8)]; const uint8_t mask = (uint8_t)(1u << (7 - (col % 8))); if (byte & mask) px(x + col, y + row, true); } } } // 8x8 "link active" filled circle (drawn when DS5 is paired) static const uint8_t kIconLinkOn[8] = { 0b00111100, 0b01111110, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b01111110, 0b00111100, }; // 8x8 "link inactive" hollow circle (drawn when waiting for DS5) static const uint8_t kIconLinkOff[8] = { 0b00111100, 0b01000010, 0b10000001, 0b10000001, 0b10000001, 0b10000001, 0b01000010, 0b00111100, }; // Battery icon — body 52x8 + small nub on the right. Inside fill scales with pct. void draw_battery_icon(int x, int y, int pct) { rect_outline(x, y, 52, 8); rect_filled(x + 52, y + 2, 3, 4); int fill = (pct * 48) / 100; if (fill < 0) fill = 0; if (fill > 48) fill = 48; if (fill > 0) rect_filled(x + 2, y + 2, fill, 4); } void send_rumble(uint8_t amplitude) { uint8_t pkt[78] = {}; pkt[0] = 0x31; pkt[1] = 0x00; pkt[2] = 0x10; pkt[3] = 0x03; pkt[5] = amplitude; pkt[6] = amplitude; bt_write(pkt, sizeof(pkt)); } void rumble_burst_tick(uint32_t now) { if (rumble_active && (int32_t)(now - rumble_off_at_us) >= 0) { send_rumble(0); rumble_active = false; } } // Trigger effect param format follows dualsensectl's reverse-engineering. // Modes 0x21/0x25/0x26 use bitpacked 10-zone arrays, not raw position bytes. void send_trigger_effect(int preset) { uint8_t pkt[78] = {}; pkt[0] = 0x31; pkt[2] = 0x10; pkt[3] = 0x0C; // valid_flag0: RIGHT_TRIGGER_MOTOR_ENABLE | LEFT_TRIGGER_MOTOR_ENABLE uint8_t mode = 0x05; // OFF uint8_t p[9] = {0}; switch (preset) { case 0: // Off mode = 0x05; break; case 1: { // Feedback — all 10 zones at max strength 8 mode = 0x21; const uint16_t active = 0x03FF; uint32_t strength = 0; for (int i = 0; i < 10; i++) strength |= (uint32_t)(7u << (3 * i)); p[0] = active & 0xFF; p[1] = (active >> 8) & 0xFF; p[2] = strength & 0xFF; p[3] = (strength >> 8) & 0xFF; p[4] = (strength >> 16) & 0xFF; p[5] = (strength >> 24) & 0xFF; break; } case 2: { // Weapon — snap between positions 3 and 5, force 8 mode = 0x25; const uint16_t start_stop = (1u << 3) | (1u << 5); p[0] = start_stop & 0xFF; p[1] = (start_stop >> 8) & 0xFF; p[2] = 7; // force = strength - 1 break; } case 3: { // Vibration — all 10 zones at amplitude 8, frequency 30 Hz mode = 0x26; const uint16_t active = 0x03FF; uint32_t strength = 0; for (int i = 0; i < 10; i++) strength |= (uint32_t)(7u << (3 * i)); p[0] = active & 0xFF; p[1] = (active >> 8) & 0xFF; p[2] = strength & 0xFF; p[3] = (strength >> 8) & 0xFF; p[4] = (strength >> 16) & 0xFF; p[5] = (strength >> 24) & 0xFF; p[8] = 30; break; } case 4: { // Bow — drawing resistance + snap at position 6 mode = 0x22; const uint16_t start_stop = (1u << 2) | (1u << 6); const uint8_t force_pair = 7u | (7u << 3); // strength=8, snap=8 p[0] = start_stop & 0xFF; p[1] = (start_stop >> 8) & 0xFF; p[2] = force_pair; break; } case 5: { // Galloping mode = 0x23; const uint16_t start_stop = (1u << 0) | (1u << 9); const uint8_t ratio = (5u & 0x07) | ((1u & 0x07) << 3); p[0] = start_stop & 0xFF; p[1] = (start_stop >> 8) & 0xFF; p[2] = ratio; p[3] = 5; // frequency break; } case 6: { // Machine gun mode = 0x27; const uint16_t start_stop = (1u << 1) | (1u << 8); const uint8_t force_pair = 7u | (7u << 3); p[0] = start_stop & 0xFF; p[1] = (start_stop >> 8) & 0xFF; p[2] = force_pair; p[3] = 20; // frequency p[4] = 0; // period break; } } pkt[13] = mode; for (int i = 0; i < 9; i++) pkt[14 + i] = p[i]; pkt[24] = mode; for (int i = 0; i < 9; i++) pkt[25 + i] = p[i]; bt_write(pkt, sizeof(pkt)); } void send_lightbar_color(uint8_t r, uint8_t g, uint8_t b); void handle_buttons() { const uint32_t now = time_us_32(); const bool k0 = gpio_get(kPinKey0); const bool k1 = gpio_get(kPinKey1); // KEY0 + KEY1 chord — both held >= kChordHoldUs triggers watchdog_reboot. // Pre-empts the per-key handlers so a chord cancels any armed single // press (whichever key gets released first won't also navigate). const bool chord = !k0 && !k1; if (chord) { if (chord_held_since_us == 0) chord_held_since_us = now; key0_armed = false; key1_was_pressed = false; if ((now - chord_held_since_us) >= kChordHoldUs) { watchdog_reboot(0, 0, 0); } } else { chord_held_since_us = 0; } // KEY0: arm on debounced rising edge, fire "next screen" on release. // Releasing without a chord during the hold = pure forward-nav. if (!k0 && key0_prev && (now - key0_t_us) > kDebounceUs) { key0_t_us = now; key0_armed = true; last_activity_us = time_us_64(); } if (k0 && !key0_prev && key0_armed) { key0_armed = false; current_screen = (current_screen + 1) % kNumScreens; last_render_us = 0; last_activity_us = time_us_64(); } // KEY1: arm on press, fire on release. Short press = back; long press // = brightness cycle (unchanged). Trigger-preset / lightbar-mode cycle // moved to the DualSense △ button — see triggers_handle_input() and // lightbar_handle_input(). The chord above clears key1_was_pressed so // a released-after-chord K1 doesn't navigate back. if (!k1 && key1_prev && (now - key1_t_us) > kDebounceUs) { key1_t_us = now; key1_press_us = now; key1_was_pressed = true; last_activity_us = time_us_64(); } if (k1 && !key1_prev && key1_was_pressed) { key1_was_pressed = false; const uint32_t held = now - key1_press_us; last_activity_us = time_us_64(); if (held > kLongPressUs) { bright_idx = (bright_idx + 1) % kNumBrightLevels; // Persist so the choice survives a power cycle (issue #9). Keep // settings_local in sync too, so a later Settings-screen save can't // clobber screen_brightness with its stale snapshot. Config_body b = get_config(); b.screen_brightness = (uint8_t)bright_idx; set_config(b); config_save(); settings_local.screen_brightness = (uint8_t)bright_idx; } else { current_screen = (current_screen - 1 + kNumScreens) % kNumScreens; last_render_us = 0; } } key0_prev = k0; key1_prev = k1; } // --- Charge ETA tracker -------------------------------------------------- // The DS5 only reports battery in 10% steps (interrupt_in_data[52] low // nibble, 0..10; high nibble is power-state, 1 == charging). We can't read a // finer percentage over BT, so a smooth countdown is impossible. Instead we // time how long each 10% step takes while charging and extrapolate the // remaining steps. Sampled once per frame from oled_loop (continuously, so // the estimate stays current even while the panel is dimmed/off and even when // the user is on another screen); render_screen reads g_charge_eta. // // Taper correction: Li-ion CC/CV charging slows sharply near the top, so a // flat "time per step × steps left" runs optimistic in the last ~20%. Each // measured step is normalised to a bulk-equivalent duration (divide out the // step's taper weight); the remaining steps are then re-weighted. This makes // the estimate consistent whether the user plugged in near-empty or near-full. struct ChargeEta { bool charging; // pstate == 1 (so the token shows only while charging) bool valid; // minutes is meaningful (provisional or measured) bool provisional; // true until a full step is timed — using the default rate int minutes; // estimated minutes to 100% }; ChargeEta g_charge_eta{}; // Default bulk-step duration used for a provisional estimate before any real // step has been timed, so the token shows "~Nm?" immediately on plug-in instead // of sitting on "~--m" for ~15-20 min. Tuned to an observed ~15 min per 10% on // this dongle's charge current; it self-corrects to the measured rate (and drops // the "?") as soon as the first clean step completes. constexpr float kDefaultStepUs = 15.0f * 60.0f * 1000000.0f; // Ceiling on a single timed step's bulk-equivalent duration. A genuine idle 10% // step on this dongle is ~15 min; anything past ~30 min is almost always an // anomalous/under-load sample (e.g. the controller in use while charging, or a // battery-nibble bounce) that would otherwise balloon the projection — observed // reading ~222m at 70% off one ~47-min step. We clamp such samples instead of // trusting them, and pair that with a median over kRing steps so one bad reading // can't dominate the estimate. constexpr float kMaxStepUs = 30.0f * 60.0f * 1000000.0f; // Relative time the step *ending* at `to_level` (10% units, 1..10) takes vs a // bulk step. Tuned to the Li-ion CV taper: ~80% onward stretches out. static float charge_step_weight(int to_level) { if (to_level >= 10) return 2.2f; // 90→100% (constant-voltage tail) if (to_level == 9) return 1.5f; // 80→90% (taper begins) return 1.0f; // bulk constant-current region } void sample_charge_eta() { constexpr int kRing = 5; // median over the last few steps static float ring[kRing] = {0}; // bulk-equivalent step durations (us) static int ring_count = 0; static int ring_head = 0; static int cur_step = -1; // last observed 10% step static uint64_t step_start_us = 0; static bool was_charging = false; static bool first_step_pending = false; // discard the partial step at plug-in const uint8_t pwr = interrupt_in_data[52]; int step = pwr & 0x0F; if (step > 10) step = 10; const uint8_t pstate = pwr >> 4; const bool charging = bt_is_connected() && (pstate == 1); if (!charging) { g_charge_eta = ChargeEta{}; // clears charging/valid/minutes ring_count = ring_head = 0; cur_step = -1; was_charging = false; return; } const uint64_t now = time_us_64(); if (!was_charging) { // Just plugged in: start timing from here. The step in progress is // partial, so its duration gets discarded when it completes. cur_step = step; step_start_us = now; ring_count = ring_head = 0; first_step_pending = true; was_charging = true; } else if (step == cur_step + 1) { // One clean step completed. Skip the first (partial) one; otherwise // record its bulk-equivalent duration. const float dur = (float)(now - step_start_us); if (first_step_pending) { first_step_pending = false; } else { float be = dur / charge_step_weight(step); if (be > kMaxStepUs) be = kMaxStepUs; // clamp under-load/anomalous outliers ring[ring_head] = be; ring_head = (ring_head + 1) % kRing; if (ring_count < kRing) ring_count++; } cur_step = step; step_start_us = now; } else if (step != cur_step) { // Multi-step jump (e.g. woke from sleep across several steps) or a // small dip under heavy use — can't attribute timing cleanly, so just // resync without polluting the ring. cur_step = step; step_start_us = now; first_step_pending = false; } g_charge_eta.charging = true; if (cur_step < 10) { // Use the measured rate once we have a timed step; until then fall back // to the default rate and flag the estimate provisional (renders "?"). const bool measured = (ring_count > 0); float bulk; if (measured) { // Median of the timed steps — robust to a single slow/fast outlier // in a way the old mean wasn't (one 47-min under-load step used to // drag the whole projection up). kRing is tiny, so insertion-sort. float tmp[kRing]; for (int i = 0; i < ring_count; i++) tmp[i] = ring[i]; for (int i = 1; i < ring_count; i++) { const float v = tmp[i]; int j = i - 1; while (j >= 0 && tmp[j] > v) { tmp[j + 1] = tmp[j]; j--; } tmp[j + 1] = v; } bulk = tmp[ring_count / 2]; } else { bulk = kDefaultStepUs; } float rem_us = 0.0f; for (int L = cur_step + 1; L <= 10; L++) rem_us += bulk * charge_step_weight(L); int mins = (int)(rem_us / 60000000.0f + 0.5f); if (mins < 0) mins = 0; if (mins > 999) mins = 999; g_charge_eta.valid = true; g_charge_eta.provisional = !measured; g_charge_eta.minutes = mins; } else { // cur_step == 10 → essentially full; nothing meaningful to count down. g_charge_eta.valid = true; g_charge_eta.provisional = false; g_charge_eta.minutes = 0; } } __attribute__((noinline)) void render_screen() { fb_clear(); const bool connected = bt_is_connected(); // FIRMWARE_VERSION is set via CMake from -DVERSION=... on the build // command line (release.yml passes the tag name). Local builds get // "dev" so a non-tagged build is visible at a glance. draw_text(kContentX, 0, "DS5 Bridge " FIRMWARE_VERSION); draw_icon(120, 0, connected ? kIconLinkOn : kIconLinkOff, 8, 8); if (connected) { uint8_t a[6]; bt_get_addr(a); char buf[24]; snprintf(buf, sizeof(buf), "%02X:%02X:%02X:%02X:%02X:%02X", a[0], a[1], a[2], a[3], a[4], a[5]); draw_text(kContentX, 9, buf); const uint8_t pwr = interrupt_in_data[52]; int pct = (pwr & 0x0F) * 10; if (pct > 100) pct = 100; const uint8_t pstate = pwr >> 4; char marker = ' '; if (pstate == 1) marker = '+'; // Charging else if (pstate == 2) marker = '*'; // Complete else if (pstate >= 0xA) marker = '!'; // Error char bbuf[16]; snprintf(bbuf, sizeof(bbuf), "%3d%%%c", pct, marker); draw_text(kContentX, 18, bbuf); draw_battery_icon(36, 18, pct); // Charge ETA, right of the battery icon (icon ends at x≈90). Shown only // while charging: "~43m?" is the provisional default-rate estimate shown // immediately on plug-in; the "?" drops to "~43m" once a real 10% step // has been timed and the measured rate takes over. See sample_charge_eta(). if (g_charge_eta.charging) { char ebuf[8]; if (g_charge_eta.valid) snprintf(ebuf, sizeof(ebuf), "~%dm%s", g_charge_eta.minutes, g_charge_eta.provisional ? "?" : ""); else snprintf(ebuf, sizeof(ebuf), "~--m"); draw_text(94, 18, ebuf); } // Left-half visuals are shifted right by kContentX so the < button // chrome at (x=0, y=49) doesn't paint over the live stick dot. rect_outline(kContentX, 30, 32, 32); int lx = (kContentX + 2) + (interrupt_in_data[0] * 27) / 255; int ly = 32 + (interrupt_in_data[1] * 27) / 255; rect_filled(lx - 1, ly - 1, 3, 3); // L3 (left stick click) — invert the whole box as a pressed indicator. if (interrupt_in_data[8] & 0x40) rect_invert(kContentX, 30, 32, 32); rect_outline(96, 30, 32, 32); int rx = 98 + (interrupt_in_data[2] * 27) / 255; int ry = 32 + (interrupt_in_data[3] * 27) / 255; rect_filled(rx - 1, ry - 1, 3, 3); // R3 (right stick click) — invert the whole box. if (interrupt_in_data[8] & 0x80) rect_invert(96, 30, 32, 32); // L2/R2 analog trigger bars (vertical, fill from bottom). L2 sits // just right of the shifted left stick box. rect_outline(kContentX + 32, 33, 4, 29); const int l2_fill = (interrupt_in_data[4] * 27) / 255; if (l2_fill > 0) rect_filled(kContentX + 33, 61 - l2_fill, 2, l2_fill); rect_outline(92, 33, 4, 29); const int r2_fill = (interrupt_in_data[5] * 27) / 255; if (r2_fill > 0) rect_filled(93, 61 - r2_fill, 2, r2_fill); const uint8_t b7 = interrupt_in_data[7]; const uint8_t b8 = interrupt_in_data[8]; // D-pad indicator (4 directions; lit for primary + diagonals). // Centered between the left stick column and the face-button cluster. const int dp = b7 & 0x0F; const bool dp_n = (dp == 7 || dp == 0 || dp == 1); const bool dp_e = (dp == 1 || dp == 2 || dp == 3); const bool dp_s = (dp == 3 || dp == 4 || dp == 5); const bool dp_w = (dp == 5 || dp == 6 || dp == 7); const int dcx = 52, dcy = 46; auto dot = [&](int dx, int dy, bool on) { if (on) rect_filled(dcx + dx - 2, dcy + dy - 2, 5, 5); else rect_outline(dcx + dx - 2, dcy + dy - 2, 5, 5); }; dot(0, -7, dp_n); dot(7, 0, dp_e); dot(0, 7, dp_s); dot(-7, 0, dp_w); const int fcx = 64, fcy = 46; auto sq = [&](int dx, int dy, bool on) { if (on) rect_filled(fcx + dx - 2, fcy + dy - 2, 5, 5); else rect_outline(fcx + dx - 2, fcy + dy - 2, 5, 5); }; // shift face buttons right so they don't collide with d-pad const int fcx_off = 18; sq(fcx_off + 0, -8, b7 & 0x80); // Triangle sq(fcx_off + 8, 0, b7 & 0x40); // Circle sq(fcx_off + 0, 8, b7 & 0x20); // Cross sq(fcx_off - 8, 0, b7 & 0x10); // Square // L1 bar shifted to sit between the L2 trigger column and the d-pad. if (b8 & 0x01) rect_filled(42, 30, 8, 3); else rect_outline(42, 30, 8, 3); // L1 if (b8 & 0x02) rect_filled(80, 30, 12, 3); else rect_outline(80, 30, 12, 3); // R1 } else { draw_text(kContentX, 14, "Pair your DualSense:"); draw_text(kContentX, 26, "1. Hold Create + PS"); draw_text(kContentX, 36, "2. Wait for light bar"); draw_text(kContentX, 46, " to flash blue"); } flush_fb(); } __attribute__((noinline)) void render_screen_rssi() { fb_clear(); draw_text(kContentX, 0, "BT Signal"); if (bt_is_connected()) { int8_t rssi = 0; bt_get_signal_strength(&rssi); char buf[24]; snprintf(buf, sizeof(buf), "RSSI: %d dBm", (int)rssi); draw_text(kContentX, 12, buf); // Map RSSI range -90..-40 dBm to 0..100% bar int pct = ((int)rssi + 90) * 100 / 50; if (pct < 0) pct = 0; if (pct > 100) pct = 100; snprintf(buf, sizeof(buf), "Quality: %d%%", pct); draw_text(kContentX, 22, buf); rect_outline(kContentX, 34, 122, 10); int fill = (pct * 118) / 100; if (fill > 0) rect_filled(kContentX + 2, 36, fill, 6); const char *label = "Poor"; if (rssi > -55) label = "Excellent"; else if (rssi > -65) label = "Good"; else if (rssi > -75) label = "Fair"; snprintf(buf, sizeof(buf), "Link: %s", label); draw_text(kContentX, 48, buf); } else { draw_text(kContentX, 30, "(no controller)"); } flush_fb(); } // Diagnostics screen state. Read-only viewport that scrolls with controller // D-pad up/down. No cursor — there's nothing to select. int diag_scroll = 0; uint8_t diag_last_dpad = 8; // edge-trigger N/E/S/W like settings_handle_input // Per-second rates sampled once per render, shared across format_diag_row's // rate-based rows so they stay in sync. struct DiagRates { uint32_t usb_rate; uint32_t bt_rate; uint32_t mic_rate; uint32_t bt31_rate; }; DiagRates g_diag_rates{}; void sample_diag_rates() { static uint32_t prev_us_frames = 0, prev_bt_packets = 0, prev_mic_frames = 0, prev_bt31 = 0; static uint32_t prev_sample_us = 0; const uint32_t now_us = time_us_32(); const uint32_t cur_us_frames = audio_usb_frames(); const uint32_t cur_bt_packets = audio_bt_packets(); const uint32_t cur_mic_frames = audio_mic_frames(); const uint32_t cur_bt31 = bt_31_packet_count(); if (prev_sample_us != 0 && now_us > prev_sample_us) { const uint32_t dt_us = now_us - prev_sample_us; if (dt_us > 0) { g_diag_rates.usb_rate = (uint32_t)(((uint64_t)(cur_us_frames - prev_us_frames) * 1000000u) / dt_us); g_diag_rates.bt_rate = (uint32_t)(((uint64_t)(cur_bt_packets - prev_bt_packets) * 1000000u) / dt_us); g_diag_rates.mic_rate = (uint32_t)(((uint64_t)(cur_mic_frames - prev_mic_frames) * 1000000u) / dt_us); g_diag_rates.bt31_rate = (uint32_t)(((uint64_t)(cur_bt31 - prev_bt31) * 1000000u) / dt_us); } } prev_us_frames = cur_us_frames; prev_bt_packets = cur_bt_packets; prev_mic_frames = cur_mic_frames; prev_bt31 = cur_bt31; prev_sample_us = now_us; } // Row list ordered by relevance: always-useful at top, parked-mic-investigation // data at bottom. To add a row, bump kNumDiagRows and add a case. constexpr int kNumDiagRows = 12; __attribute__((noinline)) void format_diag_row(int idx, char* line, size_t n) { switch (idx) { case 0: { const uint32_t s = time_us_32() / 1000000u; snprintf(line, n, "Up:%luh %02lum %02lus", (unsigned long)(s / 3600u), (unsigned long)((s / 60u) % 60u), (unsigned long)(s % 60u)); break; } case 1: snprintf(line, n, "BT: %s", bt_is_connected() ? "connected" : "waiting"); break; case 2: snprintf(line, n, "host02: %lu", (unsigned long)host_out02_total()); break; case 3: snprintf(line, n, "trig %lu / tx %lu", (unsigned long)host_out02_trig_allow(), (unsigned long)host_out02_to_bt()); break; case 4: // Trigger reports folded into the 0x36 audio path (speaker active), // not sent as 0x31. trig == tx-trig-share + this → no drops (#6). snprintf(line, n, "trig fold: %lu", (unsigned long)host_out02_trig_folded()); break; case 5: snprintf(line, n, "BT31 in: %lu/s", (unsigned long)g_diag_rates.bt31_rate); break; case 6: snprintf(line, n, "USB aud: %lu/s", (unsigned long)g_diag_rates.usb_rate); break; case 7: snprintf(line, n, "BT32 out: %lu/s", (unsigned long)g_diag_rates.bt_rate); break; case 8: snprintf(line, n, "Mic in: %lu/s", (unsigned long)g_diag_rates.mic_rate); break; case 9: snprintf(line, n, "Mic dec=%ld w=%u", (long)audio_mic_last_decoded(), (unsigned)audio_mic_last_wrote()); break; case 10: snprintf(line, n, "Mic PLC: %lu", (unsigned long)audio_mic_plc_frames()); break; case 11: { uint8_t pfx[6]; bt_31_mic_prefix(pfx); snprintf(line, n, "%02X %02X %02X %02X %02X %02X", pfx[0], pfx[1], pfx[2], pfx[3], pfx[4], pfx[5]); break; } default: line[0] = '\0'; break; } } void diag_handle_input(int visible) { if (!bt_is_connected()) return; const uint8_t dpad = (uint8_t)(interrupt_in_data[7] & 0x0F); if (dpad != diag_last_dpad && dpad != 8) { if (dpad == 0) diag_scroll--; // up else if (dpad == 4) diag_scroll++; // down } diag_last_dpad = dpad; const int max_top = (kNumDiagRows > visible) ? (kNumDiagRows - visible) : 0; if (diag_scroll < 0) diag_scroll = 0; if (diag_scroll > max_top) diag_scroll = max_top; } __attribute__((noinline)) void render_screen_diag() { fb_clear(); draw_text(kContentX, 0, "Diagnostics"); sample_diag_rates(); constexpr int kVisible = 5; diag_handle_input(kVisible); char line[28]; for (int i = 0; i < kVisible && diag_scroll + i < kNumDiagRows; i++) { format_diag_row(diag_scroll + i, line, sizeof(line)); draw_text(kContentX, 9 + i * 9, line); } // Scroll indicators along the right edge, adjacent to the visible content // rather than in a footer — keeps the bottom row available for content. if (diag_scroll > 0) draw_text(120, 9, "^"); if (diag_scroll + kVisible < kNumDiagRows) draw_text(120, 45, "v"); flush_fb(); } __attribute__((noinline)) void render_screen_cpu(bool entered) { fb_clear(); draw_text(kContentX, 0, "CPU / Clock"); char buf[24]; // Configured system clock — compile-time SYS_CLOCK_KHZ, set in main() // via set_sys_clock_khz(). This is the *target*. const uint32_t set_khz = (uint32_t)SYS_CLOCK_KHZ; snprintf(buf, sizeof(buf), "Set : %lu MHz", (unsigned long)(set_khz / 1000u)); draw_text(kContentX, 12, buf); // Actually running clk_sys, measured by the on-chip frequency counter // against the crystal reference (not just what we asked for). The counter // busy-waits a few ms per call, so measure ONCE on screen entry and cache // it — clk_sys is fixed at boot and never changes, so the temperature // (which legitimately drifts) is the only thing worth refreshing per // frame. cached_real_khz==0 also forces a (re)measure as a safety net. static uint32_t cached_real_khz = 0; if (entered || cached_real_khz == 0) { cached_real_khz = frequency_count_khz(CLOCKS_FC0_SRC_VALUE_CLK_SYS); } const uint32_t real_khz = cached_real_khz; snprintf(buf, sizeof(buf), "Real: %lu.%01lu MHz", (unsigned long)(real_khz / 1000u), (unsigned long)((real_khz % 1000u) / 100u)); draw_text(kContentX, 22, buf); // Core voltage actually programmed into the regulator, read back (not the // compile-time constant). Codes 0..15 are linear 0.05 V steps from 0.55 V. const int vcode = (int)vreg_get_voltage(); if (vcode >= 0 && vcode <= 0b01111) { const unsigned mv = 550u + 50u * (unsigned)vcode; snprintf(buf, sizeof(buf), "Vcore: %u.%02u V", mv / 1000u, (mv % 1000u) / 10u); } else { snprintf(buf, sizeof(buf), "Vcore: code %d", vcode); } draw_text(kContentX, 32, buf); // RP2350 on-die temperature sensor. Smoothed + averaged in cmd.cpp // (single source of truth shared with the 0xfc web telemetry) so the // reading converges to the true die temp instead of chasing ADC noise. const uint16_t raw = cpu_temp_raw_smoothed(); const float volts = (float)raw * 3.3f / 4096.0f; const float temp_c = 27.0f - (volts - 0.706f) / 0.001721f; const int t10 = (int)(temp_c * 10.0f + (temp_c >= 0 ? 0.5f : -0.5f)); snprintf(buf, sizeof(buf), "Temp : %d.%d C", t10 / 10, (t10 < 0 ? -t10 : t10) % 10); draw_text(kContentX, 42, buf); flush_fb(); } // △ rising edge on the Trigger Test screen cycles trigger_preset and // re-applies the new effect to the paired controller. KEY1 used to do // this; moving it to the controller frees K0/K1 for navigation only. void triggers_handle_input() { if (!bt_is_connected()) { triggers_last_face = 0; return; } const uint8_t face = interrupt_in_data[7] & 0xF0; const bool tri_now = (face & 0x80) != 0; const bool tri_prev = (triggers_last_face & 0x80) != 0; if (tri_now && !tri_prev) { trigger_preset = (trigger_preset + 1) % kNumTrigPresets; send_trigger_effect(trigger_preset); } triggers_last_face = face; } __attribute__((noinline)) void render_screen_triggers() { triggers_handle_input(); fb_clear(); draw_text(kContentX, 0, "Trigger Test"); char buf[24]; snprintf(buf, sizeof(buf), "Mode: %s", kTrigPresetNames[trigger_preset]); draw_text(kContentX, 12, buf); if (bt_is_connected()) { const uint8_t l2 = interrupt_in_data[4]; const uint8_t r2 = interrupt_in_data[5]; snprintf(buf, sizeof(buf), "L2:%3d R2:%3d", l2, r2); draw_text(kContentX, 24, buf); rect_outline(kContentX, 35, 56, 9); int lfill = (l2 * 52) / 255; if (lfill > 0) rect_filled(kContentX + 2, 37, lfill, 5); rect_outline(72, 35, 56, 9); int rfill = (r2 * 52) / 255; if (rfill > 0) rect_filled(74, 37, rfill, 5); } else { draw_text(kContentX, 24, "(no controller)"); } draw_text(kContentX, 56, "Tri=cycle"); flush_fb(); } // --- IMU calibration (DS5 feature report 0x05) --------------------------- // The DualSense ships per-unit gyro/accel calibration in feature report 0x05, // which bt.cpp already fetches and caches at connect (init_feature). Parsing it // lets the Gyro Tilt screen and the tilt->RGB lightbar mode use bias- and // sensitivity-corrected accel instead of raw counts, so the tilt dot recenters // per controller. Parse + apply mirror SDL's SDL_hidapi_ps5.c (zlib-licensed) // LoadCalibrationData/ApplyCalibrationData (credit); feature_data[0x05]'s byte // layout matches SDL's data[] (index 0 = report id, calibration words from 1). // // imu_apply keeps accel in the same +-8192 == 1g count space the callers already // scale by, so existing /8192 (gyro screen) and +-8192 (lightbar) math is // unchanged — calibration only removes the per-axis zero offset and corrects gain. struct ImuCal { int16_t bias; float sens; }; // 0..2 gyro P/Y/R, 3..5 accel X/Y/Z ImuCal g_imu_cal[6]; bool g_imu_cal_valid = false; // a plausible calibration was loaded bool g_imu_cal_tried = false; // 0x05 has been seen this connection (good or bad) constexpr float kGyroResPerDeg = 1024.0f; constexpr float kAccelResPerG = 8192.0f; inline int16_t cal_ld16(const std::vector& d, int i) { return (int16_t)((uint16_t)d[i] | ((uint16_t)d[i + 1] << 8)); } __attribute__((noinline)) void imu_cal_parse(const std::vector& d) { g_imu_cal_valid = false; if (d.size() < 35) return; // SDL requires >= 35 calibration bytes const int16_t gPB = cal_ld16(d, 1), gYB = cal_ld16(d, 3), gRB = cal_ld16(d, 5); const int16_t gPp = cal_ld16(d, 7), gPm = cal_ld16(d, 9); const int16_t gYp = cal_ld16(d, 11), gYm = cal_ld16(d, 13); const int16_t gRp = cal_ld16(d, 15), gRm = cal_ld16(d, 17); const int16_t gSp = cal_ld16(d, 19), gSm = cal_ld16(d, 21); const int16_t aXp = cal_ld16(d, 23), aXm = cal_ld16(d, 25); const int16_t aYp = cal_ld16(d, 27), aYm = cal_ld16(d, 29); const int16_t aZp = cal_ld16(d, 31), aZm = cal_ld16(d, 33); const float num = (float)(gSp + gSm) * kGyroResPerDeg; g_imu_cal[0] = { gPB, num / (float)(gPp - gPm) }; g_imu_cal[1] = { gYB, num / (float)(gYp - gYm) }; g_imu_cal[2] = { gRB, num / (float)(gRp - gRm) }; int16_t r; r = aXp - aXm; g_imu_cal[3] = { (int16_t)(aXp - r / 2), 2.0f * kAccelResPerG / (float)r }; r = aYp - aYm; g_imu_cal[4] = { (int16_t)(aYp - r / 2), 2.0f * kAccelResPerG / (float)r }; r = aZp - aZm; g_imu_cal[5] = { (int16_t)(aZp - r / 2), 2.0f * kAccelResPerG / (float)r }; // Sanity gate (same as SDL): a wild bias or a gain off by >50% means a bad // factory cal or a short/garbled read — fall back to raw rather than amplify it. for (int i = 0; i < 6; i++) { const float divisor = (i < 3) ? 64.0f : 1.0f; const int ab = g_imu_cal[i].bias < 0 ? -g_imu_cal[i].bias : g_imu_cal[i].bias; float gain = 1.0f - g_imu_cal[i].sens / divisor; if (gain < 0) gain = -gain; if (ab > 1024 || gain > 0.5f) return; // leave g_imu_cal_valid = false } g_imu_cal_valid = true; } // Poll once per frame from oled_loop: parse 0x05 the first time it is available // for this controller, and reset on disconnect so the next controller re-reads. void imu_cal_service() { if (!bt_is_connected()) { g_imu_cal_valid = false; g_imu_cal_tried = false; return; } if (g_imu_cal_tried) return; auto d = bt_peek_feature(0x05); if (d.size() < 35) return; // not arrived yet — retry next frame imu_cal_parse(d); g_imu_cal_tried = true; } // index 0..2 gyro, 3..5 accel. Returns the calibrated value in the same count // scale the raw value used (+-8192 == 1g for accel); identity when no valid // calibration is loaded, so behaviour matches the pre-calibration firmware. inline int16_t imu_apply(int index, int16_t raw) { if (!g_imu_cal_valid) return raw; return (int16_t)((float)(raw - g_imu_cal[index].bias) * g_imu_cal[index].sens); } __attribute__((noinline)) void render_screen_gyro() { fb_clear(); draw_text(kContentX, 0, "Gyro Tilt"); if (bt_is_connected()) { int16_t ax, ay, az; memcpy(&ax, &interrupt_in_data[21], 2); memcpy(&ay, &interrupt_in_data[23], 2); memcpy(&az, &interrupt_in_data[25], 2); ax = imu_apply(3, ax); // bias/sensitivity-corrected accel (identity if no cal) ay = imu_apply(4, ay); az = imu_apply(5, az); char buf[16]; snprintf(buf, sizeof(buf), "X%+5d", ax); draw_text(kContentX, 10, buf); snprintf(buf, sizeof(buf), "Y%+5d", ay); draw_text(50, 10, buf); snprintf(buf, sizeof(buf), "Z%+5d", az); draw_text(94, 10, buf); const int bx = 44, by = 22, bw = 40, bh = 40; rect_outline(bx, by, bw, bh); for (int x = bx + 1; x < bx + bw - 1; x++) px(x, by + bh / 2, true); for (int y = by + 1; y < by + bh - 1; y++) px(bx + bw / 2, y, true); // Plot the two axes that read ~0 when the controller lies flat: X (roll, // left/right) and Z (pitch, fwd/back). Gravity rests on Y when flat, so // driving the dot from Y pegged it to the bottom edge at rest — using Z // keeps the dot centred flat and it tracks as you tilt. (Readout above // still shows all three raw axes.) // Negated so the dot follows the tilt direction: tilt left -> dot left, // tilt forward -> dot up (gravity pulls the opposite way on the axis). int dx = -((int)ax * (bw / 2 - 3)) / 8192; int dy = -((int)az * (bh / 2 - 3)) / 8192; int cx = bx + bw / 2 + dx; int cy = by + bh / 2 + dy; if (cx < bx + 2) cx = bx + 2; if (cx > bx + bw - 3) cx = bx + bw - 3; if (cy < by + 2) cy = by + 2; if (cy > by + bh - 3) cy = by + bh - 3; rect_filled(cx - 1, cy - 1, 3, 3); } else { draw_text(kContentX, 30, "(no controller)"); } flush_fb(); } __attribute__((noinline)) void render_screen_touchpad() { fb_clear(); draw_text(kContentX, 0, "Touchpad"); if (bt_is_connected()) { rect_outline(kContentX + 2, 12, 116, 30); int active = 0; for (int finger = 0; finger < 2; finger++) { const int off = 32 + finger * 4; const uint32_t f = (uint32_t)interrupt_in_data[off] | ((uint32_t)interrupt_in_data[off + 1] << 8) | ((uint32_t)interrupt_in_data[off + 2] << 16) | ((uint32_t)interrupt_in_data[off + 3] << 24); const bool not_touching = (f >> 7) & 1u; if (not_touching) continue; const uint16_t fx = (f >> 8) & 0xFFFu; const uint16_t fy = (f >> 20) & 0xFFFu; int sx = (kContentX + 3) + ((int)fx * 110) / 1919; int sy = 13 + ((int)fy * 26) / 1079; if (sx < kContentX + 3) sx = kContentX + 3; if (sx > 122) sx = 122; if (sy < 13) sy = 13; if (sy > 40) sy = 40; rect_filled(sx - 1, sy - 1, 3, 3); active++; } char buf[20]; snprintf(buf, sizeof(buf), "Fingers: %d", active); draw_text(kContentX, 46, buf); } else { draw_text(kContentX, 30, "(no controller)"); } flush_fb(); } void send_lightbar_color(uint8_t r, uint8_t g, uint8_t b) { uint8_t pkt[78] = {}; pkt[0] = 0x31; pkt[2] = 0x10; pkt[4] = 0x04; // valid_flag1: LIGHTBAR_CONTROL_ENABLE (bit 2) pkt[47] = r; // lightbar_red pkt[48] = g; // lightbar_green pkt[49] = b; // lightbar_blue bt_write(pkt, sizeof(pkt)); } // Tiny 32-step sine LUT (no ). angle 0..255 → amplitude -127..127. static const int8_t kSine32[32] = { 0, 24, 49, 70, 90, 106, 117, 125, 127, 125, 117, 106, 90, 70, 49, 24, 0, -24, -49, -70, -90, -106, -117, -125, -127, -125, -117, -106, -90, -70, -49, -24, }; int sin_lut(uint8_t a) { return kSine32[(a >> 3) & 0x1F]; } void hsv_to_rgb(uint16_t h, uint8_t s, uint8_t v, uint8_t *r, uint8_t *g, uint8_t *b) { if (h >= 360) h %= 360; const uint8_t region = (uint8_t)(h / 60); const uint16_t remainder = (uint16_t)((h - region * 60u) * 256u / 60u); const uint8_t p = (uint8_t)(((uint16_t)v * (255u - s)) >> 8); const uint8_t q = (uint8_t)(((uint16_t)v * (255u - (((uint16_t)s * remainder) >> 8))) >> 8); const uint8_t t = (uint8_t)(((uint16_t)v * (255u - (((uint16_t)s * (255u - remainder)) >> 8))) >> 8); switch (region) { case 0: *r = v; *g = t; *b = p; break; case 1: *r = q; *g = v; *b = p; break; case 2: *r = p; *g = v; *b = t; break; case 3: *r = p; *g = q; *b = v; break; case 4: *r = t; *g = p; *b = v; break; default: *r = v; *g = p; *b = q; break; } } const char* lb_mode_tag(int mode) { switch (mode) { case 0: return "[LIVE]"; case 1: return "[FAV0]"; case 2: return "[FAV1]"; case 3: return "[FAV2]"; case 4: return "[FAV3]"; case 5: return "[BREA]"; case 6: return "[RAIN]"; case 7: return "[FADE]"; case 8: return "[HOST]"; default: return "[????]"; } } // R1 rising edge on Lightbar cycles lb_mode. Used to be KEY1; that moved // to back-nav. Triangle on this screen stays as "save current RGB to // favorite slot 0" (the existing favorite-save UX), so R1 is the next // free button that doesn't break a mental model. void lightbar_handle_input() { if (!bt_is_connected()) { lb_last_buttons = 0; return; } const uint8_t btns = interrupt_in_data[8]; const bool r1_now = (btns & 0x02) != 0; const bool r1_prev = (lb_last_buttons & 0x02) != 0; if (r1_now && !r1_prev) { lb_mode = (lb_mode + 1) % kNumLbModes; lb_dirty = true; // persisted on leaving the Lightbar screen } lb_last_buttons = btns; } __attribute__((noinline)) void render_screen_lightbar() { lightbar_handle_input(); fb_clear(); draw_text(kContentX, 0, "Lightbar"); draw_text(86, 0, lb_mode_tag(lb_mode)); if (bt_is_connected()) { // lb_r/lb_g/lb_b are computed every frame by lightbar_service() (which // runs ahead of this render in oled_loop), so here we only display them. char buf[16]; snprintf(buf, sizeof(buf), "R:%3u", lb_r); draw_text(kContentX, 12, buf); snprintf(buf, sizeof(buf), "G:%3u", lb_g); draw_text(48, 12, buf); snprintf(buf, sizeof(buf), "B:%3u", lb_b); draw_text(90, 12, buf); const int by = 22, bh = 8; rect_outline(kContentX, by, 38, bh); int rf = (lb_r * 34) / 255; if (rf > 0) rect_filled(kContentX + 2, by + 2, rf, bh - 4); rect_outline(48, by, 38, bh); int gf = (lb_g * 34) / 255; if (gf > 0) rect_filled(50, by + 2, gf, bh - 4); rect_outline(90, by, 38, bh); int bf = (lb_b * 34) / 255; if (bf > 0) rect_filled(92, by + 2, bf, bh - 4); // Face button rising-edge -> save current color to slot 0..3 const uint8_t face = interrupt_in_data[7] & 0xF0; const uint8_t pressed = face & ~lb_last_face; lb_last_face = face; int save_slot = -1; if (pressed & 0x80) save_slot = 0; // Triangle else if (pressed & 0x40) save_slot = 1; // Circle else if (pressed & 0x20) save_slot = 2; // Cross else if (pressed & 0x10) save_slot = 3; // Square if (save_slot >= 0) { lb_fav_r[save_slot] = lb_r; lb_fav_g[save_slot] = lb_g; lb_fav_b[save_slot] = lb_b; lb_dirty = true; // persisted on leaving the Lightbar screen } draw_text(kContentX, 38, "Sv:T=0 C=1 X=2 S=3"); const char* hint = (lb_mode == 0) ? "Tilt = R/G/B" : (lb_mode == 5) ? "Breathing FAV0" : (lb_mode == 6) ? "Rainbow sweep" : (lb_mode == 7) ? "Fade thru FAVs" : (lb_mode == kLbModeHost) ? "Host controls" : "Locked to fav"; draw_text(kContentX, 48, hint); // No send here: lightbar_service() owns pushing the color to the // controller every frame, on this screen and every other. } else { draw_text(kContentX, 30, "(no controller)"); } draw_text(kContentX, 56, "R1=mode"); flush_fb(); } // Compute lb_r/lb_g/lb_b for an OLED lightbar mode (0..7). HOST (8) is handled // by the caller (no firmware color). noinline keeps the float/HSV literals out // of lightbar_service's / oled_loop's literal pool (same Thumb reach constraint // the render_screen_* functions hit). __attribute__((noinline)) void lightbar_compute_mode(int mode, uint32_t now_ms) { if (mode == 0) { // LIVE: tilt -> RGB int16_t ax, ay, az; memcpy(&ax, &interrupt_in_data[21], 2); memcpy(&ay, &interrupt_in_data[23], 2); memcpy(&az, &interrupt_in_data[25], 2); ax = imu_apply(3, ax); // calibrated accel keeps the +-8192 == 1g scale below ay = imu_apply(4, ay); az = imu_apply(5, az); const int rr = ((int)ax + 8192) * 255 / 16384; const int gg = ((int)ay + 8192) * 255 / 16384; const int bb = ((int)az + 8192) * 255 / 16384; lb_r = (uint8_t)(rr < 0 ? 0 : rr > 255 ? 255 : rr); lb_g = (uint8_t)(gg < 0 ? 0 : gg > 255 ? 255 : gg); lb_b = (uint8_t)(bb < 0 ? 0 : bb > 255 ? 255 : bb); } else if (mode <= 4) { // FAV slot: fixed color const int slot = mode - 1; lb_r = lb_fav_r[slot]; lb_g = lb_fav_g[slot]; lb_b = lb_fav_b[slot]; } else if (mode == 5) { // BREATHING: modulate FAV0 brightness with a sine wave (~3 s cycle) const uint8_t phase = (uint8_t)(now_ms / 12); const int s = sin_lut(phase); // -127..127 const uint16_t scale = (uint16_t)(32 + (s + 127) / 2); // 32..191 lb_r = (uint8_t)((lb_fav_r[0] * scale) / 255); lb_g = (uint8_t)((lb_fav_g[0] * scale) / 255); lb_b = (uint8_t)((lb_fav_b[0] * scale) / 255); } else if (mode == 6) { // RAINBOW: hue sweep over ~6 s const uint16_t hue = (uint16_t)((now_ms / 17) % 360); hsv_to_rgb(hue, 255, 255, &lb_r, &lb_g, &lb_b); } else { // FADE between FAV slots, 2 s per slot const uint32_t kSlotMs = 2000; const uint32_t total = now_ms % (4 * kSlotMs); const int slot = (int)(total / kSlotMs); const int next = (slot + 1) & 3; const uint16_t blend = (uint16_t)(((total - slot * kSlotMs) * 256u) / kSlotMs); lb_r = (uint8_t)((lb_fav_r[slot] * (255 - blend) + lb_fav_r[next] * blend) / 255); lb_g = (uint8_t)((lb_fav_g[slot] * (255 - blend) + lb_fav_g[next] * blend) / 255); lb_b = (uint8_t)((lb_fav_b[slot] * (255 - blend) + lb_fav_b[next] * blend) / 255); } } // The single owner of the controller LED. Runs every frame (~10 Hz) from // oled_loop, on every screen, so a chosen mode "sticks" everywhere instead of // only while the Lightbar screen renders. Priority: // 1. Charging -> amber-orange breathing pulse (status indicator). // 2. lb_mode != HOST -> the selected OLED mode/color. // 3. HOST (or disconnected) -> hand the LED back to the host/game. // When the firmware owns the LED it (a) writes state[] so the color rides every // host/audio packet and (b) actively pushes it via send_lightbar_color so it // updates even when the host is idle and animations keep moving. g_lightbar_ // override gates state_update() so host AllowLedColor writes can't stomp us. __attribute__((noinline)) void lightbar_service() { if (!bt_is_connected()) { g_lightbar_override = false; return; } const uint32_t now_ms = time_us_32() / 1000; if (g_charge_eta.charging) { // ~4.6 s breathing cycle (256 phase steps × 18 ms). Base amber // (255,100,0) sine-enveloped from dim (24) to bright (240). const uint8_t phase = (uint8_t)(now_ms / 18); const int s = sin_lut(phase); // -127..127 const uint16_t scale = (uint16_t)(24 + ((s + 127) * 216) / 254); // 24..240 lb_r = (uint8_t)((255u * scale) / 255u); lb_g = (uint8_t)((100u * scale) / 255u); lb_b = 0; } else if (lb_mode == kLbModeHost) { // Reflect the host's current LED on the OLED bars, then stand down. state_get_led(&lb_r, &lb_g, &lb_b); g_lightbar_override = false; return; } else { lightbar_compute_mode(lb_mode, now_ms); } g_lightbar_override = true; state_set_led(lb_r, lb_g, lb_b); // ride every host/audio frame if (!spk_active) { // Active push so the LED updates when the host is idle and animations // keep moving. Skipped during audio: the 0x36 frames already carry // state[]'s LED at audio rate, and slipping a 0x31 between them would // intrude on the load-bearing audio/haptic packet cadence. send_lightbar_color(lb_r, lb_g, lb_b); } } void lightbar_load_config() { const Config_body& c = get_config(); lb_mode = c.lightbar_mode; if (lb_mode < 0 || lb_mode >= kNumLbModes) lb_mode = kLbModeHost; for (int i = 0; i < 4; i++) { lb_fav_r[i] = c.lb_fav_r[i]; lb_fav_g[i] = c.lb_fav_g[i]; lb_fav_b[i] = c.lb_fav_b[i]; } lb_dirty = false; } void lightbar_save_config() { Config_body b = get_config(); b.lightbar_mode = (uint8_t)lb_mode; for (int i = 0; i < 4; i++) { b.lb_fav_r[i] = lb_fav_r[i]; b.lb_fav_g[i] = lb_fav_g[i]; b.lb_fav_b[i] = lb_fav_b[i]; } set_config(b); config_save(); lb_dirty = false; } __attribute__((noinline)) void render_screen_vu() { fb_clear(); draw_text(kContentX, 0, "Audio Meters"); if (bt_is_connected()) { const uint8_t spk = audio_peak_speaker(); const uint8_t hap = audio_peak_haptic(); char buf[16]; snprintf(buf, sizeof(buf), "SPK %3u", spk); draw_text(kContentX, 14, buf); rect_outline(48, 14, 80, 8); int sfill = (spk * 76) / 255; if (sfill > 0) rect_filled(50, 16, sfill, 4); snprintf(buf, sizeof(buf), "HAP %3u", hap); draw_text(kContentX, 28, buf); rect_outline(48, 28, 80, 8); int hfill = (hap * 76) / 255; if (hfill > 0) rect_filled(50, 30, hfill, 4); draw_text(kContentX, 42, "Live USB audio peaks"); } else { draw_text(kContentX, 30, "(no controller)"); } flush_fb(); } void settings_adjust(int delta) { Config_body &c = settings_local; settings_dirty = true; switch (settings_sel) { case 0: { // haptics_gain [1.0, 2.0] step 0.1 int v = (int)(c.haptics_gain * 10.0f + 0.5f) + delta; if (v < 10) v = 10; if (v > 20) v = 20; c.haptics_gain = v / 10.0f; break; } case 1: { // speaker_volume [-100, 0] step 5 int v = (int)c.speaker_volume + delta * 5; if (v < -100) v = -100; if (v > 0) v = 0; c.speaker_volume = (float)v; break; } case 2: { // inactive_time [10, 60] step 5 int v = (int)c.inactive_time + delta * 5; if (v < 10) v = 10; if (v > 60) v = 60; c.inactive_time = (uint8_t)v; break; } case 3: c.disable_inactive_disconnect ^= 1; break; case 4: c.disable_pico_led ^= 1; break; case 5: { // polling_rate_mode 0..2 int v = (int)c.polling_rate_mode + delta; if (v < 0) v = 2; if (v > 2) v = 0; c.polling_rate_mode = (uint8_t)v; break; } case 6: { // audio_buffer_length [16, 128] step 4 int v = (int)c.audio_buffer_length + delta * 4; if (v < 16) v = 16; if (v > 128) v = 128; c.audio_buffer_length = (uint8_t)v; break; } case 7: { // controller_mode 0..2 int v = (int)c.controller_mode + delta; if (v < 0) v = 2; if (v > 2) v = 0; c.controller_mode = (uint8_t)v; break; } case 8: { // auto_haptics_enable 0..3 int v = (int)c.auto_haptics_enable + delta; if (v < 0) v = 3; if (v > 3) v = 0; c.auto_haptics_enable = (uint8_t)v; break; } case 9: { // auto_haptics_gain [0, 200] step 10 int v = (int)c.auto_haptics_gain + delta * 10; if (v < 0) v = 0; if (v > 200) v = 200; c.auto_haptics_gain = (uint8_t)v; break; } case 10: { // auto_haptics_lowpass 0..3 int v = (int)c.auto_haptics_lowpass + delta; if (v < 0) v = 3; if (v > 3) v = 0; c.auto_haptics_lowpass = (uint8_t)v; break; } case 11: { // screen_dim_timeout [0,250] min, 0 = disabled int v = (int)c.screen_dim_timeout + delta; if (v < 0) v = 0; if (v > 250) v = 250; c.screen_dim_timeout = (uint8_t)v; break; } case 12: { // screen_off_timeout [0,250] min, 0 = disabled int v = (int)c.screen_off_timeout + delta; if (v < 0) v = 0; if (v > 250) v = 250; c.screen_off_timeout = (uint8_t)v; break; } case 13: c.bt_mic_enable ^= 1; break; // BT mic on/off case 14: c.controller_wakes_display ^= 1; break; // controller activity wakes OLED on/off } } void settings_handle_input() { if (!bt_is_connected()) return; const uint8_t dpad = (uint8_t)(interrupt_in_data[7] & 0x0F); const uint8_t face = (uint8_t)(interrupt_in_data[7] & 0xF0); // Edge-trigger on D-pad direction CHANGE; only pure N/E/S/W to avoid diagonals if (dpad != settings_last_dpad && dpad != 8) { if (dpad == 0) settings_sel = (settings_sel - 1 + kNumSettingsItems) % kNumSettingsItems; else if (dpad == 4) settings_sel = (settings_sel + 1) % kNumSettingsItems; else if (dpad == 6) settings_adjust(-1); else if (dpad == 2) settings_adjust(+1); } settings_last_dpad = dpad; // Triangle handling — Reset and Wipe-slots items both require a 2 s hold; // every other item saves edits on a normal short press. const bool tri_now = (face & 0x80) != 0; const bool tri_prev = (settings_last_face & 0x80) != 0; const bool is_hold_item = (settings_sel == kSettingsResetIdx || settings_sel == kSettingsWipeSlotsIdx); if (tri_now && !tri_prev) { settings_tri_press_us = (uint32_t)time_us_32(); settings_reset_triggered = false; } if (is_hold_item && tri_now && !settings_reset_triggered && ((uint32_t)time_us_32() - settings_tri_press_us) >= kResetHoldUs) { settings_reset_triggered = true; if (settings_sel == kSettingsResetIdx) { config_default(); if (config_save()) { settings_local = get_config(); lightbar_load_config(); // refresh RAM lightbar state (no reboot here) settings_dirty = false; settings_save_status = "Reset!"; } else { settings_save_status = "Reset FAIL"; } } else { bt_wipe_all_slots(); settings_save_status = "Slots wiped!"; } } if (!tri_now && tri_prev) { if (!is_hold_item && !settings_reset_triggered) { set_config(settings_local); settings_save_status = config_save() ? "Saved!" : "Save FAIL"; if (settings_save_status[0] == 'S' && settings_save_status[1] == 'a') { settings_dirty = false; } } settings_reset_triggered = false; } settings_last_face = face; } __attribute__((noinline)) void format_settings_item(int idx, char* line, size_t n) { const Config_body &c = settings_local; const char *cur = (idx == settings_sel) ? ">" : " "; switch (idx) { case 0: { int g = (int)(c.haptics_gain * 10.0f + 0.5f); snprintf(line, n, "%s Hap Gain %d.%dx", cur, g / 10, g % 10); break; } case 1: snprintf(line, n, "%s Spk Vol %ddB", cur, (int)c.speaker_volume); break; case 2: snprintf(line, n, "%s Inact %umin", cur, c.inactive_time); break; case 3: snprintf(line, n, "%s InactDC %s", cur, c.disable_inactive_disconnect ? "off" : "on"); break; case 4: snprintf(line, n, "%s Pico LED %s", cur, c.disable_pico_led ? "off" : "on"); break; case 5: { const char* names[3] = {"250Hz", "500Hz", "RT"}; snprintf(line, n, "%s Poll %s", cur, names[c.polling_rate_mode % 3]); break; } case 6: snprintf(line, n, "%s AudBuf %u", cur, c.audio_buffer_length); break; case 7: { const char* names[3] = {"DS5", "DSE", "Auto"}; snprintf(line, n, "%s Ctrl %s", cur, names[c.controller_mode % 3]); break; } case 8: { const char* names[4] = {"Off", "Fallback", "Mix", "Replace"}; snprintf(line, n, "%s AutoHap %s", cur, names[c.auto_haptics_enable & 3]); break; } case 9: snprintf(line, n, "%s AH Gain %u%%", cur, c.auto_haptics_gain); break; case 10: { const char* names[4] = {"80Hz", "160Hz", "250Hz", "400Hz"}; snprintf(line, n, "%s AH LP %s", cur, names[c.auto_haptics_lowpass & 3]); break; } case 11: if (c.screen_dim_timeout == 0) snprintf(line, n, "%s ScrDim off", cur); else snprintf(line, n, "%s ScrDim %umin", cur, c.screen_dim_timeout); break; case 12: if (c.screen_off_timeout == 0) snprintf(line, n, "%s ScrOff off", cur); else snprintf(line, n, "%s ScrOff %umin", cur, c.screen_off_timeout); break; case 13: snprintf(line, n, "%s BT Mic %s", cur, c.bt_mic_enable ? "on" : "off"); break; case 14: snprintf(line, n, "%s CtrlWake %s", cur, c.controller_wakes_display ? "on" : "off"); break; case 15: snprintf(line, n, "%s Reset to defaults", cur); break; case 16: snprintf(line, n, "%s Wipe all slots", cur); break; } } __attribute__((noinline)) void render_screen_settings() { if (!settings_init_done) { settings_local = get_config(); settings_init_done = true; } settings_handle_input(); fb_clear(); char buf[24]; snprintf(buf, sizeof(buf), "Settings %s", settings_dirty ? "(*)" : " "); draw_text(kContentX, 0, buf); if (settings_save_status[0]) { draw_text(86, 0, settings_save_status); } constexpr int kVisible = 5; int top = 0; if (settings_sel >= kVisible) top = settings_sel - kVisible + 1; char line[28]; for (int i = 0; i < kVisible && top + i < kNumSettingsItems; i++) { format_settings_item(top + i, line, sizeof(line)); draw_text(kContentX, 9 + i * 9, line); } if (settings_sel == kSettingsResetIdx) { draw_text(kContentX, 56, "Hold Tri 2s = RESET"); } else if (settings_sel == kSettingsWipeSlotsIdx) { draw_text(kContentX, 56, "Hold Tri 2s = WIPE"); } else { draw_text(kContentX, 56, "DP nav/adj Tri=save"); } flush_fb(); } // ---- Slots screen (Phase G) ---------------------------------------------- // Multi-slot persistent pairing UI. Modeled on zurce/DS5Dongle-OLED. // Credit to zurce. int slots_cursor = -1; // initialized to active slot on first entry uint8_t slots_last_dpad = 8; uint8_t slots_last_face = 0; uint32_t slots_sq_press_us = 0; bool slots_wipe_triggered = false; const char* slots_status = ""; uint32_t slots_status_until_us = 0; constexpr uint32_t kSlotsWipeHoldUs = 1500000; // 1.5 s void slots_handle_input() { if (slots_cursor < 0) slots_cursor = bt_get_slot(); if (!bt_is_connected()) { // Even without a DS5 connected we still want to navigate / wipe; // we just can't read the controller's D-pad / face inputs. Return // here and require KEY0/KEY1 for screen switching. slots_last_dpad = 8; slots_last_face = 0; return; } const uint8_t dpad = (uint8_t)(interrupt_in_data[7] & 0x0F); const uint8_t face = (uint8_t)(interrupt_in_data[7] & 0xF0); if (dpad != slots_last_dpad && dpad != 8) { if (dpad == 0) slots_cursor = (slots_cursor - 1 + kNumSlots) % kNumSlots; else if (dpad == 4) slots_cursor = (slots_cursor + 1) % kNumSlots; } slots_last_dpad = dpad; // Triangle rising edge: switch to cursor slot if different from active const bool tri_now = (face & 0x80) != 0; const bool tri_prev = (slots_last_face & 0x80) != 0; if (tri_now && !tri_prev) { if (slots_cursor != bt_get_slot()) { bt_set_slot(slots_cursor); slots_status = "Switched!"; slots_status_until_us = (uint32_t)time_us_32() + 1500000; } } // Square hold 1.5 s: wipe cursor slot const bool sq_now = (face & 0x10) != 0; const bool sq_prev = (slots_last_face & 0x10) != 0; if (sq_now && !sq_prev) { slots_sq_press_us = (uint32_t)time_us_32(); slots_wipe_triggered = false; } if (sq_now && !slots_wipe_triggered && ((uint32_t)time_us_32() - slots_sq_press_us) >= kSlotsWipeHoldUs) { slots_wipe_triggered = true; bt_forget_slot(slots_cursor); slots_status = "Wiped!"; slots_status_until_us = (uint32_t)time_us_32() + 1500000; } if (!sq_now && sq_prev) slots_wipe_triggered = false; slots_last_face = face; } __attribute__((noinline)) void render_screen_slots() { slots_handle_input(); if (slots_cursor < 0) slots_cursor = bt_get_slot(); fb_clear(); char hdr[24]; const int active = bt_get_slot(); const bool conn = bt_is_connected(); snprintf(hdr, sizeof(hdr), "Slots [s%d %s]", active, conn ? "ON" : "--"); draw_text(kContentX, 0, hdr); if (slots_status[0] && (uint32_t)time_us_32() < slots_status_until_us) { draw_text(80, 0, slots_status); } for (int i = 0; i < kNumSlots; i++) { char line[28]; const char *cursor_mark = (i == slots_cursor) ? ">" : " "; const char *active_mark = (i == active) ? "*" : " "; if (slot_occupied(i)) { uint8_t a[6]; slot_get_addr(i, a); snprintf(line, sizeof(line), "%s%d%s %02X:%02X:%02X:%02X:%02X:%02X", cursor_mark, i, active_mark, a[0], a[1], a[2], a[3], a[4], a[5]); } else { snprintf(line, sizeof(line), "%s%d%s (empty)", cursor_mark, i, active_mark); } draw_text(kContentX, 9 + i * 9, line); } draw_text(kContentX, 56, "Tri=switch Sq hold=wipe"); flush_fb(); } void boot_splash() { fb_clear(); auto cx_for = [](const char* s) { int n = 0; while (s[n]) n++; return (128 - (n * 6 - 1)) / 2; }; const char* l1 = "DS5 Bridge"; const char* l2 = "v0.6.0"; const char* l3 = "Pico2W + OLED"; draw_text(cx_for(l1), 16, l1); draw_text(cx_for(l2), 30, l2); draw_text(cx_for(l3), 44, l3); flush_fb(); sleep_ms(1500); } } // namespace void oled_init() { spi_init(spi1, 10 * 1000 * 1000); gpio_set_function(kPinCLK, GPIO_FUNC_SPI); gpio_set_function(kPinMOSI, GPIO_FUNC_SPI); gpio_init(kPinCS); gpio_set_dir(kPinCS, GPIO_OUT); gpio_put(kPinCS, 1); gpio_init(kPinDC); gpio_set_dir(kPinDC, GPIO_OUT); gpio_put(kPinDC, 0); gpio_init(kPinRST); gpio_set_dir(kPinRST, GPIO_OUT); gpio_put(kPinRST, 1); gpio_init(kPinKey0); gpio_set_dir(kPinKey0, GPIO_IN); gpio_pull_up(kPinKey0); gpio_init(kPinKey1); gpio_set_dir(kPinKey1, GPIO_IN); gpio_pull_up(kPinKey1); hw_reset(); sh1107_init(); fb_clear(); boot_splash(); // Restore the persisted lightbar mode + favorites (config_load() already ran // in main() before this). Defaults to HOST passthrough on a fresh flash. lightbar_load_config(); // Restore the persisted OLED brightness (KEY1-long-press choice). config_valid // clamps screen_brightness to a legal kBrightLevels index, so this is safe to // use directly. Fresh flash → 0 (full brightness). Issue #9. bright_idx = get_config().screen_brightness; } // Dim-tier renderer: blank the panel and draw a tiny "I'm alive" dot that // breathes (1s on / 1s off) and walks through 8 evenly-spaced positions every // ~30 s. Two goals: (1) reduce total pixel-on-time to a tiny fraction so the // panel barely glows even with the contrast register pinned, (2) prevent any // single pixel from accumulating wear. noinline keeps oled_loop's literal pool // in Thumb's 4 KB reach (same constraint the other render_screen_* hit). __attribute__((noinline)) void render_dim_pulse(uint32_t dim_elapsed_us) { fb_clear(); constexpr uint32_t kPulsePeriodUs = 2UL * 1000000UL; // 2 s blink cycle constexpr uint32_t kPulseOnUs = 1UL * 1000000UL; // 1 s on, 1 s off constexpr uint32_t kPosStepUs = 30UL * 1000000UL; // 30 s per position constexpr int kPositions[][2] = { { 16, 8}, { 64, 8}, {112, 8}, {112, 32}, {112, 56}, { 64, 56}, { 16, 56}, { 16, 32}, }; constexpr int kNumPositions = sizeof(kPositions) / sizeof(kPositions[0]); const bool dot_on = (dim_elapsed_us % kPulsePeriodUs) < kPulseOnUs; if (dot_on) { const int idx = (int)((dim_elapsed_us / kPosStepUs) % (uint32_t)kNumPositions); const int cx = kPositions[idx][0]; const int cy = kPositions[idx][1]; // 2x2 dot — small enough to barely register, big enough to see across a desk. rect_filled(cx, cy, 2, 2); } flush_fb_raw(); // skip chrome arrows; nothing to navigate to from sleep } void oled_loop() { handle_buttons(); const uint32_t now = time_us_32(); rumble_burst_tick(now); if ((now - last_render_us) < kFrameUs) return; last_render_us = now; // Track charge progress every frame — before the power-ladder early-returns // below, so step timing stays correct even while the panel is dimmed/off. sample_charge_eta(); // Parse the DS5's per-unit IMU calibration once it lands (no-op until then), // so the tilt screen + tilt->RGB lightbar use corrected accel. See imu_apply(). imu_cal_service(); // Drive the controller LED every frame (any screen / power state): charging // pulse, selected OLED mode, or hand-off to the host. See lightbar_service(). lightbar_service(); // Bump activity on controller input changes (cheap rolling hash over input // bytes). Mirror bt.cpp's inactivity heuristic so resting-controller noise // doesn't read as activity: the analog sticks (idata[0..3]) jitter by ±1 LSB // at rest, so collapse their rest band [120,140] to a constant, and skip // idata[6] (the volatile counter byte bt.cpp's idle check also ignores). // Without this the dot/dim tier never engages while a controller is // connected, because a stick flicker resets the idle timer every few frames. uint32_t hash = 0; for (int i = 0; i < 10; i++) { if (i == 6) continue; uint8_t b = interrupt_in_data[i]; if (i < 4 && b >= 120 && b <= 140) b = 128; // stick deadzone hash = hash * 31u + b; } if (hash != last_input_hash) { last_input_hash = hash; // Controller input only keeps the panel awake when the user has left // "CtrlWake" on (the default). With it off, the dim/off timers count // down during gameplay and only KEY0/KEY1 wake the screen — see // handle_buttons(), which bumps last_activity_us unconditionally. // Issues #8 / #9. if (get_config().controller_wakes_display) last_activity_us = time_us_64(); } // Rising-edge: BT-connect itself counts as activity, so the screen wakes // the moment a controller pairs rather than waiting for the first input. const bool bt_connected_now = bt_is_connected(); if (bt_connected_now && !prev_bt_connected) last_activity_us = time_us_64(); prev_bt_connected = bt_connected_now; // Power-state ladder: Active → Dim (breathing dot) → Off based on idle time. // Thresholds are user-configurable (minutes; 0 = that tier disabled) — #5. // While charging we cap the ladder at Dim — the panel keeps doing the // low-power breathing dot but never fully sleeps. This stops the user from // unplugging the controller just to "wake" the dongle (which would reset the // charge-ETA calibration). The dot tier already draws ~no current, so this // costs little; sample_charge_eta() runs before this block regardless. const uint64_t idle = time_us_64() - last_activity_us; const uint64_t dim_us = (uint64_t)get_config().screen_dim_timeout * 60ULL * 1000000ULL; const uint64_t off_us = (uint64_t)get_config().screen_off_timeout * 60ULL * 1000000ULL; const bool off_enabled = get_config().screen_off_timeout != 0; const bool dim_enabled = get_config().screen_dim_timeout != 0; if (off_enabled && idle > off_us && !g_charge_eta.charging) { if (oled_power_state != OLED_OFF) { cmd(0xAE); oled_power_state = OLED_OFF; } return; // panel is off, nothing to draw } if (oled_power_state == OLED_OFF) cmd(0xAF); // wake panel before drawing if (dim_enabled && idle > dim_us) { sh1107_set_contrast(kDimContrast); oled_power_state = OLED_DIM; render_dim_pulse((uint32_t)(idle - dim_us)); return; // skip the regular per-screen render path } sh1107_set_contrast(kBrightLevels[bright_idx]); oled_power_state = OLED_ACTIVE; // True on the first render after navigating to a different screen. // Lets a screen do expensive one-shot work on entry (the CPU screen // caches its frequency-counter measurement here). static int last_rendered_screen = -1; const bool screen_entered = (current_screen != last_rendered_screen); // Leaving Trigger Test in either direction → reset the adaptive // trigger preset to OFF and push it to the controller. Otherwise // the last-cycled effect (Weapon snap, Galloping pulse, etc.) // stays active on the DS5 indefinitely, which surprised users // who'd just navigated away expecting a clean slate. if (last_rendered_screen == kScreenTriggers && current_screen != kScreenTriggers) { trigger_preset = 0; send_trigger_effect(0); } // Leaving the Lightbar screen → persist mode/favorite changes made there, // batched into a single flash write instead of one per button press. if (last_rendered_screen == kScreenLightbar && current_screen != kScreenLightbar && lb_dirty) { lightbar_save_config(); } last_rendered_screen = current_screen; switch (current_screen) { case kScreenStatus: render_screen(); break; case kScreenSlots: render_screen_slots(); break; case kScreenLightbar: render_screen_lightbar(); break; case kScreenTriggers: render_screen_triggers(); break; case kScreenGyro: render_screen_gyro(); break; case kScreenTouchpad: render_screen_touchpad(); break; case kScreenDiag: render_screen_diag(); break; case kScreenCpu: render_screen_cpu(screen_entered); break; case kScreenRssi: render_screen_rssi(); break; case kScreenVU: render_screen_vu(); break; case kScreenSettings: render_screen_settings(); break; } }