Skip to content

fix(touch): an injected touch ends on its own clock, not inside mapTouchPoint( ) - #280

Merged
angeloINTJ merged 1 commit into
mainfrom
fix/injected-touch-never-ends
Oct 16, 2026
Merged

angeloINTJ merged 1 commit into
mainfrom
fix/injected-touch-never-ends

Conversation

@angeloINTJ

@angeloINTJ angeloINTJ commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

What was wrong

injectTouch( ) (POST /api/touch, the CLI touch sim / touch hold, the web mirror) raises _simTouchActive with a hold
time. The flag was cleared in exactly one place, inside mapTouchPoint( ) (DisplayManager_Calibration.cpp), so a screen that
never calls mapTouchPoint( ) for an injection left it up for good. Two handlers in handleTouch( ) do exactly that:

  1. The touch sensitivity screen (MODE_SETTINGS_TOUCH_SENS): Cancelar was looked for only under p.z >= 50, and the touch
    library reads zraw = 0 with nothing on the glass (XPT2046_Touchscreen.cpp, lines 177-179).
  2. The calibration's measuring phase (MODE_SETTINGS_TOUCH_CAL, _calPhase == 0): it passes the pressure gate (simNow)
    and accumulates p.x / p.y, which with an injection are the library's cached values of the last real touch.

With the flag up, _rawTouchState stays true and the release branch at the top of handleTouch( ) never runs. So the 30 s
idle return never comes; on the calibration screen a real finger cannot finish a point, because its release is never seen;
the capture fast path stays off (!_simTouchActive); and the first real touch afterwards is read at the injected tap's
coordinates.

Bench, 2026-10-16 about 11:00 (UI branch image, whose handleTouch( ) logic is main's): an injected Cancelar on the
sensitivity screen was ignored, and the board stayed on that screen for 42 s and beyond, until a web reboot.

The change

  1. The injected touch ends in loopCore1( ), once per pass and right after handleTouch( ), with simTouchExpired( )
    (SystemDefs_Time.h: wrap-safe now - set > hold). mapTouchPoint( ) keeps returning the injected coordinates while the
    flag is up and no longer clears it. A 100 ms tap still reads as touched for its hold and then releases, so the release edge
    fires on every screen, one pass after the clear, as it does today. The comments that put the clearing in mapTouchPoint( )
    (at injectTouch( ), in mapTouchPoint( ), at the flag's declaration and in the helper's doc) say where it lives now.
  2. Sensitivity screen: the Cancelar gate is p.z >= 50 || simNow. simNow is read once per handleTouch( ) pass, at the
    top, and the pressure gate further down uses that same value. An injection is still never a pressure sample: with nothing
    on the glass z = 0 and the existing p.z < 30 return drops it. A finger pressing during an injection gives its own, real
    sample. The branch also records the touch's provenance (_lastTouchInjected = simNow) before acceptTouch(0): it returns
    above the common assignment, so a finger that opened the screen would leave a stale false, and the accepted Cancelar opens
    the 5 s priority window, in which /api/screen_stream answers 503 unless the last touch was injected. That would blind the
    mirror that sent the tap.
  3. Calibration measuring phase: an injected touch is ignored before the hold starts (if (simNow) return;). It has no raw
    position. Without this, change 1 alone would let an injected hold of CAL_HOLD_MS (400 ms) or more record a corner from
    stale values: a wedge would become a corrupted calibration. The line sits inside _calStep < 8, after the
    _calPhase >= 1 verdict block, so an injected tap still presses the verdict buttons.

What proves it

  • Host (pinned): test_simTouchExpired_tap_and_hold and test_simTouchExpired_wrap_safe, in test/test_validators
    (env native, next to the timeReached / timeSince cases). Strictly greater than the hold (a 100 ms tap is down at 100 ms,
    released at 101), and across the millis( ) wrap. Negative controls: changing > to >= fails both cases; the non-wrapping
    form now > setMs + holdMs fails the wrap case. Run on this helper and these tests; neither has changed since.
  • Source: grep -rn "_simTouchActive, \(true\|false\)" src finds two writes: the raise in injectTouch( )
    (DisplayManager.cpp:939) and the clear in loopCore1( ) (DisplayManager.cpp:1412). The latter's only handleTouch( )
    call site is the one it follows.
  • Provenance gap, checked: the Cancelar's acceptTouch(0) is the only acceptTouch, acceptHoldTouch or
    acceptSlideTouch call above the common _lastTouchInjected = simNow (DisplayManager_Touch.cpp:308). Every other call in
    handleTouch( ) is below it, so none has the same gap.
  • Not host-testable: handleTouch( ) and loopCore1( ) (no harness invented for them). Their proof is the bench run below.
  • Native suites: 703 cases in 11 suites, all pass (701 before; the READMEs say 703 and native 302).

Firmware images

All nine built from the committed tree (clean git status, each build directory removed first). The baseline is the record
in tools/flash_budget.json, and the instrument was proved first: pico_wiretft built from untouched origin/main came out
at 774,588 B used and a .bin of 786,624 B (sha256 3aff19e8), the record to the byte.

Image Flash used (B) Change .bin (B) OTA headroom (B)
pico_w_release 1,022,772 +40 1,034,804 5,339
pico_w_test 1,027,196 +40 1,039,228 915
pico_w_test_https 1,032,252 +40 1,044,284 exempt
pico_w_asserts 1,025,068 +40 1,037,100 3,043
pico_w_alpha 976,012 0 990,380 49,763
pico_w_air 1,019,344 0 1,035,340 4,803
pico2_w_release 998,872 +24 998,896 569,631 (slot)
pico_wire 592,116 0 604,152 435,991
pico_wiretft 774,636 +48 786,672 253,471

tools/flash_budget.json (budget, measured and .bin of the six that grew, and the two provenance lines), the five rows
of docs/analysis/PLANO_STABLE.md section 3.1 and the test counts in the three READMEs are re-recorded in this change.
pico_w_test was already under one flash sector of OTA headroom (955 B on main, 915 B now) and pico_w_asserts too (3,083 B,
now 3,043 B): the gate's warning is the same one.

Gates run locally

All nine pio run builds pass the extra-script gates (-Werror on src/, log codes, language packs, authorization matrix
untouched, CLI help, flash probes). check_flash_budget.py per image (with each firmware.bin present) and --table;
test_flash_budget.py (19 ok); run_fuzz.sh (60 s, 13.4 M runs, clean, on this commit); check_air_consistency.py;
check_angulo.py; check_readme_numbers.py; check_license.py; gen_notices.py --check. Not run: cppcheck (not
installed here), the ASan/UBSan pass (it needs PLATFORMIO_BUILD_FLAGS), and CI.

For review

  • Placement (resolved). The test runs after handleTouch( ), not before _rawTouchState is computed. Before it, a tap
    whose first Core 1 pass came more than holdMs after injectTouch( ) (a Core 1 pause, such as a flash save) was released
    unseen, where mapTouchPoint( ) used to return the coordinates once and only then clear the flag. After handleTouch( ),
    the pass that first sees the flag has processed it; the flag clears on that pass or a later one, _rawTouchState goes false
    on the next, and the release comes one pass after the clear. Against the first version of this change (the test before
    _rawTouchState) the placement costs 8 B on pico_w_release and pico_w_test_https and nothing on the other seven; the
    provenance store costs 16 B on release, test, test_https, asserts and wiretft, and 8 B on pico2_w_release.
  • Calibration. if (simNow) return; is strict. An injection over a real finger's hold only pauses that finger's samples
    until the injection ends; it never adds a wrong one. simNow && !_calHolding is the narrower form.
  • A corner left. A pass that handleTouch( ) skips with its 15 ms gate (a touch was processed less than 15 ms earlier) can
    still see an expired flag cleared unseen, and that needs a late first pass on top. Not measured.
  • Unchanged race. A second injectTouch( ) landing between the expiry test and the clear can be wiped by it. The same
    window existed in mapTouchPoint( ).

Bench

Run on 2026-10-16, 12:45-12:57, on the bench target (plain Pico with the TFT, original UI, plain HTTP on 8080), with the same order
on both images: main_pico_wiretft.uf2 (origin/main ea4fcb1, .bin sha256 3aff19e8) and fix_pico_wiretft.uf2 (this change,
.bin sha256 ea9b1723, built from the commit's clean tree). Each check starts from a fresh boot: the image is flashed and a web
reboot comes between the checks. The product images carry the emergency console (SIMUT_CLI_FULL=0), so there is no touch sim
and no screen. The checks therefore drive the panel the way the web mirror does (POST /api/touch, 100 ms taps), through the
original UI's bench walk: a test account and its PIN, the Settings menu, Touch calibration, the sensitivity screen. The screen is
read from /api/screenshot captures.

Check Before (3aff19e8) After (ea9b1723)
Inject Cancelar (60, 220) on the sensitivity screen no effect in 23 s, tapped twice; still on the screen; the menu's Back and the dashboard steps skipped the Settings menu; its Back reaches the dashboard (8 of 8 steps)
Inject a tap off Cancelar (160, 120), then wait for the dashboard still on the sensitivity screen when the wait timed out at 52.5 s the dashboard 35.5 s into the wait, which began 3.2 s after the tap: the 30 s idle return

The original UI's full walk, A/B between the two images (13:13-13:55, ab_original.py --lenient --ajustes --ajustes2 --criar-conta, ab_comparar.py). It covers the dashboard taps, holds and long presses, the chart, and every settings screen that
the walk visits without saving.

Result Steps
Equal 103
Skipped in both 19
Not comparable (the keypad's cards are dealt at random) 3
Different 2
Missing in A (a2_cfg_sair, a2_dash_final) 2

Every difference is accounted for:

  • sens_cancelar: 47,514 px. Before, no effect and still the sensitivity screen. After, the Settings menu.
  • a2_cfg_sair and a2_dash_final follow from it. Before, they were skipped, because the board was still on the sensitivity screen.
    After, the menu's Back reached the dashboard: back on the dashboard at the end, true against false.
  • detail_open: 46 px of a live reading at the edge of its mask. That tap does not open the detail on either image, as in every A/B
    of this walk.

Not run on the board:

  • the calibration rows (the measuring phase, a real finger after an injection, the verdict button): this bench's rules forbid
    entering or injecting into the calibration screen, so they rest on the source and the review;
  • the mirror's 503 window after an injected Cancelar;

🤖 Generated with Claude Code

https://claude.ai/code/session_01LyLm7Q1o7kCHngE63BnoTx

…uchPoint( )

injectTouch( ) raises _simTouchActive with a hold time, and the flag was cleared in
exactly one place, inside mapTouchPoint( ). Two handlers in handleTouch( ) never call
it for an injection: the touch sensitivity screen (Cancelar was looked for only under
p.z >= 50, and the library reads z = 0 with nothing on the glass) and the calibration's
measuring phase (it accumulates the library's cached x/y of the last real touch). The
flag stayed up for good, so _rawTouchState stayed true and the release branch never ran:
no 30 s idle return, no release for a real finger on the calibration screen, the capture
fast path off, and the next real touch read at the injected coordinates. Bench,
2026-10-16 about 11:00 (UI branch image, whose handleTouch logic is main's): an injected
Cancelar was ignored and the board stayed on the sensitivity screen for 42 s and beyond,
until a web reboot.

- loopCore1( ) ends the injected touch once per pass, right after handleTouch( )
  (simTouchExpired( ), wrap-safe now - set > hold). After, not before _rawTouchState is
  computed: the pass that first sees the flag has then processed the tap even when it
  comes later than the hold (a Core 1 pause, such as a flash save), which is what
  mapTouchPoint( ) did when it returned the coordinates once and only then cleared the
  flag. The flag clears on that pass or a later one, _rawTouchState goes false on the
  next, and the release edge comes one pass after the clear, as before. mapTouchPoint( )
  keeps returning the injected coordinates and no longer clears the flag.
- Sensitivity: Cancelar takes an injection (p.z >= 50 || simNow, simNow read once per
  handleTouch( ) pass) and records the touch's provenance (_lastTouchInjected) before
  acceptTouch(0). That branch returns above the common assignment, so a finger that
  opened the screen would leave a stale false, and the accepted Cancelar opens the 5 s
  priority window: /api/screen_stream answers 503 in it unless the last touch was
  injected, which would blind the mirror that sent the tap. It is the only accept call
  above that assignment; every acceptTouch, acceptHoldTouch and acceptSlideTouch call in
  handleTouch( ) below it already has the provenance. The p.z < 30 return still keeps an
  injection out of the pressure samples.
- Calibration measuring phase: an injected touch is ignored before the hold starts, and
  after the verdict buttons, which an injected tap still presses. No corner is ever
  recorded from the library's stale x/y; an injection over a real finger's hold only
  pauses that finger's samples until it ends.

Proof, on this tree: test_simTouchExpired_tap_and_hold and test_simTouchExpired_wrap_safe
in test_validators pin the expiry (strictly greater than the hold, across the millis( )
wrap); changing > to >= fails both, the non-wrapping form fails the wrap case. All eleven
native suites pass, 703 cases (701 before). All nine images build from this commit and
pass check_flash_budget.py: flash used +40 B on pico_w_release, pico_w_test,
pico_w_test_https and pico_w_asserts, +24 B on pico2_w_release, +48 B on pico_wiretft, no
change on pico_w_alpha, pico_w_air and pico_wire. The same test placed before
_rawTouchState was 8 B smaller on release and test_https and the same on the other four.
The budgets, the OTA headroom table (PLANO_STABLE section 3.1) and the README test counts
are re-recorded here.

Bench, 2026-10-16 12:45-12:57, pico_wiretft on the bench target, each check from a fresh
boot, 100 ms taps injected by POST /api/touch as the web mirror sends them. On origin/main
(.bin 3aff19e8) an injected Cancelar on the sensitivity screen had no effect in 23 s, and
after a tap off it the board was still on that screen when the wait ended at 52.5 s. With
this change (.bin ea9b1723) Cancelar reached the Settings menu and its Back the dashboard,
and after the tap off it the idle return reached the dashboard 35.5 s into the wait, which
began 3.2 s after the tap. Not run on the board: the calibration (the bench's rules forbid
entering it) and the mirror's 503 window.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyLm7Q1o7kCHngE63BnoTx
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests Testing infrastructure and test coverage display Display, TFT, touchscreen, and themes tools Build tools, scripts, and developer tooling code Firmware source changes (src/, WebUI.h) labels Oct 16, 2026
@angeloINTJ
angeloINTJ merged commit 90e82d1 into main Oct 16, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

code Firmware source changes (src/, WebUI.h) display Display, TFT, touchscreen, and themes documentation Improvements or additions to documentation tests Testing infrastructure and test coverage tools Build tools, scripts, and developer tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant