Driving a Logitech MX Creative Keypad (046D:C354) from macOS over raw USB HID.
The Keypad's nine LCD keys are painted with images generated at runtime and its key presses read directly, with no Logitech software installed. The Dialpad is driven too, over Bluetooth: both rollers and all four buttons.
It runs as a Hammerspoon helper, so the two devices work as a second surface
next to an Elgato Stream Deck, sharing the same hs.canvas rendering.
make # binaries land in bin/Requires clang and the macOS SDK. No third-party dependencies.
Each binary does one thing.
bin/probe # parse the HID report descriptor, list collections
bin/features # enumerate the HID++ feature table
bin/cids # enumerate 0x1B04 controls
bin/writetest # confirm one handle accepts reports 0x11, 0x13 and 0x14
bin/claim # keepalive and divert experiments
bin/listen # print every report the device emits, never writes
bin/paint # paint one LCD key
bin/demo # paint all nine keys, then listen for presses
bin/dial # Dialpad 0x4610 MultiRoller, read state or divert and listen
bin/display # probe feature 0x00c3features, cids and listen take a hex PID, so bin/features bc00 reads the
Dialpad rather than the Keypad.
paint, demo and claim write to the device. Everything else is read-only.
bin/helper is the long-lived process the Hammerspoon side talks to. It holds
the keepalive, so the display and the input stream stay up for as long as it
runs, and it speaks a line protocol.
commands paint <key 0-8> <byteLen>\n followed by byteLen raw JPEG bytes
paintpng <key 0-8> <byteLen>\n same, PNG, converted here
paintfile <key 0-8> <path>\n a PNG or JPEG on disk
brightness <0-100>\n
quit\n
events ready keypad [dialpad]
key <1-9> LCD key down
key up all LCD keys released
nav left|right page arrow down
nav up page arrows released
dial <0|1> <delta> roller 1 the large dial, 0 the small wheel
dialkey <1-4> Dialpad button down
dialkey up Dialpad buttons released
ping every 10 s, to a socket client only
The ping exists so a client can tell a dead socket from an idle one. The helper only speaks when a device is touched, so silence is otherwise meaningless.
Commands arrive on stdin or on a unix socket, /tmp/mx-creative-console.sock
or $MXCC_SOCKET. Events go to stdout and to a connected socket client, and a
client is sent the ready line when it connects.
Hammerspoon has to use the socket. hs.task:setInput only delivers data queued
before start(), and silently drops everything written after, which is no use
for a command stream that lives as long as the process.
paintpng exists because hs.canvas hands back PNG and Hammerspoon has no
JPEG encoder. ImageIO is already linked for the paint path, so the conversion
happens here rather than shelling out per repaint. paintfile keeps image bytes
off the socket entirely, which hs.socket needs: it reports success on a large
write and then drops the connection.
The helper holds the devices, so it wants to be a launch agent rather than a
child of whatever is talking to it. com.shenshen.mx-creative-console runs it at
login with KeepAlive, and the Hammerspoon module attaches to its socket.
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.shenshen.mx-creative-console.plist
launchctl kickstart -k gui/$UID/com.shenshen.mx-creative-console # restart after a rebuildAdd bin/helper to Input Monitoring in System Settings. The grant is per binary
and launchd does not pass on a terminal's, so without it the Dialpad fails to
open with kIOReturnNotPermitted (0xe00002e2) while the Keypad still works.
Only the Dialpad needs it, because it presents as a mouse on Generic Desktop
where the Keypad's vendor collection does not.
The Makefile signs with a self-signed identity, "MX Creative Console Helper", so the binary keeps one designated requirement across rebuilds and the grant sticks to the certificate rather than to a hash of the build. Signing is skipped when that identity is absent, so the build still works without it.
A linker-signed binary is ad-hoc, and TCC keys the grant to its cdhash, so every rebuild silently revoked it and the Dialpad quietly stopped opening. Creating the identity once:
openssl req -x509 -newkey rsa:2048 -keyout k.pem -out c.pem -days 3650 -nodes \
-subj "/CN=MX Creative Console Helper" \
-addext "basicConstraints=critical,CA:false" \
-addext "keyUsage=critical,digitalSignature" \
-addext "extendedKeyUsage=critical,codeSigning"
openssl pkcs12 -export -out c.p12 -inkey k.pem -in c.pem -passout pass:x \
-keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1
security import c.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign -P x
security add-trusted-cert -r trustRoot -p codeSign -k ~/Library/Keychains/login.keychain-db c.pemmacOS rejects OpenSSL 3's default PKCS#12 encryption, hence the legacy
algorithms, and wants digitalSignature in keyUsage as well as the codeSigning
extended usage or the identity imports but will not sign.
Tunables, all optional:
| Variable | Default | Purpose |
|---|---|---|
MXCC_SOCKET |
/tmp/mx-creative-console.sock |
socket path |
MXCC_KEEPALIVE_INTERVAL |
1.0 |
seconds; 3.0 loses the display |
MXCC_REFRESH_INTERVAL |
4.0, agent sets 0 |
repaint timer, 0 to disable |
MXCC_BRIGHTNESS_INTERVAL |
0 |
re-assert timer, 0 to set once |
The Dialpad is optional, since it runs on batteries. The ready line says which
devices were found.
Painting one key from the shell:
sips -s format jpeg -z 118 118 icon.png --out key.jpg
{ printf 'paint 4 %d\n' "$(stat -f%z key.jpg)"; cat key.jpg; sleep 20; } | bin/helperdocs/PROTOCOL.md holds the wire format, the feature table, the LCD frame
layout and what is verified versus untested. It also records two corrections to
the published reverse engineering, on the meaning of byte 3 and on the geometry
fields.
docs/DECISIONS.md compares the three ways to drive the device and records
which one this repo takes and why.
docs/OPTIONS-PLUS.md documents the vendor stack, the Actions SDK limits, the
on-disk profile store and how Options+ encodes keyboard chords.
Both devices work. The Keypad is USB; the Dialpad is Bluetooth, which makes it
its own HID endpoint addressed at device index 0xFF, so the receiver-index
problem never arises.
Devices are enumerated once at startup, so a Dialpad asleep at that moment stays invisible until the helper restarts. A hot-plug callback would fix it.