Wake the capture loop on the touch edge

The measurement that made this worth building: gpio75 is silent at idle under
WAIT_TOUCH -- zero edges in sixty seconds -- and bursts within milliseconds of
a finger landing. In the observed taps the burst appeared in the transcript
hundreds of milliseconds before the polled capture noticed the finger, which is
why a quick tap only ever produced one frame however cheap the frame became.

So the inter-frame wait is now a wait on the line rather than a sleep. At idle
it times out at the fallback cadence and costs nothing. At contact it returns
at once, so the first capture of a press happens when the finger lands. While
the finger stays down the sensor keeps pulsing, so the wait keeps returning
immediately and the loop runs as fast as QTEE allows -- which is what a press
wants and what stock's architecture does.

Queued edges are dropped when a session arms, or the previous press's burst
would wake the first wait instantly. --edge-wake gates it as a single variable
against the labelled 2-of-10 baseline; it and --irq-observe are mutually
exclusive because both would drain the same fd.
This commit is contained in:
Jorijn van der Graaf 2026-09-03 00:03:25 +02:00
commit 578e2321fa

View file

@ -91,6 +91,7 @@ int g_rescan = -1; // -1 = leave the config's value alone
// arise (every frame is terminal), so the knob only matters with a budget. // arise (every frame is terminal), so the knob only matters with a budget.
bool g_undecidedIsNoMatch = false; bool g_undecidedIsNoMatch = false;
bool g_irqObserve = false; bool g_irqObserve = false;
bool g_edgeWake = false;
// The namespace key the trustlet hashes into the SFS group's directory name. // The namespace key the trustlet hashes into the SFS group's directory name.
// It defaults to Android's because that is what this device's existing store // It defaults to Android's because that is what this device's existing store
@ -718,6 +719,10 @@ public:
// The line fd, for poll(): readable when an edge event is queued. // The line fd, for poll(): readable when an edge event is queued.
int IrqFd() const { return irq_; } int IrqFd() const { return irq_; }
// Discard anything already queued, so a wait sees only NEW edges. Without
// this the burst from the previous press wakes the next wait instantly.
void DrainEdges() { ReadEdges(); }
// Drain queued edge events. Each is a gpio_v2_line_event with a kernel // Drain queued edge events. Each is a gpio_v2_line_event with a kernel
// timestamp in ns and RISING/FALLING. // timestamp in ns and RISING/FALLING.
struct Edge { std::uint64_t ns; bool rising; }; struct Edge { std::uint64_t ns; bool rising; };
@ -735,6 +740,13 @@ public:
return out; return out;
} }
// Block up to timeoutMs for an edge, then drain. Empty on timeout. This is
// what turns the capture loop from a fixed-cadence poll into a
// wake-on-contact: the line is silent at idle and pulses within
// milliseconds of a finger landing, hundreds of milliseconds before a
// polled capture notices.
std::vector<Edge> WaitEdges(int timeoutMs);
// Rail up, settle, release reset, settle. Both lines are driven low first // Rail up, settle, release reset, settle. Both lines are driven low first
// so a warm restart starts where a cold one does. // so a warm restart starts where a cold one does.
bool PowerOn() { bool PowerOn() {
@ -820,6 +832,17 @@ private:
bool on_ = false; bool on_ = false;
}; };
std::vector<Sensor::Edge> Sensor::WaitEdges(int timeoutMs) {
if (irq_ < 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(timeoutMs));
return {};
}
pollfd pfd{ irq_, POLLIN, 0 };
int r = ::poll(&pfd, 1, timeoutMs);
if (r <= 0) return {};
return ReadEdges();
}
// ---- The trustlet // ---- The trustlet
// //
// The loader is IQSEEComCompatAppLoader (UID 122): op 1 loadFromBuffer, op 2 // The loader is IQSEEComCompatAppLoader (UID 122): op 1 loadFromBuffer, op 2
@ -1284,6 +1307,9 @@ public:
auto a = SendCommand(app_, ta::Cmd::Authenticate, au); auto a = SendCommand(app_, ta::Cmd::Authenticate, au);
Report(ta::Cmd::Authenticate, a); Report(ta::Cmd::Authenticate, a);
if (!a.Ok()) return out; if (!a.Ok()) return out;
// The previous press's burst is still queued; drop it so the first
// wait of this session cannot be woken by an old finger.
if (g_edgeWake) sensor_.DrainEdges();
en::TouchTracker tracker; en::TouchTracker tracker;
bool inPress = false, pressMatched = false, pressRejected = false; bool inPress = false, pressMatched = false, pressRejected = false;
@ -1362,15 +1388,24 @@ public:
} }
SendCommand(app_, ta::Cmd::QueryEventStatus, q); SendCommand(app_, ta::Cmd::QueryEventStatus, q);
if (g_verbose) { if (g_verbose) {
// The IRQ line alongside the metric: if it tracks the finger
// under an armed session, lift detection can become an edge
// wait instead of a poll.
auto irq = sensor_.ReadIrq(); auto irq = sensor_.ReadIrq();
std::println(" frame {:3} @{:5}ms: metric={:<4}{} irq={}{}", i + 1, std::println(" frame {:3} @{:5}ms: metric={:<4}{} irq={}{}", i + 1,
msSince(t0), c.metric, finger ? " FINGER" : " ", msSince(t0), c.metric, finger ? " FINGER" : " ",
irq ? std::to_string(*irq) : "?", note); irq ? std::to_string(*irq) : "?", note);
} }
std::this_thread::sleep_for(std::chrono::milliseconds(g_frameGapMs)); // WAKE ON CONTACT. Measured: gpio75 is silent at idle under
// WAIT_TOUCH (0 edges in 60 s) and bursts within milliseconds of a
// finger landing -- and the burst arrives HUNDREDS of ms before a
// fixed-cadence capture notices, which is why a quick tap only
// ever yielded one frame. Waiting on the edge instead of sleeping
// means the first capture of a press happens at contact.
//
// While a finger is DOWN the sensor keeps pulsing, so the wait
// returns immediately and the loop runs as fast as QTEE allows --
// exactly what a press wants. The timeout is the idle fallback, so
// a release is still noticed promptly.
if (g_edgeWake) sensor_.WaitEdges(g_frameGapMs);
else std::this_thread::sleep_for(std::chrono::milliseconds(g_frameGapMs));
} }
fingerPresent_.store(false); fingerPresent_.store(false);
std::println(" verify loop: {} frames in {} ms ({} ms/frame incl. {} ms gap)", std::println(" verify loop: {} frames in {} ms ({} ms/frame incl. {} ms gap)",
@ -1534,7 +1569,8 @@ private:
return; return;
} }
PostEvent(std::make_unique<Event>(Event{ .kind = Event::Kind::Ready })); PostEvent(std::make_unique<Event>(Event{ .kind = Event::Kind::Ready }));
if (g_irqObserve) session_.StartIrqObserver(); if (g_irqObserve && !g_edgeWake) session_.StartIrqObserver();
else if (g_irqObserve) std::println("--irq-observe ignored: --edge-wake owns the line fd");
for (;;) { for (;;) {
Job j; Job j;
@ -2147,6 +2183,9 @@ int main(int argc, char** argv) {
if (a.starts_with("--frame-gap=")) g_frameGapMs = std::stoi(std::string(a.substr(12))); if (a.starts_with("--frame-gap=")) g_frameGapMs = std::stoi(std::string(a.substr(12)));
if (a == "--undecided=nomatch") g_undecidedIsNoMatch = true; if (a == "--undecided=nomatch") g_undecidedIsNoMatch = true;
if (a == "--irq-observe") g_irqObserve = true; if (a == "--irq-observe") g_irqObserve = true;
// The observer thread and the loop would both drain the same fd, so
// the diagnostic and the wake are mutually exclusive.
if (a == "--edge-wake") g_edgeWake = true;
if (a.starts_with("--log-dir=")) g_logDir = a.substr(10); if (a.starts_with("--log-dir=")) g_logDir = a.substr(10);
if (a.starts_with("--state-dir=")) g_stateDir = a.substr(12); if (a.starts_with("--state-dir=")) g_stateDir = a.substr(12);
if (a.starts_with("--rescan=")) g_rescan = std::stoi(std::string(a.substr(9))); if (a.starts_with("--rescan=")) g_rescan = std::stoi(std::string(a.substr(9)));