A reference for controlling the Eilik robot over USB. Everything marked as
confirmed here was verified against a real device on 25 August 2026.
This document supersedes earlier community notes on the packet structure, the
“session token”, and the motor map. Those contained several errors, listed
under Corrections.
The byte at offset 5 is a command id, not a “channel type”. Frames from
the official application make this plain — they carry 01, 20, 02 there:
AA AA AA 04 00 01 FA ping
AA AA AA 04 00 20 DB read_all (SD)
AA AA AA 09 00 02 00 09 00 00 00 EB confirm_upgrade
Command 0x61 is the legacy servo format, whose first five data bytes are a
nonce field. That is where the mistaken reading of 14 00 61 as a “header”
came from: it is really a length followed by a command id.
The length field holds on every captured packet.len equals the full
packet size minus the three magic bytes. The protocol is self-describing and
variable-length — no per-command header is needed, only arithmetic.
This is the one’s complement of the low byte of the sum. The formula holds on
every captured packet, including those sent by the device.
The firmware really does verify it. A packet with a corrupted checksum is
dropped silently — no reply at all. That is convenient: an acknowledgement
means the frame arrived intact.
The full table was recovered by the community from PackAnalyData.py inside
the official EnergizeLab Windows application (PyInstaller extraction plus
bytecode decompilation). This is the manufacturer’s own table, not guesswork.
cmd
purpose
our status
0x01
ping, read MCU info
confirmed by us
0x02
confirm_upgrade
⛔ do not touch
0x03
content_update
⛔ do not touch
0x04
firmware_flash — writes firmware to flash
⛔ do not touch
0x05
firmware_flash_direct
⛔ do not touch
0x20
read_all (SD), returns 2 KB
no reply on our device
0x21
read_single (SD)
untried
0x31
write_specified (SD)
⛔ do not touch
0x41
reinit_sd
⛔ do not touch
0x42
format_sd — formats the SD card
⛔ do not touch
0xA1
read_servo_angles
confirmed and decoded by us
0xA2
write_servo_angles, up to 4 motors per packet
confirmed by us
0xA3
read_display, 1024 bytes
confirmed by us
0xA4
write_display, 1024 bytes
confirmed by us
0xA5
read_running_number
replies, but echoing 0xA4 — see below
0xA6
write_running_number
acknowledged but inert
0x61
legacy servo
confirmed
0xFF
heartbeat, inside 0x61
confirmed
The presence of 0x04, 0x05 and 0x42 in this table is the reason
never to sweep command ids blindly. One writes firmware to flash, another
formats the SD card. Work only from the table.
Confirmed on our device. A safe read, useful both as a link test and for
identifying the firmware version.
TX: AA AA AA 04 00 01 FA
RX: AA AA AA 26 00 01 94 DA "4424" 0E 00 "H090" 5B 92 01 00 0D 00 ... B4
41 bytes in total: status 0x94, a 33-byte payload, then zeros. Partially
decoded — two ASCII strings are visible in the payload:
offset 1..4 "4424" probably firmware_number
offset 7..10 "H090" probably boot_firmware
offset 11..14 5B 92 01 00 = 0x0001925B, looks like an identifier
The field names come from the manufacturer’s table (chip_id, mode_number,
firmware_number, boot_firmware, mcu_status); the exact layout has not
been pinned down.
Firmware version matters. Some commands that work for other people are
absent on our firmware — see the section on 0xA5. Before comparing your
behaviour against someone else’s report, check these identifiers first.
The minimum working body is 6 bytes. The five-zero tail seen in earlier
notes is optional: a six-byte body is accepted and executed. What the tail
does is unknown.
One packet drives exactly one motor. The “list of motor slots in one
packet” hypothesis was tested and disproved: a packet with four slots
produced no movement at all and returned 15 acknowledgements instead of one.
A 19-byte body confuses the firmware’s parser.
Limit: do not send a body longer than 11 bytes. The lengths known to
work are 6 and 11.
Confirmed and decoded on our device. The community report listed this
reply format as undecoded.
TX: AA AA AA 04 00 A1 5A
RX: AA AA AA 11 00 A1 04 <12 bytes> <cksum> 20 bytes total
The payload is four <motor> <pos_lo> <pos_hi> triples — the same layout the
0xA2 write uses:
01 E7 05 motor 1 -> 0x05E7 = 1511
02 DE 05 motor 2 -> 0x05DE = 1502
03 DA 05 motor 3 -> 0x05DA = 1498
04 EB 05 motor 4 -> 0x05EB = 1515
These were read immediately after commanding every joint to 1500, so the ±15
spread is real servo slack rather than a decoding error. Position feedback is
available.
The legacy 0x61 command has no multi-motor mode; that was tested and
confirmed, with the four-slot packet producing no movement and 15
acknowledgements. Multi-motor exists under 0xA2, and it is confirmed on
our device:
data: <count> <id, pos_lo, pos_hi> × count up to 4 motors per packet
Three bytes per motor, not four. The reply is AA AA AA 05 00 A2 01 57, with
status 0x01 as on every write.
The verification needs no human observer: command, then read the actual
angles back through 0xA1.
before: m1=1653 m2=1350 m3=1769 m4=1334
command: m1 -> 1750, m2 -> 1250 in a single packet
after: m1=1747 m2=1253 m3=1769 m4=1334
Both joints reached their own targets, accurate to ±3 units. Motors absent
from the packet held their position — the command is addressed, not global.
0xA1 plus 0xA2 closes the loop. This is the only way to verify
movement programmatically: the acknowledgement, as shown below, confirms
only that the frame was intact. Use 0xA2 for animation — it places every
joint in one frame and removes the skew that separate legacy 0x61 packets
inevitably produce.
The layout was re-checked statistically: across every candidate width and bit
order, 128 columns, LSB on top yields the fewest transitions between
neighbouring pixels — that is, the only coherent picture. A frame captured
from the robot decoded into a recognisable image (a magnifying glass from one
of the stock animations).
Writing with 0xA4 is confirmed on our device too:
TX: AA AA AA 04 04 A4 <1024 bytes> <cksum>
RX: AA AA AA 05 00 A4 01 55 status 0x01 = success
The SDK author reports that the firmware smooths what it stores slightly —
reading back differs by 58–429 pixels out of 1024. We have not measured this.
The panel is rotated 180° relative to the natural byte order of the
buffer. A captured frame only reads as a sensible image after rotating it.
This applies in both directions: when reading for a human to look at, and
when writing. In tools/video2fb.py the rotation is on by default and can be
disabled with --no-rotate.
The 125000 baud in the port settings is fiction. The device enumerates
as USB CDC-ACM (/dev/cu.usbmodem*), where the termios rate affects nothing
and data moves at USB speed. Measured: 258 frames per second across a
200-frame run, each acknowledged. That is twenty times what a genuine 125000
baud would allow (1032 bytes per frame works out to roughly 12 frames per
second).
Practical upshot: the display is not the bottleneck. Video at 30 fps runs
with eight times the headroom needed — measured at exactly 30.0 fps with not
a single late frame.
Presumed to be an index into the stock animations. Tested, with a negative
result.
Writing 0xA6 with indices 0..7 returns a success acknowledgement every
time:
TX: AA AA AA 05 00 A6 <idx> <cksum>
RX: AA AA AA 05 00 A6 01 53
But physically nothing happens — no sound, no movement, no change on the
screen. Remember that the acknowledgement proves nothing here: it is
identical for a non-existent motor, which was tested separately.
This is established solidly. Every other command echoes its own id back, and
only 0xA5 replies with a different one:
command
echo in reply
0x01
0x01
matches
0xA1
0xA1
matches
0xA3
0xA3
matches
0xA5
0xA4
does not match
The reply is byte-for-byte stable across repeats and passes the checksum
test, so this is not receive-buffer desynchronisation:
AA AA AA 09 00 A4 04 00 FF 00 FF 50
The firmware’s command dispatcher answers 0xA5 with a reply tagged 0xA4.
Together with the complete inertness of 0xA6, this means the
running-number pair does not exist on this firmware version, and the
USER_DISPLAY_RUNNING_NUMBER = 100 constant from the community SDK belongs
to a different one.
The entire 0..255 range was swept with the display acting as the
detector: after each index write the framebuffer was read back with 0xA3
and compared against the previous one. The picture changed at exactly one
value — 0, matching the documented “release the user-display lock”. Values
1..255 did nothing.
This method only catches visual effects. An index that produces only
sound would not be found this way; that needs a listener.
In the community’s working SDK these values are meaningful:
RELEASE_DISPLAY_RUNNING_NUMBER=0# releases the lock
On our firmware only 0 did anything. Conclusion: as a trigger for built-in
animations and sounds, 0xA6 does not work — either the firmware must first
be put into some mode, or the command is inert on this version.
The device acknowledges every frame it accepts. The reply is a 13-byte
packet whose body echoes the request’s opcode.
request body: 03 01 01 01 DC 05 00 00 00 00 00
reply body: 03
It arrives essentially instantly — below the resolution of the measurement,
under 1 ms.
What an acknowledgement does and does not mean:
test
result
conclusion
valid packet
ACK
—
corrupted checksum
silence
the checksum is verified; bad frames are dropped
non-existent motor 0x63
ACK
the ACK is blind to meaning
6-byte body instead of 11
ACK
body length is not validated per opcode
So an acknowledgement means “the frame was intact and handed to a handler”,
not “the command was carried out”. It cannot be used to check whether a motor
id or a position is valid.
Waiting for the ACK is mandatory when mixing commands
This is not politeness, it is a stability requirement. Sending 0xA2 while
the firmware is still parsing a 1032-byte 0xA4 frame makes the device
crash and drop off the USB bus: the next write returns ENXIO
(Device not configured), and the robot reboots and re-enumerates.
The cause was isolated by experiment, not guessed:
scenario
result
display only, 30 fps, no ACK wait
6572 frames, stable
motors only via 0xA2, 10 Hz, 4 joints
116 ticks, stable
display + motors, no ACK wait
crash between frames 11 and 83, ENXIO
display + motors, with ACK wait
30.0 fps, zero failures
Neither the servos themselves nor the data rate is at fault — what breaks it
is interleaving commands without synchronisation. A round trip costs about
4 ms against a 33 ms frame budget, so the wait is free.
The damage outlives a reconnect. After several such crashes our servo
controller stopped working entirely: 0xA2 is accepted and acknowledged,
0xA1 returns a well-formed frame with correct motor ids, but all four
positions read zero and nothing moves. The display (0xA3/0xA4) and
0x01 keep answering normally, so it is specifically the servo subsystem
that fails.
The cure is cutting power at the switch on the body. Unplugging the USB
cable is not enough, and this is the main trap: Eilik has an internal
battery, so without the cable it keeps running and the servo controller
stays wedged. Verified: after a USB reconnect 0xA1 still returned zeros;
after a power cycle it returned working positions.
Zeros in the 0xA1 reply are a reliable fault signature. If all four
positions read zero, power-cycle the robot rather than hunting for a bug in
your code. The check takes a second:
Terminal window
./eilik_proberawA1
This is implemented in tools/eilik_play.c: a short wait for a reply after
every write, with a timeout that is not treated as fatal — better to fall a
frame behind than to stall the whole clip.
As an indicator of frame integrity the acknowledgement is free and reliable,
and it is the main tool for further reverse engineering without watching the
robot.
Side convention: the robot’s own, not the observer’s.ARM_LEFT is the
robot’s left arm, which is the one you see on your right when facing it.
Both frames of reference are defensible, but they must never be mixed, and
anatomical naming is the one other work on this device already uses — the
community SDK likewise calls motor 1 the right arm. Following it means motor
numbers and side names agree across sources instead of quietly disagreeing.
The practical consequence is worth stating plainly: arm_left.to(1800)
raises the arm on your right.
Motors 3 and 4 were listed as unconfirmed in earlier notes. They are now
verified, and both parts move independently.
Measured with eilik_probe slew: a step is commanded through 0xA2, then the
position is polled through 0xA1 as fast as the round trip allows.
arms 617 units in under 200 ms → at least 3000 units/s
body, head 290 units in under 200 ms → at least 1450 units/s
This is a lower bound, not a ceiling. The joint had already arrived by
the first sample obtained, so the measurement hit the read latency rather
than the mechanics. The real ceiling is higher and undetermined; finding it
needs a different method — for instance driving a sine of rising frequency
and watching for the feedback amplitude to fall behind the commanded one.
In practice: 3000 units/s is 100 units per frame at 30 fps. The limiter in
tools/audio2motion.py sits just under that, at MAX_STEP_PER_FRAME = 90.
The range 1000..2000 is verified on the arms, with 1500 as centre. Only
small excursions (1200..1800) have been tried on the body and head.
The safe limits of each joint are not established. Going beyond the
verified range risks driving a servo into a mechanical stop. Any SDK needs
hard position clamping.
No handshake is required. A servo command works on a cold port.
The link does not go stale. After 30 seconds of complete silence the
next command is accepted and acknowledged.
The device sends nothing on its own. Thirty seconds of idling produced
zero unsolicited packets. There is no event stream by default; if the
sensors are reachable at all, they will have to be polled.
No sound playback command was found. This is the outcome of a search, not
an omission: it is absent from the EnergizeLab Windows application and from
the Android application alike (Hermes bytecode decompiled from the official
APK). The SDK author notes explicitly that no headId/cmd pair corresponds
to “show this face” or “play this sound”.
The microphone appears not to be exposed at all: samples are processed on the
robot itself (wake word, sound detection) and never cross USB.
The hypothesis that sound is tied to the stock animations and selected
through 0xA6 was tested and not confirmed: indices 0..7 are
acknowledged but produce no sound, no movement, and no change on screen. See
the 0xA5/0xA6 section.
Reading the SD card with 0x20 also produces no reply.
The robot has a speaker and it works — but the firmware owns it, and there is
no entry point to that code over USB. Having the hardware is not the same as
having an interface to it.
The only remaining path is modifying the firmware: writing code that
accepts audio over USB and hands it to the speaker. The flashing commands
exist (0x04, 0x05) and the manufacturer’s servers distribute images. But
that is a project of an entirely different risk class: those same commands
are what turn the robot into a brick on a mistake, and there is no way back
without a working dump of the original firmware.
Until then, the sensible approach is to play the soundtrack from the computer
in sync with the picture on the robot, which is what tools/eilik_play.c
does.
Do not sweep command ids blindly. The manufacturer’s table places 0x04
and 0x05 (writing firmware to flash), 0x31 (writing to SD) and 0x42
(formatting the SD card) right next to harmless reads. Landing on any of them
by accident can end in a brick.
Work strictly from the command table above. It came out of the official
application rather than guesswork, and it covers everything needed for the
display and the motors.
The reads (0xA1, 0xA3, 0xA5, 0x01) are safe — the worst case is no
reply. Any investigation should start there.
Acknowledgements give a cheap oracle: a command that produces a reply has a
handler, while silence means either a dropped frame or no handler. But
remember that the acknowledgement confirms frame integrity only, never
execution.
tools/eilik_probe.c is the rig that produced every result above. It emits
only confirmed opcodes and contains a resynchronising framer and reply reads
with timeouts.
./eilik_probeslew# measure joint speed via 0xA1 feedback
tools/fb2png.py decodes a captured buffer into a PNG and prints an ASCII
preview:
Terminal window
python3tools/fb2png.pyframebuffer.binscreen.png
tools/find_stride.py sweeps candidate framebuffer geometries and ranks them
by picture coherence — this is what confirmed the 128×64 LSB-on-top layout.
tools/audio2motion.py builds a joint-position track from two independent
sources: the rhythm comes from the audio (an energy envelope and onset
detection), while the head’s rotation comes from the video itself, following
the horizontal centre of the lit pixels. The result is a robot that watches
whatever is happening on its own screen.
The guitar style relies on a tempo grid rather than raw onsets: onsets
fire on any sharp attack, including ones off the grid, so a repeating gesture
built on them staggers. The estimator sweeps periods from 60 to 200 beats per
minute and scores each by how much more often its grid nodes coincide with a
real onset than chance alone would produce, then fits the phase.
The estimator prints a confidence figure — that ratio against chance.
Below 1.45 the track is treated as having no steady pulse: music with a
beat scores around 1.5, music without one around 1.4, and its best
candidates are not multiples of each other.
Scoring hits with a tolerance proportional to the period is wrong — a
long period gets a wider window in frames and wins for free. The window is
fixed, which is why the score is divided by the chance rate.
Octave ambiguity is handled separately: a pulse and half that pulse describe
the same music and score alike, so a preference for tempi near 120 per minute
is applied — the rate people most naturally tap along to. Without it the
estimator picked half speed, and the strum looked listless.
The script prints how much movement the style asks for and how much the
limiter trimmed. A high clipped percentage means the pattern is faster than
the joint can follow, and the cure is a smaller amplitude or a slower
pattern — not a higher limit, which will not make the hardware any quicker.
The .mv format: one entry per video frame, four uint16 LE values — target
positions for motors 1 to 4. Its size must match the frame count in the
.fb file.
Built-in constraints, change them deliberately:
LIMITS= {1: (1150, 1850), # arms
2: (1150, 1850),
3: (1350, 1650), # body
4: (1300, 1700)} # head
MAX_STEP_PER_FRAME=90# servos do not teleport
The arm range is verified; the body and head values are deliberately
conservative because their safe limits were never measured. The step limiter
prevents asking for movement faster than a joint can physically manage.
Positions are sent every third frame (10 Hz). The servos cannot track 30 Hz
anyway, and the extra traffic serves no purpose.
tools/eilik_live.c drives the joints in real time from audio on any input.
Capture is delegated to ffmpeg so there are no third-party dependencies: it
pipes raw mono PCM in, and the program reads it in 512-sample blocks (23 ms).
Terminal window
tools/eilik_dance.sh--list# what inputs exist right now
Pointing it at a loopback device such as BlackHole makes the robot dance
to whatever the Mac is playing rather than to the noise in the room.
The difference from the offline path is fundamental: there, the whole track
can be examined in advance and a tempo chosen; here it cannot, and every
decision is made from what has already been heard. So followers and
oscillators stand in for a beat grid:
Automatic gain against a long-term reference. The reference adapts over
seconds rather than instantly, so a loud moment rises above it instead of
being normalised onto it. An earlier version divided by a running peak,
which flattened away exactly the dynamics the robot is meant to dance to.
Two followers with different time constants: a fast one with a sharp
attack and slow release drives one arm, a smooth one drives the other.
Accents on jumps in the fast follower: they blend in and decay rather
than snapping a joint, which a servo could not execute anyway.
A silence gate. A second and a half below threshold and the joints
settle to neutral, so the robot does not twitch at room noise.
Loudness drives oscillator rate as well as amplitude, so phase is integrated
rather than recomputed — feeding a changing rate into sin(t * rate) would
jump the arm every time the music got louder.
Before starting, the program reads 0xA1 and refuses to run if all four
positions are zero: otherwise a wedged servo controller looks exactly like a
program that does nothing.
The protocol model and the command table come from community work; everything
critical was re-verified on our own device.
strognoff/eilik-sdk — the fullest
source. Display, decompilation of the official Windows and Android
applications, the command table, and a PNG-to-framebuffer converter.