feat(oled): add CPU / Clock diagnostics screen

New CPU/Clock OLED screen (kScreenCpu), between Diagnostics and BT
Signal in the K0 cycle. Shows:

- Set  - configured system clock (SYS_CLOCK_KHZ overclock target)
- Real - clk_sys from the RP2350 frequency counter, measured once on
  screen entry and cached (it is fixed at boot; avoids a per-frame
  multi-ms stall)
- Vcore - core voltage read back via vreg_get_voltage()
- Temp - on-die sensor via cpu_temp_raw_smoothed()

render_screen_cpu() is noinline like the other render functions (Thumb
literal-pool reach). Hardware-in-the-loop verified on Pico 2 W +
Waveshare Pico-OLED-1.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thierry Perraut
2026-05-18 07:31:13 -06:00
committed by Marceline
co-authored by Claude Opus 4.7
parent 5425b21f00
commit 63fcfb91ea
2 changed files with 79 additions and 4 deletions
+9
View File
@@ -8,6 +8,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
## [Unreleased] ## [Unreleased]
### Added
- **CPU / Clock diagnostics screen** (`kScreenCpu`, inserted between Diagnostics and BT Signal in the K0 cycle). Shows the configured system clock (`SYS_CLOCK_KHZ` — the overclock target), the *actually running* `clk_sys` measured live by the RP2350 on-chip frequency counter against the crystal reference, the core voltage read back from the regulator (`vreg_get_voltage()`, not the compile-time constant), and the RP2350 on-die temperature (ADC input 4). Pure read-only instrumentation; one-time ADC bring-up and no other code path uses the ADC, so it is conflict-free. `render_screen_cpu()` is `noinline` like the other render functions (Thumb literal-pool reach). Adds `hardware_adc` to `target_link_libraries`. The frequency-counter measurement (a multi-ms busy-wait) runs **once on screen entry** and is cached — `clk_sys` is fixed at boot, so only the temperature refreshes per frame, avoiding a per-frame BT/audio hitch while the screen is visible. `oled_loop()` gained a generic `screen_entered` flag for this. Hardware-verified on Pico 2 W + OLED. Also exported over a new HID feature report **`0xfc`** (`src/cmd.cpp`) — 11 bytes: set_khz, cached real_khz, vreg code, ADC ch4 raw — so the web config emulator can show live CPU telemetry (volts/temp math done web-side to keep the firmware HID path float-free).
### Fixed (web telemetry — latent since the slots/diag reports landed)
- **CPU/Clock temperature was a single noisy ADC sample.** The RP2350 temp sensor has a shallow slope (1.721 mV/°C, ~1 LSB ≈ 0.47 °C) so a lone 12-bit reading swings several tenths of a degree per frame — the displayed value just mirrored the latest noisy sample instead of the true die temperature. New `cpu_temp_raw_smoothed()` in `src/cmd.cpp` averages a 256-sample block then runs a slow EMA (α=0.15, seeded on first call). It is the **single source of truth**: both `render_screen_cpu()` and the `0xfc` web telemetry call it, and the duplicated per-site ADC bring-up was removed (ADC now initialised in exactly one place). `oled.cpp` no longer touches the ADC directly (drops `hardware/adc.h`, adds `cmd.h`).
- **Live web telemetry over WebHID: not feasible on the target setup; abandoned.** A browser-side read-only diagnostic proved Chrome WebHID returns `NotAllowedError` for any report ID **not declared** in the parsed HID report descriptor (declared `0xF7`/`0xF8`/`0xF9` read fine; undeclared `0xFA`/`0xFB`/`0xFC` fail). Declaring them is therefore mandatory for the web read — but doing so (even applied atomically with a matching `wDescriptorLength`, correct bytes identical in shape to the working `0xF6``0xF9`, and a `bcdDevice` cache-bust bump) made the device fail to enumerate as a usable HID device on the user's real Windows machine in **two** independent attempts (Device Manager showed it; WebHID and the PlayStation Accessories app did not). The cloned DualSense HID report descriptor cannot be safely extended on this environment. Reverted to the original descriptor (`0x0141`/`0x01B5`, no vendor feature reports, `bcdDevice 0x0100`). Retained with **no USB impact**: the `0xfc` firmware handler and the temperature smoothing/`cpu_temp_raw_smoothed()`. The on-device CPU/Clock OLED screen is fully working and hardware-verified; the web preview's CPU screen stays on representative mock values (the slots/diagnostics web screens were never readable for the same root cause and are likewise mock-only when connected).
### Fixed ### Fixed
- **Low-battery LED keeps blinking after controller disconnect** (`fb68ea5`). When the DualSense's battery dropped low enough to trigger `battery_led_tick`'s blink and the controller subsequently disconnected (typically: battery fully depletes and the BT link drops), the Pico's onboard LED stayed frozen in whichever half-cycle it was in at the moment of disconnect; reconnect-retry windows could even briefly resume blinking. New `battery_led_on_disconnect()` clears blink state, forces LED off, and zeros `last_report_us` so the stale-check early-return blocks any new blink until a fresh 0x31 report arrives on the next connection. Stale-check in the tick also now forces LED off when it fires mid-blink (defense in depth for ungraceful disconnects). Reported by Sura Academy on Discord. Same bug present in upstream — sent back as [awalol/DS5Dongle#101](https://github.com/awalol/DS5Dongle/pull/101). - **Low-battery LED keeps blinking after controller disconnect** (`fb68ea5`). When the DualSense's battery dropped low enough to trigger `battery_led_tick`'s blink and the controller subsequently disconnected (typically: battery fully depletes and the BT link drops), the Pico's onboard LED stayed frozen in whichever half-cycle it was in at the moment of disconnect; reconnect-retry windows could even briefly resume blinking. New `battery_led_on_disconnect()` clears blink state, forces LED off, and zeros `last_report_us` so the stale-check early-return blocks any new blink until a fresh 0x31 report arrives on the next connection. Stale-check in the tick also now forces LED off when it fires mid-blink (defense in depth for ungraceful disconnects). Reported by Sura Academy on Discord. Same bug present in upstream — sent back as [awalol/DS5Dongle#101](https://github.com/awalol/DS5Dongle/pull/101).
+70 -4
View File
@@ -10,6 +10,9 @@
#include "hardware/spi.h" #include "hardware/spi.h"
#include "hardware/gpio.h" #include "hardware/gpio.h"
#include "hardware/watchdog.h" #include "hardware/watchdog.h"
#include "hardware/clocks.h"
#include "hardware/vreg.h"
#include "cmd.h"
#include "pico/time.h" #include "pico/time.h"
extern uint8_t interrupt_in_data[63]; // defined in main.cpp extern uint8_t interrupt_in_data[63]; // defined in main.cpp
@@ -70,10 +73,11 @@ constexpr int kScreenTriggers = 3;
constexpr int kScreenGyro = 4; constexpr int kScreenGyro = 4;
constexpr int kScreenTouchpad = 5; constexpr int kScreenTouchpad = 5;
constexpr int kScreenDiag = 6; constexpr int kScreenDiag = 6;
constexpr int kScreenRssi = 7; constexpr int kScreenCpu = 7;
constexpr int kScreenVU = 8; constexpr int kScreenRssi = 8;
constexpr int kScreenSettings = 9; constexpr int kScreenVU = 9;
constexpr int kNumScreens = 10; constexpr int kScreenSettings = 10;
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
@@ -601,6 +605,60 @@ __attribute__((noinline)) void render_screen_diag() {
flush_fb(); flush_fb();
} }
__attribute__((noinline)) void render_screen_cpu(bool entered) {
fb_clear();
draw_text(0, 0, "CPU / Clock");
char buf[24];
// Configured system clock — compile-time SYS_CLOCK_KHZ, set in main()
// via set_sys_clock_khz(). This is the *target*.
const uint32_t set_khz = (uint32_t)SYS_CLOCK_KHZ;
snprintf(buf, sizeof(buf), "Set : %lu MHz", (unsigned long)(set_khz / 1000u));
draw_text(0, 12, buf);
// Actually running clk_sys, measured by the on-chip frequency counter
// against the crystal reference (not just what we asked for). The counter
// busy-waits a few ms per call, so measure ONCE on screen entry and cache
// it — clk_sys is fixed at boot and never changes, so the temperature
// (which legitimately drifts) is the only thing worth refreshing per
// frame. cached_real_khz==0 also forces a (re)measure as a safety net.
static uint32_t cached_real_khz = 0;
if (entered || cached_real_khz == 0) {
cached_real_khz = frequency_count_khz(CLOCKS_FC0_SRC_VALUE_CLK_SYS);
}
const uint32_t real_khz = cached_real_khz;
snprintf(buf, sizeof(buf), "Real: %lu.%01lu MHz",
(unsigned long)(real_khz / 1000u),
(unsigned long)((real_khz % 1000u) / 100u));
draw_text(0, 22, buf);
// Core voltage actually programmed into the regulator, read back (not the
// compile-time constant). Codes 0..15 are linear 0.05 V steps from 0.55 V.
const int vcode = (int)vreg_get_voltage();
if (vcode >= 0 && vcode <= 0b01111) {
const unsigned mv = 550u + 50u * (unsigned)vcode;
snprintf(buf, sizeof(buf), "Vcore: %u.%02u V", mv / 1000u, (mv % 1000u) / 10u);
} else {
snprintf(buf, sizeof(buf), "Vcore: code %d", vcode);
}
draw_text(0, 32, buf);
// RP2350 on-die temperature sensor. Smoothed + averaged in cmd.cpp
// (single source of truth shared with the 0xfc web telemetry) so the
// reading converges to the true die temp instead of chasing ADC noise.
const uint16_t raw = cpu_temp_raw_smoothed();
const float volts = (float)raw * 3.3f / 4096.0f;
const float temp_c = 27.0f - (volts - 0.706f) / 0.001721f;
const int t10 = (int)(temp_c * 10.0f + (temp_c >= 0 ? 0.5f : -0.5f));
snprintf(buf, sizeof(buf), "Temp : %d.%d C", t10 / 10,
(t10 < 0 ? -t10 : t10) % 10);
draw_text(0, 42, buf);
draw_text(0, 56, "K0=next K1=back");
flush_fb();
}
__attribute__((noinline)) void render_screen_triggers() { __attribute__((noinline)) void render_screen_triggers() {
fb_clear(); fb_clear();
draw_text(0, 0, "Trigger Test"); draw_text(0, 0, "Trigger Test");
@@ -1204,6 +1262,13 @@ void oled_loop() {
const bool idle = (now - last_activity_us) > kAutoDimUs; const bool idle = (now - last_activity_us) > kAutoDimUs;
sh1107_set_contrast(idle ? kDimContrast : kBrightLevels[bright_idx]); sh1107_set_contrast(idle ? kDimContrast : kBrightLevels[bright_idx]);
// True on the first render after navigating to a different screen.
// Lets a screen do expensive one-shot work on entry (the CPU screen
// caches its frequency-counter measurement here).
static int last_rendered_screen = -1;
const bool screen_entered = (current_screen != last_rendered_screen);
last_rendered_screen = current_screen;
switch (current_screen) { switch (current_screen) {
case kScreenStatus: render_screen(); break; case kScreenStatus: render_screen(); break;
case kScreenSlots: render_screen_slots(); break; case kScreenSlots: render_screen_slots(); break;
@@ -1212,6 +1277,7 @@ void oled_loop() {
case kScreenGyro: render_screen_gyro(); break; case kScreenGyro: render_screen_gyro(); break;
case kScreenTouchpad: render_screen_touchpad(); break; case kScreenTouchpad: render_screen_touchpad(); break;
case kScreenDiag: render_screen_diag(); break; case kScreenDiag: render_screen_diag(); break;
case kScreenCpu: render_screen_cpu(screen_entered); break;
case kScreenRssi: render_screen_rssi(); break; case kScreenRssi: render_screen_rssi(); break;
case kScreenVU: render_screen_vu(); break; case kScreenVU: render_screen_vu(); break;
case kScreenSettings: render_screen_settings(); break; case kScreenSettings: render_screen_settings(); break;