Cherry-picks ONLY the two separable good features from the withdrawn
v0.6.11 (commit 71cead4) — the `controller_wakes_display` (CtrlWake /
OLED-sleep-while-playing) toggle and `screen_brightness` persistence —
onto the verified-working native-trigger build.
The v0.6.11 regressions are intentionally NOT carried over:
- 71cead4's state_mgr.cpp trigger-FFB allow-bit mirror (#11 suspect)
- all of 84393c0's audio resampler retiming + config audio-defaults
+ chunked-SPI flush (#12 suspect)
Touches only src/config.{h,cpp} and src/oled.cpp, so the trigger and
audio code paths compile byte-identical to the tested firmware and
cannot regress. Two new Config_body fields append at the struct tail
(erased flash -> clamped to defaults by config_valid, clean upgrade).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1987 lines
79 KiB
C++
1987 lines
79 KiB
C++
#include "oled.h"
|
||
#include "oled_font.h"
|
||
#include "bt.h"
|
||
#include "slots.h"
|
||
#include "audio.h"
|
||
#include "config.h"
|
||
#include "state_mgr.h"
|
||
|
||
#include <cstdio>
|
||
#include <cstring>
|
||
#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<uint8_t>& 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<uint8_t>& 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 <cmath>). 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;
|
||
}
|
||
}
|