Skip to content

Protocol

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 protocol is stateless. No handshake, no session, no token, no keepalive. Open the port and send commands.

/dev/cu.usbmodem101 @ 125000 baud, 8N1, raw
AA AA AA │ len │ cmd │ data │ cksum
the device acknowledges with the command id echoed back

The device shows up as /dev/cu.usbmodem101.

125000 is a non-standard rate, so stty will not set it:

stty: tcsetattr: Invalid argument

On macOS the rate is set through IOKit’s IOSSIOSPEED, after tcsetattr:

#include <IOKit/serial/ioss.h>
speed_t speed = 125000;
ioctl(fd, IOSSIOSPEED, &speed);

Compile with:

Terminal window
clang eilik.c -o eilik -framework IOKit

Port setup:

int fd = open("/dev/cu.usbmodem101", O_RDWR | O_NOCTTY);
struct termios tty;
tcgetattr(fd, &tty);
cfmakeraw(&tty);
tty.c_cflag |= CLOCAL | CREAD;
tty.c_cflag &= ~(PARENB | CSTOPB | CSIZE);
tty.c_cflag |= CS8;
/* Important: cfmakeraw leaves VMIN=1, VTIME=0, so read() blocks and hands
* back torn packets. Reading replies needs a timeout. */
tty.c_cc[VMIN] = 0;
tty.c_cc[VTIME] = 2; /* 200 ms */
tcsetattr(fd, TCSANOW, &tty);

One envelope for every command, in both directions:

offset size field
────────────────────────────────────────────────────────────
0 3 AA AA AA magic
3 2 len (u16 LE) packet size minus 3
5 1 cmd command id
6 len-4 data command arguments
len+2 1 cksum checksum

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.

packet total bytes len field len == total − 3
heartbeat 13 0A 00 = 10 yes
servo 23 14 00 = 20 yes
ACK 13 0A 00 = 10 yes

Computed over every byte from the length field through the end of the data. The checksum byte itself is excluded:

static unsigned char checksum(const unsigned char *data, int len) {
unsigned int sum = 0;
for (int i = 0; i < len; i++)
sum += data[i];
return 255 - (sum % 256);
}
/* for a packet pkt of length n: */
pkt[n - 1] = checksum(pkt + 3, n - 4);

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 nonce field (formerly “session token”)

Section titled “The nonce field (formerly “session token”)”

Five bytes at offset 6. This is not a session token.

Established by experiment:

  • The value changes in every packet the device sends, not once per session. Two consecutive replies on one connection differ.
  • It is not validated on input. A servo command carrying deliberate garbage — DE AD BE EF 99 — physically moves the arms.
  • No handshake is needed: commands work on a freshly opened port with any value in this field.
  • In every packet from the device, byte[0] == byte[4]. Packets we send do not satisfy this, and the device does not care.

Practical upshot: fill it with any five bytes. Its purpose is unknown — it looks like a PRNG or an internal counter leaking outward to no effect.

Values captured from the device:

CD A0 2E 0B CD 66 6A 8D 16 66 FC 39 E4 57 FC
99 77 2C 1F 99 4D E6 62 24 4D 1C 0E 46 45 1C

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.

A one-byte body. Not required for anything, but handy as a link check.

TX: AA AA AA 0A 00 61 E4 C6 F1 CA 83 FF AD
RX: AA AA AA 0A 00 61 <nonce×5> FF <cksum>
body: 03 01 <motor> 01 <pos_lo> <pos_hi> [tail]
│ │ │ │ └────┬────┘
│ │ │ │ └─ position, u16 LE
│ │ │ └───────── parameter, always 0x01
│ │ └─────────────── motor id
│ └──────────────────── subcommand, always 0x01
└─────────────────────── opcode

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.

Confirmed on our device: 0xA3 reads back the screen contents.

TX: AA AA AA 04 00 A3 58
RX: AA AA AA 05 04 A3 04 <1024 bytes> <cksum> 1032 bytes total

Replies parse as magic(3) len(2) cmd_echo(1) status(1) payload(N) cksum(1), with status 0x04 for 0xA3. The framebuffer format:

1024 bytes = 128 columns × 64 rows × 1 bit
SSD1306 page mode: 8 pages of 128 columns
within a byte, the least significant bit is the page's top row
pixel (x, y) = (fb[(y / 8) * 128 + x] >> (y % 8)) & 1

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:

USER_DISPLAY_RUNNING_NUMBER = 100 # enters user-display mode
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

Section titled “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_probe raw A1

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.


ID constant part status
1 ARM_RIGHT the robot’s right arm — the one on your left confirmed
2 ARM_LEFT the robot’s left arm — the one on your right confirmed
3 BODY torso, rotates left and right confirmed
4 HEAD head, rotates confirmed

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.

uint16, little-endian.

1000 = 0x03E8 → E8 03
1500 = 0x05DC → DC 05 neutral
1900 = 0x076C → 6C 07
2000 = 0x07D0 → D0 07

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.


Real captures, suitable as golden vectors for parser and checksum tests.

heartbeat TX AA AA AA 0A 00 61 E4 C6 F1 CA 83 FF AD
heartbeat RX AA AA AA 0A 00 61 CD A0 2E 0B CD FF 22
heartbeat RX AA AA AA 0A 00 61 66 6A 8D 16 66 FF BC
heartbeat RX AA AA AA 0A 00 61 FC 39 E4 57 FC FF 29
servo motor 1 → 2000
AA AA AA 14 00 61 FC 39 E4 57 FC 03 01 01 01 D0 07 00 00 00 00 00 41
servo motor 2 → 1000
AA AA AA 14 00 61 FC 39 E4 57 FC 03 01 02 01 E8 03 00 00 00 00 00 2C
servo motor 1 → 1500, short body
AA AA AA 0F 00 61 CE 2A C5 03 CE 03 01 01 01 DC 05 1A
servo ACK AA AA AA 0A 00 61 1C 0E 46 45 1C 03 C0
ping TX AA AA AA 04 00 01 FA
0xA2 ACK AA AA AA 05 00 A2 01 57
0xA1 reply, all joints zero
AA AA AA 11 00 A1 04 01 00 00 02 00 00 03 00 00 04 00 00 3F

  • 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.

claim reality
14 00 61 is a command header 14 00 is the length field, 61 is the command id. There is no per-command header
the five bytes are a session token and must not be hardcoded Not a token. It changes in every packet from the device and is ignored on input
the token must be obtained after a handshake No handshake is needed at all
1 is the right arm Correct, and now the convention here too: motor 1 = ARM_RIGHT, the robot’s own right arm
3 is the body, 4 the head (unconfirmed) Confirmed
the 00 00 00 00 00 tail holds “extra fields” Optional; a 6-byte body works. Purpose unknown

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.


  • sound playback (see above — the command appears simply not to exist)
  • the microphone
  • touch sensors, IMU, tilt
  • how to trigger a stock animation at all: 0xA5/0xA6 are absent on this firmware, so the question turns on firmware version
  • why 0x20 (SD read) does not answer — no card, or arguments needed
  • the contents of the resource store: the official application sends hundreds of cmd=0x03 frames carrying paths like a/0/01/00/01
  • whether sound is reachable over BLE — the one untried transport
  • the exact layout of the 33-byte 0x01 reply: the strings 4424 and H090 were found, the remaining fields were not verified
  • the safe limits of each joint
  • the true ceiling on servo speed and acceleration
  • what the five tail bytes in the legacy servo body are for
  • why a 19-byte 0x61 body produces 15 acknowledgements
  • the meaning of the nonce field

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.

Terminal window
clang -Wall -O2 tools/eilik_probe.c -o eilik_probe -framework IOKit
./eilik_probe listen 10 # passively listen for events
./eilik_probe ack # check that the device acknowledges
./eilik_probe controls # what the firmware validates
./eilik_probe daemon # is a handshake needed, does the link go stale
./eilik_probe sides # which arm is which, head versus body
./eilik_probe readdisplay # capture the framebuffer into framebuffer.bin
./eilik_probe writedisplay <f> # write a 1024-byte frame to the screen
./eilik_probe fps <f> [n] # measure the real frame rate
./eilik_probe reads # whitelisted read-only commands
./eilik_probe slew # measure joint speed via 0xA1 feedback

tools/fb2png.py decodes a captured buffer into a PNG and prints an ASCII preview:

Terminal window
python3 tools/fb2png.py framebuffer.bin screen.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/video2fb.py turns a video file into a stream of frames, and tools/eilik_play.c pushes that stream to the screen on a precise clock.

Terminal window
clang -Wall -O2 tools/eilik_play.c -o eilik_play -framework IOKit
python3 tools/video2fb.py input.mp4 movie.fb --fps 30
./eilik_play movie.fb 30 --audio soundtrack.wav
# a 4:3 source is letterboxed into 128×64 with black bars at the sides;
# --fill crops instead and uses the whole panel
python3 tools/video2fb.py input.mp4 movie.fb --fps 30 --fill
# check the pipeline without a video file:
python3 tools/video2fb.py --demo demo.fb --fps 30 --seconds 6
./eilik_play demo.fb 30

The --audio argument plays through the computer’s speaker: Eilik has no command for playing arbitrary audio.

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.

Terminal window
python3 tools/audio2motion.py soundtrack.wav movie.fb movie.mv --fps 30
./eilik_play movie.fb 30 --audio soundtrack.wav --motion movie.mv

The movement style is chosen with --style:

style arms needs a tempo
mirror rigidly in opposition; the head follows the silhouette on screen no
guitar one strums on the beat, the other shifts on bar lines and holds between them yes
free independent, on incommensurate periods; one answers sharp attacks, the other sustained loudness no

If the music has no beat, guitar falls back to free on its own — see the note on estimator confidence below. Override the fallback with --force-style.

Terminal window
python3 tools/audio2motion.py song.wav clip.fb clip.mv --style guitar
python3 tools/audio2motion.py song.wav clip.fb clip.mv --style guitar --strum-hand right

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
tools/eilik_dance.sh # the MacBook microphone
tools/eilik_dance.sh 1 # another input, by index
tools/eilik_dance.sh 2 1.6 # microphone, livelier response

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.
  • uDamocles/EilikSerialController — the original work on the legacy 0x61 servo command. Motors only.
  • ailynux/Eilik-Robot — an investigation of the BLE interface. Research scaffolding so far, with no working protocol.
  • Reddit thread — the starting point.

There is no official API: EnergizeLab does not publish an open interface to Eilik.