diff --git a/src/support/ir.c b/src/support/ir.c index 5dc2a09..30751b5 100644 --- a/src/support/ir.c +++ b/src/support/ir.c @@ -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 @@ -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(); @@ -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 diff --git a/stick/audits/diode-detection.md b/stick/audits/diode-detection.md new file mode 100644 index 0000000..ae5ad44 --- /dev/null +++ b/stick/audits/diode-detection.md @@ -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. diff --git a/stick/audits/diode_feedback.cpp b/stick/audits/diode_feedback.cpp new file mode 100644 index 0000000..5dca916 --- /dev/null +++ b/stick/audits/diode_feedback.cpp @@ -0,0 +1,143 @@ +#include "../diode_feedback.h" +#include "../diode_presence.h" + +#include +#include +#include + +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)"); +} diff --git a/stick/board_diagnostics.cpp b/stick/board_diagnostics.cpp index 77e2a15..942f083 100644 --- a/stick/board_diagnostics.cpp +++ b/stick/board_diagnostics.cpp @@ -15,6 +15,7 @@ #include "power_sleep.h" #include "backlight.h" #include "native_runtime.h" +#include "diode_status.h" #include #include @@ -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(); diff --git a/stick/board_runtime.cpp b/stick/board_runtime.cpp index db8e1c3..dac7007 100644 --- a/stick/board_runtime.cpp +++ b/stick/board_runtime.cpp @@ -13,6 +13,7 @@ #include "power_sleep.h" #include "backlight.h" #include "peripheral_power.h" +#include "diode_status.h" #include #include @@ -50,7 +51,7 @@ 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; } @@ -58,6 +59,7 @@ uint64_t next_deadline() { void StickPresentGame(int force) { if (StickSettingsIsOpen()) return; + StickDiodeDraw(); if (force) StickDisplayInvalidate(); const uint64_t revision = StickDisplayRevision(); if (revision == presented_revision && !StickDisplayPresentationPending()) return; @@ -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(); diff --git a/stick/board_settings.cpp b/stick/board_settings.cpp index c81bcfb..b68c11c 100644 --- a/stick/board_settings.cpp +++ b/stick/board_settings.cpp @@ -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" @@ -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"); diff --git a/stick/check.py b/stick/check.py index a9dbfd1..6d3b1b3 100644 --- a/stick/check.py +++ b/stick/check.py @@ -27,6 +27,12 @@ SCENARIOS = ( "settings", "connect", + "diode", + "diode-peer", + "diode-ready", + "diode-presence", + "diode-timeout", + "diode-stall", "controls", "layouts", "wake", @@ -66,7 +72,7 @@ def read_screen(path: Path, rotation: int) -> str: return text -def expect_screen(path: Path, *phrases: str, rotation: int = -90) -> None: +def expect_screen(path: Path, *phrases: str, rotation: int = 90) -> None: text = read_screen(path, rotation) for phrase in phrases: if phrase not in text: @@ -79,6 +85,29 @@ def expect_appearance(path: Path, light: bool) -> None: raise AssertionError(f"{path}: selected appearance does not match the visible background") +def expect_diode(path: Path, state: str | None) -> None: + image = Image.open(path).rotate(90, expand=True).convert("L") + bits = bytes(image.getpixel((4 + col, 4 + row)) >= 128 + for row in range(16) for col in range(16)) + hashes = { + "missing": "9dd801c7cf41981ecdab05068b9aafad57b6991fd9086bc5b3d115556ae61325", + "connected": "59df9fbf1e7334530d2c4dfdb0f6abd4aea4de8f585b714e208583cacc793cbd", + } + if state is None: + assert not any(bits), f"{path}: diode feedback did not clear" + else: + assert hashlib.sha256(bits).hexdigest() == hashes[state], f"{path}: expected {state}" + + +def diode_report(path: Path) -> dict[str, str]: + report = dict(line.split("=", 1) for line in path.read_text().splitlines()) + assert report["reboots"] == "0", f"{path}: diode check rebooted" + assert report["output_enabled"] == "false", f"{path}: GPIO5 output remains enabled" + assert report["touch_timer"] == "false", f"{path}: continuous touch scans enabled" + assert report["touch_mask"] == "0", f"{path}: touch controller left enabled" + return report + + def optical_frames(path: Path) -> list[bytes]: frames: list[list[tuple[int, bool]]] = [] for line in path.read_text().splitlines(): @@ -121,11 +150,11 @@ def optical_frames(path: Path) -> list[bytes]: def screen_region(path: Path, box: tuple[int, int, int, int]) -> bytes: - return Image.open(path).rotate(-90, expand=True).crop(box).tobytes() + return Image.open(path).rotate(90, expand=True).crop(box).tobytes() def expect_cursor(path: Path, x: int, y: int) -> None: - image = Image.open(path).rotate(-90, expand=True).convert("L") + image = Image.open(path).rotate(90, expand=True).convert("L") for row in range(8): for column in range(8): lit = image.getpixel((24 + (x + column) * 2, 3 + (y + row) * 2)) >= 128 @@ -272,6 +301,21 @@ def main() -> None: destination = args.output_dir.resolve() destination.mkdir(parents=True, exist_ok=True) out = Path(tempfile.mkdtemp(prefix="run-", dir=destination)) + policy = out / "diode-feedback" + subprocess.run( + [ + "c++", + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "stick/audits/diode_feedback.cpp", + "-o", + str(policy), + ], + check=True, + ) + subprocess.run([str(policy)], check=True) firmware = out / "firmware.merged.bin" shutil.copyfile(build / "PwStick.ino.merged.bin", firmware) if digest(firmware) != manifest["outputs"]["PwStick.ino.merged.bin"]: @@ -296,6 +340,11 @@ def main() -> None: "stick/simulation/Cargo.lock", "stick/simulation/settings.actions", "stick/simulation/connect.actions", + "stick/simulation/diode.actions", + "stick/simulation/diode-peer.actions", + "stick/simulation/diode-ready.actions", + "stick/simulation/diode-presence.actions", + "stick/simulation/diode-timeout.actions", "stick/simulation/controls.actions", "stick/simulation/layouts.actions", "stick/simulation/wake.actions", @@ -307,11 +356,13 @@ def main() -> None: "stick/simulation/peer-play.actions", "stick/simulation/peer.rs", "stick/simulation/fixture.py", + "stick/audits/diode_feedback.cpp", ) } scenarios = args.scenario or SCENARIOS fixture = None - if any(name not in ("settings", "connect") for name in scenarios): + bare_scenarios = ("settings", "connect", "diode", "diode-presence", "diode-timeout", "diode-stall", "diode-ready") + if any(name not in bare_scenarios for name in scenarios): fixture = storage(build / "PwStick.ino.partitions.bin", out) evidence["fixture_sha256"] = digest(out / "fixture.eeprom") (out / "results.json").write_text(json.dumps(evidence, indent=2) + "\n") @@ -322,6 +373,8 @@ def main() -> None: if scenario.startswith("recovery-") or scenario == "peer-play": scenario_fixture = storage(build / "PwStick.ino.partitions.bin", folder, scenario) action = "recovery" if scenario.startswith("recovery-") else scenario + if scenario == "diode-stall": + action = "diode-timeout" script = ROOT / f"stick/simulation/{action}.actions" if scenario in ("peer-transfer", "peer-transfer-compressed", "peer-play"): script = folder / f"{scenario}.actions" @@ -342,6 +395,8 @@ def main() -> None: # Exercise the unsaved three-key default, then explicitly select the # two-key layout used by the established navigation/rollover scripts. actions = script.read_text() + if scenario == "diode-stall": + actions = actions.replace("touch timeout 1", "touch hang 1") script = folder / "configured.actions" script.write_text( "run 8000\npress L\npress R\nrun 1200\nrelease L\nrelease R\n" @@ -350,6 +405,8 @@ def main() -> None: "press R\nrun 70\nrelease R\nrun 300\n" "press L\nrun 70\nrelease L\nrun 300\n" + actions ) + if scenario in ("diode-timeout", "diode-stall"): + script.write_text(actions) with (folder / "run.log").open("w") as log: subprocess.run( [ @@ -361,7 +418,7 @@ def main() -> None: ] + ( [str(scenario_fixture[0]), str(scenario_fixture[1])] - if scenario not in ("settings", "connect") + if scenario not in bare_scenarios else [] ), stdout=log, @@ -378,30 +435,148 @@ def main() -> None: }, ) if scenario == "settings": - expect_screen( - folder / "defaults.png", "STICK SETTINGS", "3-key", "Left side down" - ) + expect_screen(folder / "defaults.png", "STICK SETTINGS", "3-key", "Left side down") expect_screen(folder / "settings.png", "STICK SETTINGS", "Left side down", "Dark") expect_appearance(folder / "settings.png", light=False) for name in ("changed.png", "retained.png"): expect_screen( - folder / name, "STICK SETTINGS", "Right side down", "Light", rotation=90 + folder / name, "STICK SETTINGS", "Right side down", "Light", rotation=-90 ) expect_appearance(folder / name, light=True) evidence["scenarios"][scenario] = ( "Unsaved controls use L/M/R and left-side-down; buttons change rotation and appearance, and reset retains both." ) elif scenario == "connect": + expect_diode(folder / "no-peer.png", None) expect_screen(folder / "settings-after.png", "STICK SETTINGS") expect_screen(folder / "settings-retry.png", "STICK SETTINGS") - first = optical_frames(folder / "first.optical") + if (folder / "first.optical").read_text().strip(): + raise AssertionError("Held-low input must fail preflight before transmitting") retry = optical_frames(folder / "retry.optical") - if bytes([0xFC]) not in first or bytes([0xFC]) not in retry[len(first) :]: - raise AssertionError("Connect and retry must emit the Pokéwalker advertisement") + if bytes([0xFC]) not in retry: + raise AssertionError("Retry after clearing the input must advertise") evidence["scenarios"][scenario] = { - "result": "Receiver held low and held high: Connect finishes, retries transmit, Settings remains usable.", + "result": "Held-low input is rejected before transmission; an inconclusive diode check permits retry without a hardware warning on no reply; Settings remains usable.", "logical_frames": [frame.hex() for frame in retry], } + elif scenario == "diode": + for name in ("blocked", "bright", "resistive", "reversed", "rotated"): + report = (folder / f"{name}.txt").read_text() + if "high_drives=0" not in report or "reboots=0" not in report: + raise AssertionError(f"Blocked diode entered precharge or rebooted: {report}") + if "output_enabled=false" not in report: + raise AssertionError(f"Diode output was not released: {report}") + if (folder / "blocked.optical").read_text().strip(): + raise AssertionError("A blocked diode transmitted before preflight completed") + # Match the approved artwork, including its empty pixels. The hash + # covers a 16x16 binary raster, independent of theme and rotation. + icons = { + "reversed": "2ece536104eb88144176747b280c66d0c111d76b56ca6f912fd0b4cd59c67d6d", + "connected": "59df9fbf1e7334530d2c4dfdb0f6abd4aea4de8f585b714e208583cacc793cbd", + } + for name, state, rotated in ( + ("reversed", "reversed", False), + ("rotated", "reversed", True), + ("rotated-persist", "reversed", True), + ): + image = ( + Image.open(folder / f"{name}.png") + .rotate(-90 if rotated else 90, expand=True) + .convert("L") + ) + x = 220 if rotated else 4 + y = 115 if rotated else 4 + bits = bytes( + ( + image.getpixel((x + col, y + row)) < 128 + if rotated + else image.getpixel((x + col, y + row)) >= 128 + ) + for row in range(16) + for col in range(16) + ) + if hashlib.sha256(bits).hexdigest() != icons[state]: + raise AssertionError(f"{name}: diode icon differs from approved artwork") + for name, rotated in ( + ("idle", False), + ("unknown", False), + ("bright", False), + ("resistive", False), + ("expired", False), + ("corrected", False), + ("rotated-left", True), + ): + image = ( + Image.open(folder / f"{name}.png") + .rotate(-90 if rotated else 90, expand=True) + .convert("L") + ) + x = 220 if rotated else 4 + y = 115 if rotated else 4 + if any( + ( + image.getpixel((x + col, y + row)) < 128 + if rotated + else image.getpixel((x + col, y + row)) >= 128 + ) + for row in range(16) + for col in range(16) + ): + raise AssertionError(f"{name}: diode icon did not clear") + evidence["scenarios"][scenario] = ( + "Ambiguous and illuminated-reversed inputs never drive GPIO5 high or reboot; " + "approved icons occupy the diode margin in both rotations and themes, " + "uncertain inputs stay quiet, faults persist while relevant, and leaving clears feedback." + ) + elif scenario == "diode-peer": + peer = (folder / "peer.txt").read_text() + if "complete=true error=None" not in peer: + raise AssertionError(f"Optical peer did not complete: {peer}") + expect_diode(folder / "connected.png", None) + expect_diode(folder / "expired.png", None) + evidence["scenarios"][scenario] = ( + "A completed exchange clears diode confirmation immediately." + ) + elif scenario == "diode-ready": + observed = False + for index in range(8): + try: + expect_diode(folder / f"ready-{index}.png", "connected") + observed = True + except AssertionError: + pass + assert observed, "Connected diode was never shown during connection" + expect_diode(folder / "finished.png", None) + diode_report(folder / "finished.txt") + evidence["scenarios"][scenario] = ( + "The inserted diode is shown during connection, then clears on completion without lingering on the result." + ) + elif scenario == "diode-presence": + for name in ("missing", "missing-connect", "missing-persist", "missing-uncertain"): + expect_diode(folder / f"{name}.png", "missing") + expect_diode(folder / "inserted.png", None) + for name in ("missing-left", "inserted-expired"): + expect_diode(folder / f"{name}.png", None) + bounded = diode_report(folder / "missing-bounded.txt") + idle = diode_report(folder / "missing-idle.txt") + assert bounded["touch_scans"] == idle["touch_scans"], "Missing detection kept polling" + blocked = diode_report(folder / "missing-connect.txt") + assert blocked["high_drives"] == "0", "Empty socket was strongly precharged" + assert not (folder / "missing.optical").read_text().strip(), "Empty socket transmitted" + diode_report(folder / "restored.txt") + evidence["scenarios"][scenario] = ( + "Empty capacitance shows the approved missing icon and blocks reception startup; " + "the fault persists in the result, clears on leaving or correction, and rechecks stop." + ) + elif scenario in ("diode-timeout", "diode-stall"): + expect_diode(folder / "unknown.png", None) + expect_diode(folder / "unknown-expired.png", None) + bounded = diode_report(folder / "timeout-bounded.txt") + idle = diode_report(folder / "timeout-idle.txt") + assert bounded["touch_scans"] == idle["touch_scans"], "Timed-out probe kept polling" + evidence["scenarios"][scenario] = ( + "Stalled scans remain inconclusive, return within the deadline and release the controller." + ) elif scenario == "controls": for name, label in ( ("settings-menu", "SETTINGS"), @@ -455,7 +630,7 @@ def main() -> None: pending, (24, 24, 184, 72) ): raise AssertionError("Joining the Settings hold withheld gameplay Right") - if "STICK SETTINGS" in read_screen(pending, -90): + if "STICK SETTINGS" in read_screen(pending, 90): raise AssertionError("Settings opened before its one-second hold") expect_cursor(folder / f"two-choice-{mapping}.png", 48, 20) for prefix in ("held", "partial"): diff --git a/stick/control_settings.h b/stick/control_settings.h index 5ff280d..272586f 100644 --- a/stick/control_settings.h +++ b/stick/control_settings.h @@ -3,9 +3,16 @@ #include "controls.h" namespace pw_stick { +enum class Orientation : uint8_t { RightSideDown = 0, LeftSideDown = 1 }; +constexpr bool right_side_down(unsigned setting) { + return setting == unsigned(Orientation::RightSideDown); +} +constexpr unsigned panel_rotation(unsigned setting) { + return right_side_down(setting) ? 3 : 1; +} struct ControlSettings { Layout layout = Layout::ThreeKeyLR; - uint8_t orientation = 0; + uint8_t orientation = uint8_t(Orientation::LeftSideDown); uint8_t chord_window = 0; }; diff --git a/stick/diode_feedback.h b/stick/diode_feedback.h new file mode 100644 index 0000000..554098d --- /dev/null +++ b/stick/diode_feedback.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include "control_settings.h" + +namespace pw_stick { +enum class DiodeState : uint8_t { + Hidden, + Missing, + Reversed, + Connected, + Unknown +}; + +constexpr uint16_t kDiodeIcons[3][16] = { + {0x0000, 0x0078, 0x0084, 0x0102, 0x0102, 0x0102, 0x0102, 0x01fe, 0x0048, + 0x0048, 0x4448, 0x2848, 0x1048, 0x2848, 0x4400, 0x0000}, + {0x0000, 0x0078, 0x00fc, 0x01f6, 0x01f6, 0x01fe, 0x01fe, 0x01fe, 0x0048, + 0x4048, 0xfc48, 0x4048, 0x0048, 0x0848, 0xfc00, 0x0800}, + {0x0000, 0x0078, 0x00fc, 0x01f6, 0x01f6, 0x01fe, 0x01fe, 0x01fe, 0x0048, + 0x0048, 0x0048, 0x0048, 0x0048, 0x0048, 0x0000, 0x0000}, +}; + +struct DiodeFeedback { + DiodeState measurement = DiodeState::Unknown; + bool transferring = false; + + void begin() { measurement = DiodeState::Unknown; transferring = true; } + void observe(DiodeState value) { measurement = value; } + void end() { transferring = false; } + void clear() { measurement = DiodeState::Unknown; transferring = false; } + DiodeState visible(bool result_open) const + { + if (!transferring && !result_open) return DiodeState::Hidden; + if (measurement == DiodeState::Missing || measurement == DiodeState::Reversed) + return measurement; + return transferring && measurement == DiodeState::Connected + ? DiodeState::Connected : DiodeState::Hidden; + } +}; + +// Keep the connector corner and four pixels of padding after rotation. +constexpr unsigned diode_icon_x(unsigned setting) +{ + return right_side_down(setting) ? 220 : 4; +} +constexpr unsigned diode_icon_y(unsigned setting) +{ + return right_side_down(setting) ? 115 : 4; +} + +// With a weak pull-down, positive photocurrent into GPIO5 identifies an +// illuminated reversed diode. Correct polarity sinks photocurrent instead. +// Darkness and an empty socket remain indistinguishable here. +inline DiodeState diode_voltage_state(const int (&millivolts)[3]) +{ + int low = 800, high = 200; + for (int value : millivolts) { + if (value < 200 || value > 800) + return DiodeState::Unknown; + if (value < low) + low = value; + if (value > high) + high = value; + } + return high - low <= 50 ? DiodeState::Reversed : DiodeState::Unknown; +} + +} // namespace pw_stick diff --git a/stick/diode_presence.cpp b/stick/diode_presence.cpp new file mode 100644 index 0000000..758cfe0 --- /dev/null +++ b/stick/diode_presence.cpp @@ -0,0 +1,274 @@ +#include "diode_presence.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +void restore(unsigned pin) +{ + const auto gpio = gpio_num_t(pin); + rtc_gpio_pullup_dis(gpio); + rtc_gpio_pulldown_dis(gpio); + rtc_gpio_deinit(gpio); + gpio_reset_pin(gpio); + gpio_set_direction(gpio, GPIO_MODE_INPUT); + gpio_pullup_dis(gpio); + gpio_pulldown_dis(gpio); +} + +bool can_charge(unsigned pin) +{ + const auto gpio = gpio_num_t(pin); + gpio_set_direction(gpio, GPIO_MODE_INPUT); + gpio_set_pull_mode(gpio, GPIO_PULLUP_ONLY); + unsigned highs = 0; + for (unsigned sample = 0; sample < 32; ++sample) { + delayMicroseconds(103 + (sample * 37) % 89); + highs = gpio_get_level(gpio) ? highs + 1 : 0; + if (highs == 4) break; + } + gpio_set_pull_mode(gpio, GPIO_FLOATING); + return highs == 4; +} + +pw_stick::DiodeState bias_state(unsigned pin) +{ + adc_oneshot_unit_handle_t unit = nullptr; + adc_cali_handle_t calibration = nullptr; + const auto gpio = gpio_num_t(pin); + const auto adc_channel = adc_channel_t(pin - 1); + adc_oneshot_unit_init_cfg_t init = {}; + init.unit_id = ADC_UNIT_1; + adc_oneshot_chan_cfg_t channel = {}; + channel.atten = ADC_ATTEN_DB_12; + channel.bitwidth = ADC_BITWIDTH_12; + adc_cali_curve_fitting_config_t config = {}; + config.unit_id = ADC_UNIT_1; + config.chan = adc_channel; + config.atten = channel.atten; + config.bitwidth = channel.bitwidth; + pw_stick::DiodeState state = pw_stick::DiodeState::Unknown; + bool ok = adc_oneshot_new_unit(&init, &unit) == ESP_OK; + if (ok) ok = adc_oneshot_config_channel(unit, adc_channel, &channel) == ESP_OK; + if (ok) ok = adc_cali_create_scheme_curve_fitting(&config, &calibration) == ESP_OK; + pw_stick::DiodeBiasWindow windows[3]; + // ADC setup selects the digital mux on S3. Control its digital weak pulls, + // not the independent RTC pulls. Reverse sweep order to catch changing light. + for (unsigned sweep = 0; sweep < 3 && ok; ++sweep) { + const gpio_pull_mode_t modes[] = {GPIO_PULLUP_ONLY, GPIO_PULLUP_PULLDOWN, + GPIO_PULLDOWN_ONLY}; + for (unsigned step = 0; step < 3 && ok; ++step) { + const unsigned bias = sweep & 1 ? 2 - step : step; + ok = gpio_set_pull_mode(gpio, modes[bias]) == ESP_OK; + delayMicroseconds(300); + int raw = 0, sum = 0; + if (ok) ok = adc_oneshot_read(unit, adc_channel, &raw) == ESP_OK; + for (unsigned sample = 0; sample < 8 && ok; ++sample) { + int mv = 0; + ok = adc_oneshot_read(unit, adc_channel, &raw) == ESP_OK && + adc_cali_raw_to_voltage(calibration, raw, &mv) == ESP_OK; + sum += mv; + delayMicroseconds(31); + } + int *values[] = {&windows[sweep].up, &windows[sweep].both, + &windows[sweep].down}; + *values[bias] = sum / 8; + } +#ifdef PW_STICK_BENCH_CONTROL + Serial.printf("PW_STICK_DIODE_BIAS pin=%u sweep=%u up=%d both=%d down=%d ok=%u\n", + pin, sweep, windows[sweep].up, windows[sweep].both, + windows[sweep].down, unsigned(ok)); +#endif + } + if (ok) state = pw_stick::diode_bias_state(windows); + if (calibration) adc_cali_delete_scheme_curve_fitting(calibration); + if (unit) adc_oneshot_del_unit(unit); + restore(pin); + return state; +} + +bool IRAM_ATTR timed_out(touch_sensor_handle_t, + const touch_timeout_event_data_t *, void *context) +{ + *static_cast(context) = true; + return false; +} + +bool measure(unsigned pin, unsigned speed, unsigned window, unsigned cap, + uint32_t (&values)[2], bool &noisy, int64_t deadline) +{ + touch_sensor_handle_t controller = nullptr; + touch_channel_handle_t channel = nullptr; + bool enabled = false; + volatile bool timeout = false; + restore(pin); + touch_sensor_sample_config_t sample = {}; + sample.charge_times = 32; + sample.charge_volt_lim_l = + window ? TOUCH_VOLT_LIM_L_0V8 : TOUCH_VOLT_LIM_L_0V5; + sample.charge_volt_lim_h = + window ? TOUCH_VOLT_LIM_H_2V4 : TOUCH_VOLT_LIM_H_0V9; + sample.idle_conn = TOUCH_IDLE_CONN_HIGHZ; + sample.bias_type = TOUCH_BIAS_TYPE_BANDGAP; + touch_sensor_config_t config = TOUCH_SENSOR_DEFAULT_BASIC_CONFIG(1, &sample); + config.max_meas_time_us = 3000; + touch_channel_config_t pad = {}; + pad.charge_speed = touch_charge_speed_t(speed); + pad.init_charge_volt = TOUCH_INIT_CHARGE_VOLT_FLOAT; + touch_denoise_chan_config_t denoise = {}; + denoise.charge_speed = pad.charge_speed; + denoise.init_charge_volt = pad.init_charge_volt; + denoise.ref_cap = touch_denoise_chan_cap_t(cap); + denoise.resolution = TOUCH_DENOISE_CHAN_RESOLUTION_BIT4; + touch_event_callbacks_t callbacks = {}; + callbacks.on_timeout = timed_out; + + esp_err_t error = touch_sensor_new_controller(&config, &controller); + if (error == ESP_OK) + error = touch_sensor_new_channel(controller, pin, &pad, &channel); + if (error == ESP_OK) + error = touch_sensor_config_denoise_channel(controller, &denoise); + if (error == ESP_OK) + error = touch_sensor_register_callbacks(controller, &callbacks, + const_cast(&timeout)); + if (error == ESP_OK) { + error = touch_sensor_enable(controller); + enabled = error == ESP_OK; + } + uint32_t samples[2][5] = {}; + // Discard settling scans before comparing five retained samples. + for (unsigned scan = 0; scan < 7 && error == ESP_OK; ++scan) { + if (esp_timer_get_time() >= deadline) { + error = ESP_ERR_TIMEOUT; + break; + } + error = touch_sensor_trigger_oneshot_scanning(controller, 10); + if (error != ESP_OK || timeout) { + error = ESP_ERR_TIMEOUT; + break; + } + uint32_t raw[2] = {}; + error = touch_channel_read_data(channel, TOUCH_CHAN_DATA_TYPE_RAW, &raw[0]); + touch_ll_denoise_read_data(&raw[1]); + if (scan < 2) + continue; + for (unsigned i = 0; i < 2; ++i) { + if (raw[i] > 1000000 || !raw[i]) { + error = ESP_ERR_INVALID_RESPONSE; + break; + } + samples[i][scan - 2] = raw[i]; + } + } + if (enabled) + touch_sensor_disable(controller); + if (channel) + touch_sensor_del_channel(channel); + if (controller) + touch_sensor_del_controller(controller); + restore(pin); + if (timeout || error != ESP_OK || esp_timer_get_time() >= deadline) + return false; + for (unsigned i = 0; i < 2; ++i) { + for (unsigned a = 0; a < 5; ++a) + for (unsigned b = a + 1; b < 5; ++b) + if (samples[i][b] < samples[i][a]) { + const uint32_t temporary = samples[i][a]; + samples[i][a] = samples[i][b]; + samples[i][b] = temporary; + } + // Interrupted charge cycles can increase counts. Require a repeatable + // lower cluster, and never diagnose absence from a noisy measurement. + values[i] = (samples[i][0] + samples[i][1]) / 2; + const unsigned spread = values[i] / 25 + 32; + if (samples[i][1] - samples[i][0] > spread) return false; + const bool variable = samples[i][4] - samples[i][0] > spread; + if (i == 1 && variable) return false; + noisy |= variable; + } + return true; +} + +pw_stick::DiodeState profile(unsigned sensor_pin, unsigned speed, int64_t deadline) +{ + pw_stick::DiodeCapWindow windows[2]; + bool noisy = false; + for (unsigned window = 0; window < 2; ++window) { + uint32_t small[2][2], large[2][2]; + for (unsigned i = 0; i < 2; ++i) { + // One enabled pad per controller avoids mixing partially completed + // S3 software scans. The same path produced the original live traces. + const unsigned pin = i ? sensor_pin : 4; + if (!measure(pin, speed, window, 0, small[i], noisy, deadline) || + !measure(pin, speed, window, 7, large[i], noisy, deadline)) + return pw_stick::DiodeState::Unknown; + const uint32_t difference = small[i][0] > large[i][0] + ? small[i][0] - large[i][0] : large[i][0] - small[i][0]; + if (difference > (small[i][0] + large[i][0]) / 50 + 32) + return pw_stick::DiodeState::Unknown; + } + auto &w = windows[window]; + w.reference = (small[0][0] + large[0][0]) / 2; + w.diode = (small[1][0] + large[1][0]) / 2; + w.internal_low = (small[0][1] + small[1][1]) / 2; + w.internal_high = (large[0][1] + large[1][1]) / 2; +#ifdef PW_STICK_BENCH_CONTROL + Serial.printf("PW_STICK_DIODE_CAP speed=%u window=%u diode=%u bare=%u " + "internal_low=%u internal_high=%u\n", speed, window, + w.diode, w.reference, w.internal_low, w.internal_high); +#endif + } + const auto state = pw_stick::diode_cap_state(windows); + return noisy && state == pw_stick::DiodeState::Missing + ? pw_stick::DiodeState::Unknown : state; +} +} // namespace + +namespace pw_stick { +DiodeProbe diode_probe(unsigned pin) +{ + if (pin < 5 || pin > 8) return {}; + restore(pin); + if (!can_charge(pin)) return {bias_state(pin), false}; + const DiodeState state = diode_presence_detect(pin); + return {state, state != DiodeState::Missing}; +} + +DiodeState diode_presence_detect(unsigned sensor_pin) +{ + // GPIO4 is unused by Stickwalker and has a matching exposed Hat trace. + // A loaded/driven reference must leave the result inconclusive. + if (sensor_pin < 5 || sensor_pin > 8 || GPIO.enable & (1 << 4)) + return DiodeState::Unknown; + restore(4); + restore(sensor_pin); + gpio_set_direction(GPIO_NUM_4, GPIO_MODE_INPUT); + gpio_pulldown_dis(GPIO_NUM_4); + gpio_pullup_en(GPIO_NUM_4); + delayMicroseconds(300); + const bool high = gpio_get_level(GPIO_NUM_4); + gpio_pullup_dis(GPIO_NUM_4); + if (!high) + return DiodeState::Unknown; + const int64_t deadline = esp_timer_get_time() + 150000; + const DiodeState first = profile(sensor_pin, 3, deadline); + if (first == DiodeState::Unknown) + return first; + const DiodeState second = profile(sensor_pin, 7, deadline); + return first == second ? first : DiodeState::Unknown; +} +} // namespace pw_stick + +#ifdef PW_STICK_BENCH_CONTROL +extern "C" unsigned StickDiodeBiasProbe(void) +{ + return unsigned(bias_state(5)); +} +#endif diff --git a/stick/diode_presence.h b/stick/diode_presence.h new file mode 100644 index 0000000..eface93 --- /dev/null +++ b/stick/diode_presence.h @@ -0,0 +1,78 @@ +#pragma once + +#include "diode_feedback.h" + +namespace pw_stick { +struct DiodeCapWindow { + uint32_t diode = 0; + uint32_t reference = 0; + uint32_t internal_low = 0; + uint32_t internal_high = 0; +}; + +inline DiodeState diode_cap_state(const DiodeCapWindow (&windows)[2]) +{ + int64_t added[2]; + for (unsigned i = 0; i < 2; ++i) { + const auto &w = windows[i]; + if (w.internal_high <= w.internal_low + 128 || !w.reference || + !w.diode || w.diode > 1000000 || w.reference > 1000000) + return DiodeState::Unknown; + const int64_t span = w.internal_high - w.internal_low; + // Two on-chip capacitors calibrate the count scale in each voltage window. + const int64_t bare = 5000 + + (int64_t(w.reference) - w.internal_low) * 9800 / span; + if (bare < 7000 || bare > 14000) + return DiodeState::Unknown; + added[i] = (int64_t(w.diode) - w.reference) * 9800 / span; + } + const int64_t curvature = added[0] - added[1]; + // The unused controls measure below GPIO4, with channel-dependent offsets. + // A near-reference input must be flat; a substantially lower high-window + // capacitance is also bare. A loaded GPIO4 is rejected above. + if (added[0] >= -6000 && added[0] <= 1800 && + added[1] >= -6000 && added[1] <= 1500 && + curvature >= -6000 && (curvature <= 1200 || added[1] <= -1500)) + return DiodeState::Missing; + if (added[0] >= 4500 && added[0] <= 22000 && + added[1] >= 4000 && added[1] <= 18000 && + curvature >= 700 && curvature <= 8000) + return DiodeState::Connected; + return DiodeState::Unknown; +} + +struct DiodeBiasWindow { + int up = 0; + int both = 0; + int down = 0; +}; + +inline DiodeState diode_bias_state(const DiodeBiasWindow (&windows)[3]) +{ + int photo[3]; + int minimum = 10000, maximum = 0; + bool junction = true; + for (unsigned i = 0; i < 3; ++i) { + const auto &w = windows[i]; + photo[i] = w.down; + if (w.up < minimum) minimum = w.up; + if (w.up > maximum) maximum = w.up; + // A forward-biased junction remains near its forward voltage when the + // second weak pull is added. Photocurrent and resistive loads drop more. + junction &= w.up >= 400 && w.up <= 1000 && w.down >= -30 && + w.down <= 100 && w.up - w.both >= -20 && + w.up - w.both <= 35; + } + if (diode_voltage_state(photo) == DiodeState::Reversed || + (junction && maximum - minimum <= 40)) + return DiodeState::Reversed; + return DiodeState::Unknown; +} + +struct DiodeProbe { + DiodeState state = DiodeState::Unknown; + bool receive_safe = false; +}; +DiodeState diode_presence_detect(unsigned sensor_pin = 5); +DiodeProbe diode_probe(unsigned sensor_pin = 5); +} // namespace pw_stick diff --git a/stick/diode_status.cpp b/stick/diode_status.cpp new file mode 100644 index 0000000..0a28818 --- /dev/null +++ b/stick/diode_status.cpp @@ -0,0 +1,123 @@ +#include "diode_status.h" +#include "diode_feedback.h" +#include "diode_presence.h" +#include "board_hal.h" +#include "board_settings.h" +#include "display_bus.h" +#include "display_panel.h" +#include "foreground_bridge.h" +#include "input_bridge.h" +#include +#include +#include + +namespace { +using pw_stick::DiodeState; +pw_stick::DiodeFeedback feedback; +bool dirty = true; +bool blocked = false; +unsigned rechecks = 0; +uint64_t next_check = UINT64_MAX; +DiodeState drawn = DiodeState::Hidden; + +void draw() +{ + if (!StickDisplayPanelIsReady() || StickSettingsIsOpen()) return; + const DiodeState visible = StickDisplayIsPowered() + ? feedback.visible(StickForegroundIsConnectionScreen()) : DiodeState::Hidden; + if (!dirty && visible == drawn) return; + auto *screen = StickBoardScreen(); + const unsigned x = pw_stick::diode_icon_x(StickInputOrientation()); + const unsigned y = pw_stick::diode_icon_y(StickInputOrientation()); + uint16_t pixels[16 * 16]; + const uint16_t background = StickDisplayBackground(); + const uint16_t foreground = StickDisplayForeground(); + for (unsigned row = 0; row < 16; ++row) { + const uint16_t bits = visible == DiodeState::Hidden + ? 0 : pw_stick::kDiodeIcons[unsigned(visible) - 1][row]; + for (unsigned col = 0; col < 16; ++col) + pixels[row * 16 + col] = bits & (1u << col) ? foreground : background; + } + screen->pushImage(x, y, 16, 16, pixels); + drawn = visible; + dirty = false; +} +} // namespace + +extern "C" void StickDiodeBegin(void) +{ + feedback.begin(); + blocked = false; + rechecks = 0; + next_check = UINT64_MAX; + dirty = true; +} + +extern "C" int StickDiodePreflight(void) +{ + const auto probe = pw_stick::diode_probe(); + feedback.observe(probe.state); + blocked = !probe.receive_safe; + dirty = true; + draw(); + return probe.receive_safe; +} + +extern "C" void StickDiodeReceivedPacket(void) +{ + feedback.observe(DiodeState::Connected); +} + +extern "C" void StickDiodeEnd(unsigned) +{ + feedback.end(); + dirty = true; + if (blocked) { + rechecks = 3; + next_check = uint64_t(esp_timer_get_time()) + 500000; + } +} + +extern "C" void StickDiodeService(void) +{ + if (StickForegroundIsIr()) return; + if (StickSettingsIsOpen() || !StickForegroundIsConnectionScreen()) { + feedback.clear(); + next_check = UINT64_MAX; + rechecks = 0; + } + if (!StickDisplayIsPowered()) { + next_check = UINT64_MAX; + rechecks = 0; + return; + } + const uint64_t now = uint64_t(esp_timer_get_time()); + if (now < next_check) return; + --rechecks; + const auto probe = pw_stick::diode_probe(); + if (probe.state != DiodeState::Unknown) { + feedback.observe(probe.state); + dirty = true; + } + if (probe.state == DiodeState::Connected) rechecks = 0; + next_check = rechecks ? now + 500000 : UINT64_MAX; +} + +extern "C" uint64_t StickDiodeNextDeadline(void) { return next_check; } +extern "C" void StickDiodeInvalidate(void) { dirty = true; } +extern "C" void StickDiodeDraw(void) +{ + if (!StickForegroundIsIr()) draw(); +} + +#ifdef PW_STICK_BENCH_CONTROL +extern "C" unsigned StickDiodeProbe(void) +{ + return unsigned(pw_stick::diode_probe().state); +} +extern "C" unsigned StickDiodeProbePin(unsigned pin) +{ + if (pin < 6 || pin > 8) return unsigned(DiodeState::Unknown); + return unsigned(pw_stick::diode_probe(pin).state); +} +#endif diff --git a/stick/diode_status.h b/stick/diode_status.h new file mode 100644 index 0000000..43b120d --- /dev/null +++ b/stick/diode_status.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif +/* A bounded weak-bias check, before the receiver can drive GPIO5 high. */ +int StickDiodePreflight(void); +void StickDiodeBegin(void); +void StickDiodeReceivedPacket(void); +void StickDiodeEnd(unsigned connection_error); +void StickDiodeService(void); +uint64_t StickDiodeNextDeadline(void); +void StickDiodeDraw(void); +void StickDiodeInvalidate(void); +#ifdef PW_STICK_BENCH_CONTROL +unsigned StickDiodeProbe(void); +unsigned StickDiodeBiasProbe(void); +unsigned StickDiodeProbePin(unsigned pin); +#endif +#ifdef __cplusplus +} +#endif diff --git a/stick/display_panel.cpp b/stick/display_panel.cpp index 02e4754..8d86fd7 100644 --- a/stick/display_panel.cpp +++ b/stick/display_panel.cpp @@ -5,6 +5,8 @@ #include "backlight.h" #include "peripheral_power.h" #include "display_palette.h" +#include "diode_status.h" +#include "control_settings.h" #include #include @@ -30,7 +32,7 @@ bool panel_awake = true; uint64_t valid_rows = 0; bool presentation_pending = true; unsigned panel_generation = 0; -unsigned orientation = 3; +unsigned orientation = pw_stick::panel_rotation(unsigned(pw_stick::Orientation::LeftSideDown)); unsigned wake_stage = 0; int64_t wake_deadline_us = 0; int64_t wake_retry_us = 0; @@ -51,7 +53,7 @@ extern "C" int StickDisplayPanelInit(void) { // pushImage(uint16_t*) buffer as pre-swapped RGB565, which turns neutral // grays into colored pixels while black and white appear unchanged. screen->setSwapBytes(true); - screen->setRotation(3); + screen->setRotation(orientation); screen->setBrightness(backlight_level); screen->fillScreen(StickDisplayBackground()); panel_generation = StickPeripheralGeneration(); @@ -66,10 +68,10 @@ extern "C" int StickDisplayPanelInit(void) { return panel_pixels != nullptr; } -extern "C" void StickDisplayPanelSetOrientation(unsigned right_side_down) { +extern "C" void StickDisplayPanelSetOrientation(unsigned setting) { auto *screen = StickBoardScreen(); if (!screen) return; - orientation = right_side_down ? 1 : 3; + orientation = pw_stick::panel_rotation(setting); if (panel_awake) { screen->setRotation(orientation); screen->fillScreen(StickDisplayBackground()); @@ -227,6 +229,7 @@ extern "C" uint64_t StickDisplayNextDeadline(void) { extern "C" void StickDisplayInvalidate(void) { valid_rows = 0; presentation_pending = true; + StickDiodeInvalidate(); } extern "C" int StickDisplayPresentationPending(void) { diff --git a/stick/display_panel.h b/stick/display_panel.h index eac319e..cb9ee0a 100644 --- a/stick/display_panel.h +++ b/stick/display_panel.h @@ -11,7 +11,7 @@ extern "C" { /* Initialize after board setup. Present from the application task between * optical receive intervals. */ int StickDisplayPanelInit(void); -void StickDisplayPanelSetOrientation(unsigned right_side_down); +void StickDisplayPanelSetOrientation(unsigned setting); void StickDisplayPanelSetBacklight(unsigned enabled); void StickDisplayPanelSetContrastDelta(unsigned delta); int StickDisplayPresent(const StickDisplaySnapshot *snapshot); diff --git a/stick/docs/controls.md b/stick/docs/controls.md index 18544d4..7c959ed 100644 --- a/stick/docs/controls.md +++ b/stick/docs/controls.md @@ -14,3 +14,5 @@ Hold M for about half a second to wake the screen. For two-key layouts, M+R window sets how much time you have to press the second button. Contrast controls the Stick's backlight. + +The diode icon appears by the connector when connecting: × means missing, arrows mean reverse the diode, and solid means connected. diff --git a/stick/foreground_bridge.c b/stick/foreground_bridge.c index 5801932..41ac470 100644 --- a/stick/foreground_bridge.c +++ b/stick/foreground_bridge.c @@ -14,6 +14,12 @@ void BeepTick(void); void MotionSessionEnd(void); int StickForegroundIsIr(void) { return g_task == IrProtocolTick; } +int StickForegroundIsConnectionScreen(void) { + SystemFlags flags; + flags.byte = g_state.flags.byte; + return g_state.view == VIEW_IR_RESULT || + (g_state.view == VIEW_HOME && !flags.bits.registered); +} int StickForegroundIsMain(void) { return g_task == MainTick; } int StickForegroundIsBeep(void) { return g_task == BeepTick; } int StickForegroundIsInactive(void) { diff --git a/stick/foreground_bridge.h b/stick/foreground_bridge.h index 652142e..db1b4c3 100644 --- a/stick/foreground_bridge.h +++ b/stick/foreground_bridge.h @@ -6,6 +6,7 @@ extern "C" { #endif int StickForegroundIsIr(void); +int StickForegroundIsConnectionScreen(void); int StickForegroundIsMain(void); int StickForegroundIsBeep(void); int StickForegroundIsInactive(void); diff --git a/stick/ir_transport.cpp b/stick/ir_transport.cpp index 9d961eb..e17cf98 100644 --- a/stick/ir_transport.cpp +++ b/stick/ir_transport.cpp @@ -5,6 +5,7 @@ #include "ir_tx.h" #include "sound_bridge.h" #include "board_hal.h" +#include "diode_status.h" #include #include @@ -371,6 +372,11 @@ extern "C" void StickIrConfigure(void) { return; } const bool clock_ok = setCpuFrequencyMhz(240); + StickDiodeBegin(); + if (clock_ok && !StickDiodePreflight()) { + __atomic_store_n(&failed, true, __ATOMIC_RELEASE); + return; + } __atomic_store_n(&failed, !clock_ok || !pw_stick::prepare_ir_tx(), __ATOMIC_RELEASE); } diff --git a/stick/simulation/board.rs b/stick/simulation/board.rs index f9939d5..98d246b 100644 --- a/stick/simulation/board.rs +++ b/stick/simulation/board.rs @@ -15,6 +15,7 @@ pub struct Inputs { pub peer: Option, pub local_echo: bool, pub reply_window_erases: u64, + pub diode_high_drives: u64, } enum Device { @@ -224,7 +225,11 @@ impl BoardModel for StickS3 { } fn gpio_output_at(&mut self, cycle: u64, changes: &[(u8, bool)], enabled: u64, output: u64) { self.gpio_changes(changes); - if let Some(peer) = self.inputs.lock().unwrap().peer.as_mut() { + let mut state = self.inputs.lock().unwrap(); + if enabled & output & (1 << 5) != 0 { + state.diode_high_drives += 1; + } + if let Some(peer) = state.peer.as_mut() { peer.gpio_output(cycle, enabled, output); } } diff --git a/stick/simulation/connect.actions b/stick/simulation/connect.actions index db15e8a..719e0a1 100644 --- a/stick/simulation/connect.actions +++ b/stick/simulation/connect.actions @@ -18,6 +18,8 @@ screen settings-after.png optical first.optical # Retry with the input held high: wait for the ordinary no-reply result. gpio 5 1 +# An inconclusive electrical check must not turn no reply into a diode warning. +cap 5 15 14 press L run 100 release L @@ -27,7 +29,9 @@ press R run 300 release M release R -run 10000 +run 5000 +screen no-peer.png +run 5000 press L run 100 release L diff --git a/stick/simulation/diode-peer.actions b/stick/simulation/diode-peer.actions new file mode 100644 index 0000000..d731c66 --- /dev/null +++ b/stick/simulation/diode-peer.actions @@ -0,0 +1,19 @@ +run 4000 +peer start +press M +press R +run 100 +release M +release R +run 300 +press M +press R +run 100 +release M +release R +run-until-peer 5000 +run 100 +peer report peer.txt +screen connected.png +run 2200 +screen expired.png diff --git a/stick/simulation/diode-presence.actions b/stick/simulation/diode-presence.actions new file mode 100644 index 0000000..6a25e9f --- /dev/null +++ b/stick/simulation/diode-presence.actions @@ -0,0 +1,44 @@ +# Same bare-pad capacitance at the socket: an electrically empty input. +cap 5 11.8 11.2 +press M +press R +run 300 +release M +release R +run 100 +screen missing.png +# An uncertain recheck must not erase a diagnosed fault. +cap 5 15 14 +run 600 +screen missing-uncertain.png +cap 5 11.8 11.2 +run 1900 +diode report missing-bounded.txt +run 4000 +screen missing-persist.png +diode report missing-idle.txt +press L +run 100 +release L +run 200 +press L +run 100 +release L +run 200 +screen missing-left.png +press M +press R +run 300 +release M +release R +run 100 +screen missing-connect.png +diode report missing-connect.txt +optical missing.optical +# Plugging the diode in during the bounded correction window. +cap 5 20.2 17.9 +run 600 +screen inserted.png +run 2500 +screen inserted-expired.png +diode report restored.txt diff --git a/stick/simulation/diode-ready.actions b/stick/simulation/diode-ready.actions new file mode 100644 index 0000000..f772f5d --- /dev/null +++ b/stick/simulation/diode-ready.actions @@ -0,0 +1,24 @@ +run 6000 +press M +press R +run 50 +screen ready-0.png +run 50 +screen ready-1.png +run 50 +screen ready-2.png +run 50 +screen ready-3.png +run 50 +screen ready-4.png +run 50 +screen ready-5.png +run 50 +screen ready-6.png +run 50 +screen ready-7.png +release M +release R +run 5000 +screen finished.png +diode report finished.txt diff --git a/stick/simulation/diode-timeout.actions b/stick/simulation/diode-timeout.actions new file mode 100644 index 0000000..34be6af --- /dev/null +++ b/stick/simulation/diode-timeout.actions @@ -0,0 +1,14 @@ +# A stalled touch scan must stay inconclusive and release both pins. +touch timeout 1 +run 6000 +press M +press R +run 300 +release M +release R +run 5500 +screen unknown.png +diode report timeout-bounded.txt +run 7500 +screen unknown-expired.png +diode report timeout-idle.txt diff --git a/stick/simulation/diode.actions b/stick/simulation/diode.actions new file mode 100644 index 0000000..eef260f --- /dev/null +++ b/stick/simulation/diode.actions @@ -0,0 +1,117 @@ +run 6000 +screen idle.png +# Ambiguous held-low input: do not call it missing or backwards. +gpio 5 0 +adc-bias 5 0 0 0 +press M +press R +run 300 +release M +release R +run 100 +screen unknown.png +diode report blocked.txt +optical blocked.optical +run 6500 +screen expired.png +adc-bias 5 0.54 0.27 0 +press L +run 100 +release L +run 200 +press L +run 100 +release L +run 2500 +press M +press R +run 300 +release M +release R +run 100 +screen bright.png +diode report bright.txt +adc-bias 5 0.55 0.471 0 +press L +run 100 +release L +run 200 +press L +run 100 +release L +run 2500 +press M +press R +run 300 +release M +release R +run 100 +screen resistive.png +diode report resistive.txt +# A dark forward-biased junction confirms reversal without light. +adc-bias 5 0.54 0.523 0 +press L +run 100 +release L +run 200 +press L +run 100 +release L +run 2500 +press M +press R +run 300 +release M +release R +run 100 +screen reversed.png +diode report reversed.txt +# Correcting the input briefly confirms the inserted diode. +gpio 5 1 +run 600 +screen corrected.png +# Repeat in the opposite rotation and light appearance. +press L +run 100 +release L +run 200 +press M +run 80 +release M +run 200 +press R +run 80 +release R +run 200 +press M +run 80 +release M +run 200 +press R +run 80 +release R +run 200 +press L +run 100 +release L +run 2500 +gpio 5 0 +press M +press R +run 300 +release M +release R +run 100 +screen rotated.png +diode report rotated.txt +run 6500 +screen rotated-persist.png +press L +run 100 +release L +run 200 +press L +run 100 +release L +run 200 +screen rotated-left.png diff --git a/stick/simulation/main.rs b/stick/simulation/main.rs index 9d4a50c..1440a3c 100644 --- a/stick/simulation/main.rs +++ b/stick/simulation/main.rs @@ -138,6 +138,44 @@ fn main() { ["gpio", pin, level] => machine .bus .gpio_set_input(pin.parse().unwrap(), *level == "1"), + ["adc", pin, volts] => machine.bus.analog_set( + pin.parse().unwrap(), + esp_periph::AnalogSource::Const(volts.parse().unwrap()), + ), + ["adc-bias", pin, up, both, down] => machine.bus.analog_set( + pin.parse().unwrap(), + esp_periph::AnalogSource::Bias([0.0, down.parse().unwrap(), + up.parse().unwrap(), both.parse().unwrap()]), + ), + ["cap", pin, low, high] => { + machine.bus.periph.rtc.touch_caps[pin.parse::().unwrap()] = + [low.parse().unwrap(), high.parse().unwrap()]; + } + ["touch", "timeout", value] => { + machine.bus.periph.rtc.touch_timeout = *value == "1"; + } + ["touch", "hang", value] => { + machine.bus.periph.rtc.touch_hang = *value == "1"; + } + ["diode", "report", name] => { + let state = inputs.lock().unwrap(); + let touch_scans = machine.bus.periph.rtc.touch_scans; + let touch_timer = machine.bus.periph.rtc.read(0x10c) & (1 << 13) != 0; + let touch_mask = (machine.bus.periph.rtc.read(0x110) >> 10) & 0x7fff; + std::fs::write( + out.join(name), + format!( + "high_drives={}\nreboots={}\noutput_enabled={}\ntouch_scans={}\ntouch_timer={}\ntouch_mask={}\n", + state.diode_high_drives, + machine.reboots, + machine.bus.periph.gpio.enable & (1 << 5) != 0, + touch_scans, + touch_timer, + touch_mask, + ), + ) + .unwrap(); + } ["serial", text] => machine.bus.serial_input(text.as_bytes()), ["vbus", millivolts] => { inputs.lock().unwrap().vbus_mv = Some(millivolts.parse().unwrap()); diff --git a/stick/simulation/soc.patch b/stick/simulation/soc.patch index 4596f62..2e2ea3f 100644 --- a/stick/simulation/soc.patch +++ b/stick/simulation/soc.patch @@ -10,6 +10,73 @@ index 67738ae..e1f8c73 100644 fn read8(&mut self, addr: u32) -> Result; fn read16(&mut self, addr: u32) -> Result; fn read32(&mut self, addr: u32) -> Result; +diff --git a/esp-periph/src/analog.rs b/esp-periph/src/analog.rs +index 8918636..bcaa1a0 100644 +--- a/esp-periph/src/analog.rs ++++ b/esp-periph/src/analog.rs +@@ -9,6 +9,8 @@ use std::sync::Arc; + #[derive(Clone)] + pub enum AnalogSource { + Const(f32), ++ /// Voltages for no pulls, pull-down, pull-up, and both pulls. ++ Bias([f32; 4]), + /// `samples` volts at `rate_hz`, starting at `start_cycles`; before the start it reads the first + /// sample, after the end it holds the last one. + Wave { samples: Arc>, rate_hz: f64, start_cycles: u64 }, +@@ -17,6 +19,7 @@ impl std::fmt::Debug for AnalogSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AnalogSource::Const(v) => write!(f, "Const({v} V)"), ++ AnalogSource::Bias(v) => write!(f, "Bias({v:?} V)"), + AnalogSource::Wave { samples, rate_hz, .. } => write!(f, "Wave({} samples @ {rate_hz} Hz)", samples.len()), + } + } +@@ -27,7 +30,7 @@ impl std::fmt::Debug for AnalogSource { + pub struct AdcObservation { pub generation: u64, pub raw: u16 } + + #[derive(Default)] +-pub struct AnalogInputs { pins: HashMap, raw: HashMap, observed: HashMap, pub cpu_hz: u64 } ++pub struct AnalogInputs { pins: HashMap, raw: HashMap, observed: HashMap, pulls: HashMap, pub cpu_hz: u64 } + impl AnalogInputs { + pub fn new(cpu_hz: u64) -> Self { AnalogInputs { cpu_hz, ..Default::default() } } + pub fn set(&mut self, pin: u8, src: AnalogSource) { self.raw.remove(&pin); self.pins.insert(pin, src); } +@@ -36,6 +39,9 @@ impl AnalogInputs { + if raw > 4095 { return false; } + self.pins.remove(&pin); self.raw.insert(pin, raw); true + } ++ pub fn set_pulls(&mut self, pin: u8, up: bool, down: bool) { ++ self.pulls.insert(pin, usize::from(up) * 2 + usize::from(down)); ++ } + pub fn observation(&self, pin: u8) -> AdcObservation { self.observed.get(&pin).copied().unwrap_or_default() } + /// Complete one sample. Reading or replacing a source never advances its generation. + pub(crate) fn convert(&mut self, pin: u8, now: u64, code: impl FnOnce(f32) -> u32) -> u32 { +@@ -49,6 +55,7 @@ impl AnalogInputs { + match self.pins.get(&pin) { + None => 0.0, + Some(AnalogSource::Const(v)) => *v, ++ Some(AnalogSource::Bias(v)) => v[*self.pulls.get(&pin).unwrap_or(&0)], + Some(AnalogSource::Wave { samples, rate_hz, start_cycles }) => { + if samples.is_empty() { return 0.0; } + let dt = now_cycles.saturating_sub(*start_cycles) as f64 / self.cpu_hz.max(1) as f64; +@@ -62,6 +69,18 @@ impl AnalogInputs { + mod tests { + use super::*; + #[test] ++ fn bias_source_follows_the_selected_pull_network() { ++ let mut a = AnalogInputs::new(1_000); ++ a.set(5, AnalogSource::Bias([0.0, 0.02, 0.54, 0.523])); ++ assert_eq!(a.volts(5, 0), 0.0); ++ a.set_pulls(5, true, false); ++ assert_eq!(a.volts(5, 0), 0.54); ++ a.set_pulls(5, true, true); ++ assert_eq!(a.volts(5, 0), 0.523); ++ a.set_pulls(5, false, true); ++ assert_eq!(a.volts(5, 0), 0.02); ++ } ++ #[test] + fn wave_follows_emulated_time_and_holds_the_ends() { + let mut a = AnalogInputs::new(1_000); + a.set(4, AnalogSource::Wave { samples: Arc::new(vec![0.1, 0.2, 0.3]), rate_hz: 10.0, start_cycles: 500 }); diff --git a/esp-periph/src/rmt.rs b/esp-periph/src/rmt.rs index ad89f33..b44ab63 100644 --- a/esp-periph/src/rmt.rs @@ -84,10 +151,10 @@ index ad89f33..b44ab63 100644 c.rd += 1; c.since_thr += 1; diff --git a/esp-periph/src/rtc_cntl.rs b/esp-periph/src/rtc_cntl.rs -index 5c625c1..16e17a0 100644 +index 5c625c1..82a1133 100644 --- a/esp-periph/src/rtc_cntl.rs +++ b/esp-periph/src/rtc_cntl.rs -@@ -15,6 +15,7 @@ pub fn reset_cause_name(c: u32) -> &'static str { +@@ -15,9 +15,11 @@ pub fn reset_cause_name(c: u32) -> &'static str { /// WDTCONFIG0..WDTWPROTECT live at 0x98..0xb0 on S3 and 0x90..0xa8 on C3. /// `esp_restart()` on ESP-IDF 5.x arms this watchdog and spins until it resets the chip. pub struct RtcCntl { pub ram: RegRam, pub slow_ticks: u64, pub time_latch: u64, pub sw_reset: bool, pub reset_cause: u32, @@ -95,7 +162,11 @@ index 5c625c1..16e17a0 100644 wdt_base: u32, wdt_count: u64, wdt_stage: usize, wdt_unlocked: bool, /// S3 only: the SENS block at +0x800 converts on SAR_MEAS1_START (see `sens_meas`); the chip refreshes /// `now_cycles` before each access so a waveform source is sampled at the right emulated time. -@@ -26,6 +27,7 @@ impl RtcCntl { ++ pub touch_caps: [[f32; 2]; 15], pub touch_hang: bool, pub touch_timeout: bool, pub touch_scans: u64, touch_pending: Option<(u64, u32)>, touch_cold: bool, touch_next: usize, + pub sens_adc: bool, pub analog: crate::analog::AnalogInputs, pub now_cycles: u64 } + impl RtcCntl { + pub fn preset_after_bootloader(&mut self) { self.ram.write(0xc0, 0xFFD7_0028); self.ram.write(0xc4, 0xFF0F_00F0); } +@@ -26,6 +28,7 @@ impl RtcCntl { pub fn wdt_tick(&mut self, ticks: u64) { let conf0 = self.ram.read(self.wdt_base); if conf0 & (1 << 31) == 0 { return; } @@ -103,15 +174,21 @@ index 5c625c1..16e17a0 100644 self.wdt_count += ticks; while self.wdt_stage < 4 { let timeout = self.ram.read(self.wdt_base + 4 + 4 * self.wdt_stage as u32) as u64; -@@ -48,6 +50,7 @@ impl RtcCntl { +@@ -44,10 +47,12 @@ impl RtcCntl { + } + if self.wdt_stage >= 4 { self.wdt_stage = 0; } + } +- pub fn new() -> Self { let mut r = Self::with_wdt_base(0x98); r.sens_adc = true; r } ++ pub fn new() -> Self { let mut r = Self::with_wdt_base(0x98); r.sens_adc = true; r.touch_caps[5] = [20.2, 17.9]; r } pub fn new_c3() -> Self { Self::with_wdt_base(0x90) } fn with_wdt_base(wdt_base: u32) -> Self { let mut r = RtcCntl { ram: RegRam::new(), slow_ticks: 0, time_latch: 0, sw_reset: false, reset_cause: RST_POWERON, wdt_base, wdt_count: 0, wdt_stage: 0, wdt_unlocked: false, + sleeping: false, gpio_input: 0x3fffff, alarm_armed: false, ++ touch_caps: [[11.8, 11.2]; 15], touch_hang: false, touch_timeout: false, touch_scans: 0, touch_pending: None, touch_cold: true, touch_next: 1, sens_adc: false, analog: crate::analog::AnalogInputs::new(240_000_000), now_cycles: 0 }; r.ram.write(0x38, 1 | (1 << 6)); // RESET_STATE: reset cause POWERON for both CPUs r.ram.write(0x74, 0); // CLK_CONF -@@ -55,6 +58,9 @@ impl RtcCntl { +@@ -55,6 +60,9 @@ impl RtcCntl { } pub fn read(&mut self, off: u32) -> u32 { match off { @@ -121,7 +198,7 @@ index 5c625c1..16e17a0 100644 0x10 => self.time_latch as u32, 0x14 => (self.time_latch >> 32) as u32, 0xc => self.ram.read(off) | (1 << 30), // TIME_UPDATE: valid 0x1fc => 0x2007270, -@@ -66,7 +72,36 @@ impl RtcCntl { +@@ -66,7 +74,51 @@ impl RtcCntl { let wdt = self.wdt_base; match off { 0x0 => { if v & (1 << 31) != 0 { self.request_reset(RST_SW_SYS); } else if v & (1 << 5) != 0 { self.request_reset(RST_SW_CPU); } self.ram.write(off, v & !((1 << 31) | (1 << 5))); } // OPTIONS0.SW_SYS_RST / SW_PROCPU_RST @@ -154,15 +231,71 @@ index 5c625c1..16e17a0 100644 + 0xe0 if self.sens_adc => { + if v & (1 << 22) != 0 { self.ram.write(0xe4, 0); } + self.ram.write(off, v & !(1 << 22)); ++ } ++ 0x138 if self.sens_adc => self.ram.write(0x40, self.ram.read(0x40) | v), ++ 0x13c if self.sens_adc => self.ram.write(0x40, self.ram.read(0x40) & !v), ++ 0x110 if self.sens_adc => { ++ if (v ^ self.ram.read(off)) & 0x01ff_fc00 != 0 { self.touch_next = 1; } ++ self.ram.write(off, v); ++ } ++ 0x10c if self.sens_adc => { ++ let previous = self.ram.read(off); ++ self.ram.write(off, v); ++ if v & (1 << 29) != 0 { self.touch_cold = true; self.touch_pending = None; } ++ if v & (1 << 15) != 0 && previous & (1 << 15) == 0 { self.touch_start(v); } ++ } ++ 0x89c if self.sens_adc => { ++ self.ram.write(off, self.ram.read(off) & (1 << 31)); + } 0x80c | 0x830 if self.sens_adc => self.sens_meas(off, v), _ if off == wdt + 0x18 => { self.wdt_unlocked = v == 0x50D8_3AA1; self.ram.write(off, v); } _ if (wdt..=wdt + 0x10).contains(&off) => { if self.wdt_unlocked { if off == wdt && (v ^ self.ram.read(wdt)) & (1 << 31) != 0 { self.wdt_count = 0; self.wdt_stage = 0; } self.ram.write(off, v); } } -@@ -74,6 +109,44 @@ impl RtcCntl { +@@ -74,6 +126,85 @@ impl RtcCntl { _ => self.ram.write(off, v), } } + ++ // S3 one-shot touch scan. Counts use the measured bare-pad/reference scale; ++ // electrical scenarios supply capacitance, not a requested UI classification. ++ fn touch_start(&mut self, control: u32) { ++ self.touch_scans += 1; ++ self.ram.write(0x89c, 0); ++ let mask = (self.ram.read(0x110) >> 10) & 0x7fff; ++ if mask == 0 || self.touch_hang { self.touch_pending = None; return; } ++ let high = ((control >> 6) & 3) | (control & 0xc); ++ let window = usize::from(high >= 12); ++ let cycles = f64::from((self.ram.read(0x108) >> 16) & 0xffff); ++ let reference_pf = 5.0 + 1.4 * f64::from((control >> 9) & 7); ++ let speed = ((self.ram.read(0x14c) >> 29) & 7).max(1); ++ let gain = if window == 0 { 5.48 } else { 21.95 } * cycles / f64::from(speed); ++ let offset = if window == 0 { 8.73 } else { 31.17 } * cycles / f64::from(speed); ++ let reference = (offset + gain * reference_pf).round() as u32; ++ self.ram.write(0x860, reference); ++ let pin = (self.touch_next..15).chain(1..self.touch_next) ++ .find(|pin| mask & (1 << pin) != 0).unwrap(); ++ self.touch_next = pin + 1; ++ // A timed-out ADC value is unspecified; inject plausible bare-pad data ++ // to ensure firmware uses the timeout signal instead of trusting it. ++ let measured_pin = if self.touch_timeout { 4 } else { pin }; ++ let count = (offset + gain * f64::from(self.touch_caps[measured_pin][window])).round() as u32; ++ self.ram.write(0x8a0 + pin as u32 * 4, ++ if self.touch_cold { 0x3fffff } else { count }); ++ if pin == (31 - mask.leading_zeros()) as usize { self.touch_cold = false; } ++ self.ram.write(0x8a0, (pin as u32) << 22 | reference); ++ // RTC_FAST=8MHz, RTC_SLOW=150kHz, including the denoise reference. ++ let ticks = if self.touch_timeout { 450 } else { (u64::from(count + reference + 2048) * 3 / 160).max(1) }; ++ self.touch_pending = Some((self.slow_ticks + ticks, pin as u32)); ++ } ++ ++ fn touch_tick(&mut self) { ++ if self.touch_pending.is_some_and(|(deadline, _)| self.slow_ticks >= deadline) { ++ self.touch_pending = None; ++ self.ram.write(0x89c, 1 << 31); ++ self.ram.write(0x44, self.ram.read(0x44) | (1 << 4) | (1 << 6) ++ | if self.touch_timeout { 1 << 18 } else { 0 }); ++ } ++ } ++ + fn alarm_target(&self) -> u64 { + u64::from(self.ram.read(0x4)) | (u64::from(self.ram.read(0x8) & 0xffff) << 32) + } @@ -203,20 +336,27 @@ index 5c625c1..16e17a0 100644 } impl RtcCntl { -@@ -120,5 +193,19 @@ impl Device for RtcCntl { +@@ -119,6 +250,26 @@ impl Default for RtcCntl { fn default() -> Self { Self::new() } } + impl Device for RtcCntl { fn read(&mut self, off: u32) -> u32 { RtcCntl::read(self, off) } fn write(&mut self, off: u32, v: u32) -> WriteEffect { RtcCntl::write(self, off, v); WriteEffect::NONE } ++ fn irq_sources(&self) -> u64 { u64::from(self.ram.read(0x44) & self.ram.read(0x40) != 0) } fn clock(&self) -> Option { Some(ClockDomain::RtcSlow) } - fn tick(&mut self, ticks: u64) { self.slow_ticks += ticks; self.wdt_tick(ticks); } + fn tick(&mut self, ticks: u64) { + self.wdt_tick(ticks); + self.slow_ticks += ticks; + self.update_alarm(); ++ self.touch_tick(); + self.check_wakeup(); + } + fn has_deadline(&self) -> bool { true } + fn next_deadline(&self) -> Option { -+ let alarm = self.alarm_armed.then(|| self.alarm_target().saturating_sub(self.slow_ticks).max(1)); ++ let mut alarm = self.alarm_armed.then(|| self.alarm_target().saturating_sub(self.slow_ticks).max(1)); ++ if let Some((deadline, _)) = self.touch_pending { ++ let ticks = deadline.saturating_sub(self.slow_ticks).max(1); ++ alarm = Some(alarm.map_or(ticks, |current| current.min(ticks))); ++ } + let conf0 = self.ram.read(self.wdt_base); + if conf0 & (1 << 31) == 0 || (self.sleeping && conf0 & (1 << 9) != 0) { return alarm; } + let timeout = u64::from(self.ram.read(self.wdt_base + 4 + 4 * self.wdt_stage as u32)); @@ -766,7 +906,7 @@ index 7639ba8..27deeb9 100644 + } } diff --git a/esp32s3/src/periph.rs b/esp32s3/src/periph.rs -index d699cca..0eeed0d 100644 +index d699cca..30a7985 100644 --- a/esp32s3/src/periph.rs +++ b/esp32s3/src/periph.rs @@ -17,6 +17,7 @@ pub const SRC_UART0: usize = 27; @@ -777,7 +917,15 @@ index d699cca..0eeed0d 100644 pub const SRC_PWM0: usize = 31; pub const SRC_PWM1: usize = 32; pub const SRC_LEDC: usize = 35; -@@ -413,6 +414,7 @@ pub struct Peripherals { +@@ -25,6 +26,7 @@ pub const SRC_AES: usize = 77; + pub const SRC_LCD_CAM: usize = 24; + pub const SRC_I2S0: usize = 25; + pub const SRC_I2S1: usize = 26; ++pub const SRC_RTC_CORE: usize = 39; + pub const SRC_RMT: usize = 40; + pub const SRC_I2C0: usize = 42; + pub const SRC_I2C1: usize = 43; +@@ -413,6 +415,7 @@ pub struct Peripherals { pub i2c: [crate::i2c::I2c; 2], pub lcd_cam: LcdCam, pub spi2: GpSpi, @@ -785,7 +933,7 @@ index d699cca..0eeed0d 100644 pub pcnt: Pcnt, pub wifi: WifiMac, pub fe: FeIq, -@@ -446,8 +448,8 @@ device_set! { Peripherals; clock: (clock) CPU_HZ, [(ClockDomain::Systimer, 15), +@@ -446,14 +449,14 @@ device_set! { Peripherals; clock: (clock) CPU_HZ, [(ClockDomain::Systimer, 15), 0x2e "UART2" (uart[2]) => [SRC_UART2]; 0x38 "USB_SERIAL_JTAG" (usb) => [SRC_USB_SERIAL_JTAG]; 0x23 "SYSTIMER" (systimer) => [SRC_SYSTIMER_T0, SRC_SYSTIMER_T1, SRC_SYSTIMER_T2]; @@ -796,7 +944,14 @@ index d699cca..0eeed0d 100644 0x19 "LEDC" optional (ledc) => [SRC_LEDC]; 0x1e "MCPWM0" optional (mcpwm[0]) => [SRC_PWM0]; 0x2c "MCPWM1" optional (mcpwm[1]) => [SRC_PWM1]; -@@ -471,6 +473,7 @@ device_set! { Peripherals; clock: (clock) CPU_HZ, [(ClockDomain::Systimer, 15), + 0xc2 "INTERRUPT" (intmatrix) => []; + 0x04 "GPIO" (gpio) => [SRC_GPIO]; +- 0x08 "RTC" (rtc) => []; ++ 0x08 "RTC" (rtc) => [SRC_RTC_CORE]; + 0x07 "EFUSE" (efuse) => []; + 0xc0 "SYSTEM" (system) => [SRC_FROM_CPU0, SRC_FROM_CPU0 + 1, SRC_FROM_CPU0 + 2, SRC_FROM_CPU0 + 3]; + 0xc4 "EXTMEM" (extmem) => []; +@@ -471,6 +474,7 @@ device_set! { Peripherals; clock: (clock) CPU_HZ, [(ClockDomain::Systimer, 15), 0x27 "I2C1" (i2c[1]) => [SRC_I2C1]; 0x41 "LCD_CAM" (lcd_cam) => [SRC_LCD_CAM]; 0x24 "SPI2" (spi2) => [SRC_SPI2]; @@ -804,7 +959,7 @@ index d699cca..0eeed0d 100644 0x17 "PCNT" (pcnt) => [SRC_PCNT]; 0x33 "WIFI_MAC" (wifi) => [SRC_WIFI_MAC]; 0x34 "WIFI_MAC" alias (wifi) delta 0x1000 => []; -@@ -494,7 +497,11 @@ impl DeviceSet for Peripherals { +@@ -494,7 +498,11 @@ impl DeviceSet for Peripherals { match block { 0xc2 if !write => self.intmatrix.status = self.source_status(), // INTERRUPT_*_STATUS reads the live sources 0x35 => self.wifi.now_cycles = self.clock.cycles(), // TSF timestamps @@ -817,7 +972,7 @@ index d699cca..0eeed0d 100644 0x06 => self.fe.done = self.wifi.link.ap().is_some(), // IQ estimation completes once there is an AP _ => {} } -@@ -502,12 +509,13 @@ impl DeviceSet for Peripherals { +@@ -502,12 +510,13 @@ impl DeviceSet for Peripherals { } impl Peripherals { @@ -832,7 +987,7 @@ index d699cca..0eeed0d 100644 aes: Aes::new(), rsa: Rsa::new(), sha: Sha::new(), wdev: Wdev::new(), i2c_mst: I2cMst::new(), gdma: Gdma::new(), i2s0: I2s::new(CPU_HZ), i2s1: I2s::new(CPU_HZ), rmt: Rmt::new(CPU_HZ), io_mux: RegRam::new(), misc: Misc::new(), fake_reads: std::env::var("ESP_EMU_FAKE_READ").ok().map(|v| v.split(',').filter_map(|e| { let mut p = e.split(':'); let a = u32::from_str_radix(p.next()?.trim_start_matches("0x"), 16).ok()?; let o = u32::from_str_radix(p.next().unwrap_or("0").trim_start_matches("0x"), 16).ok()?; let m = u32::from_str_radix(p.next().unwrap_or("ffffffff").trim_start_matches("0x"), 16).ok()?; Some((a, (o, m))) }).collect()).unwrap_or_default(), clock: Self::new_clock(), -@@ -551,7 +559,10 @@ impl Peripherals { +@@ -551,7 +560,11 @@ impl Peripherals { pub fn write32(&mut self, addr: u32, v: u32) { // IDF v5.5.4 components/soc/esp32s3/register/soc/io_mux_reg.h:42-50 (FUN_PD/PU). if (0x60009004..=0x600090c4).contains(&addr) { @@ -840,11 +995,12 @@ index d699cca..0eeed0d 100644 + let pin = ((addr - 0x60009004) / 4) as u8; + if pin >= 22 || self.rtc.ram.read(0x484 + u32::from(pin) * 4) & (1 << 19) == 0 { + self.gpio.set_pad(pin, v); ++ self.rtc.analog.set_pulls(pin, v & (1 << 8) != 0, v & (1 << 7) != 0); + } } if matches!(addr, 0x6001_3004 | 0x6002_7004) && v & (1 << 5) != 0 { let bus = usize::from(addr == 0x6002_7004); -@@ -566,6 +577,13 @@ impl Peripherals { +@@ -566,6 +579,20 @@ impl Peripherals { for (group, bit) in [17, 20].iter().enumerate() { if v & (1 << bit) != 0 { self.mcpwm[group] = Mcpwm::new(160 + group as u32 * 6, 9); } } } let fx = mmio::write32(self, addr, v); @@ -852,13 +1008,20 @@ index d699cca..0eeed0d 100644 + let pin = ((addr - 0x60008484) / 4) as u8; + if v & (1 << 19) != 0 { self.gpio.set_pulls(pin, v & (1 << 27) != 0, v & (1 << 28) != 0); } + else { self.gpio.set_pad(pin, self.io_mux.read(4 + u32::from(pin) * 4)); } ++ let pad = if v & (1 << 19) != 0 { ++ (v & (1 << 27) != 0, v & (1 << 28) != 0) ++ } else { ++ let digital = self.io_mux.read(4 + u32::from(pin) * 4); ++ (digital & (1 << 8) != 0, digital & (1 << 7) != 0) ++ }; ++ self.rtc.analog.set_pulls(pin, pad.0, pad.1); + self.rtc.gpio_input = self.gpio.input as u32; + self.rtc.check_wakeup(); + } if fx.contains(WriteEffect::SPI_EXEC) { self.spi_exec = true; } if fx.contains(WriteEffect::INTMAP) { self.intmatrix_dirty = true; } // ESP-IDF v5.5.4 soc/system_reg.h: PERIP_CLK_EN1/RST_EN1 LCD_CAM bit 8. -@@ -600,7 +618,19 @@ impl Peripherals { +@@ -600,7 +627,19 @@ impl Peripherals { /// Advance device time by `cycles` CPU cycles (every clocked device gets its own clock's /// ticks), then route GPIO input edges to the pulse counters. pub fn tick(&mut self, cycles: u64) -> bool { @@ -879,7 +1042,7 @@ index d699cca..0eeed0d 100644 if !self.gpio.input_changes.is_empty() { let before = self.pcnt.irq(); let gpio = &mut self.gpio; -@@ -626,7 +656,13 @@ impl Peripherals { +@@ -626,7 +665,13 @@ impl Peripherals { /// Cycles until the next systimer or timer-group alarm can fire — the only device events /// firmware times precisely. Slightly conservative (one device tick early), never late.