Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
585a385219 | ||
|
|
4edcd20181 |
@@ -10,6 +10,28 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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, 0–10; 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, ~15–20 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
|
## [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`).
|
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`).
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ void config_valid() {
|
|||||||
body->auto_haptics_lowpass = 1; // 160 Hz
|
body->auto_haptics_lowpass = 1; // 160 Hz
|
||||||
printf("[Config] auto_haptics_lowpass invalid, defaulting to 1 (160 Hz)\n");
|
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->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");
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ struct __attribute__((packed)) Config_body {
|
|||||||
uint8_t auto_haptics_enable; // 0=Off, 1=Fallback (default), 2=Mix, 3=Replace
|
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_gain; // [0,200] percent, default 100
|
||||||
uint8_t auto_haptics_lowpass; // 0=80Hz, 1=160Hz (default), 2=250Hz, 3=400Hz
|
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];
|
||||||
};
|
};
|
||||||
|
|
||||||
struct __attribute__((packed)) Config {
|
struct __attribute__((packed)) Config {
|
||||||
|
|||||||
+303
-57
@@ -4,6 +4,7 @@
|
|||||||
#include "slots.h"
|
#include "slots.h"
|
||||||
#include "audio.h"
|
#include "audio.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
#include "state_mgr.h"
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
@@ -27,6 +28,12 @@ extern uint8_t bt_31_b2_or_mask();
|
|||||||
extern uint16_t bt_31_len_min();
|
extern uint16_t bt_31_len_min();
|
||||||
extern uint16_t bt_31_len_max();
|
extern uint16_t bt_31_len_max();
|
||||||
extern void bt_31_mic_prefix(uint8_t out[6]);
|
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 {
|
namespace {
|
||||||
|
|
||||||
@@ -108,8 +115,12 @@ constexpr int kScreenSettings = 10;
|
|||||||
constexpr int kNumScreens = 11;
|
constexpr int kNumScreens = 11;
|
||||||
int current_screen = 0;
|
int current_screen = 0;
|
||||||
|
|
||||||
// Lightbar mode cycle: 0=LIVE, 1-4=FAV0-3, 5=BREATHING, 6=RAINBOW, 7=FADE
|
// Lightbar mode cycle: 0=LIVE, 1-4=FAV0-3, 5=BREATHING, 6=RAINBOW, 7=FADE,
|
||||||
constexpr int kNumLbModes = 8;
|
// 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
|
// Settings screen state
|
||||||
constexpr int kNumSettingsItems = 13; // 8 fields + 3 auto-haptic + Reset + Wipe
|
constexpr int kNumSettingsItems = 13; // 8 fields + 3 auto-haptic + Reset + Wipe
|
||||||
@@ -134,12 +145,16 @@ constexpr uint32_t kResetHoldUs = 2000000;
|
|||||||
|
|
||||||
uint8_t lb_r = 0, lb_g = 0, lb_b = 0;
|
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
|
// Lightbar mode + favorite slots: 0 = LIVE tilt preview; 1..4 = saved slots F0..F3.
|
||||||
int lb_mode = 0;
|
// 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_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_g[4] = {0, 255, 0, 255};
|
||||||
uint8_t lb_fav_b[4] = {0, 0, 255, 255};
|
uint8_t lb_fav_b[4] = {0, 0, 255, 255};
|
||||||
uint8_t lb_last_face = 0;
|
uint8_t lb_last_face = 0;
|
||||||
|
bool lb_dirty = false;
|
||||||
|
|
||||||
uint32_t rumble_off_at_us = 0;
|
uint32_t rumble_off_at_us = 0;
|
||||||
bool rumble_active = false;
|
bool rumble_active = false;
|
||||||
@@ -500,6 +515,109 @@ void handle_buttons() {
|
|||||||
key1_prev = k1;
|
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() {
|
__attribute__((noinline)) void render_screen() {
|
||||||
fb_clear();
|
fb_clear();
|
||||||
|
|
||||||
@@ -532,6 +650,18 @@ __attribute__((noinline)) void render_screen() {
|
|||||||
draw_text(kContentX, 18, bbuf);
|
draw_text(kContentX, 18, bbuf);
|
||||||
draw_battery_icon(36, 18, pct);
|
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
|
// 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.
|
// chrome at (x=0, y=49) doesn't paint over the live stick dot.
|
||||||
rect_outline(kContentX, 30, 32, 32);
|
rect_outline(kContentX, 30, 32, 32);
|
||||||
@@ -966,6 +1096,7 @@ const char* lb_mode_tag(int mode) {
|
|||||||
case 5: return "[BREA]";
|
case 5: return "[BREA]";
|
||||||
case 6: return "[RAIN]";
|
case 6: return "[RAIN]";
|
||||||
case 7: return "[FADE]";
|
case 7: return "[FADE]";
|
||||||
|
case 8: return "[HOST]";
|
||||||
default: return "[????]";
|
default: return "[????]";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -981,6 +1112,7 @@ void lightbar_handle_input() {
|
|||||||
const bool r1_prev = (lb_last_buttons & 0x02) != 0;
|
const bool r1_prev = (lb_last_buttons & 0x02) != 0;
|
||||||
if (r1_now && !r1_prev) {
|
if (r1_now && !r1_prev) {
|
||||||
lb_mode = (lb_mode + 1) % kNumLbModes;
|
lb_mode = (lb_mode + 1) % kNumLbModes;
|
||||||
|
lb_dirty = true; // persisted on leaving the Lightbar screen
|
||||||
}
|
}
|
||||||
lb_last_buttons = btns;
|
lb_last_buttons = btns;
|
||||||
}
|
}
|
||||||
@@ -992,49 +1124,8 @@ __attribute__((noinline)) void render_screen_lightbar() {
|
|||||||
draw_text(86, 0, lb_mode_tag(lb_mode));
|
draw_text(86, 0, lb_mode_tag(lb_mode));
|
||||||
|
|
||||||
if (bt_is_connected()) {
|
if (bt_is_connected()) {
|
||||||
const uint32_t now_ms = time_us_32() / 1000;
|
// lb_r/lb_g/lb_b are computed every frame by lightbar_service() (which
|
||||||
if (lb_mode == 0) {
|
// runs ahead of this render in oled_loop), so here we only display them.
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
char buf[16];
|
char buf[16];
|
||||||
snprintf(buf, sizeof(buf), "R:%3u", lb_r); draw_text(kContentX, 12, buf);
|
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), "G:%3u", lb_g); draw_text(48, 12, buf);
|
||||||
@@ -1058,18 +1149,20 @@ __attribute__((noinline)) void render_screen_lightbar() {
|
|||||||
lb_fav_r[save_slot] = lb_r;
|
lb_fav_r[save_slot] = lb_r;
|
||||||
lb_fav_g[save_slot] = lb_g;
|
lb_fav_g[save_slot] = lb_g;
|
||||||
lb_fav_b[save_slot] = lb_b;
|
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");
|
draw_text(kContentX, 38, "Sv:T=0 C=1 X=2 S=3");
|
||||||
const char* hint =
|
const char* hint =
|
||||||
(lb_mode == 0) ? "Tilt = R/G/B" :
|
(lb_mode == 0) ? "Tilt = R/G/B" :
|
||||||
(lb_mode == 5) ? "Breathing FAV0" :
|
(lb_mode == 5) ? "Breathing FAV0" :
|
||||||
(lb_mode == 6) ? "Rainbow sweep" :
|
(lb_mode == 6) ? "Rainbow sweep" :
|
||||||
(lb_mode == 7) ? "Fade thru FAVs" :
|
(lb_mode == 7) ? "Fade thru FAVs" :
|
||||||
"Locked to fav";
|
(lb_mode == kLbModeHost) ? "Host controls" :
|
||||||
|
"Locked to fav";
|
||||||
draw_text(kContentX, 48, hint);
|
draw_text(kContentX, 48, hint);
|
||||||
|
// No send here: lightbar_service() owns pushing the color to the
|
||||||
send_lightbar_color(lb_r, lb_g, lb_b);
|
// controller every frame, on this screen and every other.
|
||||||
} else {
|
} else {
|
||||||
draw_text(kContentX, 30, "(no controller)");
|
draw_text(kContentX, 30, "(no controller)");
|
||||||
}
|
}
|
||||||
@@ -1077,6 +1170,124 @@ __attribute__((noinline)) void render_screen_lightbar() {
|
|||||||
flush_fb();
|
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() {
|
__attribute__((noinline)) void render_screen_vu() {
|
||||||
fb_clear();
|
fb_clear();
|
||||||
draw_text(kContentX, 0, "Audio Meters");
|
draw_text(kContentX, 0, "Audio Meters");
|
||||||
@@ -1197,6 +1408,7 @@ void settings_handle_input() {
|
|||||||
config_default();
|
config_default();
|
||||||
if (config_save()) {
|
if (config_save()) {
|
||||||
settings_local = get_config();
|
settings_local = get_config();
|
||||||
|
lightbar_load_config(); // refresh RAM lightbar state (no reboot here)
|
||||||
settings_dirty = false;
|
settings_dirty = false;
|
||||||
settings_save_status = "Reset!";
|
settings_save_status = "Reset!";
|
||||||
} else {
|
} else {
|
||||||
@@ -1424,6 +1636,10 @@ void oled_init() {
|
|||||||
sh1107_init();
|
sh1107_init();
|
||||||
fb_clear();
|
fb_clear();
|
||||||
boot_splash();
|
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
|
// Dim-tier renderer: blank the panel and draw a tiny "I'm alive" dot that
|
||||||
@@ -1462,9 +1678,26 @@ void oled_loop() {
|
|||||||
rumble_burst_tick(now);
|
rumble_burst_tick(now);
|
||||||
if ((now - last_render_us) < kFrameUs) return;
|
if ((now - last_render_us) < kFrameUs) return;
|
||||||
last_render_us = now;
|
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;
|
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) {
|
if (hash != last_input_hash) {
|
||||||
last_input_hash = hash;
|
last_input_hash = hash;
|
||||||
last_activity_us = now;
|
last_activity_us = now;
|
||||||
@@ -1476,8 +1709,13 @@ void oled_loop() {
|
|||||||
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.
|
||||||
|
// 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;
|
const uint32_t idle = now - last_activity_us;
|
||||||
if (idle > kAutoOffUs) {
|
if (idle > kAutoOffUs && !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;
|
||||||
@@ -1511,6 +1749,14 @@ void oled_loop() {
|
|||||||
send_trigger_effect(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;
|
last_rendered_screen = current_screen;
|
||||||
|
|
||||||
switch (current_screen) {
|
switch (current_screen) {
|
||||||
|
|||||||
+19
-1
@@ -6,6 +6,12 @@
|
|||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
#include "utils.h"
|
#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 {
|
namespace {
|
||||||
constexpr size_t kAudioControlOffset = offsetof(SetStateData, MuteLightMode) - sizeof(uint8_t);
|
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);
|
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) {
|
void state_update(const uint8_t *data, const uint8_t size) {
|
||||||
if (size < sizeof(SetStateData)) {
|
if (size < sizeof(SetStateData)) {
|
||||||
printf(
|
printf(
|
||||||
@@ -147,7 +165,7 @@ void state_update(const uint8_t *data, const uint8_t size) {
|
|||||||
sizeof(uint8_t)
|
sizeof(uint8_t)
|
||||||
);
|
);
|
||||||
copy_if_allowed(
|
copy_if_allowed(
|
||||||
update.AllowLedColor,
|
update.AllowLedColor && !g_lightbar_override,
|
||||||
offsetof(SetStateData, LedRed),
|
offsetof(SetStateData, LedRed),
|
||||||
sizeof(update.LedRed) * 3
|
sizeof(update.LedRed) * 3
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,17 @@
|
|||||||
#ifndef DS5_BRIDGE_STATE_MGR_H
|
#ifndef DS5_BRIDGE_STATE_MGR_H
|
||||||
#define DS5_BRIDGE_STATE_MGR_H
|
#define DS5_BRIDGE_STATE_MGR_H
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
void state_init();
|
void state_init();
|
||||||
void state_set(uint8_t *data, const uint8_t size);
|
void state_set(uint8_t *data, const uint8_t size);
|
||||||
void state_update(const 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
|
#endif //DS5_BRIDGE_STATE_MGR_H
|
||||||
|
|||||||
Reference in New Issue
Block a user