Skip to content

fix: tolerant arrival detection in move_to_target#489

Merged
newAM merged 2 commits into
newAM:mainfrom
pedropombeiro:pr/tolerant-arrival-detection
May 26, 2026
Merged

fix: tolerant arrival detection in move_to_target#489
newAM merged 2 commits into
newAM:mainfrom
pedropombeiro:pr/tolerant-arrival-detection

Conversation

@pedropombeiro
Copy link
Copy Markdown
Contributor

Summary

Make move_to_target resilient to transient speed == 0 readings during travel by:

  • requiring multiple consecutive speed == 0 readings before treating the desk as stopped
  • checking the height is within a small tolerance of the target on stop, rather than just relying on speed
  • allowing a bounded number of stall recoveries by re-sending the reference input
  • reducing the reference input write interval from 200ms to 100ms

Why

The current loop exits as soon as get_speed() returns 0:

while self._moving:
    await self._client.write_gatt_char(_UUID_REFERENCE_INPUT, data)
    await asyncio.sleep(0.2)
    speed = await self.get_speed()
    if speed == 0:
        break

That works most of the time over a direct BLE connection, but in practice the desk controller can briefly read speed == 0 mid-travel — for example when it momentarily decelerates between reference input writes, or when the speed read races a write. Exiting on the first zero stops movement early and leaves the desk short of its target.

The effect is much worse over Bluetooth proxies (e.g. ESPHome Bluetooth proxies used by the Home Assistant idasen_desk integration), where ATT round-trips can spike to hundreds of milliseconds, transient speed == 0 samples become common, and the 200ms write interval can starve the controller of reference input and cause it to physically stop and restart several times per move.

Changes

In move_to_target:

  • Use get_height_and_speed() instead of get_speed() so both values are available
  • Require _MOVE_CONSECUTIVE_ZERO_SPEED = 2 consecutive speed == 0 readings before considering stopping
  • When that threshold is reached, only break if the height is within _MOVE_HEIGHT_TOLERANCE = 0.005 m of the target; otherwise increment a stall counter and continue
  • After _MOVE_MAX_STALL_RETRIES = 3 stalls, give up to preserve the existing "abort when the desk does not move" behavior
  • Reduce the reference input write interval to _MOVE_LOOP_INTERVAL = 0.1 s
  • Treat "already at target" with the same tolerance to avoid pointless no-op moves

All new constants are module-level so they are easy to find and tune.

Tests

  • All existing tests continue to pass (including test_move_to_target, test_move_abort_when_no_movement, test_move_stop)
  • New test_move_to_target_already_at_target_with_tolerance covers the tolerance path on the initial height check
  • New test_move_to_target_tolerates_transient_zero_speed exercises a transient speed == 0 mid-travel followed by recovery
  • New test_move_to_target_stalled_away_from_target_aborts covers the stall-retry guard ensuring the loop exits when the desk never reaches the target

Local CI checks:

$ uv run ruff check
All checks passed!
$ uv run ruff format --check
7 files already formatted
$ uv run mypy idasen tests
Success: no issues found in 5 source files
$ uv run pytest -vvv --cov=idasen --doctest-modules
91 passed

Notes

  • No public API change. Callers that use move_to_target see the same interface and the same is_moving semantics.
  • The 200ms historical interval was occasionally enough time for the DPG1C controller to decelerate between writes; 100ms is closer to the cadence used by other Linak-compatible projects and removed the observable pauses in my own testing.
  • Reported behavior for the Home Assistant idasen_desk integration matches this pattern: see home-assistant/core#155912 and abmantis/idasen-ha#38 for downstream context.

A single speed == 0 sample during travel currently exits the move
loop. This can happen mid-travel when the controller momentarily
decelerates between reference input writes, or when the speed reading
races a write. Over BLE proxies the variance is even larger because
ATT round-trips can spike to hundreds of milliseconds, so transient
zero-speed readings are common.

Tighten arrival detection to require multiple consecutive
``speed == 0`` readings together with the height being within a small
tolerance of the target. If the desk stalls away from the target,
allow a bounded number of stall recoveries by re-sending the reference
input rather than exiting immediately. Reduce the reference input
write interval from 200ms to 100ms so the controller is less likely
to decelerate between writes.

The existing test_move_abort_when_no_movement is preserved by the
stall-retry guard: after _MOVE_MAX_STALL_RETRIES runs of
consecutive zero-speed readings without progress, the loop exits.
@coveralls
Copy link
Copy Markdown

coveralls commented May 19, 2026

Coverage Status

coverage: 89.082% (+0.4%) from 88.66% — pedropombeiro:pr/tolerant-arrival-detection into newAM:main

@newAM
Copy link
Copy Markdown
Owner

newAM commented May 19, 2026

Thanks for opening the PR! This may take me a week to review, I'm traveling right now. I'll review when I get home, or if I can find a stable WiFi connection.

Copy link
Copy Markdown
Owner

@newAM newAM left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me! One minor question, but otherwise good to go and I'll merge this after an answer.

Comment thread idasen/__init__.py
Comment on lines +403 to +407
# Stalled away from the target. Allow a bounded
# number of stall recoveries before giving up; this
# lets the loop ride out transient pauses while still
# exiting if the desk is physically stuck.
stall_retries += 1
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't reset after a stall condition is cleared, it's one retry counter for the entire movement. Should this get reset to zero when a stall condition is cleared?

I'll trust your judgement either way. I haven't been able to reproduce this stall at home, I need to get some ESPHome relays to reproduce for the future.

Copy link
Copy Markdown
Contributor Author

@pedropombeiro pedropombeiro May 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch @newAM - you're right that this should reset. I've seen the DPG1C decelerate to a brief stop several times during a single long move (most visibly through ESPHome Bluetooth proxies where ATT latency varies), so a lifetime cap of 3 would prematurely abort otherwise-healthy moves.

Pushed a809bf7 that resets stall_retries = 0 whenever a non-zero speed is observed (forward progress clears the stall budget), and added test_move_to_target_recovers_from_multiple_stalls that exercises multiple stall-recover-stall cycles within one move.

A long move that pauses multiple times along the way (e.g. the DPG1C
controller briefly decelerating between reference input writes over a
Bluetooth proxy) would otherwise exhaust the stall budget and abort
before reaching the target.

Reset `stall_retries` whenever a non-zero speed is observed, so the
budget only tracks consecutive stall attempts without intervening
movement.  This matches the intent: bail out when the desk is
physically stuck, not when it has paused several times in an
otherwise-healthy move.
@newAM newAM merged commit 5541f58 into newAM:main May 26, 2026
20 checks passed
@newAM
Copy link
Copy Markdown
Owner

newAM commented May 26, 2026

Thanks for the fix! This is included in the v0.13.0 release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants