Skip to content

xiaomi: pan/tilt motor control + ONVIF PTZ for xiaomi:// sources - #2531

Open
bcamarneiro wants to merge 2 commits into
AlexxIT:masterfrom
bcamarneiro:xiaomi-ptz
Open

bcamarneiro wants to merge 2 commits into
AlexxIT:masterfrom
bcamarneiro:xiaomi-ptz

Conversation

@bcamarneiro

Copy link
Copy Markdown

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

  • Motor command. MISS cameras move with cmdMotorReq (0x112) and {"operation": N} (1 left, 2 right, 3 up, 4 down) — the same thing the Mi Home app sends. go2rtc already declared cmdMotorReq but 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.
  • Reaching the live producer. streams.Stream.Conns() returns the connected producers, so a module can find one that implements Motor(int) error (duck-typed, no vendor imports in internal/onvif).
  • HTTP. POST /api/xiaomi/motor?src=<stream>&operation=<1-4>.
  • ONVIF PTZ. A minimal PTZ service on the built-in ONVIF server, advertised in GetCapabilities/GetServices and as a PTZConfiguration in the profile only for streams with a xiaomi:// 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.
  • Continuous → discrete. ContinuousMove becomes one motor step per tick until Stop; if Stop never 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 its onvif: at go2rtc:

  • Frigate detects the camera as pt and shows the arrows; press → moves, release → stops.
  • One step ≈ 70 px of a 640 px frame on pan, ≈ 40 px on tilt. Five steps 400 ms apart all landed (≈ 345 px), and the same steps in the opposite direction returned to the original framing.
  • Video keeps flowing while moving.

go vet clean on the touched packages, new unit tests in internal/onvif/ptz_test.go (velocity → steps mapping, and parsing a zeep-serialised ContinuousMove). TestTempate in internal/streams fails on current master with or without this change.

Open points

  • The 400 ms step interval was measured on one model; other Xiaomi PTZ cameras may take longer per step.
  • PTZ is advertised for every xiaomi:// stream, including cameras without a motor — there the move just fails and stops. A per-model list, or probing 0x113 responses, could narrow it.
  • The Frigate side needs placeholder user/password in onvif: (its client fails building WS-Security without them); go2rtc ignores them.

Refs #2162

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
Copilot AI lite review requested due to automatic review settings September 28, 2026 13:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved PTZ advertisement, move cancellation, parsing, and timeout-handling issues remain.

Review effort: Lite
Findings: 5 Medium severity

Open (5)

Comment thread internal/onvif/ptz.go Outdated
Comment on lines +38 to +39
func initPTZ() {
onvif.PTZ = func(name string) bool {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/onvif/ptz.go
Comment on lines +103 to +105
go func() {
defer stopMove(name)
ticker := time.NewTicker(interval)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/onvif/ptz.go Outdated
return ops, interval
}

var reVelocity = regexp.MustCompile(`PanTilt[^>]*?\bx="([^"]+)"[^>]*?\by="([^"]+)"`)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pkg/onvif/server.go Outdated
Comment on lines +73 to +77
if PTZ != nil {
e.Appendf(`
<tt:PTZ>
<tt:XAddr>http://%s%s</tt:XAddr>
</tt:PTZ>`, host, PathPTZ)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pkg/onvif/server.go Outdated
Comment on lines +98 to +104
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)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@AlexxIT AlexxIT self-assigned this Oct 1, 2026
@bkulpa

bkulpa commented Oct 5, 2026

Copy link
Copy Markdown

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.
Hardware-verified findings:

  • cmdMotorReq operation 6 returns the current PTZ position through 0x113
  • CS2 command IDs need to be decoded as big-endian (00 00 10 01 → 0x1001)
  • the 0x113 JSON response on this camera has a trailing NUL byte
  • operation 13 provides absolute positioning
  • feedback-driven auto-center works using RefreshPosition -> SetPosition(50,50) -> RefreshPosition
    Real-device test: starting at angle=45, elevation=37, auto-center finished at 49/49, and a separate refresh confirmed the same position while video remained active.
    Branch:
    https://github.com/bkulpa/go2rtc/tree/feat/xiaomi-auto-center
    My follow-up commits are:
  • bkulpa@c88e1c9 — fix CS2 PTZ feedback handling
  • bkulpa@a23db13 — add feedback-driven PTZ auto-center
    This goes beyond the current ContinuousMove scope of this PR. I’d be happy to prepare it as a follow-up PR, or adapt it to this PR if that is preferred.

@bkulpa

bkulpa commented Oct 5, 2026

Copy link
Copy Markdown

Draft PR with the hardware-verified feedback / absolute positioning / auto-center work is now available here:
korasino#1

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants