Drive the zoom/focus/iris lens motors and the pan/tilt head of Anjoy AF camera modules (SigmaStar-based boards) over the motor-MCU UART, read back the MCU's live position reports, and servo the zoom to an absolute optical multiple.
Verified on an MTF45-4G_AF (FW V3.4.5.6), /dev/ttyS2, 57600 8N1 —
not 115200: at 115200 the MCU is completely deaf (rx counter stays flat,
zero ACKs); at 57600 it ACKs and streams reports. The vendor rate is also
baked into comm_server as the termios constant B57600|CS8|CREAD|CLOCAL.
The wire format was recovered from the vendor's own comm_server with an
LD_PRELOAD write() logger while driving the web PTZ verbs, then verified
standalone: handshake + state stream + a command frame moves the real
motor with every vendor daemon dead.
The MCU ignores bare command frames. What it demands is a live session:
/* state frame, 16 bytes, sent every ~280 ms, idle and during motion */
51 01 04 78 01 0d 59 56 07 20 2f 00 20 00 00 CK
CK = sum of bytes 0..14 (INCLUDING 0x51)- bytes 6..9 are live lens telemetry (they ramp while a motor moves), byte 10 drifts slowly — a frame frozen from a capture works fine;
- the MCU needs the stream for a few seconds before it answers at all: prime with ~14 frames (~4 s, vendor cold-start pacing, 300-500 ms handshake gaps) and ACKs + reports flow; two frames buy total silence.
- optional one-way boot handshake before priming ( vendor sends it, the
MCU never replies):
END×2,VER,EXVER×2, ASCII + 8-bit payload sum (END=45 4e 44 d7,VER=56 45 52 ed,EXVER=45 58 56 45 52 8a), plus a poll frame51 01 04 79 91 01 00 61 00every ~10-30 s; - without this stream,
txon/proc/tty/driver/ms_uartclimbs butrxnever twitches — that flat rx is the signature of a rejected session.
7-byte Pelco-D-style frames, address 0x01,
CK = (01 + C1 + C2 + D1 + D2) & 0xff:
| Verb | C1 | C2 | D1 | D2 | Frame |
|---|---|---|---|---|---|
| zoom tele | 00 | 20 | 14 | 14 | FF 01 00 20 14 14 49 |
| zoom wide | 00 | 40 | 14 | 14 | FF 01 00 40 14 14 69 |
| focus near | 01 | 00 | 30 | 30 | FF 01 01 00 30 30 62 |
| focus far | 00 | 80 | 30 | 30 | FF 01 00 80 30 30 E1 |
| iris open | 02 | 00 | 30 | 30 | FF 01 02 00 30 30 63 |
| iris close | 04 | 00 | 30 | 30 | FF 01 04 00 30 30 65 |
| head up | 00 | 08 | 00 | 14 | FF 01 00 08 00 14 1D |
| head down | 00 | 10 | 00 | 14 | FF 01 00 10 00 14 25 |
| head left | 00 | 04 | 14 | 00 | FF 01 00 04 14 00 19 |
| head right | 00 | 02 | 14 | 00 | FF 01 00 02 14 00 17 |
| stop | 00 | 00 | 00 | 00 | FF 01 00 00 00 00 01 |
- D1/D2 are speed bytes; the vendor maps UI speed 1..10 to
5*v+5(1→0x0a,3→0x14,10→0x37). Focus/iris verbs always carry30 30. - C2 direction bits OR for diagonals (vendor emitted
0x0C= left+up). - Motion starts on the verb frame and runs until a stop frame (all-zero direction fields) — one stop frame is enough, the motor obeyed ~20 ms after it left the wire (measured via report cutoff).
While a motor is actually moving (pushing against an endstop counts), the MCU continuously sends 17-byte position reports at ~150-300 ms, with each position typically seen twice:
51 01 04 78 01 0A 0A 10 3B ZZ ZZ 21 3B FF FF 3B CK
^^^^^ zoom ^^^^^ focus
CK(byte 16) = 8-bit sum of bytes 0..15.- zz (bytes 9-10) decode as DECIMAL DIGITS: byte 9 = tens (zero is
transmitted as
0x3B), byte 10 = units. Scale is 1..30, wide → tele: wide endstop reads3B 01(=1), tele endstop03 00(=30). The old "16-bit big-endian counter" theory is dead —3B 3B 08is position 8, not0x3B08. - bytes 13-14 = raw auxiliary field, semantics unknown (drifted
14 0F→16 0Fonce overnight). It holds still through real zoom AND focus travel — unchanged across a 2.5 s focus jog — so the JSONfocus_poskey is continuity naming, not true focus feedback: the report carries none. All-3Bframes (bytes 9..15 drowned in0x3B) appear during link shutdown and, on this module, as a one-shot "no lens telemetry" marker emitted while the iris axis is moving; decoded they land zoom > 30 and the range guard drops them — they are printed for diagnosis but do not count as feedback, so the goto dead-link watchdog keys on position-valid frames only. - single-byte ACKs sprinkle through the stream (
21,78,3b…); they are not position data.
Decode live with -j (one JSON object per report on stdout, TX
diagnostics on stderr — the stream is clean for web-UI consumption):
{"zoom_pos":8,"focus_pos":5135,"flags":"040A","raw":"51010478010A0A103B3B08213B140F3B2B"}
-m <multiple> maps the target onto the 4.4×..45× geometric lens curve
(multiple(pos) = 4.4 * (45/4.4)^((pos-1)/29), exact at the endstops,
midpoints uncalibrated), -p <pos> aims at a position 1..30 directly.
The loop then:
- drives fast (speed
0x14) toward the target — first move is blind (reports only come once a motor moves), direction is corrected on the first report; coverage ≈ 2 pos/s; - stops on the first crossing report (~0 coast observed at
0x14); - settles 700 ms, then runs one slow (
0x0A) closed-loop correction approach if the residual delta is nonzero — corrections shorter than the report cadence are invisible to the loop, so an earlier design blind-pulsed and oscillated (7 → 12 → runaway); - always ends with a stop frame, even on the 30 s budget expiry or a dead RX link — and the dead-link watchdog fires even before the first report ever arrives (4 s of RX silence from a standing start stops the motor instead of driving it blind for the whole budget). Clock-valid shutdown garbage does not refresh the watchdog.
Verified live in both directions with exact landings and exit 0:
21→8, 9→20, 20→3 (-p), 3→15 (-m 12.5 → position 15), plus a
-p 1 park at the wide endstop. A miss, a dead link, or a failed
safety-stop write exits 1; the session ends as soon as the loop is done
(the 30 s budget is a ceiling, not a run time), and a stop frame always
goes out on the way out. All numeric options are strictly parsed —
negative -t/-r (which would disable the auto-stop) and -m nan
(which would decode to a full-tele target) are rejected before the
port is even opened.
./anjoy-motor -d T -m 12.5 -j # zoom to ~12.5x
./anjoy-motor -d T -p 8 -j -r 800 # zoom to position 8 on the 1..30 scale
The module keeps a usable image across a zoom move with zero focus
traffic. Wired down with the vendor's own writes captured (LD_PRELOAD
write() logger riding a replacement comm_server, media_server
left alive) plus the kernel UART counters sampled at 2 Hz
(/proc/tty/driver/ms_uart tx/rx against the logger's byte count):
- a zoom action is one jog frame held until release plus one stop frame — no focus frame is ever sent during or after the move;
- kernel TX matched the logger's accounted writes byte-exactly across
idle, jog and post-jog windows —
comm_serveris the only writer on the wire, so no daemon runs a hidden focus loop (media_server included); - kernel RX rises only while a motor actually moves and is flat zero through the post-stop seconds while the image visibly sharpens — the lens is idle and nobody talks to it, so that recovery is the encoder's rate control catching up after the motion burst, not a lens act;
- there is no software autofocus: a lens knocked out of focus with
a focus jog stays out of focus (20 s+ observed, zero rescue bytes).
What the vendor UI does after zooming is a manual focus jog — the
same
-d r/-d lthis tool offers.
Zoom sharpness in the first place is optics: the lens is near-parfocal and tracks on a stationary focus. "Zoom and stay sharp" therefore needs nothing beyond the verbs in this Readme — jog the zoom, then jog focus by eye if the scene warrants it.
There is no absolute iris control anywhere in the stack, and no iris position feedback either:
- the 17-byte reports carry zoom digits plus one opaque auxiliary
field (bytes 13-14 — see above); there is no iris feedback. Byte-diffing reports
captured around real iris travel (0.8 s open / 1.5 s close with the
vendor stack frozen) shows every byte identical before and after —
the only event during the move is a single all-
3Bframe. A closed-loop iris is therefore impossible over this link. - the vendor surface is binary everywhere: CGI
/ptz_ctrl/iristakes-1 or 1; the 8091 protocol exposes onlyirisopen/irisclosejog verbs, which is exactly what the web UI's IrisSmall/IrisLarge paddle buttons drive (press-and-hold, release =stopPtz); the localhik_serverISAPI emulation adds onlyIrisOpenAutoOff/IrisCloseAutoOffjog verbs and anIrisModeauto/manual flag. No numeric aperture verb exists in any of them.
Intermediate apertures are therefore pulse-width only: -d I/-d O
hold the axis moving for -t seconds and a mid-travel stop parks it
there — the same thing the vendor's own UI does with button hold
times. Calibrate per lens module. Caveat: an auto-exposure gain swing
can mask small aperture steps in the video, and on the MTF45-4G_AF the
image brightness did not measurably move across 2.5 s / 3.5 s pulses in
a bench test — so before promising stops, confirm on your module that
the iris axis is physically motorized by watching DoF or brightness
with exposure fixed.
./anjoy-motor -d I -t 0.8 -j # iris open 0.8 s, parks mid-travel
./anjoy-motor -d O -t 0.5 -j # iris close 0.5 s
make # arm-openipc-linux-musleabi-gcc, static
./anjoy-motor -d u -t 2 -r 1500 -j # zoom tele 2 s, stream positions
./anjoy-motor -d s -t 0 -r 1500 -j # stop; listen for in-flight reports
./anjoy-motor -d T -m 20 -j # absolute zoom, closed loop
-r ms extends the listen window after the move (reports keep flowing
while the motor halts); -B inherits the port settings instead of
reconfiguring; -s overrides the speed bytes; -D picks another UART.
Run with no arguments for the full list.
The stock firmware has two daemons on this UART, and they are not symmetric:
comm_serveris the writer/session owner.procmanrespawns it within seconds if it dies — SIGSTOPprocmanfirst, then SIGSTOPcomm_server. A SIGSTOPped comm_server is tolerated by the system indefinitely (hours observed); the lens and RX link care only about the byte stream, and the frozen daemon stops transmitting idle stop frames that would otherwise cancel your moves every ~2 s.media_serveralso holds the port open and READS it. It is quiet while comm_server is healthy, but once comm_server is dead or frozen it wakes up and eats the incoming bytes: a test session saw 2 bytes of a +170 report burst; a goto run parsed 3 of ~140 frames. Commands still work (TX is unaffected — the motor obeys whoever writes a valid session), but closed-loopTstarves. SIGSTOPping media_server wins the RX stream back — for a while: a system watchdog reboots the camera roughly 60 s after media_server freezes (observed twice). Keep exclusive-RX test runs inside that window andkill -CONTafter. The reboot is harmless — the vendor stack comes back cleanly — but/tmpis tmpfs: write logs to/mnt/nandif a window may snap shut.
killall/killall -9 comm_server matches nothing: these daemons scrub
their argv (empty cmdline), so pidof-style matching on /proc/*/comm or
explicit PIDs is required.
On OpenIPC there is no vendor stack on the port and none of this applies —
anjoy-motor has the UART to itself.