Skip to content

Optimistic controls - #4

Merged
konradk merged 5 commits into
konradk:mainfrom
s3ansh33p:optimistic-controls
Aug 17, 2026
Merged

Optimistic controls#4
konradk merged 5 commits into
konradk:mainfrom
s3ansh33p:optimistic-controls

Conversation

@s3ansh33p

Copy link
Copy Markdown
Contributor

Follows on from #3 to have 'optimistic controls' in that when I use an input / slider, previously it was go to the desired value, then jump back to the previous value, then jump to the desired value again.
See video for context:
https://github.com/user-attachments/assets/96551a93-7967-4942-8b18-749a875e1da8

@konradk

konradk commented Aug 16, 2026

Copy link
Copy Markdown
Owner

@s3ansh33p Thank you for this PR! Very nice feature and improvements. Few comments:

  1. I think the dragging action ignores "step" value, so you can choose 21.2, when step is .5 and current value is 21.0.

  2. Pending operations should prpbably have unique tags, so the matching pending values can be indetified and rolled back when a command fails.

  3. An empty favorite_colors array is currently treated as not set, but it is wrong. We should treat it as set but no favorite colors were chosen intentionally.

Could you prepare these fixes? I would love to merge your PR.

@s3ansh33p

Copy link
Copy Markdown
Contributor Author

@konradk thanks for the feedback, have made relevant changes

s3ansh33p and others added 5 commits August 17, 2026 23:31
Expand a colour-capable light row with a hue/saturation wheel, the
light's favourite colours, and a warmth slider for colour temperature.

Capability comes from supported_color_modes, never a live hs_color: a
colour light that is off reports none, which is exactly when the picker
is wanted. Favourites come from the entity registry the panel already
fetches, falling back to a port of the frontend's
computeDefaultFavoriteColors so the swatches match the app's, and are
normalized to hue/saturation or kelvin rather than carried as raw
registry objects. Sliders and the wheel commit on release, as brightness
does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A control cleared its drag value the moment the command went out, rebinding
to the stale entity value for a full round trip: the slider showed the new
value, snapped back, then jumped forward again when the state arrived.

Each control now pins what was picked in a PendingValue until Model.*Settled
matches it against the returned state, or a 6500ms expiry gives up. Every
tolerance covers the round trip while staying strictly inside one slider
step, so a state event still carrying the pre-command value can never count
as confirmation. Brightness travels as a 0-255 byte: 0.5 of a percentage
point absorbs the 0.39 that costs, within a step of 1. Colour temperature is
compared in mireds, where a 50K step is 12 mireds at 2000K but only 1.17 at
6500K, so the slack is 1 mired — exactly Home Assistant's integer
quantization, and still under the narrowest step. A light answers a hue in
its own colour space, and an hs-xy-hs round trip loses hue accuracy in
inverse proportion to saturation, so the hue tolerance is 500/saturation
degrees: five at full saturation, the whole circle near the white centre
where every angle is the same colour anyway.

Both colour channels can be pending at once, since a wheel drag holds a
colour without sending anything that would clear a pending warmth, so each
PendingValue records when it was picked and the swatch highlighting follows
the more recent one.

The low/high climate band commits only the end that was touched. Both ends
still go out together, because set_temperature rejects a partial range, and
both are still read from what is on screen so the pair stays consistent with
it. What changed is that the untouched end is no longer re-committed: an end
the thermostat clamped away gets re-sent by a nudge to the other one, which
is harmless, but its deadline is not pushed back, so it still expires on
schedule and hands the display back to the device.

Reconciliation runs in onEntityChanged rather than a readonly binding, which
would loop: clearing the pending value changes what the binding reads. The
signal fires because EntityRow rebuilds its `entity` property whenever
Service bumps stateRevision, not because a new entity object arrived —
EntityStore.upsertState mutates the map in place for an entity it already
knows.

The panel list is a Flickable and was stealing the grab whenever a drag
strayed off-axis, cancelling it mid-gesture. preventStealing fixes the wheel;
sliders need an overlay MouseArea since PanelSlider's own is private. The
trade-off is that a drag starting on a slider or the wheel no longer scrolls
the list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A drag read the pointer straight off the track, so a thermostat with a
half-degree step could be sent 21.2. Only climate opts in: volume and
warmth step by a UI nudge size, and quantizing a drag to that would leave
the track with a couple of dozen reachable positions. Brightness rounds
where it is sent instead, so the value held on screen is the one sent.

The nudge buttons ask for a direction rather than the nearest point, so
each lands on the step beside the target it started from. Rounding to
nearest moved them by half a step one way and a step and a half the
other, whenever the thermostat reported a target off its own grid.

Both ends of a low/high band pin stepBase to the range minimum: those
limits ride each other as either end is dragged, so the default base
would move the grid under them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dropping the empty array made a cleared list indistinguishable from a
light nobody had customised, so the computed defaults came back. A list
whose entries no longer validate — temperatures kept from before the
light stopped advertising color_temp — is the same choice, so it draws
what survives and nothing else. Only an absent list takes the defaults.

That leaves no room for a format we simply cannot read, so xy joins the
colour forms that parse. The wheel already drives an xy light, and its
saved favourites would now draw nothing at all rather than fall back to
the defaults that used to cover the gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tags were per-entity, so a failure could not be told from a newer call
and a rejected value sat out its full 6500ms hold. Tags now carry a
counter and commandFailed reports it, so only the value that call put on
screen comes back down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@konradk
konradk force-pushed the optimistic-controls branch from 04518cd to 86bb4d8 Compare August 17, 2026 21:35
@konradk
konradk merged commit 91288ba into konradk:main Aug 17, 2026
1 check passed
@konradk

konradk commented Aug 17, 2026

Copy link
Copy Markdown
Owner

There was a conflict with main because I merged another PR a few minutes earlier, so I rebased your branch and merged it.
@s3ansh33p thank you for your contribution!

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.

2 participants