From 8eb2bef50b9c9f8c3388918c4a2ed1a40eba47e7 Mon Sep 17 00:00:00 2001 From: Falko Schindler Date: Mon, 26 Oct 2026 16:11:31 +0100 Subject: [PATCH] Refresh config values at 1 Hz, only the axis state at 10 Hz Every axis panel refreshed all its mirrors on the USB worker every 0.1 s: the axis state, but also every bound config value (gains, limits, bandwidths, input-mode parameters, trap_traj limits), about 22 reads per axis and tick. Every write and command queues behind those reads, and each open tab adds its own. With two axes that was ~480 reads per second. Only the axis state must feel live. Config values change only when someone edits them, and an edit made here shows at once, so their mirrors now refresh at 1 Hz: ~105 reads per second. An edit from another client or odrivetool shows up within a second instead of 0.1 s. Closes #17 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FUjP7mHeiHytZSgfnFC3W2 --- src/controls.py | 15 ++++++++++----- tests/test_main.py | 2 +- tests/test_render.py | 12 ++++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/controls.py b/src/controls.py index 9afaef5..c0d185e 100644 --- a/src/controls.py +++ b/src/controls.py @@ -225,11 +225,14 @@ def _create_axis_column(index: int, axis: Any) -> None: axis_state, ctr_cfg, mtr_cfg, enc_cfg, trp_cfg = mirrors cc = axis.motor.current_control - async def sync_mirrors() -> None: - for mirror in mirrors: - await mirror.sync() + async def sync_state() -> None: + await axis_state.sync() show_state(axis_state.current_state) + async def sync_config() -> None: + for mirror in mirrors[1:]: + await mirror.sync() + async def update_power() -> None: if (watts := await _on_usb(axis, lambda: cc.Iq_measured * cc.v_current_control_integral_q)) is not None: power.set_text(f'{watts:.1f} W') @@ -239,9 +242,11 @@ async def update_error() -> None: button.set_enabled(error != 0) # every property read is a USB round-trip, so keep the 10 Hz path to the two values - # that must feel live; the error flag only enables a button and can follow at 1 Hz. + # that must feel live. The error flag only enables a button, and config values only + # change when someone edits them (an edit here shows at once), so they follow at 1 Hz. ui.timer(0.1, update_power) - ui.timer(0.1, sync_mirrors) + ui.timer(0.1, sync_state) + ui.timer(1.0, sync_config) ui.timer(1.0, update_error) def request_state(state: int) -> None: diff --git a/tests/test_main.py b/tests/test_main.py index 65bce0a..f289b7e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -174,7 +174,7 @@ async def test_gain_edits_reach_the_device_and_device_changes_reach_the_ui(user: assert field.value == 42.0 config.pos_gain = 7.0 # changed behind the UI's back (odrivetool, another client) - await asyncio.sleep(1.2) + await asyncio.sleep(2.0) # config values are refreshed at 1 Hz assert field.value == 7.0 diff --git a/tests/test_render.py b/tests/test_render.py index bfab7e7..be41c96 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -81,6 +81,18 @@ async def test_live_values_are_not_read_on_the_event_loop(user: User) -> None: assert READS['odrive-usb_0'] > before['odrive-usb_0'] # the live values are still read, on the worker +async def test_config_values_are_not_polled_at_10_hz(user: User) -> None: + """Every worker read delays the writes and commands queued behind it. Only the axis + state and the power label must feel live; the ~22 bound config values per axis are + refreshed at 1 Hz (two calibrated axes: ~105 reads per second instead of ~480).""" + await user.open('/') + await user.should_see('Axis 1') + await asyncio.sleep(0.5) # past the reads of the panel build + before = READS['odrive-usb_0'] + await asyncio.sleep(2.0) + assert (READS['odrive-usb_0'] - before) / 2.0 < 150 + + async def test_a_refresh_that_read_before_an_edit_does_not_undo_it(user: User, mock_odrive) -> None: """A refresh reads on the USB worker while the user may edit the same value: the read sees the old device value, and applying it would snap the widget back -- whose change