Transport¶
This page describes how a host talks to a Naya Create half over its USB CDC data port: the frame,
the checksum, addressing, the flag and status bytes, chunked reads and writes, how a session opens,
and what silence means. It also declares the byte convention that every protocol and
connectivity page on this site uses. The one thing to know first: every request and every reply is
the same small frame, and the byte just before the final 04 is a checksum, not data.
At a glance
- One frame:
aa, two address bytes, a countdown byte, category, LEN, command (2 bytes), a flag or status byte, data, an XOR checksum,04. 50is the left half,51the right. Only the left half holds keymaps, LED maps, the layer list and module configs.- Replies start
aa 50 00(left) oraa 51 00(right), and byte 3 of a reply counts down the chunks still to come: both measured in our own captures (3.41.0, 2026-09-01 to 2026-09-17). - Reads longer than one frame continue with flag
01; writes longer than one frame re-send the index byte in every frame. - Nothing on this protocol is a commit: every write takes effect at once and persists.
MEASURED on this page means measured on the owner's board (one Create) unless a source is named;
the firmware version and date follow each tag. Keyboard firmware is written 3.41.0 in prose; the
four-part form the device reports is explained on Versions.
Byte convention¶
Every byte string on the protocol and connectivity pages follows these rules.
- A frame is written as lower-case hex bytes separated by spaces, with no
0x:aa 00 50 00 fe 03 10 02 00 12 04. A single value in prose may be written0x50. - A command is named
cc/xxxx: the category byte, then the two command bytes in the order they are sent.fe/1002is categoryfe, command bytes10 02. Command ids repeat across categories (see Command map), so a command is always named with its category. - params (in a request) and reply (in a response) mean every byte after the two command
bytes, up to but not including the checksum. The first byte of every params or reply string is
frame byte 8, and it is always shown. In a request it is the flag byte (
00, or01to continue a chunked read); in a reply it is the status byte. So thefe/1002reply00 00 03 29 00is status00, then four version bytes. - The checksum and the
04terminator appear only in whole-frame examples, and a whole frame shows every byte. - Record bytes inside a params string follow the index byte:
30/1004params00 01 24 01 04 04 00 07 00are flag00, layer01, then one 7-byte record. - Every length says which count it is: frame bytes, params bytes, reply bytes or record bytes.
- Placeholders:
<addr6>for a 6-byte Bluetooth address,<uuid16>for a 16-byte layer or profile id,<ascii>for a serial string. Per-device values are never reproduced.
Converting other sources to this convention:
- nayactl's output and most tool code print the bytes after the flag byte (nayactl calls it
flags); put the flag byte in front to get this site's form. edstrings: this site's params start with the flag byte, then the target (see LEDs for the framing this site measured). This differs from naya-create-kb, whoseedstrings leave out the flag byte10.
The frame¶
Every binary exchange on the data port is one frame, and the same layout runs in both directions except for bytes 1 and 2. MEASURED 3.41.0, 2026-09-01 (NayaFlow 1.25.1 captured on USB) STATIC14
| Offset | This site's name | NayaCore's name | In a request | In a reply |
|---|---|---|---|---|
| 0 | start | header | aa |
aa |
| 1 | sender | sender | 00 (the host) |
50 or 51, the half that answers |
| 2 | destination | destination | 50 left, 51 right |
00 |
| 3 | countdown | ID | frames still to come in a chunked write; 00 otherwise |
chunks still to come in a chunked read reply; 00 otherwise |
| 4 | category | type | 30, be, ca, de, ed, ee, f1, fa, fe, ff |
the same |
| 5 | LEN | length | 2 + params length | 2 + reply length |
| 6-7 | command | command | two bytes, high byte first | the same |
| 8 | flag / status | status | flag 00 (or 01) |
status (see status values) |
| 9 to LEN+5 | data | data | parameters | reply data |
| LEN+6 | checksum | checksum ("CRC") | XOR of bytes 6 to LEN+5 | the same |
| LEN+7 | end | EOT | 04 |
04 |
In a request byte 1 is 00 and byte 2 is the destination. In a reply byte 1 is the
address of the half that answers and byte 2 is 00: a left reply starts aa 50 00 00 and a right
reply aa 51 00 00. All 5 239 replies in our own captures follow this pattern, and NayaCore's port
detector expects the reply aa 50 00 00 fe 03 10 01 00 11 04.
MEASURED 3.41.0, 2026-09-01 to 2026-09-17
STATIC4 (An earlier note of ours drew the reply as aa 00 <dest> 00; that was wrong.)
Destination bytes: 50 is the left half and 51 the right half. nayactl also uses 50
for the Speedlink dongle, which answers nothing (see USB).
MEASURED 3.41.0 (50, 51)
STATIC1
NayaCore names eleven fields, in the order it checks a received frame: header, sender, destination, ID, type, length, command, status, data, checksum, EOT (its log messages include "Invalid sender received. Trimming packet." and siblings for each). Byte 3 is the vendor's "ID" and byte 8 its "status". STATIC4
LEN (byte 5) counts every byte from the first command byte through the last data byte,
so LEN = 2 + the params length, where the params include the flag byte. A whole frame is LEN + 8
bytes. The smallest frame, whose params are the flag byte alone, is 11 bytes:
aa 00 50 00 fe 03 10 01 00 11 04. MEASURED 3.41.0, 2026-09-01
STATIC1
The command travels high byte first: 0x1001 is sent 10 01. NayaCore keeps it
little-endian in its message structure and writes the high byte first.
STATIC1
Every request carries the flag byte, even with no data: all 795 parameterless requests
in one NayaFlow capture decode as flag 00, and the vendor's own probe fe/1001 has LEN 03.
MEASURED 3.41.0, 2026-09-01 STATIC4
Checksum and the framing trap¶
The checksum is the XOR of every byte from the first command byte through the last data
byte (frame bytes 6 to LEN+5). NayaCore's logs call it a CRC, but it is a plain XOR. The terminator
is always 04. MEASURED 3.41.0 (every frame of our captures re-checked,
2026-09-23) STATIC1
Start the XOR at the command bytes, not at LEN
A checksum that includes the LEN byte is wrong for every frame. Two published code sketches start their XOR one byte too early; the reference code below starts at byte 6.
The framing trap¶
The byte just before 04 is the checksum and changes with every payload. Several
published reply strings carry it as if it were data, and each can be checked by XOR:
| Published reply | What it is | Check |
|---|---|---|
fe/1002 00 00 03 29 00 38 |
reply 00 00 03 29 00, checksum 38 |
10 ^ 02 ^ 00 ^ 00 ^ 03 ^ 29 ^ 00 = 38 |
be/100f 00 02 1d ("BLE firmware 0.2.29") |
reply 00 02, checksum 1d |
10 ^ 0f ^ 00 ^ 02 = 1d |
be/1002 "8 B" |
status + 6 address bytes, plus the checksum | LEN 09 |
de/1001 fourth byte "X" |
the checksum of 00 01 <addr> |
e.g. 10 ^ 01 ^ 00 ^ 01 ^ 20 = 30 |
ed ack "byte 2 = internal slot id" |
the checksum of an empty ack | 10 ^ <C1> ^ 00 (see Command map) |
MEASURED 3.41.0 (our captures show fe/1002
aa 50 00 00 fe 07 10 02 00 00 03 29 00 38 04, be/100f aa 50 00 00 be 04 10 0f 00 02 1d 04 from
both halves, be/1002 with LEN 09, 2026-09-01 to 2026-09-17)
INFERRED (the XOR arithmetic on the published bytes).
Several published "payload" sizes are whole-frame sizes. Use this list when a size does not add up:
| Published size | Command | What it counts |
|---|---|---|
| "43 B" | fa/1001 (left half) |
the whole frame: 8 header bytes + status + 32 data bytes + checksum + 04; the right half's frame is 31 bytes |
| "72 B" | 30/1001 (three layers) |
the whole frame: status, index echo, three 20-byte entries |
| "41 B" | 30/1009 (one stored profile) |
the whole frame; 61, 101, 121 and 141 bytes with more profiles |
| "250 B" | be/100c |
the whole frame (LEN f2); the status blob is 239 bytes |
MEASURED 3.41.0, 2026-09-01 to 2026-09-17 INFERRED (arithmetic).
Addressing and relaying¶
Only the left half answers the REMAP category 30 (keymap, LED maps, layer list, module
configs): the right half holds none of those stores, NayaFlow never sends a REMAP frame to 51,
and a 30/1009 read sent to the right half on 2026-09-01 got no reply.
MEASURED 3.41.0 Also reported by nayactl PR #6 (the right half returns nothing to 30/100d)3.
Relaying through the left half is partial. Through the left half's port, dst 51
reaches the right half for the firmware-version read: on 2026-09-20 the left port read the right
half's version (3.35.4) while the right half's own port answered only empty payloads (see the hollow
port below). The Bluetooth identity reads be/1008, be/1002 and be/1005 sent to 51 on the
left port answer for the left half. Through the right half's port the left half is not
reachable. MEASURED 2026-09-20, 2026-09-227 OpenFlow
has no automatic retry through this route, but the route itself is measured. This differs from
naya-create-kb, which describes the left half as a full proxy for 518; for these
reads it is not.
Which sender byte (byte 1) a dst 51 reply carries when it comes back through the left
port is not recorded by us. OPEN One read of fe/1002 at dst 51 with the raw bytes logged
settles it.
A half running different keyboard firmware from its partner has a hollow port: it
opens, completes the handshake, accepts every command and answers each with an empty payload, while
it still types. Seen with a newer left half (3.41.0) and an older right half (3.35.4); during that
state the right half still answered the version read at 51 through the left port (3.35.4). Matching
the firmware ends it. MEASURED 2026-09-207 See
Split link and Differences by firmware.
Flag and status bytes¶
Request flag values: 00 is normal, and also "start over" for a chunked read; 01 asks
for the next chunk of a chunked read. NayaCore has never been seen sending another value.
MEASURED 3.41.0, 2026-09-01 See chunked reads.
Reply status values seen on the wire:
| Status | Meaning | Where seen | Evidence |
|---|---|---|---|
00 |
final or only frame; write accepted | everywhere | MEASURED 3.41.0, 3.28.7 |
01 |
more read chunks follow, or the ack of a non-final write chunk | chunked reads and writes | MEASURED 3.41.0 |
11 |
command not implemented | macro opcodes and an invented opcode on 3.28.7 (2026-09-19); the one macro-list read captured on 3.41.0 (params 00 only, 2026-09-01) |
MEASURED |
16 |
nothing stored for that index; header-only reply (e.g. an LED-map read of a layer with no map) | 3.28.7, 2026-09-19 | MEASURED |
18 |
the same, on a continuation | 3.28.7 | MEASURED |
19 |
the index does not exist: 30/1003 reads of layers 81, e4 and e5 answered 19 81, 19 e4, 19 e5 |
3.41.0, 2026-09-01 | MEASURED |
ea |
value refused, nothing stored | fe/100a under 30 s; fe/1007 value ff (3.41.0, 2026-09-02 and 2026-09-11) |
MEASURED |
ff |
no data | be/1006 with no name set (3.28.7) |
MEASURED |
NayaCore's own status names, in table order: Final Packet / Success, Multi Packet /
Continue, Invalid Command, Busy, Memory Full, Invalid Format, Save Failed, Load Failed, NVS, No
Data, Invalid ID, then "Unknown Status: 0x". It retries on Busy. Its error table lists the same
remap errors in the same order (invalid command, busy, memory full, invalid format, save failed,
load failed, NVS, no data, invalid ID). STATIC45
Numbering the error names from 11 gives 11 Invalid Command, 12 Busy, 13 Memory Full, 14
Invalid Format, 15 Save Failed, 16 Load Failed, 17 NVS, 18 No Data, 19 Invalid ID, which
fits all four measured error codes; 12 to 15 and 17 have not been seen.
INFERRED ea and ff are outside NayaCore's table.
An ack proves that the frame parsed, not that the write took effect. The firmware acks
writes it silently discards (every macro write, a record of unknown type 7e) and stores
short-parameter records that then do nothing. Verify by reading back, and verify behavior by
pressing the key. MEASURED 3.41.0, 2026-09-07
Chunked reads¶
A read longer than one frame: send the read with params 00 <index>; while the reply
status is 01, send the same command with params 01 <index> for the next chunk. Every reply chunk
starts with the index echo; strip it from every chunk after the first and join the chunks by bytes,
because chunks split records. The last chunk has status 00 and is shorter than a full one.
MEASURED 3.41.0, 2026-09-01
The first params byte of a read is a continue flag (00 for the first chunk, 01 for
every later chunk), not a part number: NayaCore never sends 02.
MEASURED 3.41.0, 2026-09-01 to 2026-09-17 (our captures carry only
00 and 01)
Byte 3 of a reply counts the chunks still to come: 03 02 01 00 for a four-chunk layer,
02 01 00 for an LED map. It is 00 on single replies and on write acks.
MEASURED 3.41.0 (235 non-final chunks in our captures,
2026-09-01 to 2026-09-17)
A full read chunk carries LEN f5: status, index, then 241 record bytes.
MEASURED 3.41.0, 2026-09-01
Sending 00 again restarts the read at chunk 1, so a reader that never sends 01 sees
only the first chunk (about 35 keys of a layer, or 60 LED entries). The read position belongs to the
open connection: reopening the port or sending the opener again resets it.
MEASURED 3.41.0, 2026-09-011
The number of chunks depends on the data. NayaCore read layers 0, 1 and 2 in 4, 3 and 4 frames and each LED map in 3 (3.41.0); a 764-byte layer comes back in 4. MEASURED 3.41.0, 2026-09-01
A layer read, as a sequence (layer 0, 764 record bytes):
| Step | Request params | Reply byte 3 | Reply status and first data byte | Record bytes in this chunk |
|---|---|---|---|---|
| 1 | 00 00 |
03 |
01 00 |
241 |
| 2 | 01 00 |
02 |
01 00 |
241 |
| 3 | 01 00 |
01 |
01 00 |
241 |
| 4 | 01 00 |
00 |
00 00 |
41 |
Chunked writes¶
Byte 3 of a request is 00 for a single frame and counts the frames still to come in a
chunked write: a three-frame write goes out with byte 3 = 02, 01, 00.
MEASURED 3.41.0, 2026-09-01
A write longer than one frame is split: every frame's params are 00 <index> followed by
the next slice of up to 241 record bytes, so each frame re-sends the index byte and slices can split
records. Byte 3 counts the frames still to come. Each non-final frame is acked with status 01, the
last with 00, and the ack data is the index. MEASURED 3.41.0,
2026-09-01
A captured example: NayaFlow writing a new layer 3.
| Frame | Byte 3 | Params | Ack |
|---|---|---|---|
| 1 | 02 |
00 03 + 241 record bytes (243 params) |
01 03 |
| 2 | 01 |
00 03 + 241 record bytes (243 params) |
01 03 |
| 3 | 00 |
00 03 + 4 record bytes (6 params) |
00 03 |
Frame size limits: a full frame has LEN f5 (245), is 253 bytes long and carries 243
params (flag, index, 241 record bytes). Two frames therefore carry at most 482 record bytes. Counts
that drop the flag byte and count the index once (nayactl's) say 242 per frame and 483 per two.
NayaCore quotes a "hard limit 257".
MEASURED 3.41.0, 2026-09-01 STATIC3
Writes apply by record index, so one large write can be sent as several smaller writes of whole records: LED records 120 to 135 written alone landed exactly there, and a layer written in two parts read back byte-identical. MEASURED 3.28.7, 2026-09-19
Firmware 3.28.7 cannot receive a three-frame write: the half stops answering and typing
until it is unplugged, while two-frame writes land at any index. 3.41.0 accepts NayaFlow's
three-frame writes. MEASURED 3.28.7, 2026-09-19; 3.41.0,
2026-09-01 See Differences by firmware. The author of nayactl PR #6
reports a different wedge on 3.41.0: 30/100e writes of 241 or 41 bytes stopped the parser until a
power cycle REPORTED3.
Three-frame writes wedge 3.28.7
On 3.28.7 any write that needs three frames stops the half (no typing, no answers) until it is
unplugged. Read fe/1002 on both halves first, and on every firmware prefer two-frame writes of
whole records (TR-21). Tested on the owner's donor board, 2026-09-19.
A writer must accept status 01 on continuation acks: a matcher that accepts only 00
stops at frame 2 and leaves a layer half written. It must also hold the port for every frame of a
chunked write: a status poll every 6 s that slipped between two frames cost one flash its
continuation ack (2026-09-16). MEASURED 3.41.0, 2026-09-16
Write acks echo the index in their data: a layer write to layer 1 is acked 00 01, to
layer 2 00 02; a layer-list or module-list write is acked 00 00; a module-config write echoes the
slot. A strict "equals 00 00" matcher rejects good writes to layers above 0.
MEASURED 3.41.0, 2026-09-01 to 2026-09-17
Opening a session and polling¶
The session opener used by NayaCore and nayactl: open the port (nayactl asserts DTR and
RTS; its reading of NayaCore's setup is 115200 8N1 with DTR and RTS), send fe/1001 MEDIA ID
REQUEST (params 00, reply 00), then fe/1002 GET FW VERSION. The first connect can take about
1 s, and nayactl retries up to three times, waiting 0.3, 0.7 and 1.0 s.
STATIC1 MEASURED 3.41.0,
2026-09-01 (NayaCore's opener on the wire)
The first frame on a freshly opened port is often lost, so retry it: in 23 of 30 nayactl
connections on 2026-09-01 the first fe/1001 got no reply and the retry did, with both halves awake
on USB; NayaCore's own connects were answered the first time.
MEASURED 3.41.0, 2026-09-01 STATIC1
NayaCore's port broker sends fe/1001 to both 50 and 51 to tell left from right,
logs a short reply as "ProtocolCDC ... DEFECTIVE", and, when no binary reply comes, falls back to the
text command fwvchk (the retired text channel). STATIC4
When a port gives no binary reply, NayaCore's broker sends a newline, the text command
fwvchk, then keyboard_mode_release_toggle, and tries fwvchk again. It classifies text ports as
SystemCDC, Outdated, Legacy or Manufacturing SystemCDC, accepts text version replies of the forms
fw_version#N.N.N.N, N.N.N.N or a three-group number, and closes a port it judges an "Outdated
ProtocolCDC". STATIC4
NayaCore's captured connect sequence to the left half: fe/1001, fe/1002, fa/1001,
be/1008, be/1002, be/1006, be/100f, be/100c, 30/1001 (params 00 00), 30/1003 for each
layer with the continue loop, 30/1009 (params 00 00), 30/100b for slots 0 to 4, 30/100d for
each layer, fe/100b. To the right half: fe/1001, fe/1002, fa/1001, be/1008, be/1002,
be/100f, then module polling. MEASURED 3.41.0, 2026-09-01
NayaCore then polls each half every 6 s: the left with be/100c, de/1001, de/1008,
de/100b and fe/1006; the right with the same except be/100c. The keyboard never pushes battery
or module state. MEASURED 3.41.0, 2026-09-01
STATIC4
NayaCore retries a command that gets the status "Busy" ("'Busy' response received. Trying to send again") up to a per-command budget ("Max message retries reached"). We have never seen Busy on the wire. STATIC4 MEASURED (absence, 3.41.0 and 3.28.7)
The 30/10xx commands do not need a 30/1001 read on the same connection: our captures
show fresh connections answering 30/1003, 30/1009, 30/100d and 30/1005 after the session
opener alone (fe/1001, fe/1002), with no 30/1001 on that connection (full three-layer reads of
4, 3 and 4 chunks followed the opener directly). MEASURED 3.41.0,
2026-09-01 Whether one 30/1001 is needed after power-up is untested, because the board had been
read by NayaFlow earlier; a read-only keyboard check settles it. OPEN
NayaCore and OpenFlow always read 30/1001 first anyway, and 30/1001 is READ LAYER LIST, not a
dedicated handshake (see Layers).
The baud rate is nominal on this CDC port: NayaCore opens the bootloader port at 1 000 000 baud, and 115200 reads the same bytes. MEASURED 2026-09-08
Timeouts seen in practice: a Bluetooth read that the firmware does not implement costs the client's whole timeout (about 2.08 s per command in OpenFlow on 3.28.7; caching which commands a half answers, per serial and firmware, cut a full read from 18.6 s to 6.1 s); a missing module battery command costs about 1.09 s; a present one answers in about 0.22 s. MEASURED 3.28.7 and 3.41.0, 2026-09-19
Read-only session check
The opener and a version read change nothing on the keyboard:
aa 00 50 00 fe 03 10 01 00 11 04 (expect aa 50 00 00 fe 03 10 01 00 11 04), then
aa 00 50 00 fe 03 10 02 00 12 04 (on 3.41.0, expect
aa 50 00 00 fe 07 10 02 00 00 03 29 00 38 04). If the first frame gets no answer, send it
again. Close NayaFlow first: only one program can hold the port (see USB).
Empty payloads and silence¶
Opcodes a half does not implement answer with silence, not an error: on 3.28.7
be/100c to be/100f return no frame at all, and ff/1000 sent to the left half on 3.41.0 got no
frame back. A client must time out rather than wait. MEASURED
3.28.7, 2026-09-19; 3.41.0, 2026-09-01 (ff/1000)
An empty ed params string is a write, not a read: short ed params are zero-filled
(see LEDs), so probing ed/1012 to ed/1014 with empty params writes 0.
MEASURED (zero-fill, 3.41.0, 2026-09-09)
INFERRED (the ed/1013 case) This differs from naya-create-kb, which calls such an empty
send a self-targeted read returning a bare ack9; the ack is real, but no read command
exists, and the probe writes.
Frames can arrive unsolicited: keyscan events (fe/1009) stream while keyscan mode is on.
Request-shaped frames inside reply streams are not confirmed: a re-decode of all our remaining
captures finds none, and our own 2026-09-01 sighting (on the right half's IN pipe) cannot be
re-checked, because those two capture files no longer exist. OPEN
nayactl clears its input buffer before every command it writes, so unsolicited frames
that arrive between commands are thrown away; only its listen and keyscan modes stream.
STATIC1
Byte order¶
Multi-byte fields use both byte orders, by field family:
| Big-endian (high byte first) | Little-endian (low byte first) |
|---|---|
| the command bytes | every REMAP u32 parameter (layer targets, Bluetooth, LED-key and two-word records) |
fe/1006 millivolts |
the key usage in a key-press record ([usage lo][usage hi][page][mods]) |
both de/1009 values |
the hold-tap term (u16) |
de/100b millivolts |
the LED-map hue (u16) |
be/100c fields (revision, interval, latency, supervision timeout) |
the ed/100e hue (u16) |
| the SMP header's length and group | the three fe/100a timeouts (u32) |
MEASURED 3.41.0 STATIC
(the be/100c connection fields read little-endian would be 1536, 0 and 36865, which are not valid
Bluetooth values).
The text channel¶
The same port also takes ASCII text commands ending in CR LF, with parameters after #
(the "SystemCDC" channel): fwvchk, dump_settings, ble_Address, ble_Pair#<addr>,
clear_bonds, mcuboot_reset, manual_fw_update_touch, manual_fw_update_track,
manual_fw_update_tune, keyboard_mode_release_toggle, force_touch_start, c_module_status. On
3.41.0 every text command returns zero bytes on both halves (with CR LF, LF or CR, with or without
the binary opener). MEASURED 3.41.0, 2026-09-01; the nayactl
maintainer saw the same2 The vendor's notes retire SystemCDC in keyboard firmware 3.31.1 and
NayaCore 6.1.5 DOC6; nayactl's author used it on 3.30.1
REPORTED1. nayactl refuses clear_bonds and mcuboot_reset without --force1; on
current firmware they do nothing.
Worked frames¶
All checksums below are computed or captured; none is per-device.
| What | Whole frame |
|---|---|
fe/1001 request (left) |
aa 00 50 00 fe 03 10 01 00 11 04 |
fe/1001 reply (left) |
aa 50 00 00 fe 03 10 01 00 11 04 |
fe/1002 request (left) |
aa 00 50 00 fe 03 10 02 00 12 04 |
fe/1002 reply (left, 3.41.0) |
aa 50 00 00 fe 07 10 02 00 00 03 29 00 38 04 |
30/1003 layer 0, first chunk |
aa 00 50 00 30 04 10 03 00 00 13 04 |
30/1003 layer 0, next chunk |
aa 00 50 00 30 04 10 03 01 00 12 04 |
30/1004 one key (layer 0, position 20, key B) |
aa 00 50 00 30 0b 10 04 00 00 20 01 04 05 00 07 00 33 04 |
ed/1008 brightness 40 |
aa 00 50 00 ed 05 10 08 00 00 28 30 04 |
fe/100a write ack (status 00, no data) |
aa 50 00 00 fe 03 10 0a 00 1a 04 |
The 30/1004 and ed/1008 rows are writes
They change the keymap and the brightness at once, with no undo. Read and save what you are about to overwrite first (see Keymap and LEDs).
Reference encoder and parser¶
A minimal encoder and parser in this site's convention (params start with the flag byte). The checksum starts at byte 6, the first command byte. Both snippets were run against the worked frames above (2026-09-23). Code snippets on this site are MIT-licensed.
# SPDX-License-Identifier: MIT
def build_frame(dest: int, category: int, command: int, params: bytes = b"\x00",
frames_left: int = 0) -> bytes:
"""params = every byte after C0 C1, flag byte first (this site's convention)."""
body = bytes([command >> 8, command & 0xFF]) + params
checksum = 0
for b in body: # XOR from C0 to the last data byte; LEN is not included
checksum ^= b
return bytes([0xAA, 0x00, dest, frames_left, category, len(body)]) + body + bytes([checksum, 0x04])
def parse_frame(frame: bytes) -> dict:
"""Split one received frame; raises ValueError on a bad length, terminator or checksum."""
if len(frame) < 11 or frame[0] != 0xAA or len(frame) != frame[5] + 8 or frame[-1] != 0x04:
raise ValueError("not a whole frame")
checksum = 0
for b in frame[6:-2]:
checksum ^= b
if checksum != frame[-2]:
raise ValueError("checksum mismatch")
return {
"sender": frame[1], "frames_left": frame[3], "category": frame[4],
"command": (frame[6] << 8) | frame[7],
"reply": frame[8:-2], # status byte first, as on this site
}
assert build_frame(0x50, 0xFE, 0x1002).hex(" ") == "aa 00 50 00 fe 03 10 02 00 12 04"
// SPDX-License-Identifier: MIT
export function buildFrame(dest, category, command, params = [0x00], framesLeft = 0) {
// params = every byte after C0 C1, flag byte first (this site's convention)
const body = [command >> 8, command & 0xff, ...params];
const checksum = body.reduce((x, b) => x ^ b, 0); // XOR from C0; LEN is not included
return Uint8Array.from([0xaa, 0x00, dest, framesLeft, category, body.length, ...body, checksum, 0x04]);
}
export function parseFrame(frame) {
if (frame.length < 11 || frame[0] !== 0xaa || frame.length !== frame[5] + 8 ||
frame[frame.length - 1] !== 0x04) {
throw new Error("not a whole frame");
}
const checksum = frame.slice(6, -2).reduce((x, b) => x ^ b, 0);
if (checksum !== frame[frame.length - 2]) throw new Error("checksum mismatch");
return {
sender: frame[1], framesLeft: frame[3], category: frame[4],
command: (frame[6] << 8) | frame[7],
reply: frame.slice(8, -2), // status byte first, as on this site
};
}
For longer recipes (chunked reads, sessions, platform notes) see Python recipes and JavaScript recipes.
Safety¶
- Writing is live: every REMAP write takes effect at once and persists; there is no commit and no undo. Read and save what you are about to overwrite first.
- Firmware 3.28.7 wedges on any write that needs three frames (see above). Split large writes into
two-frame writes of whole records on every firmware, or check
fe/1002first. - Hold one port owner for every frame of a chunked write; do not run a second tool, NayaFlow or a status poll at the same time.
- Do not probe with empty or short
edparams: they are zero-filled writes, and a zeroed/1013darkens the board persistently. - The never-send list is on Command map; its text-channel entries matter only on firmware older than 3.31.1.
Open questions¶
- OPEN Status codes
12to15and17; whateameans beyond "refused" (details). - OPEN Which sender byte a
dst 51reply carries through the left port (details). - OPEN Whether one
30/1001is needed after power-up; per connection it is not (contradicted by our captures) (details). - OPEN Our own sighting of a request-shaped frame in a reply stream (details).
- OPEN What NayaCore's "hard limit 257" applies to, given a one-byte LEN (details).
- OPEN Whether a sleeping half answers nothing until a key press, and whether the first frame after waking is lost (details).
Sources¶
-
nayactl, github.com/Qonfused/nayactl (
protocol.pyframe builder and checksum,transport.pyopener, retries and buffer handling,constants.pycategories and addresses,docs/cdc-wire-format.mdcommand byte order). ↩↩↩↩↩↩↩↩↩↩↩ -
nayactl, pull request #2 and its comments (Windows serial fixes; text channel silent on 3.41.0). ↩
-
nayactl, pull request #6 (NayaCore 6.11.0 LED table, frame limits, right half silent to
30/100d). ↩↩↩ -
NayaFlow 1.25.1, NayaCore 6.11.0 strings (static reading): frame-field messages, status names, port-broker and retry messages. ↩↩↩↩↩↩↩↩↩
-
NayaFlow 1.25.1, NayaCore 6.11.0 (Windows x64), our disassembly (2026-09-23): the error-code table ("Remap error is ..."). ↩
-
create-legacy-firmware, vendor release notes, changelogs/ (NayaFlow 1.17.2: SystemCDC retired in keyboard 3.31.1 and NayaCore 6.1.5). ↩
-
create-legacy-firmware,
FLASHING-PROCEDURE.md, "Pairing and firmware mismatch" (commit cdd897c; measured 2026-09-20). ↩↩ -
naya-create-kb, protocol/transport (commit 7668067). ↩
-
naya-create-kb, protocol/commands (commit 7668067). ↩
-
naya-create-kb, protocol/led (commit 7668067). ↩