Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/support/ir.c
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ uint PacketChecksum(u8 *bytes, u32 length)
#include "stick/eeprom_backend.h"
#include "stick/board_runtime.h"
#include "stick/ir_transport.h"
#include "stick/diode_status.h"
static u8 stickEepromSessionReady;
#define IR_TIMER_NOW() StickIrTicks()
#ifdef PW_STICK_BENCH_CONTROL
Expand Down Expand Up @@ -565,6 +566,7 @@ void IrProtocolTick(void)
}
}
#ifdef PW_STICK_S3
StickDiodeReceivedPacket();
if (IR_TIMER_ELAPSED(g_state.irTimerReference) <
PW_IR_INACTIVITY_TICKS - 0x100) {
StickPlatformPresentIr();
Expand Down Expand Up @@ -1324,6 +1326,9 @@ void IrFinish(void)
if (!StickEepromHealthy())
g_work.irc.work.completionAction = IR_ACTION_NONE;
}
StickDiodeEnd(g_state.irResult == IR_RESULT_CONNECTION_ERROR ||
g_state.irResult == IR_RESULT_NO_RESPONSE ||
g_state.irResult == IR_RESULT_RECEIVE_OVERFLOW);
#ifdef PW_STICK_BENCH_CONTROL
StickPortTrace("PW_STICK_AFTER_IR_STOP");
#endif
Expand Down
36 changes: 36 additions & 0 deletions stick/audits/diode-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Diode detection evidence

Checked 2026-10-14 on a StickS3 with the BPV10NF already inserted. No lighting changes or diode removal were needed.

## Why capacitance works

The [StickS3 schematic](https://m5stack-doc.oss-cn-shenzhen.aliyuncs.com/1207/K150_Stick_S3_PRJ_V0.6_20251111_2025_11_17_16_10_24.pdf) routes GPIO4–8 directly to the Hat connector, with no external components on those nets. GPIO4 provides an unused reference; GPIO6–8 provide unpopulated controls.

The [BPV10NF datasheet](https://www.vishay.com/docs/81503/bpv10nf.pdf) gives 11 pF typical junction capacitance at zero bias, decreasing with reverse bias. That signature exists in darkness. The [ESP32-S3 touch peripheral](https://docs.espressif.com/projects/esp-idf/en/v5.5/esp32s3/api-reference/peripherals/cap_touch_sens.html) can measure it with bounded one-shot scans, without optical coupling or a transmitting peer.

## Physical measurements

The final driver detected the connected GPIO5 diode on **4/4 checks**, and classified the unpopulated GPIO6 control as missing on **4/4 checks**. Checks took about 129 ms. GPIO7 and GPIO8 remained inconclusive where channel offsets or sample variation exceeded the acceptance bounds.

Representative connected counts, using 32 charge cycles:

| Charge speed | Voltage window | GPIO5 | GPIO4 | Internal 5 pF | Internal 14.8 pF |
| --- | --- | ---: | ---: | ---: | ---: |
| 3 | 0.5–0.9 V | 1278 | 862 | 576 | 1163 |
| 3 | 0.8–2.4 V | 4828 | 3471 | 2027 | 4361 |
| 7 | 0.5–0.9 V | 808 | 454 | 274 | 530 |
| 7 | 0.8–2.4 V | 2395 | 1509 | 887 | 1887 |

The calibrated added capacitance was approximately 6.95/5.70 pF at speed 3 and 13.55/8.68 pF at speed 7. Native channel offsets make a single typical-datasheet threshold insufficient; both current strengths must agree. Host tests replay the measured connected and bare signatures and cover gain, part-capacitance and steady-photocurrent variation.

Missing detection at the GPIO5 socket is inferred from the bare-control measurements and schematic; the diode was not removed from GPIO5. Other units and lighting conditions were not measured.

## Failure checks

Weak pull-up checks precede touch excitation and strong GPIO precharge. A held-low input skips both. The reversal check sweeps weak pull-up, both weak pulls, and weak pull-down. A reversed junction holds nearly the same forward voltage under the two positive biases, including in darkness; a correctly inserted diode under strong light drops substantially when the second pull is added. A short stays near zero. These distinctions follow the [illuminated-junction equations](https://www.vishay.com/docs/80086/physicstechnology.pdf).

On the inserted diode, three physical sweeps measured pull-up voltages of 3134/3131/3130 mV, both-pull voltages of 1397/1429/1413 mV, and pull-down voltages of 16/14/11 mV. This correctly rejects reversal. Dark-reversal and strong-light signatures are exercised through the production ADC path in the simulator; a physical reversed sweep was not captured.

The S3 has independent digital and RTC pull controls. ADC channel setup selects the digital mux, so the sweep uses the digital weak pulls after configuration. [SDK GPIO implementation](https://github.com/espressif/esp-idf/blob/v5.5/components/esp_driver_gpio/src/gpio.c), [ADC setup](https://github.com/espressif/esp-idf/blob/v5.5/components/esp_adc/adc_oneshot.c).

The IDF 5.5 one-shot routine can return success after a hardware measurement timeout, so the detector also latches its timeout callback. Hardware timeout, stalled scans, invalid references and samples without a repeatable cluster produce an internal inconclusive result with no icon. Every scan shuts down its controller and restores the GPIO without pulls. Simulator checks exercise both timeout paths, feedback clearing, bounded retries and connection recovery.
143 changes: 143 additions & 0 deletions stick/audits/diode_feedback.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#include "../diode_feedback.h"
#include "../diode_presence.h"

#include <cassert>
#include <cstdio>
#include <initializer_list>

int main()
{
using namespace pw_stick;
// Replays of the live BPV10NF and bare GPIO4 at slope 3, 128 cycles.
const DiodeCapWindow live[] = {{5792, 3831, 2245, 4536},
{20093, 13800, 7985, 17164}};
assert(diode_cap_state(live) == DiodeState::Connected);
// The bare input trace replayed at the diode socket is electrically empty.
const DiodeCapWindow empty[] = {{3831, 3831, 2245, 4536},
{13800, 13800, 7985, 17164}};
assert(diode_cap_state(empty) == DiodeState::Missing);
const DiodeCapWindow fast[] = {{2548, 1740, 1066, 2067},
{8668, 5965, 3502, 7436}};
assert(diode_cap_state(fast) == DiodeState::Connected);
// Full-firmware live capture: DAC/channel offsets differ from the isolated
// probe, but both currents still separate the diode from unloaded GPIO6.
const DiodeCapWindow native[] = {{1283, 851, 577, 1163},
{4841, 3482, 2028, 4360}};
const DiodeCapWindow native_fast[] = {{803, 454, 274, 531},
{2389, 1499, 887, 1887}};
assert(diode_cap_state(native) == DiodeState::Connected);
assert(diode_cap_state(native_fast) == DiodeState::Connected);
const DiodeCapWindow bare_control[] = {{811, 830, 576, 1163},
{3255, 3481, 2027, 4361}};
const DiodeCapWindow bare_control_fast[] = {{395, 453, 274, 530},
{1407, 1498, 887, 1887}};
assert(diode_cap_state(bare_control) == DiodeState::Missing);
assert(diode_cap_state(bare_control_fast) == DiodeState::Missing);
const DiodeCapWindow loaded_reference[] = {{1283, 1400, 577, 1163},
{4841, 4900, 2028, 4360}};
assert(diode_cap_state(loaded_reference) == DiodeState::Unknown);
// Clock/gain and reference-capacitor variation must cancel, not change state.
for (unsigned scale = 50; scale <= 160; ++scale) {
DiodeCapWindow scaled[2];
for (unsigned i = 0; i < 2; ++i) {
scaled[i] = {live[i].diode * scale / 100,
live[i].reference * scale / 100,
live[i].internal_low * scale / 100,
live[i].internal_high * scale / 100};
}
assert(diode_cap_state(scaled) == DiodeState::Connected);
}
for (unsigned part = 60; part <= 150; ++part) {
DiodeCapWindow varied[2] = {live[0], live[1]};
for (unsigned i = 0; i < 2; ++i)
varied[i].diode = live[i].reference +
(live[i].diode - live[i].reference) * part / 100;
assert(diode_cap_state(varied) == DiodeState::Connected);
}
// Steady photocurrent slows the correct diode's oscillation. It cannot
// turn the recorded diode into an empty socket, even when classification
// becomes inconclusive at extreme illumination.
for (unsigned light = 100; light <= 300; ++light) {
DiodeCapWindow lit[2] = {live[0], live[1]};
for (auto &w : lit)
w.diode = w.diode * light / 100;
assert(diode_cap_state(lit) != DiodeState::Missing);
}
const DiodeCapWindow broken[] = {{0, 3831, 2245, 2245},
{0, 13800, 7985, 7985}};
assert(diode_cap_state(broken) == DiodeState::Unknown);
const DiodeCapWindow flat_capacitor[] = {{5702, 3831, 2245, 4536},
{21293, 13800, 7985, 17164}};
assert(diode_cap_state(flat_capacitor) == DiodeState::Unknown);
const DiodeCapWindow gap[] = {{4532, 3831, 2245, 4536},
{16610, 13800, 7985, 17164}};
assert(diode_cap_state(gap) == DiodeState::Unknown);
// A healthy diode sinks photocurrent with this wiring. With a pull-down,
// its voltage and an empty socket both remain near zero, including in light.
// Allow substantial ADC offset/noise without inventing a diagnosis.
for (int noise = -50; noise <= 50; ++noise) {
const int healthy[] = {noise, noise + 10, noise - 10};
assert(diode_voltage_state(healthy) == DiodeState::Unknown);
}
const int reversed_light[] = {390, 410, 400};
assert(diode_voltage_state(reversed_light) == DiodeState::Reversed);
const int reversed_dark[] = {0, 0, 0};
assert(diode_voltage_state(reversed_dark) == DiodeState::Unknown);
const int unstable[] = {250, 410, 400};
assert(diode_voltage_state(unstable) == DiodeState::Unknown);
const int shorted[] = {0, 10, 0};
assert(diode_voltage_state(shorted) == DiodeState::Unknown);
const int external_voltage[] = {3300, 3300, 3300};
assert(diode_voltage_state(external_voltage) == DiodeState::Unknown);

const DiodeBiasWindow dark_junction[] = {{540, 523, 0}, {545, 526, 0}, {542, 524, 0}};
assert(diode_bias_state(dark_junction) == DiodeState::Reversed);
const DiodeBiasWindow bright_correct[] = {{540, 270, 0}, {545, 272, 0}, {542, 271, 0}};
assert(diode_bias_state(bright_correct) == DiodeState::Unknown);
const DiodeBiasWindow resistive[] = {{550, 471, 0}, {550, 471, 0}, {550, 471, 0}};
assert(diode_bias_state(resistive) == DiodeState::Unknown);
const DiodeBiasWindow short_circuit[] = {{0, 0, 0}, {0, 0, 0}, {0, 0, 0}};
assert(diode_bias_state(short_circuit) == DiodeState::Unknown);
const DiodeBiasWindow changing[] = {{540, 523, 0}, {640, 626, 0}, {542, 524, 0}};
assert(diode_bias_state(changing) == DiodeState::Unknown);

DiodeFeedback feedback;
assert(feedback.visible(false) == DiodeState::Hidden);
feedback.begin();
feedback.observe(DiodeState::Connected);
assert(feedback.visible(false) == DiodeState::Connected);
feedback.end();
assert(feedback.visible(true) == DiodeState::Hidden);
for (auto fault : {DiodeState::Missing, DiodeState::Reversed}) {
feedback.begin();
feedback.observe(fault);
assert(feedback.visible(false) == fault);
feedback.end();
assert(feedback.visible(true) == fault);
assert(feedback.visible(false) == DiodeState::Hidden);
feedback.observe(DiodeState::Connected);
assert(feedback.visible(true) == DiodeState::Hidden);
}
feedback.begin();
feedback.observe(DiodeState::Unknown);
assert(feedback.visible(false) == DiodeState::Hidden);
feedback.end();
assert(feedback.visible(true) == DiodeState::Hidden);

assert(panel_rotation(unsigned(Orientation::LeftSideDown)) == 1);
assert(panel_rotation(unsigned(Orientation::RightSideDown)) == 3);
assert(diode_icon_x(unsigned(Orientation::LeftSideDown)) == 4);
assert(diode_icon_y(unsigned(Orientation::LeftSideDown)) == 4);
assert(diode_icon_x(unsigned(Orientation::RightSideDown)) == 220);
assert(diode_icon_y(unsigned(Orientation::RightSideDown)) == 115);

// Both rotations leave four pixels between the icon and either edge.
for (unsigned rotation : {0u, 1u}) {
const unsigned x = diode_icon_x(rotation);
const unsigned y = diode_icon_y(rotation);
assert(x >= 4 && x + 16 <= 236);
assert(x + 16 <= 24 || x >= 216);
assert(y >= 4 && y + 16 <= 131);
}
std::puts("diode feedback: PASS (ambiguous inputs, timeout, both margins)");
}
20 changes: 20 additions & 0 deletions stick/board_diagnostics.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#include "power_sleep.h"
#include "backlight.h"
#include "native_runtime.h"
#include "diode_status.h"

#include <Arduino.h>
#include <M5Unified.h>
Expand Down Expand Up @@ -160,6 +161,25 @@ int StickDiagnosticsService(void) {
}
while (Serial.available()) {
const int command = Serial.read();
if (command == 'u' && !StickForegroundIsIr()) {
Serial.printf("PW_STICK_DIODE_BIAS_RESULT state=%u\n", StickDiodeBiasProbe());
}
if (command == 'h' && !StickForegroundIsIr()) {
const uint64_t started = uint64_t(esp_timer_get_time());
const unsigned state = StickDiodeProbe();
Serial.printf("PW_STICK_DIODE_DISPLAY orientation=%u rotation=%u\n",
StickInputOrientation(), StickBoardScreen()->getRotation());
Serial.printf("PW_STICK_DIODE_PROBE state=%u elapsed_us=%llu\n",
state, (unsigned long long)(esp_timer_get_time() - started));
}
if (command == 'm' && !StickForegroundIsIr()) {
for (unsigned pin = 6; pin <= 8; ++pin) {
const uint64_t started = uint64_t(esp_timer_get_time());
const unsigned state = StickDiodeProbePin(pin);
Serial.printf("PW_STICK_DIODE_PROBE pin=%u state=%u elapsed_us=%llu\n",
pin, state, (unsigned long long)(esp_timer_get_time() - started));
}
}
if (command == 'c' && !StickForegroundIsIr()) {
StickIrBenchTrace(1);
StickForegroundRequestIr();
Expand Down
5 changes: 4 additions & 1 deletion stick/board_runtime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include "power_sleep.h"
#include "backlight.h"
#include "peripheral_power.h"
#include "diode_status.h"

#include <Arduino.h>
#include <M5Unified.h>
Expand Down Expand Up @@ -50,14 +51,15 @@ uint64_t next_deadline() {
for (const uint64_t candidate : {next_input, next_vbus_check,
StickDisplayNextDeadline(), StickSoundNextDeadline(),
StickPeripheralNextDeadline(), StickSleepNextDeadline(),
StickSettingsNextDeadline()})
StickSettingsNextDeadline(), StickDiodeNextDeadline()})
if (candidate < deadline) deadline = candidate;
return deadline;
}
}

void StickPresentGame(int force) {
if (StickSettingsIsOpen()) return;
StickDiodeDraw();
if (force) StickDisplayInvalidate();
const uint64_t revision = StickDisplayRevision();
if (revision == presented_revision && !StickDisplayPresentationPending()) return;
Expand Down Expand Up @@ -137,6 +139,7 @@ extern "C" void StickPortLoop(void) {
StickDiagnosticsSound(uint64_t(esp_timer_get_time()) - now);
StickDisplayPowerService();
StickPeripheralPowerService();
StickDiodeService();

now = uint64_t(esp_timer_get_time());
const unsigned task = StickNativeRun();
Expand Down
3 changes: 2 additions & 1 deletion stick/board_settings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include "battery_bridge.h"
#include "board_hal.h"
#include "controls.h"
#include "control_settings.h"
#include "display_bus.h"
#include "display_palette.h"
#include "display_panel.h"
Expand Down Expand Up @@ -55,7 +56,7 @@ void draw() {
pw_stick::layout_name(pw_stick::Layout(StickInputLayout())));
screen->setCursor(10, 55);
screen->printf("%c Rotation: %s", row == 1 ? '>' : ' ',
StickInputOrientation() ? "Right side down" : "Left side down");
pw_stick::right_side_down(StickInputOrientation()) ? "Right side down" : "Left side down");
screen->setCursor(10, 68);
screen->printf("%c Appearance: %s", row == 2 ? '>' : ' ',
StickDisplayIsDark() ? "Dark" : "Light");
Expand Down
Loading
Loading