Skip to content

Instantly share code, notes, and snippets.

@tobecwb
Last active September 7, 2026 00:54
Show Gist options
  • Select an option

  • Save tobecwb/3ac0ce03435e4099b73f5cc2f97eea96 to your computer and use it in GitHub Desktop.

Select an option

Save tobecwb/3ac0ce03435e4099b73f5cc2f97eea96 to your computer and use it in GitHub Desktop.
Anycubic ACE 2 Pro daisy-chain verified on two real units — no USB hub needed. Measurements plus a working Python example. Builds on hakimio's protocol research.

ACE 2 Pro daisy-chain — measured on two units

Two ACE 2 Pro units, firmware V1.1.31, chained through their own chain ports on a single USB connection, enumerated to distinct addresses and commanded individually. No USB hub, no per-unit power sequencing, no firmware modification.

Everything below is measured. Nothing here is new protocol analysis — the protocol was already fully understood by others (see Credit). What was missing was whether it actually works with real hardware sharing one bus.

Discussion: multiACE #120 — where this was reported, following the request in #62 for exactly this finding.

Why this needed measuring

The ACE 2 Pro protocol has always had the pieces for multi-unit operation: DISCOVER_DEVICE (0) and ASSIGN_DEVICE_ID (1) are implemented in the firmware, the frame carries a device address, and replies OR in bit 7. That much was already documented.

What nobody had confirmed is whether several units physically sharing one bus behave. Every unit boots at address 0, so a DISCOVER broadcast reaches all of them at once — and if they answer on top of one another, enumeration never gets off the ground. The existing research notes are explicit that this was untested, flagging another project as reported to run multiple units on one adapter with the caveat "check this before relying on it".

In practice the field had settled on a workaround: give each unit its own USB adapter through a hub, which sidesteps the shared bus entirely. That works, but it is one adapter and one cable per unit, and it leaves each unit's chain port unused.

It turns out the chain ports work. This note reports what two units did on one bus, so the question can stop being open.


1. It works

DISCOVER @0        -> two valid frames, one per unit
                      uid <first>        (3 x uint32; redacted, they are hardware serials)
                      uid <second>
ASSIGN(<first>, 1) -> ack
DISCOVER @0        -> one reply: only the still-unassigned unit remains
ASSIGN(<second>,2) -> ack
GET_INFO @1        -> addr 0x81, "V1.1.31"      3/3 rounds
GET_INFO @2        -> addr 0x82, "V1.1.31"      3/3 rounds
DISCOVER @0        -> silence; the bus is clean

Addressing behaved exactly as the existing analysis describes: the address byte selects the unit and replies OR in bit 7 (0x81, 0x82 observed).


2. Contention

Both units at address 0, answering the same broadcast. Two runs — 60 rounds with per-byte timestamping, then 300 rounds for the rate.

Rounds returning two CRC-valid frames 300 / 300, zero CRC errors
Round-trip ≈30 ms
Latency of the first reply 5.4 – 43.1 ms, median 18.2
Gap between the two replies 0 – 41.7 ms, median 12.6
Which unit replies first random — 27 / 33 across 60 rounds, no fixed order
Frame airtime at 230400 baud 27 bytes ≈ 1.2 ms

Replies serialise; they do not overlap. Extrapolating the measured bound by pair count — Anycubic printers take at most four units:

units collision rate per round
2 0 observed in 300; 95% upper bound 0.99%
3 ≤ 2.95%
4 ≤ 5.82%

The mechanism is not identified

This is worth stating plainly rather than glossing. A model in which each unit simply answers at an uncorrelated moment inside the observed ~40 ms window predicts roughly 18 collisions in 300 rounds. Zero occurred. Something is actively keeping the units out of each other's way — carrier sense before driving the bus would be the obvious way to do it — but this was not investigated and remains unknown.

Reply latency also never exceeded ~43 ms across either run, so there appears to be an upper bound on how long a unit will wait before answering — but that bound is an observation, not an explanation.

Treat the numbers as measured facts and the cause as an open question. If the serialisation turns out to be incidental on some other bus topology or unit count, the collision figures above do not transfer.


3. Enumeration is self-thinning

A round does not need every unit to answer cleanly. It needs one.

loop:
    DISCOVER @0
    take ANY one valid uid from the reply
    ASSIGN(uid, next free address)     # that unit leaves address 0
until DISCOVER @0 is silent

Each assignment removes a talker from address 0, so contention strictly decreases and the last unit enumerates alone. A collided round costs a retry and nothing else — a corrupted frame fails CRC and is discarded, which is the correct failure mode. Step 3 of §1 shows the thinning directly.

This is why the ≤5.8% four-unit figure does not need to be small: it is the probability of a retry, not of a failure.


4. Commands reach the intended unit

Two units both replying "V1.1.31" proves nothing — the strings are identical. Three independent discriminators:

Addressed DISCOVER returns that unit's own uid. Each address returned exactly the uid that had been assigned to it, and never the other unit's. (UIDs are per-device hardware serials and are redacted here; what matters is that they were distinct and stable.)

DISCOVER @1  ->  addr 0x81   uid of the unit assigned to 1
DISCOVER @2  ->  addr 0x82   uid of the unit assigned to 2

(DISCOVER order at address 0 is random from round to round, so which physical unit lands on which address depends on who answered first that time — which is itself part of the result.)

Distinct physical readings. One unit had recently run a dryer cycle; the other had never been heated. Stable across repeated rounds:

ptc1 ptc2 env humidity
@1 — never heated 21.07 20.28 22.65 61.9%
@2 — ran a dryer cycle earlier 25.14 29.19 24.22 52.4%

Visual, and reversed. FLASH_LED addressed to one unit only, components = 0x0F, slow1 = 4, loop = 2 — run twice, targeting a different unit each time:

addressed observed
the chained unit it blinked five times and repeated twice; the USB-side unit stayed dark
the USB-side unit it blinked; the chained unit stayed dark

Five blinks is slow1 + 1 and two repeats is loop — the programmed pattern, on the addressed unit and only on it.

Running it in both directions matters: a single-direction result could be explained by something about the topology always favouring one end. Swapping the target and getting the mirror image rules that out. This is selection by address, not an artefact.


5. Operating a chain

  • Address every assigned unit at least once every 3.5 s, forever — including while later units are still being enumerated. A unit that goes quiet reverts to address 0 and starts answering broadcasts meant for enumeration. Verified: a unit assigned to 5 answered on 5; after 4.2 s of silence, 5 was dead and 0 answered again.
  • The ASSIGN acknowledgement arrives on the OLD address. Receive filtering switches to the new address immediately, but the ack for the assigning frame still carries the old one. A client awaiting it at the new address times out on a successful assignment.
  • A failed ASSIGN is total silence, indistinguishable from an absent device. There is no error frame; the unit simply does not answer.
  • Addresses do not survive a power cycle. Re-enumerate on every connect.

A working implementation of all of this is in ace2_daisy_chain.py.


Method, and what this does not claim

Two ACE 2 Pro units, firmware V1.1.31, one CH343 USB link, units chained unit-to-unit. Every figure above was captured over a live serial link; nothing is inferred. Experiment design and analysis of the results used AI assistance.

Worth recording because it shaped the work: the expectation going in was that this would fail — that two units both sitting at address 0 would answer a broadcast on top of each other and make enumeration impossible. The very first measurement refuted that, and the tell was easy to miss: the order of the two replies alternated between rounds. Units talking over each other corrupt each other; they do not take turns.

Not established: the arbitration mechanism; behaviour with three or four units — only two were available; whether these figures hold on a different chain length, cable, or firmware version.

Credit

Not claimed as new: the protocol, the frame format, the DISCOVER/ASSIGN handshake, the address byte semantics, or anything else about how the ACE 2 Pro communicates.

  • hakimio did the foundational work and everything here rests on it. His research gist carries the protocol itself, the ace_com .proto schema, an interactive shell, the OTA updater and the MCU firmware analysis; he also publishes a working ACE 2 driver. Before that research the ACE 2 Pro was a black box — the generation change to RS-485, 230400 baud and Protocol Buffers meant no earlier project could talk to it at all.
  • Simon-CR/ace2-pro-firmware-research — the research notes that build on hakimio's work, including the decoded MCU descriptors and the subsystem analysis. It is that repository's docs/13 which flags multi-unit as reported but unverified, and this note is the answer to that specific caveat.
  • Kobra-S1/ACEPRO — reported to do bus discovery with multiple units, the report that prompted the check.

This document contributes measurement of one thing those notes flagged as unverified, and nothing else.


No affiliation with or endorsement by Anycubic. Contains no firmware binary, no decompiler output and no reproduction of device program code.

#!/usr/bin/env python3
"""
Anycubic ACE Pro 2 — daisy-chain enumeration and per-unit addressing.
Demonstrates driving several chained ACE Pro 2 units over ONE USB connection,
with no USB hub: units are linked to each other through their own chain ports
and share a single half-duplex bus.
What it does
------------
1. Enumerates every unit on the bus and gives each a distinct address.
2. Keeps them all addressed (they expire after 3.5 s of silence — see below).
3. Blinks the LEDs of one chosen unit so you can see, physically, that
commands reach the unit you addressed and not its neighbour.
Usage
-----
python3 ace2_daisy_chain.py [PORT] [--blink N] [--seconds S]
PORT serial device; auto-detected if omitted
--blink N blink the unit at address N (default: 1)
--seconds S how long to hold the chain addressed (default: 25)
python3 ace2_daisy_chain.py --blink 2
Requires: pyserial
Measurements and method: ace2-daisy-chain-verified.md
Protocol: see hakimio's `ace_com` schema and shell —
https://gist.github.com/hakimio/4916ff69add458fdc51aeea76f21efb9
Licence: MIT. No affiliation with Anycubic.
"""
from __future__ import annotations
import argparse
import glob
import struct
import sys
import time
try:
import serial
except ImportError:
sys.exit("pyserial is required: pip install pyserial")
# ─────────────────────────────────────────────────────────────────────────────
# Wire format
#
# FF AA | ADDR | SEQ_LO SEQ_HI | CMD | LEN | PAYLOAD | CRC_LO CRC_HI | FE
#
# ADDR device address; bit 7 set in replies (host→ACE 0x0n, ACE→host 0x8n)
# SEQ echoed back verbatim, never validated by the device
# CRC CRC-16/MCRF4XX over ADDR..payload, i.e. 5 + LEN bytes
# ─────────────────────────────────────────────────────────────────────────────
BAUD = 230400
PREAMBLE = b"\xff\xaa"
END = 0xFE
CMD_DISCOVER = 0
CMD_ASSIGN = 1
CMD_GET_STATUS = 6
CMD_GET_INFO = 7
CMD_FLASH_LED = 70
# A unit reverts to address 0 after this long without a valid frame addressed
# to it. Poll well inside the window.
ADDRESS_TIMEOUT_S = 3.5
KEEPALIVE_S = 1.0
def crc16(data: bytes) -> int:
"""Reflected polynomial 0x8408, init 0xFFFF, no final xor.
Computed over ADDR..payload inclusive — the address byte is part of the
checksummed range.
"""
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
crc = (crc >> 1) ^ 0x8408 if crc & 1 else crc >> 1
return crc & 0xFFFF
def build(cmd: int, payload: bytes = b"", seq: int = 1, addr: int = 0) -> bytes:
inner = bytes([addr & 0xFF, seq & 0xFF, (seq >> 8) & 0xFF, cmd & 0xFF, len(payload)]) + payload
c = crc16(inner)
return PREAMBLE + inner + bytes([c & 0xFF, (c >> 8) & 0xFF, END])
def parse(buf: bytes) -> list[dict]:
"""Extract every complete, CRC-valid frame in buf.
A broadcast can return several frames back to back, one per unit, so this
keeps scanning rather than returning the first hit.
"""
out, i = [], 0
while True:
j = buf.find(PREAMBLE, i)
if j < 0 or len(buf) - j < 10:
return out
plen = buf[j + 6]
end = j + 10 + plen
if end > len(buf):
return out
if buf[end - 1] != END:
i = j + 2
continue
inner = buf[j + 2 : j + 7 + plen]
rx = buf[end - 3] | (buf[end - 2] << 8)
if rx == crc16(inner):
out.append(
{
"addr": buf[j + 2],
"seq": buf[j + 3] | (buf[j + 4] << 8),
"cmd": buf[j + 5],
"payload": bytes(buf[j + 7 : j + 7 + plen]),
}
)
i = end
# ── minimal protobuf ─────────────────────────────────────────────────────────
def pb_uint32(field: int, value: int) -> bytes:
out = bytes([field << 3])
while True:
b, value = value & 0x7F, value >> 7
out += bytes([b | (0x80 if value else 0)])
if not value:
return out
def pb_decode(data: bytes) -> dict:
"""Enough of proto3 to read these messages. Repeated fields become lists."""
def varint(pos):
r = s = 0
while True:
b = data[pos]
pos += 1
r |= (b & 0x7F) << s
if not b & 0x80:
return r, pos
s += 7
out, p = {}, 0
while p < len(data):
try:
key, p = varint(p)
except IndexError:
break
tag, wire = key >> 3, key & 7
if wire == 0:
v, p = varint(p)
elif wire == 2:
n, p = varint(p)
v, p = data[p : p + n], p + n
elif wire == 5:
v, p = struct.unpack_from("<f", data, p)[0], p + 4
elif wire == 1:
v, p = struct.unpack_from("<d", data, p)[0], p + 8
else:
break
out.setdefault(tag, []).append(v)
return {k: (v[0] if len(v) == 1 else v) for k, v in out.items()}
# ── transport ────────────────────────────────────────────────────────────────
class Bus:
def __init__(self, port: str):
# Do not assert DTR/RTS — on some adapters they are wired to a reset.
self.s = serial.Serial()
self.s.port, self.s.baudrate, self.s.timeout = port, BAUD, 0.05
self.s.dtr = self.s.rts = False
self.s.open()
time.sleep(0.3)
self.s.reset_input_buffer()
self._seq = 0
def close(self):
self.s.close()
def request(self, cmd: int, payload: bytes = b"", addr: int = 0, wait: float = 0.3) -> list[dict]:
"""Send one frame and collect every reply that arrives inside `wait`.
`wait` must be generous for broadcasts: units answer at uncorrelated
moments spread over roughly 45 ms. Some commands also take noticeably
longer to reply than a status read, so a short timeout can look like
failure when it is not.
"""
self._seq = (self._seq + 1) & 0xFFFF
self.s.reset_input_buffer()
self.s.write(build(cmd, payload, seq=self._seq, addr=addr))
self.s.flush()
deadline, buf = time.time() + wait, bytearray()
while time.time() < deadline:
buf += self.s.read(256)
return parse(bytes(buf))
# ── chain ────────────────────────────────────────────────────────────────────
def enumerate_chain(bus: Bus, max_units: int = 4) -> dict[int, tuple]:
"""Give every unit on the bus a distinct address. Returns {address: uid}.
Self-thinning loop: each round needs only ONE clean reply. Assigning that
unit moves it off address 0, so contention strictly decreases and the last
unit enumerates alone. A collided round simply costs a retry.
"""
assigned: dict[int, tuple] = {}
next_addr = 1
while next_addr <= max_units:
uid = None
for _ in range(6): # retries absorb the occasional collision
for f in bus.request(CMD_DISCOVER, addr=0, wait=0.25):
if f["cmd"] != CMD_DISCOVER:
continue
d = pb_decode(f["payload"])
cand = (d.get(1, 0), d.get(2, 0), d.get(3, 0))
if cand != (0, 0, 0) and cand not in assigned.values():
uid = cand
break
if uid:
break
keepalive(bus, assigned)
if uid is None:
break # nothing left at address 0: the chain is fully enumerated
payload = (
pb_uint32(1, uid[0]) + pb_uint32(2, uid[1])
+ pb_uint32(3, uid[2]) + pb_uint32(4, next_addr)
)
# NOTE: the acknowledgement comes back on the unit's OLD address, not
# the new one. Do not wait for it at `next_addr` — you will time out
# even though the assignment succeeded.
bus.request(CMD_ASSIGN, payload, addr=0, wait=0.3)
if bus.request(CMD_DISCOVER, addr=next_addr, wait=0.25):
assigned[next_addr] = uid
next_addr += 1
keepalive(bus, assigned)
return assigned
def keepalive(bus: Bus, assigned: dict[int, tuple]) -> None:
"""Touch every assigned unit so none falls back to address 0.
This must keep running for as long as you intend to use the chain,
including while later units are still being enumerated.
"""
for addr in assigned:
bus.request(CMD_GET_INFO, addr=addr, wait=0.06)
def flash_led(bus: Bus, addr: int, loop: int = 2, slow1: int = 4) -> None:
"""Blink one unit's LEDs — the visible proof that addressing works.
Field names follow the published `ace_com` schema: `components` selects
which outputs to drive, `loop` is the repeat count, and `quick*` / `slow*`
are blink counts with short and long on-times respectively.
`components = 0x0F` selects the LED channels. Other bits in that field
drive other hardware; do not set them speculatively. `slow1` is used here
because its longer on-time is easy to see from across a room, and it
produces `slow1 + 1` flashes.
"""
payload = (
pb_uint32(1, 0x0F) # components: the LED channels
+ pb_uint32(2, loop) # loop
+ pb_uint32(3, 0) # quick1
+ pb_uint32(4, slow1) # slow1
+ pb_uint32(5, 0) # quick2
+ pb_uint32(6, 0) # slow2
)
bus.request(CMD_FLASH_LED, payload, addr=addr, wait=0.3)
def find_port() -> str:
for pattern in ("/dev/cu.usbmodem*", "/dev/ttyACM*", "/dev/ttyUSB*"):
hits = sorted(glob.glob(pattern))
if hits:
return hits[0]
sys.exit("No serial port found — pass one explicitly.")
def main() -> None:
ap = argparse.ArgumentParser(description="ACE Pro 2 daisy-chain demo")
ap.add_argument("port", nargs="?", help="serial port (auto-detected if omitted)")
ap.add_argument("--blink", type=int, default=1, metavar="N", help="blink the unit at address N")
ap.add_argument("--seconds", type=float, default=25.0, help="how long to hold the chain")
args = ap.parse_args()
port = args.port or find_port()
print(f"port: {port} @ {BAUD} 8N1")
bus = Bus(port)
try:
print("\nenumerating…")
units = enumerate_chain(bus)
if not units:
sys.exit("No units answered. Check power and the chain cable.")
for addr, uid in units.items():
info = bus.request(CMD_GET_INFO, addr=addr, wait=0.2)
ver = pb_decode(info[0]["payload"]).get(1, b"?") if info else b"?"
ver = ver.decode(errors="replace") if isinstance(ver, bytes) else ver
print(f" address {addr}: uid {uid[0]}/{uid[1]}/{uid[2]} firmware {ver}")
print(f"\n{len(units)} unit(s) on one USB connection, no hub.")
if args.blink in units:
print(f"\nblinking address {args.blink} only — the others must stay dark")
flash_led(bus, args.blink)
elif args.blink:
print(f"\n(address {args.blink} not present; skipping blink)")
print(f"holding the chain addressed for {args.seconds:.0f}s "
f"(units expire after {ADDRESS_TIMEOUT_S}s of silence)")
end = time.time() + args.seconds
while time.time() < end:
keepalive(bus, units)
time.sleep(KEEPALIVE_S)
print("\ndone — addresses will lapse back to 0 shortly")
finally:
bus.close()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment