Clevertuna

The Clevetura CLVX configuration protocol

Everything needed to write your own client. Determined by observing the wire format and confirmed against a real CLVX S; see NOTICE for provenance.

Values below are facts about the wire format — report IDs, framing bytes, protobuf field numbers — not anyone's source code.


1. Which interface

The keyboard exposes several HID interfaces. Configuration lives only on the one whose first top-level collection is usage page 0xFF00, usage 1.

Over USB there are three interfaces:

0003:36F7:5755.*   usage 0x0001/2   pointer
0003:36F7:5755.*   usage 0x0001/6   keyboard
0003:36F7:5755.*   usage 0xFF00/1   ← configuration

Over Bluetooth the keyboard presents seven top-level collections — GenericDesktop ×4, Consumer, Digitizer ×2 — and none is 0xFF00. The vendor page appears only nested inside a collection, so it is not a separate interface. This is why no HID-based tool can configure the keyboard wirelessly, the vendor's own app included.

IDs: vendor 0x36F7; product 0x5755 (CLVX S / CLVX 1), 0x1313 (laptop).

2. Transport

USB (HID reports, 64 bytes)

write   payload + 0x0A, split into 63-byte chunks,
        each sent as a report:  [0x23, ...chunk]

read    reports whose first byte is 0x24;
        drop that byte, accumulate the rest until 0x0A appears

Read with a short timeout (~50 ms) and give up after ~4 consecutive empty reads. Space requests out — a request issued immediately after the previous response is answered with BAD_REQUEST, while the same bytes after a ~250 ms pause are accepted.

Bluetooth LE (GATT)

service          d0bf1500-c402-424a-80b0-bc7aeced077e
characteristic   d0bf0001-c402-424a-80b0-bc7aeced077e
                 flags: read, write, write-without-response, notify

write   payload + 0x0A, split into 56-byte chunks, written raw
        (no report-ID byte)
read    accumulate until 0x0A

Drain any pending bytes before writing.

3. Framing

The payload is base64, as ASCII, of a protobuf message:

USB   base64(Request)
BLE   '#' + base64(Request ‖ crc32le(Request))

The BLE CRC is standard CRC-32 (IEEE/zlib), appended little-endian, and responses carry it too: strip the leading '#', base64-decode, split off the last four bytes, and check them against the CRC of the remainder.

4. Messages

Protobuf. Only the fields needed for settings are listed.

message Request {
  Type type                      = 1;   // omitted when 0, per proto3
  GetSettingsRequest getSettings  = 2;
  SetSettingsRequest setSettings  = 3;
  SetProfileSettingsRequest       = 4;
  GetDeviceInfoRequest            = 5;
  HeartBeat                       = 6;
  GetProfileSettingsRequest       = 7;
}

enum Request.Type {
  GET_SETTINGS = 0;  SET_SETTINGS = 1;  GET_DEVICE_INFO = 2;  HEARTBEAT = 3;
  SET_PROFILE_SETTINGS = 4;  GET_PROFILE_SETTINGS = 5;  CONTROL_AI = 6;
  GET_AI_STATE = 7;  SET_OS_MODE = 8;  GET_DEFAULT_SETTINGS = 9;
  PERFORM_FULL_RESET = 10;  GET_USER_AI_DATA = 11;  PERFORM_RESTART = 12;
  GET_DIAGNOSTICS = 13;
}

message Response {
  Type type                        = 1;
  GetSettingsResponse getSettings  = 2;   // { status = 1, AppSettings = 2 }
  SetSettingsResponse setSettings  = 3;   // { status = 1, AppSettings = 2 }
  GetDeviceInfoResponse            = 5;
  BadRequestResponse badRequest    = 7;   // { error = 1 }
}

enum Response.Type {           // note: NOT the same numbering as Request.Type
  GET_SETTINGS = 0;  SET_SETTINGS = 1;  GET_DEVICE_INFO = 2;  HEARTBEAT = 3;
  BAD_REQUEST = 4;   SET_PROFILE_SETTINGS = 5;  ...
}

enum BadRequestResponse.Error { CURRUPTED_REQUEST = 0; UNSUPPORTED_REQUEST = 1; }
enum SetSettingsResponse.Status { OK = 0; UNKNOWN_ERROR = 1; VALIDATION_ERROR = 2; }

Because proto3 omits zero values, a GET_SETTINGS request is just the empty getSettings submessage: the two bytes 12 00.

5. Settings

message AppSettings {
  GlobalSettings  global        = 1;
  ProfileSettings globalProfile = 2;
  uint32          counter       = 3;   // revision, increments on every write
}

message ProfileSettings {
  gestures  = 2;  touchZone = 3;  BacklightSettings backlight = 4;  keyboard = 5;
}

message BacklightSettings {
  Keyboard keyboard = 1;  Touchpad touchpad = 2;
  Slider leftSlider = 3;  Slider rightSlider = 4;
}

Each zone selects one effect:

message Zone {                       // Keyboard / Touchpad / Slider
  AutoEffect.SolidColor solidColor        = 1;
  InteractiveAnimation  interactiveAnimation = 2;
  AutoEffect.Breathing  breathing         = 4;
  AutoEffect.ColorCycle colorCycle        = 5;
  AutoEffect.ColorWave  colorWave         = 6;
  uint32                transparency      = 7;
  AutoEffect.Aurora     aurora            = 8;
}

message Color            { uint32 red = 1; green = 2; blue = 3; }
message ColorLineMarker  { Color color = 1; uint32 transparency = 2; position = 3; }
message ColorLinePicker  { uint32 markersNumber = 1; repeated ColorLineMarker markersArray = 2; }
message ColorWave        { ColorLinePicker colorLinePicker = 1; uint32 period = 2; direction = 3; length = 4; }
message SolidColor       { Color color = 1; }
message Breathing        { Color color = 1; uint32 period = 2; }
message ColorCycle       { ColorLinePicker colorLinePicker = 1; uint32 period = 2; }

// interactive animation differs per zone:
//   keyboard { Color color = 2; bool enable = 3; uint32 duration = 4; }
//   touchpad { Color color = 2; bool enable = 3; uint32 trace    = 4; }
//   slider   {                  bool enable = 3; }

Up to 5 markers. position is 0–100, direction degrees, period milliseconds, length in mm corresponding to 100%.

Request field numbers past the documented seven

Request carries more submessages than §4 lists, and they are numbered type + 2: SET_OS_MODE (8) is field 10, GET_DEFAULT_SETTINGS (9) is 11, PERFORM_FULL_RESET (10) is 12, PERFORM_RESTART (12) is 14. Four for four, and it predicted the fifth — see below.

The AI key answers

GET_AI_STATE (type 7, request field 9) returns a response with type = 8 and its payload in field 10:

0a 0a  08 00  10 00  18 00  20 00  40 00
└ field 1, 10 bytes
        └ five varints: fields 1, 2, 3, 4 and 8

So the AI key's state is five values in a submessage, and on a keyboard whose AI has never been configured every one of them is zero. What each means is not established — all-zeros carries no signal, and naming them from a guess is how the aurora's colours got invented. Establishing it needs the vendor's application driving the feature while the traffic is watched.

What is established: the request type is right, the keyboard accepts it, and the shape is a submessage of five fields. CONTROL_AI (6) would be field 8 and GET_USER_AI_DATA (11) field 13 by the same rule; neither has been sent.

6. Writing settings safely

SET_SETTINGS replaces the whole AppSettings, so read it first, replace only what you mean to change, and send the rest back untouched. Preserve fields you do not understand — that is what keeps a client working across firmware revisions.

Two behaviours worth knowing:

Colour stops

A colorWave zone carries exactly five markers in markersArray. This is not a convention — a write with four is refused with BAD_REQUEST (1), the same status the device returns for a malformed request, and every zone of a stock device is populated with five.

A palette of fewer than five colours therefore has to fill the quota. Repeating the first colour in the last slot is the useful way to do it: the wave returns to where it started instead of jumping when the cycle comes round.

7. What the numbers mean

The field numbers above say where a value goes. They do not say what it is, and three of them are counted from a different zero than any control a person touches. A client that offers sliders needs all six of these, or it writes schemes the firmware refuses and reports the refusal as a timing fault.

ControlRangeStored asConversion
Opacity0–100, 100 = fully littransparencytransparency = 100 − opacity
Speed500–10000, higher is fasterperiod (ms)period = 10500 − speed, and the same the other way
Stretch100–1000lengthwritten through unchanged
Spread0–359°, 0° = to the rightdirectiondirection = (180 − angle) mod 360
Duration (keys)1–3 — low, medium, highinteractiveAnimation.durationunchanged
Trace (touchpad)1–5 — short … longinteractiveAnimation.traceunchanged

Three consequences are easy to get wrong:

| the control says | the keyboard does | |---|---| | 0° right | down | | 90° up | left | | 180° left | up | | 270° down | right |

Right↔down with up↔left is a reflection. Two offsets were tried first — 90, then 270 — and each merely rotated the reflection somewhere else. One spot check cannot tell a half turn from a mirror; all four cardinals can. The relation is its own inverse, so one function encodes and decodes.

There is no brightness field. The device has one dial and it is opacity; anything offering brightness as well is scaling the colours before it sends them, which is what Clevertuna does.

Per-zone limits

8. When the light goes out

Two fields in GlobalSettings — not in a profile, because they are how the keyboard behaves rather than how it looks, and they survive a change of theme.

FieldNameUnitValues offered0 means
20idle timeoutseconds0, 30, 60, 180, 300, 600, 1800never dim
21backlight timeoutseconds0, 300, 600, 1800, 3600always on

The rule the vendor application enforces, and the reason it does: the idle timeout is never longer than the backlight one, because a dim that is scheduled after the light has already gone out can never happen.

Identified by reading a stock CLVX S: field 20 held 180 and field 21 held 300, which are exactly two of the values above and satisfy that rule. Confirmed by writing 600/60, reading it back independently, and restoring.

9. Everything that is not a colour

The vendor's Electron bundle publishes its generated protobuf definitions in its source maps (@clevetura/clv-firmware-clvx/app-settings.ts), so the rest of the settings tree is specification rather than inference.

AppSettings.global — one set for the whole keyboard:

FieldNameType
2tap1fEnablebool
3tap2fEnablebool
4holdEnablebool
5swapClickButtonsbool
6currentAILeveluint32
7newbieModeEnablebool
8touchActivationAfterLiftOffbool
9fnLockbool
11autoBrightnessEnablebool
12dominantHandenum — 0 unselected, 1 right, 2 left
14batterySavingModeEnablebool
15keySuppressorEnablebool
16holdDelayOnBorderEnablebool
17swapFnCtrlbool
20idleBacklightTimeuint32, seconds
21backlightTimeuint32, seconds
22autoUsbSwitchEnablebool

AppSettings.profile — per profile:

FieldName
1id
2gesture — threeFinger(3) / fourFinger(4), each swipe(1) / tap(2)
3touchZone — touchpad(1) / slider(2)
4backlight — §5
5keyboard — fKeys(1), then F1…F12 as fields 13…24

What a remapped key holds

Read off a CLVX S, 2026-08-30. Each of the F-key fields carries a field 5 holding four slots, and each slot is {1: type, 2: usage}:

Four slots means a chord: the stock F12 is three of them at once. Empty slots are {1:0, 2:0}. The factory function row decodes cleanly, which is what confirms the reading:

FieldKeySlotsWhat it is
18F61/0x70Display Brightness Decrement
19F71/0x6FDisplay Brightness Increment
20F81/0xB6Scan Previous Track
21F91/0xCDPlay/Pause
22F101/0xB5Scan Next Track
23F111/0xE2Mute
24F120/0xE1, 0/0xE3, 0/0x21Left Shift + Left GUI + 4 — the macOS screenshot chord

So remapping is a solved problem on the wire: it is an ordinary field of ProfileSettings, it round-trips through the same SET_SETTINGS that writes the backlight, and the values are standard HID usages rather than a private table. Clevertuna does not implement it yet; nothing about the protocol is in the way.

touchZone.touchpad: 1 enable, 2 oneFingerEnable, 3 twoFingerEnable. touchZone.slider: 3 left, 4 right; each carries 2 sensitivity plus a choice of action — 11 customShortcut, 12 custom, 13 nothing, 14 asGlobal, 15 predefinedShortcut.

⚠️ sensitivity is 1–4, not 1–5. The vendor's control offers five labels, and a CLVX S refuses a 5 with status 2 while accepting 1 through 4. A published option list is not the range the firmware will take — measure it.