Compare commits

...
Author SHA1 Message Date
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
MarcelineVPQandClaude Opus 4.7 585a385219 docs(changelog): cut v0.6.5 — charging UX, persistent lightbar, idle-ladder fixes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:39:00 -06:00
MarcelineVPQandClaude Opus 4.7 4edcd20181 feat(oled): charge ETA, persistent lightbar, charging-aware idle ladder
Status screen: show an estimated time-to-full ("~43m") while charging,
self-calibrating from each 10% battery notch with Li-ion taper correction
(discard the partial plug-in step; 3-sample moving average; per-step weight
1.0/1.5/2.2 for bulk/80-90/90-100%). Shows "~--m" until the first full step.

Lightbar: the chosen mode + 4 favorites now persist to config flash and
stick across every screen (and through gameplay/audio). A single
lightbar_service() owns the LED via the persistent state[] block (new
state_set_led/state_get_led) with a host-override gate (g_lightbar_override)
so the host's AllowLedColor can't stomp a firmware-chosen mode. New HOST
passthrough mode (default) keeps the game in control of the LED out of the
box. The charging amber pulse (255,100,0) is folded in as top priority.

Idle ladder: keep the panel at the dim/dot tier (never full-off) while
charging so users stop unplugging to wake it (which reset the charge ETA).
Fix idle detection to deadzone stick jitter [120,140] + skip the counter
byte (idata[6]) so the dot tier engages with a controller connected
(mirrors bt.cpp's inactivity heuristic).

New Config_body fields: lightbar_mode + lb_fav_{r,g,b}[4].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:38:51 -06:00
7 changed files with 470 additions and 86 deletions
+33
View File
@@ -10,6 +10,39 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
---
## [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`).
### Added
- **Charge ETA on the OLED Status screen.** While the DualSense is charging, the battery line shows an estimated time-to-full (`~43m`) to the right of the battery icon. The DS5 only reports battery in 10 % steps over BT (`interrupt_in_data[52]` low nibble, 010; high nibble is power-state, 1 = charging), so a smooth countdown is impossible — instead `sample_charge_eta()` times how long each 10 % step takes and extrapolates the remaining steps. It shows `~--m` while calibrating (the first estimate can't appear until one full step has been timed, ~1520 min after plug-in), then refines on each subsequent notch. The partial step in progress at plug-in is discarded so the first estimate isn't skewed by a half-measured step; a 3-entry moving average smooths the rest. **Li-ion taper correction:** a flat "time-per-step × steps-left" runs optimistic in the constant-voltage tail, so each measured step is normalised to a bulk-equivalent duration (divide out a per-step weight: 1.0× in the bulk region, 1.5× for 80→90 %, 2.2× for 90→100 %) and the remaining steps are re-weighted — keeping the estimate consistent whether the user plugs in near-empty or near-full. Sampled once per frame from `oled_loop` ahead of the idle power-ladder early-returns, so step timing stays correct even while the panel is dimmed/off or the user is on another screen.
- **Lightbar settings persist across reboot and stick across every screen.** The selected lightbar mode and the four favorite colors are now saved to the config flash sector (new `Config_body` fields `lightbar_mode` + `lb_fav_{r,g,b}[4]`), so a chosen mode/color survives a power cycle. A new **HOST** mode (the default) hands the LED back to the host/game so the dongle doesn't hijack player-indicator LEDs out of the box; on upgrade from ≤0.6.4 the unset field reads as HOST, preserving prior behavior. Mode/favorite edits made on the Lightbar screen are batched into a single flash write when you navigate away (tracked by a dirty flag) to spare flash endurance.
- **Lightbar pulses amber-orange while charging.** A slow ~4.6 s breathing pulse (base `(255,100,0)`, sine-enveloped from dim to bright via the existing 32-step LUT) shows charging at a glance from any screen. Implemented in the unified `lightbar_service()` (below) as the top-priority owner of the LED, so it overrides the selected mode while charging and reverts to it when unplugged.
### Changed
- **The OLED no longer fully sleeps while the controller is charging.** The idle power ladder is capped at the Dim tier (the low-power breathing dot) instead of advancing to full Off (`cmd(0xAE)`) when `g_charge_eta.charging` is true. The charge-ETA tracker already runs while the panel is off, but users were unplugging the controller to "wake" the dongle — which reset the ETA calibration and restarted the wait-for-the-next-10%-notch. Capping at the dot tier (which draws ~no current) removes the reason to unplug. Normal Active→Dim→Off behavior resumes once charging stops.
- **A single `lightbar_service()` now owns the controller LED, every frame, on every screen.** Previously the OLED only drove the lightbar via a transient `0x31` packet sent from inside `render_screen_lightbar()` — so the color was only asserted while that screen was open. The service (run from `oled_loop` ahead of the power-ladder early-returns) instead writes the chosen color into the persistent `state[]` block (`SetStateData` `LedRed/Green/Blue`, via new `state_set_led()`), so it rides every outbound host/audio packet, and also actively pushes a `0x31` when audio is idle so animations keep moving. A new `g_lightbar_override` flag gates `state_update()` so the host's `AllowLedColor` writes can't stomp a firmware-chosen mode. During audio the active `0x31` push is suppressed — the `0x36` frames already carry `state[]`'s LED, and slipping a `0x31` between them would intrude on the load-bearing audio path.
### Fixed
- **OLED idle dim/dot tier now actually engages while a controller is connected.** The activity detector hashed `interrupt_in_data[0..9]` with an exact compare, but the analog sticks jitter by ±1 LSB at rest, so the hash changed every few frames and reset the idle timer — meaning the breathing-dot/dim tier only ever kicked in when no controller was paired. Now it mirrors `bt.cpp`'s inactivity heuristic: the stick bytes' rest band `[120,140]` is collapsed to a constant and the volatile counter byte (`idata[6]`) is skipped, so a resting controller reads as idle. Confirmed against a live `/dev/hidraw` capture (only the left-stick X byte was flickering 129↔128).
- **Lightbar no longer reverts the instant you leave the Lightbar screen.** Root cause: the OLED's `send_lightbar_color()` wrote a one-off `0x31` packet and never touched the persistent `state[]` block, while the host's `0x02` output reports, every audio frame, and reconnect all re-stamp `state[]` (incl. the LED) into the controller. Off the Lightbar screen the OLED stopped pushing, so the next `state[]`-based packet overwrote the color — which is why saved favorites and animated modes (Rainbow/Breathing/Fade) never "stuck." Now that the lightbar is owned through `state[]` with a host override gate (see Changed), the selected mode holds across screens and through active gameplay/audio.
---
## [0.6.4-oled-edition] — 2026-05-19
Trigger-flow diagnostics (in response to issue #3) + the OLED idle power ladder. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.4-oled-edition) (built by `.github/workflows/release.yml`).
+14
View File
@@ -95,6 +95,20 @@ void config_valid() {
body->auto_haptics_lowpass = 1; // 160 Hz
printf("[Config] auto_haptics_lowpass invalid, defaulting to 1 (160 Hz)\n");
}
if (body->lightbar_mode > 8) { // 0..7 OLED modes + 8 = HOST passthrough (default)
body->lightbar_mode = 8;
printf("[Config] lightbar_mode invalid, defaulting to 8 (HOST passthrough)\n");
}
// 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");
+16
View File
@@ -23,6 +23,22 @@ struct __attribute__((packed)) Config_body {
uint8_t auto_haptics_enable; // 0=Off, 1=Fallback (default), 2=Mix, 3=Replace
uint8_t auto_haptics_gain; // [0,200] percent, default 100
uint8_t auto_haptics_lowpass; // 0=80Hz, 1=160Hz (default), 2=250Hz, 3=400Hz
// Lightbar (OLED Edition Phase H): persisted so the chosen mode/colors
// survive reboot and stick across all screens. lightbar_mode indexes the
// OLED Lightbar screen's mode list — 0=LIVE, 1..4=FAV0..3, 5=BREATHING,
// 6=RAINBOW, 7=FADE, 8=HOST (passthrough, the safe default that lets the
// host/game own the LED). Keep this numbering in sync with kNumLbModes /
// kLbModeHost in src/oled.cpp. Erased flash (0xFF) → HOST + white favorites.
uint8_t lightbar_mode;
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]{};
+363 -82
View File
@@ -4,6 +4,7 @@
#include "slots.h"
#include "audio.h"
#include "config.h"
#include "state_mgr.h"
#include <cstdio>
#include <cstring>
@@ -22,11 +23,18 @@ 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 {
@@ -76,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;
@@ -108,16 +118,22 @@ 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
constexpr int kNumLbModes = 8;
// 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 = 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;
@@ -134,12 +150,16 @@ 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
int lb_mode = 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;
@@ -464,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
@@ -482,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 {
@@ -500,6 +520,109 @@ void handle_buttons() {
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; // at least one full step timed → minutes is meaningful
int minutes; // estimated minutes to 100%
};
ChargeEta g_charge_eta{};
// 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 = 3; // average 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 {
ring[ring_head] = dur / charge_step_weight(step);
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 (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;
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.minutes = mins;
} else {
// cur_step == 10 → essentially full; nothing meaningful to count down.
g_charge_eta.valid = (cur_step >= 10);
g_charge_eta.minutes = 0;
}
}
__attribute__((noinline)) void render_screen() {
fb_clear();
@@ -532,6 +655,18 @@ __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().
if (g_charge_eta.charging) {
char ebuf[8];
if (g_charge_eta.valid)
snprintf(ebuf, sizeof(ebuf), "~%dm", g_charge_eta.minutes);
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);
@@ -671,7 +806,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) {
@@ -695,23 +830,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]);
@@ -966,6 +1106,7 @@ const char* lb_mode_tag(int mode) {
case 5: return "[BREA]";
case 6: return "[RAIN]";
case 7: return "[FADE]";
case 8: return "[HOST]";
default: return "[????]";
}
}
@@ -981,6 +1122,7 @@ void lightbar_handle_input() {
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;
}
@@ -992,49 +1134,8 @@ __attribute__((noinline)) void render_screen_lightbar() {
draw_text(86, 0, lb_mode_tag(lb_mode));
if (bt_is_connected()) {
const uint32_t now_ms = time_us_32() / 1000;
if (lb_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);
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 (lb_mode <= 4) {
// FAV slot: fixed color
const int slot = lb_mode - 1;
lb_r = lb_fav_r[slot];
lb_g = lb_fav_g[slot];
lb_b = lb_fav_b[slot];
} else if (lb_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 (lb_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);
}
// 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);
@@ -1058,18 +1159,20 @@ __attribute__((noinline)) void render_screen_lightbar() {
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" :
"Locked to fav";
(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);
send_lightbar_color(lb_r, lb_g, lb_b);
// 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)");
}
@@ -1077,6 +1180,124 @@ __attribute__((noinline)) void render_screen_lightbar() {
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);
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");
@@ -1163,6 +1384,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;
}
}
}
@@ -1197,6 +1430,7 @@ void settings_handle_input() {
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 {
@@ -1255,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]);
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;
}
}
@@ -1424,6 +1666,10 @@ void oled_init() {
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();
}
// Dim-tier renderer: blank the panel and draw a tiny "I'm alive" dot that
@@ -1462,22 +1708,49 @@ void oled_loop() {
rumble_burst_tick(now);
if ((now - last_render_us) < kFrameUs) return;
last_render_us = now;
// Bump activity on controller input changes (cheap rolling hash over input bytes)
// 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();
// 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++) hash = hash * 31u + interrupt_in_data[i];
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;
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.
const uint32_t idle = now - last_activity_us;
if (idle > kAutoOffUs) {
// 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;
@@ -1485,10 +1758,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]);
@@ -1511,6 +1784,14 @@ void oled_loop() {
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) {
+19 -1
View File
@@ -6,6 +6,12 @@
#include <cstring>
#include "utils.h"
#include "state_mgr.h"
// Set by the OLED lightbar service (src/oled.cpp). While true, the firmware
// owns the lightbar (an OLED mode or the charging pulse) and the host's
// AllowLedColor writes are suppressed below so they can't stomp it.
extern bool g_lightbar_override;
namespace {
constexpr size_t kAudioControlOffset = offsetof(SetStateData, MuteLightMode) - sizeof(uint8_t);
@@ -41,6 +47,18 @@ void state_set(uint8_t *data, const uint8_t size) {
memcpy(data, state, size);
}
void state_set_led(uint8_t r, uint8_t g, uint8_t b) {
state[offsetof(SetStateData, LedRed) + 0] = r;
state[offsetof(SetStateData, LedRed) + 1] = g;
state[offsetof(SetStateData, LedRed) + 2] = b;
}
void state_get_led(uint8_t *r, uint8_t *g, uint8_t *b) {
*r = state[offsetof(SetStateData, LedRed) + 0];
*g = state[offsetof(SetStateData, LedRed) + 1];
*b = state[offsetof(SetStateData, LedRed) + 2];
}
void state_update(const uint8_t *data, const uint8_t size) {
if (size < sizeof(SetStateData)) {
printf(
@@ -147,7 +165,7 @@ void state_update(const uint8_t *data, const uint8_t size) {
sizeof(uint8_t)
);
copy_if_allowed(
update.AllowLedColor,
update.AllowLedColor && !g_lightbar_override,
offsetof(SetStateData, LedRed),
sizeof(update.LedRed) * 3
);
+9
View File
@@ -5,8 +5,17 @@
#ifndef DS5_BRIDGE_STATE_MGR_H
#define DS5_BRIDGE_STATE_MGR_H
#include <cstdint>
void state_init();
void state_set(uint8_t *data, const uint8_t size);
void state_update(const uint8_t *data, const uint8_t size);
// Lightbar RGB lives in the persistent state[] block (SetStateData LedRed/
// Green/Blue) that gets stamped into every outbound BT packet. The OLED
// lightbar service writes it directly so a firmware-chosen color rides every
// host/audio frame instead of only the transient send_lightbar_color() packet.
void state_set_led(uint8_t r, uint8_t g, uint8_t b);
void state_get_led(uint8_t *r, uint8_t *g, uint8_t *b);
#endif //DS5_BRIDGE_STATE_MGR_H