feat(oled): configurable idle-ladder thresholds (#5) + trig-fold diag counter (#6)

Issue #5 (requested by @TerryFrench): the OLED dim/off tiers are no longer
hardcoded at 2/15 min. Two new Config_body fields screen_dim_timeout /
screen_off_timeout (minutes, 0 = tier disabled, range [0,250]) are editable
on the Settings screen (ScrDim/ScrOff) and persist to flash; defaults
preserve the 2/15 ladder and upgraders read those via the config_valid clamp.
The idle timer moved from time_us_32() to 64-bit µs so the full 250-min range
is representable without the ~71-min wrap.

Issue #6: new "trig fold" Diagnostics counter — trigger-bearing 0x02 host
reports that arrived while the speaker stream was active and were folded into
the 0x36 audio frames (via state[]) rather than sent as a standalone 0x31.
Makes trig_allow == to_bt(trig share) + fold visible, confirming the apparent
trig/tx gap is audio-path folding, not dropped reports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MarcelineVPQ
2026-05-23 20:03:45 -06:00
co-authored by Claude Opus 4.7
parent 585a385219
commit c65e0c9802
4 changed files with 91 additions and 29 deletions
+8
View File
@@ -101,6 +101,14 @@ void config_valid() {
} }
// lb_fav_{r,g,b} need no validation — any 0..255 is a legal color, and an // lb_fav_{r,g,b} need no validation — any 0..255 is a legal color, and an
// erased flash sector (0xFF) yields 4 white favorites, a usable default. // erased flash sector (0xFF) yields 4 white favorites, a usable default.
if (body->screen_dim_timeout > 250) { // 0xFF erased / out of range → default
body->screen_dim_timeout = 2; // mirrors the original 2-min dim tier
printf("[Config] screen_dim_timeout invalid, defaulting to 2 min\n");
}
if (body->screen_off_timeout > 250) {
body->screen_off_timeout = 15; // mirrors the original 15-min off tier
printf("[Config] screen_off_timeout invalid, defaulting to 15 min\n");
}
if (body->config_version != CONFIG_VERSION) { if (body->config_version != CONFIG_VERSION) {
body->config_version = CONFIG_VERSION; body->config_version = CONFIG_VERSION;
printf("[Config] Warning: Config may breaking change\n"); printf("[Config] Warning: Config may breaking change\n");
+6
View File
@@ -33,6 +33,12 @@ struct __attribute__((packed)) Config_body {
uint8_t lb_fav_r[4]; uint8_t lb_fav_r[4];
uint8_t lb_fav_g[4]; uint8_t lb_fav_g[4];
uint8_t lb_fav_b[4]; uint8_t lb_fav_b[4];
// OLED idle power-ladder thresholds, in minutes. 0 = that tier disabled.
// Defaults preserve the original hardcoded ladder (2 min dim, 15 min off).
// Range [0,250] (0xFF erased flash → default via config_valid clamp). The
// idle timer is 64-bit µs so the full range is representable. Issue #5.
uint8_t screen_dim_timeout;
uint8_t screen_off_timeout;
}; };
struct __attribute__((packed)) Config { struct __attribute__((packed)) Config {
+13
View File
@@ -66,13 +66,21 @@ void bt_31_mic_prefix(uint8_t out[6]) {
// out02_to_bt - 0x02 reports that we forwarded to the controller as // out02_to_bt - 0x02 reports that we forwarded to the controller as
// a BT 0x31 sub-0x10 packet (gated off when speaker is // a BT 0x31 sub-0x10 packet (gated off when speaker is
// active; audio.cpp's 0x36 path carries state then) // active; audio.cpp's 0x36 path carries state then)
// out02_trig_folded - of the trig_allow reports, how many arrived while the
// speaker stream was active and were therefore NOT sent as
// a standalone 0x31 — their trigger FFB was folded into the
// 0x36 audio frames via state[]. So trig_allow == to_bt's
// trigger share + this, proving the "missing" forwards
// (issue #6) aren't drops. Surfaced on the Diag screen.
// Surfaced on the OLED Diagnostics screen. // Surfaced on the OLED Diagnostics screen.
volatile uint32_t g_host_out02_total = 0; volatile uint32_t g_host_out02_total = 0;
volatile uint32_t g_host_out02_trig_allow = 0; volatile uint32_t g_host_out02_trig_allow = 0;
volatile uint32_t g_host_out02_to_bt = 0; volatile uint32_t g_host_out02_to_bt = 0;
volatile uint32_t g_host_out02_trig_folded = 0;
uint32_t host_out02_total() { return g_host_out02_total; } uint32_t host_out02_total() { return g_host_out02_total; }
uint32_t host_out02_trig_allow() { return g_host_out02_trig_allow; } uint32_t host_out02_trig_allow() { return g_host_out02_trig_allow; }
uint32_t host_out02_to_bt() { return g_host_out02_to_bt; } uint32_t host_out02_to_bt() { return g_host_out02_to_bt; }
uint32_t host_out02_trig_folded() { return g_host_out02_trig_folded; }
uint8_t interrupt_in_data[63] = { uint8_t interrupt_in_data[63] = {
0x7f, 0x7d, 0x7f, 0x7e, 0x00, 0x00, 0xa7, 0x7f, 0x7d, 0x7f, 0x7e, 0x00, 0x00, 0xa7,
@@ -274,6 +282,11 @@ void tud_hid_set_report_cb(uint8_t itf, uint8_t report_id, hid_report_type_t rep
} }
state_update(buffer + 1, bufsize - 1); state_update(buffer + 1, bufsize - 1);
if (spk_active) { if (spk_active) {
// Not forwarded as a standalone 0x31 — the trigger FFB just
// written into state[] rides the 0x36 audio frames instead.
// Count the trigger-bearing ones so the Diag screen shows
// trig_allow == to_bt(trig) + folded (issue #6: not drops).
if (bufsize > 1 && (buffer[1] & 0x0C)) g_host_out02_trig_folded++;
break; break;
} }
uint8_t outputData[78]{}; uint8_t outputData[78]{};
+61 -26
View File
@@ -23,6 +23,7 @@ extern uint32_t bt_31_packet_count();
extern uint32_t host_out02_total(); extern uint32_t host_out02_total();
extern uint32_t host_out02_trig_allow(); extern uint32_t host_out02_trig_allow();
extern uint32_t host_out02_to_bt(); 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_last_byte2();
extern uint8_t bt_31_b2_or_mask(); extern uint8_t bt_31_b2_or_mask();
extern uint16_t bt_31_len_min(); extern uint16_t bt_31_len_min();
@@ -83,16 +84,18 @@ uint8_t current_contrast = 0xFF;
// Auto-dim / auto-off after idle. Tracks last button/input activity. // Auto-dim / auto-off after idle. Tracks last button/input activity.
// Tier 1: Active → full brightness (bright_idx). // Tier 1: Active → full brightness (bright_idx).
// Tier 2: idle > kAutoDimUs → contrast drops to kDimContrast (deep dim). // Tier 2: idle > dim threshold → contrast drops to kDimContrast (deep dim).
// Tier 3: idle > kAutoOffUs → SH1107 panel turned fully off (cmd 0xAE) // Tier 3: idle > off threshold → SH1107 panel turned fully off (cmd 0xAE)
// to prevent OLED burn-in on long unattended sits. // 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 // kDimContrast tuned by eye: 0x10 looked like only ~10% reduction on this
// panel (contrast-vs-brightness is heavily non-linear near the bottom of // panel (contrast-vs-brightness is heavily non-linear near the bottom of
// the register range). 0x02 is visibly dim while still legible up close. // the register range). 0x02 is visibly dim while still legible up close.
uint32_t last_activity_us = 0; uint64_t last_activity_us = 0;
uint32_t last_input_hash = 0; uint32_t last_input_hash = 0;
constexpr uint32_t kAutoDimUs = 2UL * 60UL * 1000000UL; // 2 min — generous for pairing
constexpr uint32_t kAutoOffUs = 15UL * 60UL * 1000000UL; // 15 min
constexpr uint8_t kDimContrast = 0x01; constexpr uint8_t kDimContrast = 0x01;
enum OledPowerState { OLED_ACTIVE, OLED_DIM, OLED_OFF }; enum OledPowerState { OLED_ACTIVE, OLED_DIM, OLED_OFF };
OledPowerState oled_power_state = OLED_ACTIVE; OledPowerState oled_power_state = OLED_ACTIVE;
@@ -123,12 +126,14 @@ constexpr int kLbModeHost = 8;
constexpr int kNumLbModes = 9; constexpr int kNumLbModes = 9;
// Settings screen state // Settings screen state
constexpr int kNumSettingsItems = 13; // 8 fields + 3 auto-haptic + Reset + Wipe constexpr int kNumSettingsItems = 15; // 8 fields + 3 auto-haptic + 2 screen-timeout + Reset + Wipe
constexpr int kSettingsAutoHapEnaIdx = 8; constexpr int kSettingsAutoHapEnaIdx = 8;
constexpr int kSettingsAutoHapGainIdx = 9; constexpr int kSettingsAutoHapGainIdx = 9;
constexpr int kSettingsAutoHapLpIdx = 10; constexpr int kSettingsAutoHapLpIdx = 10;
constexpr int kSettingsResetIdx = 11; constexpr int kSettingsScrDimIdx = 11;
constexpr int kSettingsWipeSlotsIdx = 12; constexpr int kSettingsScrOffIdx = 12;
constexpr int kSettingsResetIdx = 13;
constexpr int kSettingsWipeSlotsIdx = 14;
Config_body settings_local{}; Config_body settings_local{};
int settings_sel = 0; int settings_sel = 0;
bool settings_dirty = false; bool settings_dirty = false;
@@ -479,13 +484,13 @@ void handle_buttons() {
if (!k0 && key0_prev && (now - key0_t_us) > kDebounceUs) { if (!k0 && key0_prev && (now - key0_t_us) > kDebounceUs) {
key0_t_us = now; key0_t_us = now;
key0_armed = true; key0_armed = true;
last_activity_us = now; last_activity_us = time_us_64();
} }
if (k0 && !key0_prev && key0_armed) { if (k0 && !key0_prev && key0_armed) {
key0_armed = false; key0_armed = false;
current_screen = (current_screen + 1) % kNumScreens; current_screen = (current_screen + 1) % kNumScreens;
last_render_us = 0; last_render_us = 0;
last_activity_us = now; last_activity_us = time_us_64();
} }
// KEY1: arm on press, fire on release. Short press = back; long press // KEY1: arm on press, fire on release. Short press = back; long press
@@ -497,12 +502,12 @@ void handle_buttons() {
key1_t_us = now; key1_t_us = now;
key1_press_us = now; key1_press_us = now;
key1_was_pressed = true; key1_was_pressed = true;
last_activity_us = now; last_activity_us = time_us_64();
} }
if (k1 && !key1_prev && key1_was_pressed) { if (k1 && !key1_prev && key1_was_pressed) {
key1_was_pressed = false; key1_was_pressed = false;
const uint32_t held = now - key1_press_us; const uint32_t held = now - key1_press_us;
last_activity_us = now; last_activity_us = time_us_64();
if (held > kLongPressUs) { if (held > kLongPressUs) {
bright_idx = (bright_idx + 1) % kNumBrightLevels; bright_idx = (bright_idx + 1) % kNumBrightLevels;
} else { } else {
@@ -801,7 +806,7 @@ void sample_diag_rates() {
// Row list ordered by relevance: always-useful at top, parked-mic-investigation // 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. // data at bottom. To add a row, bump kNumDiagRows and add a case.
constexpr int kNumDiagRows = 10; constexpr int kNumDiagRows = 11;
__attribute__((noinline)) __attribute__((noinline))
void format_diag_row(int idx, char* line, size_t n) { void format_diag_row(int idx, char* line, size_t n) {
switch (idx) { switch (idx) {
@@ -825,23 +830,28 @@ void format_diag_row(int idx, char* line, size_t n) {
(unsigned long)host_out02_to_bt()); (unsigned long)host_out02_to_bt());
break; break;
case 4: case 4:
snprintf(line, n, "BT31 in: %lu/s", (unsigned long)g_diag_rates.bt31_rate); // 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; break;
case 5: case 5:
snprintf(line, n, "USB aud: %lu/s", (unsigned long)g_diag_rates.usb_rate); snprintf(line, n, "BT31 in: %lu/s", (unsigned long)g_diag_rates.bt31_rate);
break; break;
case 6: case 6:
snprintf(line, n, "BT32 out: %lu/s", (unsigned long)g_diag_rates.bt_rate); snprintf(line, n, "USB aud: %lu/s", (unsigned long)g_diag_rates.usb_rate);
break; break;
case 7: case 7:
snprintf(line, n, "Mic in: %lu/s", (unsigned long)g_diag_rates.mic_rate); snprintf(line, n, "BT32 out: %lu/s", (unsigned long)g_diag_rates.bt_rate);
break; break;
case 8: 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", snprintf(line, n, "Mic dec=%ld w=%u",
(long)audio_mic_last_decoded(), (long)audio_mic_last_decoded(),
(unsigned)audio_mic_last_wrote()); (unsigned)audio_mic_last_wrote());
break; break;
case 9: { case 10: {
uint8_t pfx[6]; bt_31_mic_prefix(pfx); uint8_t pfx[6]; bt_31_mic_prefix(pfx);
snprintf(line, n, "%02X %02X %02X %02X %02X %02X", snprintf(line, n, "%02X %02X %02X %02X %02X %02X",
pfx[0], pfx[1], pfx[2], pfx[3], pfx[4], pfx[5]); pfx[0], pfx[1], pfx[2], pfx[3], pfx[4], pfx[5]);
@@ -1374,6 +1384,18 @@ void settings_adjust(int delta) {
c.auto_haptics_lowpass = (uint8_t)v; c.auto_haptics_lowpass = (uint8_t)v;
break; 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;
}
} }
} }
@@ -1467,8 +1489,16 @@ __attribute__((noinline)) void format_settings_item(int idx, char* line, size_t
snprintf(line, n, "%s AH LP %s", cur, names[c.auto_haptics_lowpass & 3]); snprintf(line, n, "%s AH LP %s", cur, names[c.auto_haptics_lowpass & 3]);
break; break;
} }
case 11: snprintf(line, n, "%s Reset to defaults", cur); break; case 11:
case 12: snprintf(line, n, "%s Wipe all slots", cur); break; 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 Reset to defaults", cur); break;
case 14: snprintf(line, n, "%s Wipe all slots", cur); break;
} }
} }
@@ -1700,22 +1730,27 @@ void oled_loop() {
} }
if (hash != last_input_hash) { if (hash != last_input_hash) {
last_input_hash = hash; last_input_hash = hash;
last_activity_us = now; last_activity_us = time_us_64();
} }
// Rising-edge: BT-connect itself counts as activity, so the screen wakes // Rising-edge: BT-connect itself counts as activity, so the screen wakes
// the moment a controller pairs rather than waiting for the first input. // the moment a controller pairs rather than waiting for the first input.
const bool bt_connected_now = bt_is_connected(); const bool bt_connected_now = bt_is_connected();
if (bt_connected_now && !prev_bt_connected) last_activity_us = now; if (bt_connected_now && !prev_bt_connected) last_activity_us = time_us_64();
prev_bt_connected = bt_connected_now; prev_bt_connected = bt_connected_now;
// Power-state ladder: Active → Dim (breathing dot) → Off based on idle time. // 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 // 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 // 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 // unplugging the controller just to "wake" the dongle (which would reset the
// charge-ETA calibration). The dot tier already draws ~no current, so this // charge-ETA calibration). The dot tier already draws ~no current, so this
// costs little; sample_charge_eta() runs before this block regardless. // costs little; sample_charge_eta() runs before this block regardless.
const uint32_t idle = now - last_activity_us; const uint64_t idle = time_us_64() - last_activity_us;
if (idle > kAutoOffUs && !g_charge_eta.charging) { 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) { if (oled_power_state != OLED_OFF) {
cmd(0xAE); cmd(0xAE);
oled_power_state = OLED_OFF; oled_power_state = OLED_OFF;
@@ -1723,10 +1758,10 @@ void oled_loop() {
return; // panel is off, nothing to draw return; // panel is off, nothing to draw
} }
if (oled_power_state == OLED_OFF) cmd(0xAF); // wake panel before drawing if (oled_power_state == OLED_OFF) cmd(0xAF); // wake panel before drawing
if (idle > kAutoDimUs) { if (dim_enabled && idle > dim_us) {
sh1107_set_contrast(kDimContrast); sh1107_set_contrast(kDimContrast);
oled_power_state = OLED_DIM; oled_power_state = OLED_DIM;
render_dim_pulse(idle - kAutoDimUs); render_dim_pulse((uint32_t)(idle - dim_us));
return; // skip the regular per-screen render path return; // skip the regular per-screen render path
} }
sh1107_set_contrast(kBrightLevels[bright_idx]); sh1107_set_contrast(kBrightLevels[bright_idx]);