Skip to main content

openlogi_core/binding/
control.rs

1//! HID++ `0x1b04` reprogrammable controls as rebindable buttons.
2//!
3//! A Logitech keyboard's media and shortcut keys are not F-keys to the host:
4//! each is a *control* the firmware identifies by a 16-bit control ID (CID),
5//! reports in its `0x1b04` table with `divertable` and friends, and — once
6//! diverted — delivers as a HID++ notification instead of a keystroke. The
7//! CID is therefore the key's real identity, on the wire, in Options+'s own
8//! slot names (`c266` is `0x010A`), and here.
9//!
10//! [`Cid`] carries that identity — the codebase's own name for a `0x1b04`
11//! control ID. The [`KNOWN_CONTROLS`] catalog gives
12//! the controls OpenLogi has met a stable config name, an English label and a
13//! locale key; a control outside the catalog is still a first-class
14//! [`ButtonId`](super::ButtonId) — it is written as `control:0x0141` and shown
15//! by its number — so a keyboard OpenLogi has never seen is bindable the day it
16//! ships, and adding a catalog row is a data change, not a new enum variant.
17
18use std::borrow::Cow;
19use std::fmt;
20
21/// One HID++ `0x1b04` control, by its firmware control ID.
22///
23/// Distinct from the HID++ layer's own `ControlId` on purpose: this is the
24/// config-facing identity of a rebindable control, and the two crates never
25/// depend on each other.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
27pub struct Cid(u16);
28
29impl Cid {
30    /// Wrap a raw `0x1b04` control ID.
31    #[must_use]
32    pub const fn new(cid: u16) -> Self {
33        Self(cid)
34    }
35
36    /// The raw control ID as the firmware reports it.
37    #[must_use]
38    pub const fn raw(self) -> u16 {
39        self.0
40    }
41
42    /// The catalog row for this control, when OpenLogi knows it.
43    #[must_use]
44    pub fn known(self) -> Option<&'static KnownControl> {
45        KNOWN_CONTROLS.iter().find(|control| control.id == self)
46    }
47
48    /// The control a config name denotes: a catalog name such as
49    /// `KeyScreenCapture`, or the `control:0x…` form any control has.
50    #[must_use]
51    pub fn from_config_name(name: &str) -> Option<Self> {
52        if let Some(hex) = name.strip_prefix(CONFIG_PREFIX) {
53            let digits = hex.strip_prefix("0x")?;
54            // `from_str_radix` would also accept a leading sign; the grammar
55            // is hex digits only.
56            if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_hexdigit()) {
57                return None;
58            }
59            return u16::from_str_radix(digits, 16).ok().map(Self);
60        }
61        KNOWN_CONTROLS
62            .iter()
63            .find(|control| control.name == name)
64            .map(|control| control.id)
65    }
66
67    /// The name this control is written with in `config.toml`: its catalog
68    /// name when it has one, else `control:0x…`. Stable — a control that later
69    /// gains a catalog row keeps loading under its numeric name.
70    #[must_use]
71    pub fn config_name(self) -> Cow<'static, str> {
72        match self.known() {
73            Some(control) => Cow::Borrowed(control.name),
74            None => Cow::Owned(format!("{CONFIG_PREFIX}{:#06x}", self.0)),
75        }
76    }
77
78    /// English label for logs and non-localized contexts. An uncataloged
79    /// control is named by number — `Control 0x01f3`, deliberately not the
80    /// `control:0x01f3` config form, so a label pasted into `config.toml`
81    /// fails to parse instead of half-working.
82    #[must_use]
83    pub fn label(self) -> Cow<'static, str> {
84        match self.known() {
85            Some(control) => Cow::Borrowed(control.label),
86            None => Cow::Owned(format!("Control {}", self.hex())),
87        }
88    }
89
90    /// Stable catalog key for the localized label. An uncataloged control
91    /// resolves to [`UNKNOWN_CONTROL_TRANSLATION_KEY`], the plain word
92    /// "Control"; callers that show the number append [`Cid::hex`].
93    #[must_use]
94    pub fn translation_key(self) -> &'static str {
95        self.known()
96            .map_or(UNKNOWN_CONTROL_TRANSLATION_KEY, |control| {
97                control.translation_key
98            })
99    }
100
101    /// The control ID as `0x01f3`, for labels of uncataloged controls.
102    #[must_use]
103    pub fn hex(self) -> String {
104        format!("{:#06x}", self.0)
105    }
106}
107
108impl fmt::Display for Cid {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        f.write_str(&self.label())
111    }
112}
113
114/// Prefix of the numeric config form, `control:0x0141`.
115const CONFIG_PREFIX: &str = "control:";
116
117/// Locale key for a control with no catalog row: the plain word "Control",
118/// to which the GUI appends [`Cid::hex`]. Deliberately not an interpolating
119/// message — every caller resolves button keys with a bare `tr!`.
120pub const UNKNOWN_CONTROL_TRANSLATION_KEY: &str = "keyboard.control_key";
121
122/// A control OpenLogi knows by name.
123#[derive(Debug, PartialEq, Eq)]
124pub struct KnownControl {
125    /// The firmware control ID.
126    pub id: Cid,
127    /// The name it is written with in `config.toml`. Persisted, so append-only
128    /// and never renamed: the nine `Key…` names predate the catalog and are
129    /// kept byte-for-byte.
130    pub name: &'static str,
131    /// English label.
132    pub label: &'static str,
133    /// Locale catalog key for the label.
134    pub translation_key: &'static str,
135}
136
137const fn known(
138    cid: u16,
139    name: &'static str,
140    label: &'static str,
141    translation_key: &'static str,
142) -> KnownControl {
143    KnownControl {
144        id: Cid(cid),
145        name,
146        label,
147        translation_key,
148    }
149}
150
151/// Every control OpenLogi names, in the order a keyboard's function row lays
152/// them out (brightness, backlight, then the shortcut and media keys, then the
153/// keys above the numpad). CIDs are Logitech's own (the `0x1b04` control-ID
154/// list, cross-checked against Solaar's `special_keys.py` and the slot IDs in
155/// Logi's device metadata).
156pub const KNOWN_CONTROLS: &[KnownControl] = &[
157    known(
158        0x00c7,
159        "KeyBrightnessDown",
160        "Brightness Down Key",
161        "keyboard.brightness_down_key",
162    ),
163    known(
164        0x00c8,
165        "KeyBrightnessUp",
166        "Brightness Up Key",
167        "keyboard.brightness_up_key",
168    ),
169    known(
170        0x00e2,
171        "KeyBacklightDown",
172        "Backlight Down Key",
173        "keyboard.backlight_down_key",
174    ),
175    known(
176        0x00e3,
177        "KeyBacklightUp",
178        "Backlight Up Key",
179        "keyboard.backlight_up_key",
180    ),
181    known(
182        0x00e0,
183        "KeyMissionControl",
184        "Mission Control Key",
185        "keyboard.mission_control_key",
186    ),
187    known(
188        0x00e1,
189        "KeyLaunchpad",
190        "Launchpad Key",
191        "keyboard.launchpad_key",
192    ),
193    known(
194        0x006e,
195        "KeyShowDesktop",
196        "Show Desktop Key",
197        "keyboard.show_desktop_key",
198    ),
199    known(0x00d4, "KeySearch", "Search Key", "keyboard.search_key"),
200    known(
201        0x0103,
202        "KeyDictation",
203        "Dictation Key",
204        "keyboard.dictation_key",
205    ),
206    known(0x0108, "KeyEmoji", "Emoji Key", "keyboard.emoji_key"),
207    known(
208        0x010a,
209        "KeyScreenCapture",
210        "Screen Capture Key",
211        "keyboard.screen_capture_key",
212    ),
213    known(
214        0x00bf,
215        "KeyPrintScreen",
216        "Print Screen Key",
217        "keyboard.print_screen_key",
218    ),
219    known(
220        0x011c,
221        "KeyMicMute",
222        "Mic Mute Key",
223        "keyboard.mic_mute_key",
224    ),
225    known(
226        0x00e4,
227        "KeyPreviousTrack",
228        "Previous Track Key",
229        "keyboard.previous_track_key",
230    ),
231    known(
232        0x00e5,
233        "KeyPlayPause",
234        "Play/Pause Key",
235        "keyboard.play_pause_key",
236    ),
237    known(
238        0x0141,
239        "KeyPlayPauseTrack",
240        "Play/Pause Key (track)",
241        "keyboard.play_pause_track_key",
242    ),
243    known(
244        0x00e6,
245        "KeyNextTrack",
246        "Next Track Key",
247        "keyboard.next_track_key",
248    ),
249    known(0x00e7, "KeyMute", "Mute Key", "keyboard.mute_key"),
250    known(
251        0x00e8,
252        "KeyVolumeDown",
253        "Volume Down Key",
254        "keyboard.volume_down_key",
255    ),
256    known(
257        0x00e9,
258        "KeyVolumeUp",
259        "Volume Up Key",
260        "keyboard.volume_up_key",
261    ),
262    known(
263        0x000a,
264        "KeyCalculator",
265        "Calculator Key",
266        "keyboard.calculator_key",
267    ),
268    known(0x006f, "KeyLock", "Lock Key", "keyboard.lock_key"),
269    known(
270        0x011d,
271        "KeyDoNotDisturb",
272        "Do Not Disturb Key",
273        "keyboard.do_not_disturb_key",
274    ),
275    known(
276        0x0104,
277        "KeyEmojiHeartEyes",
278        "Heart-Eyes Emoji Key",
279        "keyboard.emoji_heart_eyes_key",
280    ),
281    known(
282        0x0105,
283        "KeyEmojiCrying",
284        "Crying Emoji Key",
285        "keyboard.emoji_crying_key",
286    ),
287    known(
288        0x0106,
289        "KeyEmojiSmiley",
290        "Smiley Emoji Key",
291        "keyboard.emoji_smiley_key",
292    ),
293    known(
294        0x0107,
295        "KeyEmojiTears",
296        "Tears-of-Joy Emoji Key",
297        "keyboard.emoji_tears_key",
298    ),
299    known(0x00ea, "KeyLookUp", "Look Up Key", "keyboard.look_up_key"),
300    known(
301        0x0100,
302        "KeyAppSwitch",
303        "App Switch Key",
304        "keyboard.app_switch_key",
305    ),
306    known(
307        0x0109,
308        "KeyAppSwitchLaunchpad",
309        "App Switch / Launchpad Key",
310        "keyboard.app_switch_launchpad_key",
311    ),
312    known(0x0118, "KeyHome", "Home Key", "keyboard.home_key"),
313    known(0x0119, "KeyEnd", "End Key", "keyboard.end_key"),
314    known(0x011a, "KeyPageUp", "Page Up Key", "keyboard.page_up_key"),
315    known(
316        0x011b,
317        "KeyPageDown",
318        "Page Down Key",
319        "keyboard.page_down_key",
320    ),
321    // The K380 / K400 multiplatform row. Its Back key (`0x00bd`) is a member
322    // of the Back family `openlogi_device::reprog_controls::BACK_CIDS` owns
323    // and dispatches as `ButtonId::Back`, so it is deliberately not a catalog
324    // row here: a second name for one control would make the same key two
325    // buttons. It still loads and binds as `control:0x00bd`.
326    known(
327        0x00ba,
328        "KeyMultiPlatformAppSwitch",
329        "App Switch Key (multiplatform)",
330        "keyboard.multiplatform_app_switch_key",
331    ),
332    known(
333        0x00bb,
334        "KeyMultiPlatformHome",
335        "Home Key (multiplatform)",
336        "keyboard.multiplatform_home_key",
337    ),
338    known(
339        0x00bc,
340        "KeyMultiPlatformMenu",
341        "Menu Key (multiplatform)",
342        "keyboard.multiplatform_menu_key",
343    ),
344    known(
345        0x00be,
346        "KeyMultiPlatformInsert",
347        "Insert Key (multiplatform)",
348        "keyboard.multiplatform_insert_key",
349    ),
350    known(
351        0x00dd,
352        "KeyLanguageSwitch",
353        "Language Switch Key",
354        "keyboard.language_switch_key",
355    ),
356    // The POP Icon Keys' four shape keys.
357    known(0x01a3, "KeyCircle", "Circle Key", "keyboard.circle_key"),
358    known(
359        0x01a4,
360        "KeyTriangle",
361        "Triangle Key",
362        "keyboard.triangle_key",
363    ),
364    known(0x01a5, "KeyDiamond", "Diamond Key", "keyboard.diamond_key"),
365    known(0x01a6, "KeyStar", "Star Key", "keyboard.star_key"),
366];
367
368#[cfg(test)]
369mod tests {
370    use std::collections::BTreeSet;
371
372    use super::*;
373
374    #[test]
375    fn catalog_names_and_ids_are_unique() {
376        let ids: BTreeSet<_> = KNOWN_CONTROLS.iter().map(|c| c.id).collect();
377        let names: BTreeSet<_> = KNOWN_CONTROLS.iter().map(|c| c.name).collect();
378        let keys: BTreeSet<_> = KNOWN_CONTROLS.iter().map(|c| c.translation_key).collect();
379        assert_eq!(ids.len(), KNOWN_CONTROLS.len());
380        assert_eq!(names.len(), KNOWN_CONTROLS.len());
381        assert_eq!(keys.len(), KNOWN_CONTROLS.len());
382        for control in KNOWN_CONTROLS {
383            assert!(
384                Cid::from_config_name(control.name).is_some(),
385                "{} must parse back as a config name",
386                control.name
387            );
388        }
389    }
390
391    #[test]
392    fn config_names_round_trip_for_catalogued_and_raw_controls() {
393        for control in KNOWN_CONTROLS {
394            assert_eq!(
395                Cid::from_config_name(&control.id.config_name()),
396                Some(control.id)
397            );
398        }
399        let raw = Cid::new(0x01f3);
400        assert_eq!(raw.config_name(), "control:0x01f3");
401        assert_eq!(Cid::from_config_name("control:0x01f3"), Some(raw));
402        assert_eq!(Cid::from_config_name("control:0x1F3"), Some(raw));
403        // The numeric form names a catalogued control too — a file written
404        // before the row existed keeps loading.
405        assert_eq!(
406            Cid::from_config_name("control:0x010a"),
407            Some(Cid::new(0x010a))
408        );
409    }
410
411    #[test]
412    fn config_name_parser_rejects_everything_else() {
413        for bogus in [
414            "",
415            "Key",
416            "KeyBogus",
417            "control:",
418            "control:0x",
419            "control:010a",
420            "control:0xZZ",
421            "control:0x+1a3",
422            "control:0x-1",
423            "control:0x1ffff",
424            "0x010a",
425        ] {
426            assert_eq!(Cid::from_config_name(bogus), None, "{bogus:?}");
427        }
428    }
429
430    #[test]
431    fn the_nine_original_key_names_are_preserved() {
432        // These have been persisted in config.toml since 0.7; renaming any of
433        // them is a migration event.
434        for (cid, name) in [
435            (0x00d4, "KeySearch"),
436            (0x0103, "KeyDictation"),
437            (0x0108, "KeyEmoji"),
438            (0x010a, "KeyScreenCapture"),
439            (0x011c, "KeyMicMute"),
440            (0x00e5, "KeyPlayPause"),
441            (0x00e7, "KeyMute"),
442            (0x00e8, "KeyVolumeDown"),
443            (0x00e9, "KeyVolumeUp"),
444        ] {
445            assert_eq!(Cid::new(cid).config_name(), name);
446        }
447    }
448
449    #[test]
450    fn unknown_controls_label_and_localize_by_number() {
451        let raw = Cid::new(0x01f3);
452        assert_eq!(raw.label(), "Control 0x01f3");
453        assert_eq!(raw.to_string(), "Control 0x01f3");
454        assert_eq!(raw.hex(), "0x01f3");
455        assert_ne!(
456            raw.label(),
457            raw.config_name(),
458            "a label must not be a valid config name"
459        );
460        assert_eq!(raw.translation_key(), UNKNOWN_CONTROL_TRANSLATION_KEY);
461        assert_eq!(
462            Cid::new(0x010a).translation_key(),
463            "keyboard.screen_capture_key"
464        );
465    }
466}