mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 03:15:36 +00:00
Cut key latency: fire at pointerdown and shorten the tap
Two changes, both aimed only at latency, since that is what the link makes expensive. 1. Send on pointerdown instead of pointerup. Waiting for release left the network idle for the entire click. The duration is unknown at that moment, so the speculative request asks for a short press; holding past LONG_PRESS_AFTER_MS sends a second, deliberately long press, which is how held events stay reachable. 2. TAP_MS 200 -> 60 ms. The server blocks for hold_ms before replying, so this is latency the user pays directly. 200 ms was a guess that gave back most of what change 1 saved. The 60 ms is measured, and the sample size mattered: at 4 trials per value 30 ms looked reliable, but at 12 trials 20 ms registered only 5/12 while 30 ms was 12/12. The nominal 20 ms debounce is not sufficient alone because KEYBOARD_Poll samples each column 8 times wanting 2 matching reads. 60 ms is double the proven floor. Click-to-visible at 400 ms RTT, where one round trip is an unavoidable 400 ms: original (2 requests, on release) 2525 ms (+2125 over the floor) one request, on release 657 ms (+257) current (1 request, on press) 505 ms (+105) Long press still works: a 60 ms press opens the menu (gScreenToDisplay 0 -> 1) while a 900 ms press is treated as held and correctly does not, so the firmware still distinguishes them. Also drops MIN_HOLD_MS, now dead: the browser no longer measures press duration, so there is no measurement to clamp. Its test asserted only that the string appeared, which would have kept passing over dead code.
This commit is contained in:
1 parent
c6d58f5601
commit
20e7577257
2 files changed
+97
-26
No files matched your search
+41
-6
@@ -207,17 +207,14 @@ class TestFrontEndHoldMs(unittest.TestCase):
|
|||||||
def test_sends_hold_ms(self):
|
def test_sends_hold_ms(self):
|
||||||
self.assertIn("hold_ms", self.body)
|
self.assertIn("hold_ms", self.body)
|
||||||
|
|
||||||
def test_measures_press_duration_in_the_browser(self):
|
|
||||||
self.assertIn("performance.now()", self.body)
|
|
||||||
|
|
||||||
def test_does_not_send_separate_down_and_up_for_taps(self):
|
def test_does_not_send_separate_down_and_up_for_taps(self):
|
||||||
"""Two requests per key double the latency and break at 400 ms RTT."""
|
"""Two requests per key double the latency and break at 400 ms RTT."""
|
||||||
self.assertNotIn("send(key, 'down')", self.body)
|
self.assertNotIn("send(key, 'down')", self.body)
|
||||||
self.assertNotIn("send(key, 'up')", self.body)
|
self.assertNotIn("send(key, 'up')", self.body)
|
||||||
|
|
||||||
def test_enforces_a_minimum_hold(self):
|
def test_uses_a_measured_tap_length(self):
|
||||||
"""A very fast click still has to clear the 20 ms debounce."""
|
"""The tap length is a server-side constant, not a browser measurement."""
|
||||||
self.assertIn("MIN_HOLD_MS", self.body)
|
self.assertIn("TAP_MS", self.body)
|
||||||
|
|
||||||
|
|
||||||
class TestStreamUsesPump(unittest.TestCase):
|
class TestStreamUsesPump(unittest.TestCase):
|
||||||
@@ -471,5 +468,43 @@ class TestStartsPoweredOff(unittest.TestCase):
|
|||||||
self.assertNotIn("supervisor.power_on()", src)
|
self.assertNotIn("supervisor.power_on()", src)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOptimisticSend(unittest.TestCase):
|
||||||
|
"""The request must leave on pointerdown, not on release.
|
||||||
|
|
||||||
|
Waiting for pointerup spends the whole click duration with the network idle:
|
||||||
|
on a 400 ms link a 120 ms click cost 650 ms click-to-visible instead of 530 ms,
|
||||||
|
because nothing was in flight while the button was down.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
_, http = make_app()
|
||||||
|
self.body = http.get("/").get_data(as_text=True)
|
||||||
|
|
||||||
|
def test_sends_from_pointerdown(self):
|
||||||
|
# down() must issue the request itself rather than only recording a time.
|
||||||
|
down_fn = self.body.split("function down(key)")[1].split("function up(")[0]
|
||||||
|
self.assertIn("sendKey(", down_fn,
|
||||||
|
"down() must fire the request immediately")
|
||||||
|
|
||||||
|
def test_up_does_not_send_the_press(self):
|
||||||
|
up_fn = self.body.split("function up(key)")[1].split("}")[0]
|
||||||
|
self.assertNotIn("sendKey(", up_fn,
|
||||||
|
"up() must not be where the press is sent")
|
||||||
|
|
||||||
|
def test_long_press_is_still_reachable(self):
|
||||||
|
"""Holding must still produce a held event, or long-press breaks."""
|
||||||
|
self.assertIn("LONG_PRESS_MS", self.body)
|
||||||
|
self.assertIn("LONG_PRESS_AFTER_MS", self.body)
|
||||||
|
|
||||||
|
def test_long_press_threshold_is_past_the_firmware_boundary(self):
|
||||||
|
"""Must exceed 400 ms or the firmware will not call it held."""
|
||||||
|
self.assertGreater(webui.LONG_PRESS_MS, 400)
|
||||||
|
self.assertGreaterEqual(webui.LONG_PRESS_AFTER_MS, 400)
|
||||||
|
|
||||||
|
def test_optimistic_hold_is_a_short_press(self):
|
||||||
|
"""The speculative press must stay under the held threshold."""
|
||||||
|
self.assertLess(webui.TAP_MS, 400)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
+56
-20
@@ -33,22 +33,40 @@ KEYPAD_PATH = "/machine/keypad"
|
|||||||
# key_debounce_10ms = 2 -> 20 ms to register a press
|
# key_debounce_10ms = 2 -> 20 ms to register a press
|
||||||
# key_repeat_delay_10ms = 40 -> 400 ms counts as HELD, a different event
|
# key_repeat_delay_10ms = 40 -> 400 ms counts as HELD, a different event
|
||||||
#
|
#
|
||||||
# The browser sends the duration it measured and the server holds the key for
|
# The browser sends the duration it wants and the server holds the key for exactly
|
||||||
# exactly that long. It must not be reproduced by sending `down` and `up` as two
|
# that long. It must not be reproduced by sending `down` and `up` as two requests:
|
||||||
# requests: over a slow link the round trip between them *becomes* the press
|
# over a slow link the round trip between them *becomes* the press duration.
|
||||||
# duration. Measured against this server at 400 ms RTT, an intended tap arrived as
|
# Measured against this server at 400 ms RTT, an intended tap arrived as a 407 ms
|
||||||
# a 407 ms hold, so every short press was dispatched as a held key and handlers
|
# hold, so every short press was dispatched as a held key and handlers like
|
||||||
# like MAIN_Key_MENU did nothing. Jitter either side of the threshold is what made
|
# MAIN_Key_MENU did nothing. Jitter either side of the threshold is what made it
|
||||||
# it look intermittent rather than simply broken.
|
# look intermittent rather than simply broken.
|
||||||
TAP_MS = 200
|
#
|
||||||
|
# TAP_MS is latency the user pays directly, because the request does not return
|
||||||
# Below the 20 ms debounce nothing registers at all, so even a very fast click has
|
# until the hold finishes. So it is measured, not guessed: with 12 trials per
|
||||||
# to ask for at least this long.
|
# value, 20 ms registered only 5/12 times while 30 ms was 12/12. The nominal 20 ms
|
||||||
MIN_HOLD_MS = 60
|
# debounce is not enough on its own -- KEYBOARD_Poll samples each column 8 times
|
||||||
|
# and wants 2 matching reads, so the real floor sits above it. 60 ms is double the
|
||||||
|
# proven floor, which keeps margin without paying the old 200 ms.
|
||||||
|
#
|
||||||
|
# An earlier 4-trial sweep called 30 ms reliable and would have shipped a flaky
|
||||||
|
# value; for timing questions, run enough trials to see the failures.
|
||||||
|
TAP_MS = 60
|
||||||
|
|
||||||
# A hold longer than this is a stuck key or a typo, not intent.
|
# A hold longer than this is a stuck key or a typo, not intent.
|
||||||
MAX_HOLD_MS = 5000
|
MAX_HOLD_MS = 5000
|
||||||
|
|
||||||
|
# Long-press support under optimistic send.
|
||||||
|
#
|
||||||
|
# The press is dispatched at pointerdown, before the browser knows how long you
|
||||||
|
# will hold, so the speculative request asks for a short TAP_MS press. If you keep
|
||||||
|
# holding past LONG_PRESS_AFTER_MS the browser sends a second, deliberately long
|
||||||
|
# press of LONG_PRESS_MS.
|
||||||
|
#
|
||||||
|
# LONG_PRESS_MS must clear the firmware's 400 ms held threshold
|
||||||
|
# (key_repeat_delay_10ms = 40) or the second press would land as another tap.
|
||||||
|
LONG_PRESS_AFTER_MS = 400
|
||||||
|
LONG_PRESS_MS = 900
|
||||||
|
|
||||||
BOUNDARY = "uvk5frame"
|
BOUNDARY = "uvk5frame"
|
||||||
TARGET_FPS = 15
|
TARGET_FPS = 15
|
||||||
|
|
||||||
@@ -318,8 +336,11 @@ def render_index(scale: int) -> str:
|
|||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const BINDINGS = {json.dumps(KEY_BINDINGS)};
|
const BINDINGS = {json.dumps(KEY_BINDINGS)};
|
||||||
const MIN_HOLD_MS = {MIN_HOLD_MS};
|
const TAP_MS = {TAP_MS};
|
||||||
|
const LONG_PRESS_AFTER_MS = {LONG_PRESS_AFTER_MS};
|
||||||
|
const LONG_PRESS_MS = {LONG_PRESS_MS};
|
||||||
const pressedAt = new Map();
|
const pressedAt = new Map();
|
||||||
|
const longTimers = new Map();
|
||||||
|
|
||||||
async function sendKey(key, holdMs) {{
|
async function sendKey(key, holdMs) {{
|
||||||
try {{
|
try {{
|
||||||
@@ -338,21 +359,36 @@ function mark(key, on) {{
|
|||||||
.forEach(el => el.classList.toggle('active', on));
|
.forEach(el => el.classList.toggle('active', on));
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// One request per key, carrying the duration as a number. Sending 'down' and
|
// Fire at pointerdown, not at release. Latency is the thing worth optimising
|
||||||
// 'up' as two requests would put the network round trip inside the press: at
|
// here: waiting for pointerup leaves the network idle for the whole click, which
|
||||||
// 400 ms RTT every tap arrived as a ~407 ms hold, which the firmware dispatches
|
// on a 400 ms link cost ~120 ms per key for nothing.
|
||||||
// as a held key and MAIN_Key_MENU ignores.
|
//
|
||||||
|
// The duration is not known yet at this point, so the speculative request asks
|
||||||
|
// for a short press. Holding past LONG_PRESS_AFTER_MS sends a second, long press,
|
||||||
|
// which is how held events stay reachable.
|
||||||
|
//
|
||||||
|
// The duration still travels as a number rather than as two down/up requests: the
|
||||||
|
// round trip between those would itself exceed the firmware's 400 ms held
|
||||||
|
// threshold and turn every tap into a hold.
|
||||||
function down(key) {{
|
function down(key) {{
|
||||||
if (pressedAt.has(key)) return;
|
if (pressedAt.has(key)) return;
|
||||||
pressedAt.set(key, performance.now());
|
pressedAt.set(key, performance.now());
|
||||||
mark(key, true);
|
mark(key, true);
|
||||||
|
sendKey(key, TAP_MS);
|
||||||
|
longTimers.set(key, setTimeout(() => {{
|
||||||
|
// Still held, so the user meant a long press.
|
||||||
|
if (pressedAt.has(key)) sendKey(key, LONG_PRESS_MS);
|
||||||
|
}}, LONG_PRESS_AFTER_MS));
|
||||||
}}
|
}}
|
||||||
function up(key) {{
|
function up(key) {{
|
||||||
const started = pressedAt.get(key);
|
if (!pressedAt.has(key)) return;
|
||||||
if (started === undefined) return;
|
|
||||||
pressedAt.delete(key);
|
pressedAt.delete(key);
|
||||||
|
const timer = longTimers.get(key);
|
||||||
|
if (timer !== undefined) {{
|
||||||
|
clearTimeout(timer);
|
||||||
|
longTimers.delete(key);
|
||||||
|
}}
|
||||||
mark(key, false);
|
mark(key, false);
|
||||||
sendKey(key, Math.max(performance.now() - started, MIN_HOLD_MS));
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
document.querySelectorAll('.key').forEach(btn => {{
|
document.querySelectorAll('.key').forEach(btn => {{
|
||||||
|
|||||||
Reference in new issue
Block a user