xiaomi: pan/tilt motor control + ONVIF PTZ for xiaomi:// sources - #2531
bcamarneiro wants to merge 2 commits into
Conversation
MISS cameras move with command 0x112 ({"operation": 1 left|2 right|3 up|4 down}),
the same way the Mi Home app does. go2rtc already declared cmdMotorReq but never
sent it. The command is sent over the session the stream already holds: many of
these cameras accept a single P2P session, so dialing a second one would kill the
video.
- miss.Client/Producer.Motor(op) and streams.Stream.Conns() to reach a stream's
live producer
- POST /api/xiaomi/motor?src=<stream>&operation=<1-4>
- a minimal PTZ service on the built-in ONVIF server, advertised only for streams
with a xiaomi:// source: continuous pan/tilt in the generic velocity space.
ContinuousMove becomes one motor step per tick until Stop (capped at 10 s if
Stop never arrives), so NVRs such as Frigate show pan/tilt arrows.
Refs AlexxIT#2162
| func initPTZ() { | ||
| onvif.PTZ = func(name string) bool { |
There was a problem hiding this comment.
Fixed in e23a21b. Added a second hook, onvif.PTZAny, that reports whether any current stream has a PTZ-capable source. GetCapabilities and GetServices now gate on that instead of on the per-stream callback being registered, so an instance without xiaomi:// streams no longer advertises a PTZ service.
| go func() { | ||
| defer stopMove(name) | ||
| ticker := time.NewTicker(interval) |
There was a problem hiding this comment.
Fixed in e23a21b. Each move is now a *move value and the goroutine's cleanup (endMove) only deletes the map entry if it still points at its own move, so a stale goroutine cannot cancel the ContinuousMove that replaced it. Stop and replacement still go through stopMove, which cancels whatever is current. Covered by TestEndMoveKeepsReplacement.
| return ops, interval | ||
| } | ||
|
|
||
| var reVelocity = regexp.MustCompile(`PanTilt[^>]*?\bx="([^"]+)"[^>]*?\by="([^"]+)"`) |
There was a problem hiding this comment.
Fixed in e23a21b. The parser now isolates the PanTilt start tag and matches x and y independently, so attribute order (and single vs double quotes) no longer matters. TestParseVelocity gained cases for y-before-x, single quotes, and a Zoom-only body.
| if PTZ != nil { | ||
| e.Appendf(` | ||
| <tt:PTZ> | ||
| <tt:XAddr>http://%s%s</tt:XAddr> | ||
| </tt:PTZ>`, host, PathPTZ) |
There was a problem hiding this comment.
Fixed in e23a21b. This now checks hasAnyPTZ(), which asks the module whether any current stream has a PTZ-capable source, instead of testing the callback for nil.
| if PTZ != nil { | ||
| e.Appendf(` | ||
| <tds:Service> | ||
| <tds:Namespace>http://www.onvif.org/ver20/ptz/wsdl</tds:Namespace> | ||
| <tds:XAddr>http://%s%s</tds:XAddr> | ||
| <tds:Version><tt:Major>2</tt:Major><tt:Minor>5</tt:Minor></tds:Version> | ||
| </tds:Service>`, host, PathPTZ) |
There was a problem hiding this comment.
Fixed in e23a21b. Same hasAnyPTZ() check as in GetCapabilities, so the PTZ service entry is only listed when at least one profile actually carries a PTZ configuration.
- Advertise the PTZ service in GetCapabilities/GetServices only when at least one stream can actually pan/tilt (new onvif.PTZAny hook), instead of whenever the callback is registered. - Track each ContinuousMove by owner so a finished goroutine no longer cancels the move that replaced it when a client changes direction or speed without an intervening Stop. - Parse the PanTilt x and y attributes independently of their order and quote style.
|
I’ve been testing the Xiaomi MISS PTZ path on real hardware (xiaomi.camera.c01a01) and continued the work on top of @korasino’s branch.
|
|
Draft PR with the hardware-verified feedback / absolute positioning / auto-center work is now available here: |

Implements the flow proposed in #2162: go2rtc sends the Xiaomi motor command itself and exposes it through its own ONVIF server, so an NVR can pan/tilt a Xiaomi camera as if it were a native ONVIF PTZ camera.
What it does
cmdMotorReq(0x112) and{"operation": N}(1 left, 2 right, 3 up, 4 down) — the same thing the Mi Home app sends. go2rtc already declaredcmdMotorReqbut never used it.miss.Client.Motor()/miss.Producer.Motor()send it over the session the stream already holds: the camera I tested accepts a single P2P session, and dialing a second one drops the video.streams.Stream.Conns()returns the connected producers, so a module can find one that implementsMotor(int) error(duck-typed, no vendor imports ininternal/onvif).POST /api/xiaomi/motor?src=<stream>&operation=<1-4>.GetCapabilities/GetServicesand as aPTZConfigurationin the profile only for streams with axiaomi://source:GetConfigurationOptions,GetConfigurations/GetConfiguration,GetNodes/GetNode,GetPresets(empty),GetStatus,ContinuousMove,Stop. Only continuous pan/tilt in the generic velocity space — no zoom, no relative/absolute moves, no presets.ContinuousMovebecomes one motor step per tick untilStop; ifStopnever arrives the move is capped at 10 s. 400 ms per step at |v| ≥ 0.5, longer gaps for smaller velocities (up to 1.2 s), a 0.1 dead zone, both axes on a diagonal. On any motor error the loop stops rather than retrying (during a reconnect a step can land even when the call errors).Tested
On a Mi 360° Home Security Camera 2K (
chuangmi.camera.029a02, cs2), go2rtc 1.9.14 + this patch, with Frigate 0.17.2 pointing itsonvif:at go2rtc:ptand shows the arrows; press → moves, release → stops.go vetclean on the touched packages, new unit tests ininternal/onvif/ptz_test.go(velocity → steps mapping, and parsing a zeep-serialisedContinuousMove).TestTempateininternal/streamsfails on current master with or without this change.Open points
xiaomi://stream, including cameras without a motor — there the move just fails and stops. A per-model list, or probing0x113responses, could narrow it.user/passwordinonvif:(its client fails building WS-Security without them); go2rtc ignores them.Refs #2162