Compare commits

..
Author SHA1 Message Date
MarcelineVPQandClaude Opus 4.7 89847ed06e docs(changelog): cut v0.6.7 — provisional charge ETA on plug-in
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:39:35 -06:00
MarcelineVPQandClaude Opus 4.7 0eceb1d5e2 feat(oled): provisional charge ETA on plug-in — no more ~--m wait
Show a default-rate estimate "~Nm?" the moment charging starts instead of
"~--m" for the ~15-20 min until the first 10% step is timed. The "?" drops and
the number switches to the measured rate once a clean step completes. Default
~15 min per 10% step (kDefaultStepUs), taper-weighted like the measured path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:29:46 -06:00
MarcelineVPQandClaude Opus 4.7 d99689bf75 docs(changelog): cut v0.6.6 — configurable screen timeouts (#5) + trig-fold counter (#6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:03:54 -06:00
MarcelineVPQandClaude Opus 4.7 c65e0c9802 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>
2026-05-23 20:03:45 -06:00
5 changed files with 148 additions and 41 deletions
+25
View File
@@ -10,6 +10,31 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
---
## [0.6.7-oled-edition] — 2026-05-23
UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.7-oled-edition) (built by `.github/workflows/release.yml`).
### Changed
- **Charge ETA now shows a provisional estimate immediately on plug-in.** Instead of sitting on `~--m` for the ~15-20 min until the first 10% step is timed, the Status screen shows a default-rate estimate `~Nm?` (the trailing `?` marks it provisional) the moment charging starts. The `?` drops and the number switches to the measured rate once a clean 10% step completes. Default is ~15 min per 10% step (`kDefaultStepUs`), taper-weighted exactly like the measured path, so the provisional figure is in the right ballpark and self-corrects.
### Companion web tool
- `DS5Dongle-OLED-Config-Web` gains **lightbar controls** (mode dropdown + four favorite-color pickers) in the config view, the provisional charge-ETA token in the OLED preview to match this firmware, and translations for two preview notes that were English-only. Build housekeeping: `tsconfig.tsbuildinfo` is no longer tracked.
---
## [0.6.6-oled-edition] — 2026-05-23
Community-issue follow-ups: configurable OLED idle-ladder thresholds (#5) and a diagnostic counter clarifying the trigger-flow numbers (#6). UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.6-oled-edition) (built by `.github/workflows/release.yml`). The companion `DS5Dongle-OLED-Config-Web` config tool gains matching screen-timeout controls and is synced to the v0.6.5+ `Config_body` layout (fixes a latent issue where saving via the old web tool would zero the lightbar fields).
### Added
- **Configurable OLED idle-ladder thresholds (issue #5, requested by @TerryFrench).** The dim and off tiers are no longer hardcoded at 2 / 15 min — two new `Config_body` fields `screen_dim_timeout` / `screen_off_timeout` (minutes, `0 = that tier disabled`, range `[0,250]`) are editable on the Settings screen (`ScrDim`/`ScrOff`) and persist to flash. Defaults preserve the previous 2 / 15 ladder; on upgrade the unset fields read as those defaults 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. Power users with always-on dongles can bias shorter; status-watchers can bias longer or set `0` to keep a tier lit.
- **`trig fold` counter on the Diagnostics screen (issue #6).** Counts trigger-bearing `0x02` host reports that arrived while the speaker stream was active and were therefore folded into the `0x36` audio frames (via `state[]`) instead of sent as a standalone `0x31`. Makes `trig_allow == to_bt(trig) + fold` visible, confirming the apparent `trig`/`tx` gap is audio-path folding, not dropped trigger reports.
---
## [0.6.5-oled-edition] — 2026-05-23
Charging UX (Status-screen battery ETA + amber lightbar pulse), persistent and screen-sticky lightbar control, and a charging-aware idle power ladder. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.5-oled-edition) (built by `.github/workflows/release.yml`).
+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
// 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) {
body->config_version = CONFIG_VERSION;
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_g[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 {
+16 -3
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
// a BT 0x31 sub-0x10 packet (gated off when speaker is
// 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.
volatile uint32_t g_host_out02_total = 0;
volatile uint32_t g_host_out02_trig_allow = 0;
volatile uint32_t g_host_out02_to_bt = 0;
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_to_bt() { return g_host_out02_to_bt; }
volatile uint32_t g_host_out02_trig_folded = 0;
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_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] = {
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);
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;
}
uint8_t outputData[78]{};
+93 -38
View File
@@ -23,6 +23,7 @@ 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();
@@ -83,16 +84,18 @@ 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 > kAutoDimUs → contrast drops to kDimContrast (deep dim).
// Tier 3: idle > kAutoOffUs → SH1107 panel turned fully off (cmd 0xAE)
// 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.
uint32_t last_activity_us = 0;
uint64_t last_activity_us = 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;
enum OledPowerState { OLED_ACTIVE, OLED_DIM, OLED_OFF };
OledPowerState oled_power_state = OLED_ACTIVE;
@@ -123,12 +126,14 @@ constexpr int kLbModeHost = 8;
constexpr int kNumLbModes = 9;
// 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 kSettingsAutoHapGainIdx = 9;
constexpr int kSettingsAutoHapLpIdx = 10;
constexpr int kSettingsResetIdx = 11;
constexpr int kSettingsWipeSlotsIdx = 12;
constexpr int kSettingsScrDimIdx = 11;
constexpr int kSettingsScrOffIdx = 12;
constexpr int kSettingsResetIdx = 13;
constexpr int kSettingsWipeSlotsIdx = 14;
Config_body settings_local{};
int settings_sel = 0;
bool settings_dirty = false;
@@ -479,13 +484,13 @@ void handle_buttons() {
if (!k0 && key0_prev && (now - key0_t_us) > kDebounceUs) {
key0_t_us = now;
key0_armed = true;
last_activity_us = now;
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 = now;
last_activity_us = time_us_64();
}
// KEY1: arm on press, fire on release. Short press = back; long press
@@ -497,12 +502,12 @@ void handle_buttons() {
key1_t_us = now;
key1_press_us = now;
key1_was_pressed = true;
last_activity_us = now;
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 = now;
last_activity_us = time_us_64();
if (held > kLongPressUs) {
bright_idx = (bright_idx + 1) % kNumBrightLevels;
} else {
@@ -530,12 +535,20 @@ void handle_buttons() {
// 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; // at least one full step timed → minutes is meaningful
int minutes; // estimated minutes to 100%
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;
// 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) {
@@ -600,20 +613,30 @@ void sample_charge_eta() {
}
g_charge_eta.charging = true;
if (ring_count > 0 && cur_step < 10) {
float bulk = 0.0f;
for (int i = 0; i < ring_count; i++) bulk += ring[i];
bulk /= (float)ring_count;
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) {
bulk = 0.0f;
for (int i = 0; i < ring_count; i++) bulk += ring[i];
bulk /= (float)ring_count;
} 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 = (cur_step >= 10);
g_charge_eta.valid = true;
g_charge_eta.provisional = false;
g_charge_eta.minutes = 0;
}
}
@@ -650,13 +673,15 @@ __attribute__((noinline)) void render_screen() {
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" once a step has been timed, "~--m" while
// still calibrating the first step. See sample_charge_eta().
// 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", g_charge_eta.minutes);
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);
@@ -801,7 +826,7 @@ void sample_diag_rates() {
// 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 = 10;
constexpr int kNumDiagRows = 11;
__attribute__((noinline))
void format_diag_row(int idx, char* line, size_t n) {
switch (idx) {
@@ -825,23 +850,28 @@ void format_diag_row(int idx, char* line, size_t n) {
(unsigned long)host_out02_to_bt());
break;
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;
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;
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;
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;
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 9: {
case 10: {
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]);
@@ -1374,6 +1404,18 @@ void settings_adjust(int delta) {
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;
}
}
}
@@ -1467,8 +1509,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]);
break;
}
case 11: snprintf(line, n, "%s Reset to defaults", cur); break;
case 12: snprintf(line, n, "%s Wipe all slots", cur); 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 Reset to defaults", cur); break;
case 14: snprintf(line, n, "%s Wipe all slots", cur); break;
}
}
@@ -1700,22 +1750,27 @@ void oled_loop() {
}
if (hash != last_input_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
// 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 = now;
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 uint32_t idle = now - last_activity_us;
if (idle > kAutoOffUs && !g_charge_eta.charging) {
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;
@@ -1723,10 +1778,10 @@ void oled_loop() {
return; // panel is off, nothing to draw
}
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);
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
}
sh1107_set_contrast(kBrightLevels[bright_idx]);