Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89847ed06e | ||
|
|
0eceb1d5e2 | ||
|
|
d99689bf75 | ||
|
|
c65e0c9802 | ||
|
|
585a385219 | ||
|
|
4edcd20181 |
@@ -10,6 +10,53 @@ 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`).
|
||||
|
||||
### 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
|
||||
|
||||
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,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");
|
||||
|
||||
@@ -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
@@ -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]{};
|
||||
|
||||
+383
-82
@@ -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,127 @@ 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; // 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) {
|
||||
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 (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 = true;
|
||||
g_charge_eta.provisional = false;
|
||||
g_charge_eta.minutes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
__attribute__((noinline)) void render_screen() {
|
||||
fb_clear();
|
||||
|
||||
@@ -532,6 +673,20 @@ __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?" is the provisional default-rate estimate shown
|
||||
// immediately on plug-in; the "?" drops to "~43m" once a real 10% step
|
||||
// has been timed and the measured rate takes over. See sample_charge_eta().
|
||||
if (g_charge_eta.charging) {
|
||||
char ebuf[8];
|
||||
if (g_charge_eta.valid)
|
||||
snprintf(ebuf, sizeof(ebuf), "~%dm%s", g_charge_eta.minutes,
|
||||
g_charge_eta.provisional ? "?" : "");
|
||||
else
|
||||
snprintf(ebuf, sizeof(ebuf), "~--m");
|
||||
draw_text(94, 18, ebuf);
|
||||
}
|
||||
|
||||
// Left-half visuals are shifted right by kContentX so the < button
|
||||
// chrome at (x=0, y=49) doesn't paint over the live stick dot.
|
||||
rect_outline(kContentX, 30, 32, 32);
|
||||
@@ -671,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) {
|
||||
@@ -695,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]);
|
||||
@@ -966,6 +1126,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 +1142,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 +1154,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 +1179,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 +1200,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 +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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1197,6 +1450,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 +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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1424,6 +1686,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 +1728,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 +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]);
|
||||
@@ -1511,6 +1804,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
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user