Skip to content

feat: support the MX Creative Dialpad and the MX Creative Keypad's LCD keys - #2529

Open
niscz wants to merge 42 commits into
AprilNEA:masterfrom
niscz:feat/mx-creative-console
Open

niscz wants to merge 42 commits into
AprilNEA:masterfrom
niscz:feat/mx-creative-console

Conversation

@niscz

@niscz niscz commented Nov 12, 2026 •

Copy link
Copy Markdown

Summary

Adds full MX Creative Console support. The Dialpad is now recognised (it was identified as a headset) and its dial, roller and keys can be bound. The Keypad's nine LCD keys get custom faces, live values and adjustable brightness. Everything is configurable from the Keys tab.

Builds on #2322 (HID++ requests are held while a key image is in flight). Its two commits are included here and drop out once it merges.

About the file count: 1,830 of the 1,993 changed files are the Lucide 1.43.0 icon SVGs (ISC) in crates/openlogi-ui/assets/lucide/, the same set gpui-kit ships. They are data, not code; everything else is about 160 files.

Changes

  • hidpp: new 0x4610 MultiRoller feature (capabilities, resolution, reporting mode, diverted rotation events).
  • core / device-registry: 046d:bc00 modelled as DeviceKind::Dialpad with its roller IDs and key positions; DialLeft/DialRight/RollerUp/RollerDown rotation names. New per-device config key_display_brightness, key_faces and live_keys (schema v12). Live keys are bound in button_bindings_for, the one binding owner.
  • agent / agent-core: diverts the dial and roller, accumulates turns and dispatches the bound action or scroll, and restores native reporting on teardown. Writes keypad brightness via 0x8040. Renders styled key faces (label, colours/gradient, picture, Lucide icon or emoji) as single-packet JPEGs. Shows live values (command output, volume, battery, clock); an active live key takes over the keys below the grid. Picture size, tile cache and live-key loops are bounded.
  • inject: shell_output runs through the same shell owner as RunShellCommand, in its own process group / kill-on-close job, so a timeout ends the whole process tree.
  • ui: Lucide 1.43.0 catalog behind an optional lucide feature (agent and desktop only; the overlay does not get it).
  • desktop: Dialpad configuration on its product render, with a dial & roller speed slider. Keypad key panel with Action, Key appearance (face, Lucide/emoji symbol picker) and Live tabs, a key-brightness slider, custom shortcut recording for every key, and keypad keys selectable on the configuration render. A key panel's action list shrinks and scrolls inside its card when the panel is short.
  • ipc: appended to the unreleased v46: DeviceKind::Dialpad, HidppOperation::WriteKeyBrightness, Action::ActivateLiveKey (index 0x41, after the Easy-Switch channels from feat: software-initiated easy-switch host switching #2068).
  • docs / i18n / fixtures: reverse-engineering notes for both devices, the new settings documented in CONFIGURATION.md, German strings, and a Dialpad fixture with a strict replay test.

Screenshots

MX Creative Keypad: styled keys (solid colour, gradient, Lucide icons, emoji, live keys) and the brightness slider

1-keypad-keys-tab

Action tab with custom shortcut recording

2-keypad-action-tab

Key appearance: Lucide icon picker · emoji picker

3-keypad-appearance-lucide-icon 4-keypad-appearance-emoji

Live key

5-keypad-live-key

MX Creative Dialpad: dial and roller bindings, speed slider · selected dial half

6-dialpad-keys-tab 7-dialpad-dial-selected

Testing

  • Tested on hardware with the MX Creative Dialpad and the MX Creative Keypad.
  • cargo check --all-targets, cargo clippy --all-targets -- -D warnings and cargo test on core, ipc, agent-core and desktop; cargo fmt --check. Run on Windows after merging upstream master.
  • Wire-format tests cover every new variant; a GPUI test keeps the key panel's action list inside its card in a short window.
  • cargo test -p openlogi-desktop: 890 pass.
  • Screenshots: desktop app against openlogi-agent-mock --fixture with the recorded Keypad and Dialpad fixtures.

Fixes #2240

AprilNEA and others added 30 commits November 3, 2026 18:18
The MX Creative Keypad drops a HID++ request that is in flight when a key
image report arrives. On Windows a capture session's root getFeature went
out 0.1 ms before the startup image burst, never got its reply, and timed
out after 5 s. In one trace the keypad also restarted 4.4 s later. The same
overlap succeeded at another replug, so it is a firmware race.

The HID++ channel gains a request pause. It is taken once the requests in
flight are answered or abandoned. While it is held, or while a pause waits
for it, new requests park as they do behind a busy header. Each key image
write holds a pause from its first packet until the device acknowledges it,
so images and HID++ requests no longer overlap on the wire.

Refs AprilNEA#2281
…ives up

The agent runs each key image write inside a 5 s deadline. If the pause
took most of that time, the deadline could cancel the write after its
packets went out. Dropping the pause then let parked HID++ requests reach
the keypad before it acknowledged the image.

The packets and the ack wait now run in a spawned task that owns the pause
and the ack listener. The caller still waits for the pause itself, so a
caller that gives up before any packet is written writes nothing.

Refs AprilNEA#2281
# Conflicts:
#	crates/openlogi-hidpp/src/channel/pending.rs
Identify 046d:bc00 as a dialpad instead of a headset: a registry entry with
its 0x4610 roller ids and key positions, DeviceKind::Dialpad in the keyboard
family, DeviceRoute::dialpad() as the one owner of the lookup (guarded by
core-dialpad-route-owner), and the DialLeft/DialRight/RollerUp/RollerDown
rotation names with the Rotary pairing. A direct Bluetooth LE device is named
after itself on Windows. Wire additions join the unreleased v46.
The hardware ledger for the keypad and dialpad: identities, the dialpad's 0x4610 MultiRoller layout, its 0x1b04 keys, the keypad's Windows vendor collections, 0x8040 brightness and LCD face findings, plus the mx_dialpad_probe used to capture them.
Capabilities, per-roller resolution, reporting mode and the diverted rotation event of the MX Creative Dialpad's dial and roller. Function names come from Options+ descriptors; byte layouts are hardware-observed and marked so.
A keyboard session on a known dialpad resolves its 0x4610 rollers, diverts
exactly the rotary controls with a stored direction binding, reports their
turns as CapturedInput::Rotation, re-diverts them after a wake and hands
them back to native reporting on teardown through the single
PendingCaptureRestore constructor. The keyboard watcher accumulates turns
per route (24 steps per revolution, idle decay) and fires the bound action,
or posts scroll directly; dial speed reuses the thumb-wheel sensitivity.

A strict replay test arms a dialpad, receives a dial turn, ignores the
undiverted roller and checks the native hand-back.
A per-device key_display_brightness (1-100 %) is scaled onto the range the
keypad's 0x8040 BrightnessControl reports, never its bottom value, and
written on reconnect and when a reload changes it. `openlogi api
key-brightness --save` stages it through DeviceEdit like the other agent
settings. HidppOperation::WriteKeyBrightness joins the unreleased v46, and
the persisted field bumps the config schema to v12.
Lucide 1.43.0 (ISC, the set gpui-kit 0.8.1 ships) behind an optional lucide feature, so the agent can draw a chosen icon on a keypad's LCD key without linking GPUI and the overlay never carries the catalog.
…'s LCD keys

A per-device key_faces map styles each grid slot of each binding page: a
one- or two-line label, background and text colours or a gradient, a
picture, a Lucide icon or an emoji, and whether the action's icon stays.
The agent renders the styled face with the system fonts and lowers the
JPEG quality until a tile fits one image packet
(KEY_IMAGE_SINGLE_PACKET_MAX), the only hardware-verified write. A key
without a style keeps its action icon. Shrinking the page count drops the
faces of removed pages with their bindings; the field joins schema v12.
shell_output runs a command in the same platform shell a bound RunShellCommand uses, so cmd.exe quoting keeps one owner, and answers at most a byte budget of its standard output within a timeout, killing a shell that outlives it.
A per-device live_keys map turns an LCD key into one that shows a value - a
command's first line or JSON label and bar, the output volume, a device's
battery or the clock - read by a one-second watcher off the async threads,
each key at its own interval. Pressing the key activates it and runs its
press action; while it is active the keys under the grid run its decrement
and increment (the volume keys for a volume source) instead of turning the
page, until a second press or its timeout. Action::ActivateLiveKey joins
the unreleased v46 and live_keys schema v12; commands run through
openlogi_inject::shell_output.
The Keys tab shows the dialpad: on its measured product render the
picture itself is the selector, else a drawing from its registry model
stands in. The dial binds a counter-clockwise and a clockwise half, the
roller an up and a down half, and the four keys are named by their
place. Its controls bind per app profile like the keypad's, and a
'Dial & roller speed' slider sets the device's thumb-wheel sensitivity,
which the agent applies to both rotary controls.

The key being configured now stands apart from the hovered one on every
surface: keyboard callouts, keypad keycaps and dialpad parts share one
selected look, and the selected key on a device picture carries a
heavier ring with a halo.
The keypad's setting bar gains a 'Key brightness' slider (1-100 %) beside its page bar. Until the user moves it the slider rests on the keypad's factory level and nothing is saved; a release saves the level per device and the agent writes it on reload.
A keypad grid key's panel gains an Appearance tab beside its action: a two-line label, the background as one colour or a gradient, the text and icon colours, a picture (PNG or JPEG) and whether the action icon shows. Every chooser offers one shared palette and a custom hex field. The face belongs to the key on its page and device, is saved through AppState, and the agent repaints the key; the Keys tab previews it on the keypad render.
The Appearance tab gains a Symbol section: keep the action icon, search the Lucide catalog the shared UI crate embeds, or choose one of 379 emojis in eleven themed groups (or type one). A chosen symbol replaces the action icon on the key and in the Keys tab preview. The app's asset source resolves lucide/<name>.svg through openlogi_ui::lucide, which the desktop now enables.
The key panel's Action tab opens on a Custom shortcut section for every key the Keys tab shows - keypad and dialpad keys, the dial's and the roller's directions, and a keyboard's keys. A chord is recorded from the keyboard or typed, shown as key caps, and fired tapped, held, or held at its modifiers while its key is tapped; switching the mode rebinds the bound chord. ShortcutMode and ShortcutError move into binding_editor, so the mouse inspector shares them and gains the third mode too.
A keypad grid key's panel gains a Live tab: make the key show the clock, the volume, a device's battery or a shell command's output, refreshed every 1, 5, 30 or 60 seconds, and set the commands a press and the two keys under the grid run while it is active. The live key is saved per device through AppState and the agent paints and runs it.
The nine LCD keys of the MX Creative Keypad were still named in English in the German catalog; they are now 'LCD-Taste (oben links)' and so on.
The dialpad's rotary bindings and speed, and the keypad's key_display_brightness, key_faces and live_keys, with schema v12 in the version history.
The dialpad registry's 0x0053/0x0056 keys are plain keys that share the Back/Forward CIDs, so the CID family guard ignores that file as it does the registry's own record. The configured key's resting fill gets its own theme token instead of reusing the hover token the press-feedback guard reserves, and two doc links that the new ButtonId import made redundant are shortened.
shell_output killed only the shell on a timeout; a pipeline stage or a background child kept running and held the output pipe, so each refresh of a hanging live-key command leaked a process tree and a reader thread. The command now runs in its own process group (Unix) or a kill-on-close job object (Windows), ended when the answer is in or the timeout passes.
The agent added a live key's activation and the paging keys only on its own dispatch path, so the capture gate and the settings app, which read button_bindings_for, never saw them: a keypad with nothing but a live key got no capture session, and the paging-key policy lived in two places. button_bindings_for now binds a live key's grid slot and seeds the paging keys for a device with live keys; entries under non-grid buttons are ignored. The settings app names the activation instead of panicking on it. Keypad brightness changes skip a disabled device like reconnect writes do.
A live key's actions can no longer turn the page or activate a live key, which looped forever; the rendered-tile cache is capped; a key picture is read only from a regular local file within size, dimension and allocation limits (never a network or device path); labels and command values are cut before text layout. Also restores a CLI doc comment the key-brightness helper had displaced.
The asset depot serves config_core.png (1057x1227) as the keypad's buttons image, but only front_core.png (935x1105) was measured, so the Keys tab fell back to drawn keycaps beside the picture and its keys could not be picked on it. Each measured render now carries its own key centres and outlines (KeypadRender); the photo pane outlines its keys against the render it shows. A re-staged render of any other size still falls back to the keycap map.
Two picture-bound assertions use expect/expect_err, and public docs no longer link to private helpers.
Bring in the Easy-Switch channel actions (AprilNEA#2068). Both sides appended
Action variants after HoldModifier in protocol v46; upstream's
EasySwitchChannel1-3 keep indices 0x3e-0x40 and ActivateLiveKey moves
to 0x41.
@niscz
niscz marked this pull request as ready for review November 13, 2026 19:46
@greptile-apps

greptile-apps Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 0/5 Tier: apex

[Medium impact] The changes affect device controls and LCD-key workflows for supported Logitech Creative Console devices.

Do not merge yet: image shutdown can release its request pause too early, and several other blocking defects remain.

Findings

  1. P0 Shutdown releases image pause early ▶
  2. P0 Skipped writes return success ▶
  3. P1 Pictures freeze the settings window ▶
  4. P1 Page removal skips the warning ▶
  5. P1 Failed jobs leave commands running ▶
  6. P1 Queued turns lose their pauses ▶
  7. P1 Unused settings block older builds ▶
  8. P1 Picture reads block device actions ▶
  9. P1 Battery comes from sleeping route ▶
  10. P1 Stalled-write test can hang ▶
  11. P2 Live mode shows stale actions ▶
  12. P2 Watcher starts outside required schedule ▶
  13. P2 Read phases use independent fields ▶
  14. P2 Offline brightness changes disappear ▶

Summary

Adds MX Creative Dialpad bindings and configurable Keypad LCD faces, live values and brightness.

  • The MX Creative Dialpad’s keys, dial, and roller can run their own bindings.
  • The Keypad’s LCD keys can show custom faces and use saved brightness.
  • Keypad LCD keys can show values that refresh and respond to presses.
  • Key image writes hold off reply-waiting HID++ requests until the image is done.

Diagram

sequenceDiagram
    participant UI as Keys tab
    participant Config as Saved config
    participant Agent as Agent
    participant Display as Image worker
    participant Pad as Creative Console

    UI->>Config: Save bindings, faces or live keys
    Config->>Agent: Reload
    Agent->>Agent: Build capture and display settings
    Agent->>Display: Publish key faces
    Display->>Pad: Pause HID++ requests and send JPEG
    Pad-->>Display: Acknowledge image
    Display->>Pad: Allow HID++ requests again
    Pad-->>Agent: Key press or rotary turn
    Agent->>Agent: Run bound action or adjust live key
Loading

Reviews (5) · Last reviewed commit: "fix(ipc): move this branch's wire change..." · Reviewed by Greptile

Comment thread crates/openlogi-agent/src/live_keys.rs Resolved
niscz added 10 commits November 13, 2026 21:16
A keyboard capture session installed its report listener only after
arming, so a dial turned while its diversion's reply was on the way, or
while the second roller was being diverted, reached a channel with nobody
listening and was lost. The session now installs its listeners before the
first diversion write (`capture::arm_listening`, which also owns the
rollback on a failed arm) and runs with them (`run_listening`).

A roller's reporting mode was written through the timeout-cancellable
call: a diversion whose native write stalled past the response deadline
let teardown send the hand-back first, and the late diversion then left
the roller captured. `MultiRollerFeature::set_mode` now finishes its
write on the wire before returning, as the G-key row's ownership writes
do.

The dialpad replay test now turns the dial before the diversion reply and
is bounded by a deadline that names the missing rotation;
`roller_restore_waits_for_stalled_take` holds the diversion's native
write past the HID++ deadline. Counter-proofs: without the listener move
the turn is never reported; with `set_mode` back on `call` the session
rolls back while the diversion is still on the wire; without rotation
forwarding the replay test fails instead of hanging.
Every brightness save started its own worker with no write ticket and no
transport turn, so two quick saves could land in reverse and leave the
keypad at the older level while the settings showed the newer one. The
writes now take a ticket from a per-keypad `WriteOrder` when the change
is accepted and run through `within_turn`: setting a level is
idempotent, so the newest request is the one that lands.

Writes, and the app's brightness slider, were gated on the keypad's
identity rather than on a measured feature. `Capabilities` gains
`key_brightness`, set from HID++ `0x8040` in the feature table, and the
agent writes a saved level (`configured_key_brightness`,
`changed_key_brightness`, as for press force) and the app offers the
slider only on a device that reported it. The field is appended; the
wire golden is regenerated (protocol v47, see the ipc commit).

Tests: `key_brightness_latest_save_wins` holds the first worker while a
newer save arrives (without the ticket the keypad ends at the older
level); `key_brightness_requires_measured_support` in the agent and the
app.
A live read carried only its key, so a slow read of a source the key no
longer showed overwrote the new source's value, and the old read time
kept the new source waiting for up to an hour. Each read now carries a
`LiveReadTicket`; a key holds at most one read in flight, and a value is
taken in only while its ticket is that read. A changed source starts the
key over and is read at once.

An active key's time ran out only after the watcher finished every due
read, so a paging key pressed after the timeout still adjusted it and
renewed it. `press` and `paging` now expire the key themselves, the
orchestrator repaints the frame away then, and the watcher expires keys
on every tick whatever is being read.

Reads ran one after another and were published together, so a command
at its five-second limit held back every other key. `LiveReader` runs
them concurrently — commands at most four at a time, the clock and
battery never behind one — and the watcher draws each value as it
arrives.

Tests: `live_value_rejects_previous_source`,
`expired_live_key_does_not_capture_paging`,
`slow_live_source_does_not_delay_clock`, each failing with its fix
reverted.
A styled face carried only its picture's path, and the tile cache is
keyed by the face, so a file replaced at the same path — or one that was
unreadable when chosen and readable later — kept the old tile until
another field changed or the agent restarted. Choosing the same path
again saves nothing, so nothing repainted it either.

A face now carries a `KeyPicture`: the path plus the file's size and
modification time, read when the face is made. The live-key watcher's
tick republishes the key displays, which publishes only when a face
changed, so a replaced picture is drawn again within a second.

Test: `replacing_key_picture_invalidates_tile` (fails without the file
revision).
The key panel offered Hold and Hold modifiers for the dial's and the
roller's directions. A rotation has no press and no release: dispatch
runs the action once per detent, and both hold actions become an
ordinary tap on every platform, so the saved mode did not do what its
label promised. `offered_modes` gives a rotary target Tap only, and a
chord saved with a hold mode before shows as tapped.

Test: `rotary_shortcuts_offer_only_supported_modes`.
The Live tab had neither the Action tab's height bound nor a scrolling
body; its source list grows with every battery device, and with the
command and action fields it ran past the inspector column in a short
window. The panel is now bounded by the column and its fields scroll in
the same list the Action tab uses.

Test: `the_live_tab_scrolls_to_its_last_field_in_a_short_window` — a
command live key in a 520 px window at 125% scale; without the fix the
panel is 798 px tall.
The Appearance editor tracked its draft, a refused picture and an
invalid colour in three independent fields, and Reset cleared only the
draft: the fresh draft then showed the old colour error. The editor is
now one `AppearanceState` — `Closed` or `Editing { draft, feedback }` —
whose methods are its only writers, so feedback cannot outlive its
draft.

The file chooser answered asynchronously and saved through whatever
device was selected by then, so a device change while it was open put
the picture on the other device. A pick now carries the device and key
it was asked for (`FacePick`) and is dropped unless the editor still
edits them. Which device a face edit saves to is decided once, by
`AppState::key_face_device`, for both the save and the check.

Tests: `key_image_pick_rejects_changed_device`,
`resetting_a_face_drops_its_input_feedback` (fails when Reset does not
close the editor).
README's feature cards and platform table did not mention the console;
both now name the keypad's LCD keys, live keys and brightness and the
dialpad's dial and roller, with the Windows live-volume gap.
CONFIGURATION states what the fixes changed: brightness is written only
to a keypad that reported 0x8040 and the newest change wins, live
commands run concurrently and a changed source is read at once, a
replaced picture is redrawn, and a rotary shortcut is always tapped.
The vendored assets/lucide/astroid.svg is Lucide's icon name, not a misspelling of asteroid; the typos job failed on the file name.
The brightness slider saved the level and left the write to the agent's
config reload, whose outcome only reached the log: a refused or timed-out
write looked applied, and saving the same level again wrote nothing.

The slider now follows the press-force pattern end to end:

- `read_key_brightness` / `set_key_brightness` are appended to the agent
  protocol (v46, with `HidppOperation::ReadKeyBrightness`). The agent
  writes through the keypad's `WriteOrder`, shared with its reconnect and
  reload writes, and answers with the level the keypad reads back; a
  write superseded before its turn answers with a plain read.
- The device layer reads `0x8040` back as a percentage through
  `KeyDisplayBrightness::from_level`, the inverse of `scale`.
- The app reads the keypad's level for the slider, writes a release
  directly, and saves it only when the newest write's read-back is the
  level asked for. A refusal marks the keypad, shows a note beside the
  slider, and reads the keypad again so the slider returns to its own
  level; a reconnect or a landed write clears the note.

Tests: `key_brightness_failure_reaches_settings` (fails when the error
path neither marks nor re-reads), `only_the_newest_write_is_shown_or_saved`,
`a_refused_key_brightness_shows_a_note`,
`a_gui_brightness_write_answers_with_the_keypads_read_back`, the
`from_level` round trip, and the new wire goldens.
Comment on lines +92 to +96
pub fn current(path: PathBuf) -> Self {
let revision = std::fs::metadata(&path).ok().map(|meta| FileRevision {
modified: meta.modified().ok(),
len: meta.len(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Picture reads block device actions

Blocking. KeyPicture::current calls std::fs::metadata while the live watcher holds orchestrator.lock(). If a picture is on a slow or unavailable network share, this can stall page presses, config reloads and device updates. The call also runs before load_picture rejects Windows network/device paths.

Check the path before touching it, and read file revisions outside the shared lock. Add picture_revision_reads_do_not_block_device_updates.

HidppOperation::WriteKeyBrightness,
&ticket,
|c| async move { openlogi_hid::set_key_display_brightness_on(&c, brightness).await },
|c| async move { openlogi_hid::get_key_display_brightness_on(&c).await },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P0 Skipped writes return success

Blocking. The fallback passed to run_in_turn returns a successful brightness read even when a newer request caused this write to be skipped. If that reading equals the requested level, apply_key_brightness_written saves it as an accepted write.

The write-order rule requires skipped writes to return WriteSuperseded and update no saved value. Return that error here and add superseded_brightness_does_not_save_matching_readback.

Rule Used: Severity: high. Every HID++ write to one device takes a ticket from that device's WriteOrder and runs under WriteTicket::in_order (read-modify-write, strict request order) or WriteTicket::turn (idempotent set-value writes only, newest wins). Th... (source)

Comment on lines +1315 to +1316
.find(|dev| dev.config_key == *device)
.and_then(|dev| dev.battery.clone()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Battery comes from sleeping route

Blocking. This find takes the first matching config_key, even when that record is offline and the same device has an online direct connection. build_devices keeps both records and sorts receiver routes before direct routes. A live key can therefore show an old battery reading or — instead of the online device's value.

The device-identity rule requires the online, addressable record to win. Prefer that record and add live_battery_prefers_online_route.

Rule Used: Severity: medium. Per-device configuration is matched through Config::resolve_device_key (crates/openlogi-core/src/config/identity.rs) and the configured links: identity keys are stored lowercase, a route key (receiver:…:slot:…) is rejected at ... (source)

Comment on lines +295 to +301
tokio::select! {
outcome = &mut capture => panic!(
"the session ended while its diversion was still on the wire: {:?}",
outcome.map(|_| ()).map_err(|_| "arming failed")
),
() = stall => {}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Stalled-write test can hang

Blocking. The first select! has no deadline. stall waits on write_accepted(), while the other branch waits for capture to end. If the expected write never arrives and capture stays pending, the test hangs before reaching the timeout below.

The async-test rule requires every wait to be bounded. Wrap this phase, or the whole test run, in DIALPAD_DEADLINE.

Rule Used: Severity: medium. Every bug fix ships a regression test that fails without the fix, and the PR states the counter-proof (the reverted line or the killed mutant). A test that passes with the fix reverted is not a regression test: ask for inputs where ... (source)

/// Keep the live keys current until the process exits.
pub(crate) async fn watch(orchestrator: Arc<Mutex<Orchestrator>>) {
let mut reader = LiveReader::new(read_source);
let mut tick = tokio::time::interval(TICK);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Watcher starts outside required schedule

Blocking. This process-lifetime watcher uses interval, which starts with an immediate tick. The periodic-timer rule requires interval_at with the first tick one period later, unless a reconnect or fast-retry exception is documented. Neither exception is stated here.

Use interval_at(now + TICK, TICK) and add live_watcher_waits_one_period. This repository requirement must be met before merging.

Rule Used: Severity: low. Periodic timers use tokio::time::interval_at with the first tick one period out; an immediate first tick is allowed only on a reconnect or fast-retry path and is commented as deliberate. A cross-thread flag that orders a payload uses... (source)

Comment on lines +123 to +128
value: Option<LiveValue>,
/// When the last read of `source` was issued; `None`: due now.
read_at: Option<Instant>,
/// The read of `source` not answered yet. A key has at most one, so its
/// values arrive in the order they were read.
in_flight: Option<LiveReadTicket>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Read phases use independent fields

Non-blocking. Tracked::value, read_at and in_flight encode the read lifecycle, with transitions split across due, set_value and paging. They allow a recorded read time with neither a value nor a pending read. Current transitions avoid that combination, but future changes must preserve those rules by hand.

The owner's state-machine instruction calls for explicit states:

  • Replace these fields with ReadState::{Due, Reading, Ready}, carrying only each phase's valid data.
  • Replace PointerState::pending_key_brightness_writes and refused_key_brightness with one per-device BrightnessWriteState::{Idle, Pending, Refused}.

Make each enum's impl the only writer. Test stale completions and attempts to mark a pending write refused without ending it.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +33 to +34
pub fn current_key_brightness_supported(&self) -> bool {
self.current_record().is_some_and(has_key_brightness)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Offline brightness changes disappear

Blocking. current_key_brightness_supported checks only saved capabilities, so an unplugged keypad still gets an active slider through its saved device links. But commit_key_brightness now requires a live route and silently returns when none exists. Moving the slider offline saves nothing and reports no refusal, unlike the previous config-only save.

The control rule requires unavailable hardware controls to be hidden or disabled with a reason. Meet that requirement before merging and add offline_keypad_brightness_cannot_accept_release.

Rule Used: Severity: low. Every focusable control calls the shared focus_ring(pal) from crates/openlogi-desktop/src/ui/theme.rs in all its states, and text painted over accent_tint stays text_primary. A control whose change cannot be applied (device off... (source)

The brightness write state lived in three fields — a refused set, a
pending-write map and a counter — kept in step by hand. It is now one
`KeyBrightnessWrites`, whose per-keypad `WriteStage` is `Pending(write)`
or `Refused` (absent: idle), and whose methods are the only writers: a
keypad cannot be both pending and refused, and a refusal never outlives
the next write.

A live-key read whose reader is gone now logs instead of dropping the
send result silently.

Test: `writes_move_only_on_the_newest_answer` walks every transition
(fails when a reconnect also drops a pending write).
`v0.9.1-beta.144` was published from master at 9ca7ef0 with
`PROTOCOL_VERSION` 46, so v46 is released and the wire changes this
branch appended to it — `DeviceKind::Dialpad`, the dial and roller
`ButtonId`s, `HidppOperation::{WriteKeyBrightness, ReadKeyBrightness}`,
`Action::ActivateLiveKey`, `Capabilities::key_brightness` and the
brightness calls — would let a beta and this build agree on a version
they do not share. They now form v47, and the pin follows.
@niscz
niscz force-pushed the feat/mx-creative-console branch from 9775e08 to 7e9a06e Compare November 14, 2026 05:51
Comment on lines +417 to +419
let picture = style
.and_then(|style| style.image.clone())
.filter(|path| path.exists());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Pictures freeze the settings window

Blocking. PhotoKey::render calls lcd_face, which runs path.exists() for every picture on every repaint. The chooser checks only the file extension. If a saved picture is on an unavailable network share, this file check can freeze the window before GPUI starts loading it.

Check permitted paths and file availability outside rendering. Add key_picture_checks_do_not_block_render.

Comment on lines +677 to +678
device.key_faces.retain(|button, _| kept(button));
device.live_keys.retain(|button, _| kept(button));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Page removal skips the warning

Blocking. set_keypad_pages now deletes key_faces and live_keys, but keypad_page_binds still checks only ordinary bindings. A page containing only a clock key or custom faces therefore takes the “empty page” branch and is saved away without the confirmation used for other configured pages.

Include both new maps in the removal check. Add removing_live_or_styled_page_requires_confirmation.

Comment on lines +67 to +70
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if job.is_null() {
return Ok((child, None));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Failed jobs leave commands running

Blocking. If Windows job setup fails, both failure paths return a running shell with tree = None. shell_output then has no owner for the shell’s children. Its timeout kills only the shell, while a background child can keep running and keep the detached output reader blocked. Repeated live-key reads can accumulate those processes and threads.

Stop the child and return an error when job setup fails. Add shell_output_job_failure_leaves_no_child.

Comment on lines +150 to +156
let transaction = tokio::spawn(async move {
let _pause = pause;
let _ack_listener = ack_listener;
for packet in &packets {
write_whole_raw_report(&channel, packet, HidppOperation::WriteKeyImage).await?;
}
tokio::time::timeout(ACK_TIMEOUT, ack_rx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P0 Shutdown releases image pause early

Blocking. The new task keeps the request pause after its caller is dropped, but the key-display manager returns immediately on shutdown and its worker then drops the runtime. That cancels the image task and releases the pause before its acknowledgement or deadline. Keyboard restoration runs concurrently, so its HID++ requests can reach a keypad still taking an image—the overlap this change is meant to prevent.

The deadline rule requires a pause held until acknowledgement to survive caller cancellation. Await the active image task before acknowledging shutdown. Add key_display_shutdown_keeps_pause_until_ack.

Rule Used: Severity: high. Every nested I/O step has its own timeout that fits inside the caller's deadline, and a test asserts the arithmetic. An advisory read the device may ignore (LED, hosts info) gets a budget of hundreds of milliseconds and reads as unkno... (source)

.bindings
.get(&turn.button)
.map_or(Action::None, Binding::click_action);
match rotary.advance(turn, &action, bindings.rotary_sensitivity, Instant::now()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Queued turns lose their pauses

Blocking. CapturedInput::Rotation drops the device timestamp, and dispatch passes Instant::now() to the accumulator instead. Queued turns more than STEP_DECAY apart can therefore look adjacent and complete a step that should have expired.

This violates the input-timing rule: use the kernel or arrival timestamp, not the time an event is read. Carry an arrival Instant through the queue. Add queued_rotations_keep_their_idle_gap.

Rule Used: Severity: medium. Input events are timed by their kernel or arrival timestamp, not the time of the read; queued batches keep their pauses; a backwards clock step reads as zero elapsed; deadlines stay on Instant. Values that can change at runtime (d... (source)

Comment on lines +100 to +111
self.config.edit(|config| {
let live_keys = &mut config.devices.entry(key).or_default().live_keys;
match live {
Some(live) => {
live_keys.insert(ButtonId::Grid(slot), live);
}
None => {
live_keys.remove(&ButtonId::Grid(slot));
}
}
});
self.persist_and_reload("live key");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Live mode shows stale actions

Non-blocking. commit_live_key changes live_keys but leaves the cached button_bindings unchanged. The Action tab reads that cache, while the LCD preview rebuilds its actions from the current config. Enabling or disabling live mode can therefore leave the panel showing the old action until selection or inventory changes.

Refresh the bindings and announce BindingsChanged after the edit, as commit_keypad_pages does. Add live_toggle_refreshes_key_action_panel.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

This branch has not been deployed

No deployments
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.

[Device]: MX Creative Dialpad support

2 participants