Repository navigation
fix(touch): an injected touch ends on its own clock, not inside mapTouchPoint( ) - #280
Merged
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
injectTouch( )(POST /api/touch, the CLItouch sim/touch hold, the web mirror) raises_simTouchActivewith a holdtime. The flag was cleared in exactly one place, inside
mapTouchPoint( )(DisplayManager_Calibration.cpp), so a screen thatnever calls
mapTouchPoint( )for an injection left it up for good. Two handlers inhandleTouch( )do exactly that:MODE_SETTINGS_TOUCH_SENS): Cancelar was looked for only underp.z >= 50, and the touchlibrary reads
zraw = 0with nothing on the glass (XPT2046_Touchscreen.cpp, lines 177-179).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,
_rawTouchStatestays true and the release branch at the top ofhandleTouch( )never runs. So the 30 sidle 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'scoordinates.
Bench, 2026-10-16 about 11:00 (UI branch image, whose
handleTouch( )logic is main's): an injected Cancelar on thesensitivity screen was ignored, and the board stayed on that screen for 42 s and beyond, until a web reboot.
The change
loopCore1( ), once per pass and right afterhandleTouch( ), withsimTouchExpired( )(
SystemDefs_Time.h: wrap-safenow - set > hold).mapTouchPoint( )keeps returning the injected coordinates while theflag 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( ), inmapTouchPoint( ), at the flag's declaration and in the helper's doc) say where it lives now.p.z >= 50 || simNow.simNowis read once perhandleTouch( )pass, at thetop, 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 < 30return drops it. A finger pressing during an injection gives its own, realsample. The branch also records the touch's provenance (
_lastTouchInjected = simNow) beforeacceptTouch(0): it returnsabove 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_streamanswers 503 unless the last touch was injected. That would blind themirror that sent the tap.
if (simNow) return;). It has no rawposition. Without this, change 1 alone would let an injected hold of
CAL_HOLD_MS(400 ms) or more record a corner fromstale values: a wedge would become a corrupted calibration. The line sits inside
_calStep < 8, after the_calPhase >= 1verdict block, so an injected tap still presses the verdict buttons.What proves it
test_simTouchExpired_tap_and_holdandtest_simTouchExpired_wrap_safe, intest/test_validators(env
native, next to thetimeReached/timeSincecases). 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-wrappingform
now > setMs + holdMsfails the wrap case. Run on this helper and these tests; neither has changed since.grep -rn "_simTouchActive, \(true\|false\)" srcfinds two writes: the raise ininjectTouch( )(
DisplayManager.cpp:939) and the clear inloopCore1( )(DisplayManager.cpp:1412). The latter's onlyhandleTouch( )call site is the one it follows.
acceptTouch(0)is the onlyacceptTouch,acceptHoldTouchoracceptSlideTouchcall above the common_lastTouchInjected = simNow(DisplayManager_Touch.cpp:308). Every other call inhandleTouch( )is below it, so none has the same gap.handleTouch( )andloopCore1( )(no harness invented for them). Their proof is the bench run below.native302).Firmware images
All nine built from the committed tree (clean
git status, each build directory removed first). The baseline is the recordin
tools/flash_budget.json, and the instrument was proved first:pico_wiretftbuilt from untouchedorigin/maincame outat 774,588 B used and a
.binof 786,624 B (sha2563aff19e8), the record to the byte..bin(B)pico_w_releasepico_w_testpico_w_test_httpspico_w_assertspico_w_alphapico_w_airpico2_w_releasepico_wirepico_wiretfttools/flash_budget.json(budget, measured and.binof the six that grew, and the two provenance lines), the five rowsof
docs/analysis/PLANO_STABLE.mdsection 3.1 and the test counts in the three READMEs are re-recorded in this change.pico_w_testwas already under one flash sector of OTA headroom (955 B on main, 915 B now) andpico_w_assertstoo (3,083 B,now 3,043 B): the gate's warning is the same one.
Gates run locally
All nine
pio runbuilds pass the extra-script gates (-Werroronsrc/, log codes, language packs, authorization matrixuntouched, CLI help, flash probes).
check_flash_budget.pyper image (with eachfirmware.binpresent) 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(notinstalled here), the ASan/UBSan pass (it needs
PLATFORMIO_BUILD_FLAGS), and CI.For review
handleTouch( ), not before_rawTouchStateis computed. Before it, a tapwhose first Core 1 pass came more than
holdMsafterinjectTouch( )(a Core 1 pause, such as a flash save) was releasedunseen, where
mapTouchPoint( )used to return the coordinates once and only then clear the flag. AfterhandleTouch( ),the pass that first sees the flag has processed it; the flag clears on that pass or a later one,
_rawTouchStategoes falseon 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 onpico_w_releaseandpico_w_test_httpsand nothing on the other seven; theprovenance store costs 16 B on release, test, test_https, asserts and wiretft, and 8 B on
pico2_w_release.if (simNow) return;is strict. An injection over a real finger's hold only pauses that finger's samplesuntil the injection ends; it never adds a wrong one.
simNow && !_calHoldingis the narrower form.handleTouch( )skips with its 15 ms gate (a touch was processed less than 15 ms earlier) canstill see an expired flag cleared unseen, and that needs a late first pass on top. Not measured.
injectTouch( )landing between the expiry test and the clear can be wiped by it. The samewindow 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/mainea4fcb1,.binsha2563aff19e8) andfix_pico_wiretft.uf2(this change,.binsha256ea9b1723, built from the commit's clean tree). Each check starts from a fresh boot: the image is flashed and a webreboot comes between the checks. The product images carry the emergency console (
SIMUT_CLI_FULL=0), so there is notouch simand no
screen. The checks therefore drive the panel the way the web mirror does (POST /api/touch, 100 ms taps), through theoriginal UI's bench walk: a test account and its PIN, the Settings menu, Touch calibration, the sensitivity screen. The screen is
read from
/api/screenshotcaptures.3aff19e8)ea9b1723)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 thatthe walk visits without saving.
a2_cfg_sair,a2_dash_final)Every difference is accounted for:
sens_cancelar: 47,514 px. Before, no effect and still the sensitivity screen. After, the Settings menu.a2_cfg_sairanda2_dash_finalfollow 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/Bof this walk.
Not run on the board:
entering or injecting into the calibration screen, so they rest on the source and the review;
🤖 Generated with Claude Code
https://claude.ai/code/session_01LyLm7Q1o7kCHngE63BnoTx