From 7d1983660833311d2a012b7a111a28efc2e9c4b2 Mon Sep 17 00:00:00 2001
From: ucbmckee
Date: Sun, 9 Aug 2026 22:06:17 -0400
Subject: [PATCH] reolink: add native Baichuan support
---
README.md | 1 +
internal/README.md | 2 +
internal/reolink/README.md | 85 +++++
internal/reolink/reolink.go | 15 +
main.go | 2 +
pkg/README.md | 1 +
pkg/baichuan/adpcm.go | 118 +++++++
pkg/baichuan/capability.go | 124 +++++++
pkg/baichuan/capability_test.go | 230 +++++++++++++
pkg/baichuan/client.go | 369 +++++++++++++++++++++
pkg/baichuan/client_test.go | 229 +++++++++++++
pkg/baichuan/config.go | 192 +++++++++++
pkg/baichuan/config_test.go | 116 +++++++
pkg/baichuan/crypto.go | 103 ++++++
pkg/baichuan/crypto_test.go | 41 +++
pkg/baichuan/device.go | 88 +++++
pkg/baichuan/device_test.go | 41 +++
pkg/baichuan/media.go | 362 +++++++++++++++++++++
pkg/baichuan/media_test.go | 328 +++++++++++++++++++
pkg/baichuan/message.go | 245 ++++++++++++++
pkg/baichuan/message_test.go | 100 ++++++
pkg/baichuan/preview.go | 218 +++++++++++++
pkg/baichuan/talk.go | 221 +++++++++++++
pkg/baichuan/talk_test.go | 247 ++++++++++++++
pkg/baichuan/talk_xml.go | 159 +++++++++
pkg/baichuan/transport.go | 81 +++++
pkg/baichuan/transport_test.go | 144 +++++++++
pkg/baichuan/udp.go | 157 +++++++++
pkg/baichuan/udp_test.go | 68 ++++
pkg/baichuan/uid_conn.go | 293 +++++++++++++++++
pkg/baichuan/uid_conn_test.go | 241 ++++++++++++++
pkg/baichuan/uid_deadline.go | 51 +++
pkg/baichuan/uid_discovery.go | 278 ++++++++++++++++
pkg/baichuan/uid_discovery_test.go | 78 +++++
pkg/baichuan/uid_loop.go | 210 ++++++++++++
pkg/baichuan/uid_reliability_test.go | 168 ++++++++++
pkg/baichuan/uid_send.go | 118 +++++++
pkg/baichuan/uid_sockopt.go | 9 +
pkg/baichuan/uid_sockopt_windows.go | 9 +
pkg/baichuan/xml.go | 134 ++++++++
pkg/baichuan/xml_test.go | 32 ++
pkg/reolink/backchannel.go | 270 ++++++++++++++++
pkg/reolink/backchannel_test.go | 252 +++++++++++++++
pkg/reolink/camera.go | 92 ++++++
pkg/reolink/config.go | 193 +++++++++++
pkg/reolink/media.go | 360 +++++++++++++++++++++
pkg/reolink/producer.go | 198 ++++++++++++
pkg/reolink/producer_test.go | 252 +++++++++++++++
pkg/reolink/profile.go | 466 +++++++++++++++++++++++++++
pkg/reolink/profile_input.go | 149 +++++++++
pkg/reolink/profile_liveness.go | 63 ++++
pkg/reolink/profile_liveness_test.go | 259 +++++++++++++++
pkg/reolink/profile_test.go | 382 ++++++++++++++++++++++
pkg/reolink/registry.go | 160 +++++++++
pkg/reolink/registry_test.go | 418 ++++++++++++++++++++++++
pkg/reolink/track_test.go | 189 +++++++++++
pkg/reolink/video.go | 171 ++++++++++
pkg/reolink/video_test.go | 335 +++++++++++++++++++
website/.vitepress/config.js | 3 +-
www/schema.json | 1 +
60 files changed, 9920 insertions(+), 1 deletion(-)
create mode 100644 internal/reolink/README.md
create mode 100644 internal/reolink/reolink.go
create mode 100644 pkg/baichuan/adpcm.go
create mode 100644 pkg/baichuan/capability.go
create mode 100644 pkg/baichuan/capability_test.go
create mode 100644 pkg/baichuan/client.go
create mode 100644 pkg/baichuan/client_test.go
create mode 100644 pkg/baichuan/config.go
create mode 100644 pkg/baichuan/config_test.go
create mode 100644 pkg/baichuan/crypto.go
create mode 100644 pkg/baichuan/crypto_test.go
create mode 100644 pkg/baichuan/device.go
create mode 100644 pkg/baichuan/device_test.go
create mode 100644 pkg/baichuan/media.go
create mode 100644 pkg/baichuan/media_test.go
create mode 100644 pkg/baichuan/message.go
create mode 100644 pkg/baichuan/message_test.go
create mode 100644 pkg/baichuan/preview.go
create mode 100644 pkg/baichuan/talk.go
create mode 100644 pkg/baichuan/talk_test.go
create mode 100644 pkg/baichuan/talk_xml.go
create mode 100644 pkg/baichuan/transport.go
create mode 100644 pkg/baichuan/transport_test.go
create mode 100644 pkg/baichuan/udp.go
create mode 100644 pkg/baichuan/udp_test.go
create mode 100644 pkg/baichuan/uid_conn.go
create mode 100644 pkg/baichuan/uid_conn_test.go
create mode 100644 pkg/baichuan/uid_deadline.go
create mode 100644 pkg/baichuan/uid_discovery.go
create mode 100644 pkg/baichuan/uid_discovery_test.go
create mode 100644 pkg/baichuan/uid_loop.go
create mode 100644 pkg/baichuan/uid_reliability_test.go
create mode 100644 pkg/baichuan/uid_send.go
create mode 100644 pkg/baichuan/uid_sockopt.go
create mode 100644 pkg/baichuan/uid_sockopt_windows.go
create mode 100644 pkg/baichuan/xml.go
create mode 100644 pkg/baichuan/xml_test.go
create mode 100644 pkg/reolink/backchannel.go
create mode 100644 pkg/reolink/backchannel_test.go
create mode 100644 pkg/reolink/camera.go
create mode 100644 pkg/reolink/config.go
create mode 100644 pkg/reolink/media.go
create mode 100644 pkg/reolink/producer.go
create mode 100644 pkg/reolink/producer_test.go
create mode 100644 pkg/reolink/profile.go
create mode 100644 pkg/reolink/profile_input.go
create mode 100644 pkg/reolink/profile_liveness.go
create mode 100644 pkg/reolink/profile_liveness_test.go
create mode 100644 pkg/reolink/profile_test.go
create mode 100644 pkg/reolink/registry.go
create mode 100644 pkg/reolink/registry_test.go
create mode 100644 pkg/reolink/track_test.go
create mode 100644 pkg/reolink/video.go
create mode 100644 pkg/reolink/video_test.go
diff --git a/README.md b/README.md
index 529cac902..a5186176f 100644
--- a/README.md
+++ b/README.md
@@ -194,6 +194,7 @@ A summary table of all modules and features can be found [here](internal/README.
- [`kasa`](internal/kasa/README.md) - [TP-Link Kasa](https://www.kasasmart.com/) cameras.
- [`multitrans`](internal/multitrans/README.md) - Two-way audio for Chinese version of [TP-Link](https://www.tp-link.com.cn/) cameras.
- [`nest`](internal/nest/README.md) - [Google Nest](https://developers.google.com/nest/device-access/supported-devices) cameras through user-unfriendly and paid APIs.
+- [`reolink`](internal/reolink/README.md) - Experimental native Baichuan LAN TCP/UID streaming and two-way audio for Reolink cameras.
- [`ring`](internal/ring/README.md) - Ring cameras with two-way audio support.
- [`roborock`](internal/roborock/README.md) - [Roborock](https://roborock.com/) vacuums with cameras with two-way audio support.
- [`tapo`](internal/tapo/README.md) - [TP-Link Tapo](https://www.tapo.com/) cameras with two-way audio support.
diff --git a/internal/README.md b/internal/README.md
index f3e9f3b34..38f95eaa3 100644
--- a/internal/README.md
+++ b/internal/README.md
@@ -52,6 +52,7 @@ Some formats and protocols go2rtc supports exclusively. They have no equivalent
| [`multitrans`] | `rtp` | `tcp` | | | | yes |
| [`nest`] | `srtp` | `rtsp`, `webrtc` | yes | | | no |
| [`onvif`] | `rtp` | * | yes | yes | | |
+| [`reolink`] | `baichuan` | `tcp`, `udp` | yes | | | yes |
| [`ring`] | `srtp` | `webrtc` | yes | | | yes |
| [`roborock`] | `srtp` | `webrtc` | yes | | | yes |
| [`rtmp`] | `flv` | `rtmp` | yes | yes | yes | |
@@ -96,6 +97,7 @@ Some formats and protocols go2rtc supports exclusively. They have no equivalent
[`ngrok`]: ngrok/README.md
[`onvif`]: onvif/README.md
[`pinggy`]: pinggy/README.md
+[`reolink`]: reolink/README.md
[`ring`]: ring/README.md
[`roborock`]: roborock/README.md
[`rtmp`]: rtmp/README.md
diff --git a/internal/reolink/README.md b/internal/reolink/README.md
new file mode 100644
index 000000000..97821297b
--- /dev/null
+++ b/internal/reolink/README.md
@@ -0,0 +1,85 @@
+# Reolink
+
+Experimental native Baichuan source for Reolink cameras on a trusted local network.
+
+- H.264/H.265 video from `main`, `sub`, and `extern` profiles
+- AAC audio or camera ADPCM converted to PCMA
+- PCMA/PCMU/PCM/PCML two-way audio converted to camera ADPCM
+- Direct LAN TCP or UID-discovered LAN-P2P over reliable UDP
+- Explicit NVR and multi-lens channels
+
+No FFmpeg, RTSP, or HTTP-FLV input bridge is required.
+
+## Compatibility
+
+The adapter discovers the selected profile's codecs from live media and probes talkback directly. Model identity, observed channels, and capability-query status are diagnostic; they do not select a model-specific media path.
+
+Hardware testing covers:
+
+- E1 Zoom/E340 `main` and `sub` over TCP
+- dual-lens TrackFlex `main` and `sub` on both channels over TCP and UID/LAN-P2P
+- H.264, H.265, AAC, and talkback across those tests
+
+Current Reolink specifications place [E1 Outdoor Pro](https://reolink.com/us/product/e1-outdoor-pro/), [Duo 3 PoE](https://support.reolink.com/c/duo-3-poe/), and [TrackMix PoE](https://reolink.com/us/product/reolink-trackmix-poe/) inside the same codec, profile, and talkback envelope. Other likely compatibility candidates include Duo 2 WiFi, Video Doorbell, TrackMix WiFi 6, and RLC-1212A. These are compatibility candidates, not tested support claims for this adapter.
+
+## Configuration
+
+```yaml
+streams:
+ camera_main: reolink://${REOLINK_USER}:${REOLINK_PASSWORD}@camera.local/main
+ camera_sub: reolink://${REOLINK_USER}:${REOLINK_PASSWORD}@camera.local/sub?audio=0&backchannel=0
+ camera_channel: reolink://${REOLINK_USER}:${REOLINK_PASSWORD}@camera.local/main?channel=1
+ camera_uid: reolink://${REOLINK_USER}:${REOLINK_PASSWORD}@${REOLINK_UID}/main?transport=uid
+ camera_uid_routed: reolink://${REOLINK_USER}:${REOLINK_PASSWORD}@${REOLINK_UID}/main?transport=uid&local=192.0.2.10&broadcast=198.51.100.255
+```
+
+Use these URL forms:
+
+```text
+reolink://username:password@host[:port]/profile?[options]
+reolink://username:password@camera-uid/profile?transport=uid[&options]
+```
+
+For LAN-P2P, put the camera UID in place of the host and add `transport=uid`. The adapter discovers that camera on the local network, then carries the Baichuan stream over reliable UDP. Escape URL-reserved characters in credentials.
+
+Defaults are TCP port `9000`, channel `0`, the `main` profile, and enabled video, audio, and backchannel.
+
+| Option | Values | Purpose |
+|---|---|---|
+| `profile` path | `main`, `sub`, `extern` | Camera stream; `extern` is a camera-dependent third profile |
+| `stream` | `main`, `sub`, `extern` | Query-form alternative to the profile path; do not set both |
+| `channel` | `0`-`255` | NVR or multi-lens channel |
+| `transport` | `tcp`, `uid` | Direct TCP or local UID discovery and LAN-P2P over reliable UDP |
+| `local` | IPv4 address assigned to this host | Bind UID discovery and the resulting LAN-P2P socket to that address |
+| `broadcast` | IPv4 address | Override the UID discovery destination; use a directed broadcast for a routed camera VLAN |
+| `video`, `audio` | `0`/`1`, `false`/`true` | Enable or suppress received tracks; enabled by default |
+| `backchannel` | `0`/`1` | Two-way audio; enabled by default |
+
+With neither selector, UID discovery sends to each active IPv4 interface's subnet broadcast. `local` binds discovery and LAN-P2P traffic to one interface address; without `broadcast`, discovery uses that interface's subnet broadcast. `broadcast` overrides the destination. Both options are valid only with `transport=uid`. Each option may appear once; unknown options are rejected.
+The profile names `mainStream`, `subStream`, `ext`, and `externStream` are also accepted for compatibility; the shorter names above are preferred.
+
+Set both `video=0` and `audio=0` for a backchannel-only source. The source still drains its selected preview. Use a low-bandwidth profile. Dial fails if the camera does not support talkback.
+
+## Behavior
+
+- One source serves all go2rtc consumers. Duplicate sources open separate camera sessions.
+- Multiple configured sources retain go2rtc's ordered codec matching. A later H.264 source can serve a consumer that does not negotiate HEVC; `video=0` prevents a source from satisfying video.
+- Connections are demand-driven. When the last consumer detaches, go2rtc stops the source and the adapter synchronously releases its preview, talk, transport, and receivers.
+- The adapter does not guess a camera's session capacity. The camera remains authoritative and may reject a new source when its model-, firmware-, or client-dependent limit is reached.
+- Enabled audio must start within 10 seconds. A profile reconnects after 15 seconds without a valid audio frame. It also reconnects after a 60-second window below 75% of the advertised audio clock. Set `audio=0` if audio is disabled or unwanted.
+- Best-effort setup queries run concurrently per configured source and add compact model, firmware, channel, and discovery status. Failed queries do not block streaming.
+- Repeated talkback negotiation, writes, and teardown are tested. Audible quality, echo, and client UI behavior remain experimental.
+
+## Unsupported (so far)
+
+- WAN or cloud P2P, relay, NAT traversal, and UPnP
+- Battery-camera wake and cloud lifecycles
+- PTZ, presets, lights, sirens, camera settings, and event APIs; existing ONVIF or camera integrations remain separate
+- Automatic RTSP, RTMP, HTTP-FLV, or FFmpeg fallback
+
+## Security and limits
+
+- Use only on a trusted network. Baichuan does not authenticate the camera before login negotiation. It may select an unencrypted session.
+- A hostile broadcast-domain peer can impersonate a camera during UID discovery and attempt offline password guessing.
+- UID/LAN-P2P defaults to one IPv4 broadcast domain. Routed discovery requires directed-broadcast and return routes.
+- Keep credentials in environment substitutions.
diff --git a/internal/reolink/reolink.go b/internal/reolink/reolink.go
new file mode 100644
index 000000000..b10ad6fce
--- /dev/null
+++ b/internal/reolink/reolink.go
@@ -0,0 +1,15 @@
+package reolink
+
+import (
+ "github.com/AlexxIT/go2rtc/internal/app"
+ "github.com/AlexxIT/go2rtc/internal/streams"
+ "github.com/AlexxIT/go2rtc/pkg/core"
+ "github.com/AlexxIT/go2rtc/pkg/reolink"
+)
+
+func Init() {
+ registry := reolink.NewRegistry(app.GetLogger("reolink"))
+ streams.HandleFunc("reolink", func(source string) (core.Producer, error) {
+ return registry.Dial(source)
+ })
+}
diff --git a/main.go b/main.go
index 00c059e3e..88d79e83c 100644
--- a/main.go
+++ b/main.go
@@ -33,6 +33,7 @@ import (
"github.com/AlexxIT/go2rtc/internal/ngrok"
"github.com/AlexxIT/go2rtc/internal/onvif"
"github.com/AlexxIT/go2rtc/internal/pinggy"
+ "github.com/AlexxIT/go2rtc/internal/reolink"
"github.com/AlexxIT/go2rtc/internal/ring"
"github.com/AlexxIT/go2rtc/internal/roborock"
"github.com/AlexxIT/go2rtc/internal/rtmp"
@@ -101,6 +102,7 @@ func main() {
{"mpegts", mpeg.Init},
{"multitrans", multitrans.Init},
{"nest", nest.Init},
+ {"reolink", reolink.Init},
{"ring", ring.Init},
{"roborock", roborock.Init},
{"tapo", tapo.Init},
diff --git a/pkg/README.md b/pkg/README.md
index 89c1aa698..b34f8ae77 100644
--- a/pkg/README.md
+++ b/pkg/README.md
@@ -28,6 +28,7 @@ Some formats and protocols go2rtc supports exclusively. They have no equivalent
| Net (pub) | rtsp | rtsp, ws | rtsp | h264, hevc, aac, pcm*, opus | pcm*, opus | `rtsp:` |
| Net (pub) | webrtc* | webrtc | webrtc | h264, pcm_alaw, pcm_mulaw, opus | pcm_alaw, pcm_mulaw | `webrtc:` |
| Net (pub) | yuv4mpegpipe | http, tcp, pipe | http | rawvideo | | `http:` |
+| Net (priv) | baichuan | tcp, udp | | h264, hevc, aac, pcm_alaw | pcm* | `reolink:` |
| Net (priv) | bubble | http | | h264, hevc, pcm_alaw | | `bubble:` |
| Net (priv) | doorbird | http | | | | `doorbird:` |
| Net (priv) | dvrip | tcp | | h264, hevc, pcm_alaw, pcm_mulaw | pcm_alaw | `dvrip:` |
diff --git a/pkg/baichuan/adpcm.go b/pkg/baichuan/adpcm.go
new file mode 100644
index 000000000..28419975d
--- /dev/null
+++ b/pkg/baichuan/adpcm.go
@@ -0,0 +1,118 @@
+package baichuan
+
+import (
+ "encoding/binary"
+ "fmt"
+)
+
+var imaIndex = [...]int8{
+ -1, -1, -1, -1, 2, 4, 6, 8,
+ -1, -1, -1, -1, 2, 4, 6, 8,
+}
+
+var imaStep = [...]int{
+ 7, 8, 9, 10, 11, 12, 13, 14, 16, 17,
+ 19, 21, 23, 25, 28, 31, 34, 37, 41, 45,
+ 50, 55, 60, 66, 73, 80, 88, 97, 107, 118,
+ 130, 143, 157, 173, 190, 209, 230, 253, 279, 307,
+ 337, 371, 408, 449, 494, 544, 598, 658, 724, 796,
+ 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066,
+ 2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358,
+ 5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899,
+ 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767,
+}
+
+// ADPCMEncoder encodes the IMA ADPCM block variant used by Baichuan talkback.
+type ADPCMEncoder struct {
+ predictor int
+ index int
+}
+
+// DecodeADPCMBlock decodes the block variant produced by Baichuan cameras.
+func DecodeADPCMBlock(block []byte) ([]int16, error) {
+ if len(block) < 5 {
+ return nil, fmt.Errorf("baichuan: ADPCM block too short: %d", len(block))
+ }
+ predictor := int(int16(binary.LittleEndian.Uint16(block)))
+ index := int(block[2])
+ if index >= len(imaStep) {
+ return nil, fmt.Errorf("baichuan: invalid ADPCM index %d", index)
+ }
+ samples := make([]int16, (len(block)-4)*2)
+ for i, value := range block[4:] {
+ samples[i*2] = int16(decodeADPCM(value>>4, &predictor, &index))
+ samples[i*2+1] = int16(decodeADPCM(value&0x0f, &predictor, &index))
+ }
+ return samples, nil
+}
+
+func decodeADPCM(nibble byte, predictor, index *int) int {
+ step := imaStep[*index]
+ delta := step >> 3
+ if nibble&1 != 0 {
+ delta += step >> 2
+ }
+ if nibble&2 != 0 {
+ delta += step >> 1
+ }
+ if nibble&4 != 0 {
+ delta += step
+ }
+ if nibble&8 != 0 {
+ *predictor -= delta
+ } else {
+ *predictor += delta
+ }
+ *predictor = clamp(*predictor, -32768, 32767)
+ *index = clamp(*index+int(imaIndex[nibble]), 0, len(imaStep)-1)
+ return *predictor
+}
+
+func (e *ADPCMEncoder) EncodeBlock(samples []int16) ([]byte, error) {
+ if len(samples) < 2 || len(samples)&1 != 0 {
+ return nil, fmt.Errorf("baichuan: ADPCM block needs a positive even sample count, got %d", len(samples))
+ }
+ b := make([]byte, 4+len(samples)/2)
+ binary.LittleEndian.PutUint16(b, uint16(int16(e.predictor)))
+ b[2] = byte(e.index)
+ for i := 0; i < len(samples); i += 2 {
+ b[4+i/2] = e.encode(int(samples[i]))<<4 | e.encode(int(samples[i+1]))
+ }
+ return b, nil
+}
+
+func (e *ADPCMEncoder) encode(sample int) byte {
+ step := imaStep[e.index]
+ diff := sample - e.predictor
+ var nibble byte
+ if diff < 0 {
+ nibble = 8
+ diff = -diff
+ }
+ delta := step >> 3
+ for mask, part := byte(4), step; mask != 0; mask, part = mask>>1, part>>1 {
+ if diff >= part {
+ nibble |= mask
+ diff -= part
+ delta += part
+ }
+ }
+ if nibble&8 != 0 {
+ e.predictor -= delta
+ } else {
+ e.predictor += delta
+ }
+ e.predictor = clamp(e.predictor, -32768, 32767)
+ e.index = clamp(e.index+int(imaIndex[nibble]), 0, len(imaStep)-1)
+ return nibble
+}
+
+func clamp(value, low, high int) int {
+ if value < low {
+ return low
+ }
+ if value > high {
+ return high
+ }
+ return value
+}
diff --git a/pkg/baichuan/capability.go b/pkg/baichuan/capability.go
new file mode 100644
index 000000000..0e66a7a2c
--- /dev/null
+++ b/pkg/baichuan/capability.go
@@ -0,0 +1,124 @@
+package baichuan
+
+import (
+ "bytes"
+ "context"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "sort"
+)
+
+const (
+ capabilityTokens = "system, streaming, PTZ, IO, security, replay, disk, network, alarm, record, video, image"
+ maxCapabilityBody = 256 << 10
+)
+
+// Capabilities is the bounded channel set observed in a capability response.
+type Capabilities struct {
+ ObservedChannels []uint8
+}
+
+func (c Capabilities) clone() Capabilities {
+ c.ObservedChannels = append([]uint8(nil), c.ObservedChannels...)
+ return c
+}
+
+type capabilityExtension struct {
+ XMLName xml.Name `xml:"Extension"`
+ Version string `xml:"version,attr"`
+ Username string `xml:"userName"`
+ Token string `xml:"token"`
+}
+
+type capabilityModule struct {
+ Channel *uint8 `xml:"channelId"`
+}
+
+func (c *Client) Capabilities(ctx context.Context, channel uint8) (Capabilities, error) {
+ // Serialize the first query so concurrent setup cannot issue or cache duplicates.
+ c.capabilityMu.Lock()
+ defer c.capabilityMu.Unlock()
+ if value, ok := c.capabilities[channel]; ok {
+ return value.clone(), nil
+ }
+ if err := c.Login(ctx); err != nil {
+ return Capabilities{}, err
+ }
+ extension, err := marshalDocument(capabilityExtension{
+ Version: "1.1", Username: c.cfg.username, Token: capabilityTokens,
+ })
+ if err != nil {
+ return Capabilities{}, fmt.Errorf("baichuan: build capability request: %w", err)
+ }
+ response, err := c.roundTrip(ctx, request{
+ command: commandAbility, channel: channel, class: classOffset, extension: extension,
+ })
+ if err != nil {
+ return Capabilities{}, fmt.Errorf("baichuan: query capabilities: %w", err)
+ }
+ value, err := parseCapabilities(response.payload)
+ if err != nil {
+ return Capabilities{}, fmt.Errorf("baichuan: parse capabilities: %w", err)
+ }
+ if c.capabilities == nil {
+ c.capabilities = make(map[uint8]Capabilities)
+ }
+ c.capabilities[channel] = value
+ return value.clone(), nil
+}
+
+func parseCapabilities(body []byte) (Capabilities, error) {
+ if len(body) > maxCapabilityBody {
+ return Capabilities{}, fmt.Errorf("response exceeds %d bytes", maxCapabilityBody)
+ }
+ decoder := xml.NewDecoder(bytes.NewReader(body))
+ channels := make(map[uint8]struct{})
+ found, inside := false, false
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ return Capabilities{}, err
+ }
+ if end, ok := token.(xml.EndElement); ok {
+ if end.Name.Local == "AbilityInfo" {
+ inside = false
+ }
+ continue
+ }
+ start, ok := token.(xml.StartElement)
+ if !ok {
+ continue
+ }
+ if start.Name.Local == "AbilityInfo" {
+ found = true
+ inside = true
+ continue
+ }
+ if !inside || start.Name.Local != "subModule" {
+ continue
+ }
+ var module capabilityModule
+ if err = decoder.DecodeElement(&module, &start); err != nil {
+ return Capabilities{}, err
+ }
+ if module.Channel != nil {
+ channels[*module.Channel] = struct{}{}
+ }
+ }
+ if !found {
+ return Capabilities{}, fmt.Errorf("AbilityInfo missing from response")
+ }
+ var value Capabilities
+ for id := range channels {
+ value.ObservedChannels = append(value.ObservedChannels, id)
+ }
+ sort.Slice(value.ObservedChannels, func(i, j int) bool {
+ return value.ObservedChannels[i] < value.ObservedChannels[j]
+ })
+ return value, nil
+}
diff --git a/pkg/baichuan/capability_test.go b/pkg/baichuan/capability_test.go
new file mode 100644
index 000000000..f3a767c7d
--- /dev/null
+++ b/pkg/baichuan/capability_test.go
@@ -0,0 +1,230 @@
+package baichuan
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestParseCapabilities(t *testing.T) {
+ body := []byte(`` +
+ `version_ro, reboot_rw, ignored` +
+ `1live_ro` +
+ `0live_rw, audio_ro, version_rw` +
+ ``)
+ value, err := parseCapabilities(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := value.ObservedChannels, []uint8{0, 1}; !slices.Equal(got, want) {
+ t.Fatalf("channels = %v, want %v", got, want)
+ }
+ clone := value.clone()
+ clone.ObservedChannels[0] = 2
+ if value.ObservedChannels[0] == 2 {
+ t.Fatal("clone shares capability storage")
+ }
+}
+
+func TestParseCapabilitiesRejectsLimits(t *testing.T) {
+ if _, err := parseCapabilities(make([]byte, maxCapabilityBody+1)); err == nil {
+ t.Fatal("accepted oversized response")
+ }
+ if _, err := parseCapabilities([]byte(``)); err == nil {
+ t.Fatal("accepted response without AbilityInfo")
+ }
+}
+
+func TestClientDiscoveryCachesResponses(t *testing.T) {
+ clientConn, cameraConn := net.Pipe()
+ cfg, err := NewConfig("camera.local", "admin", "password").normalized()
+ if err != nil {
+ t.Fatal(err)
+ }
+ client := newClient(context.Background(), cfg, clientConn)
+ t.Cleanup(func() { _ = client.Close() })
+ cameraErr := make(chan error, 1)
+ go func() {
+ defer cameraConn.Close()
+ aes, err := serveLogin(cameraConn, cfg)
+ if err != nil {
+ cameraErr <- err
+ return
+ }
+ requests := make(map[uint32]message, 2)
+ for range 2 {
+ frame, err := readFrame(cameraConn, cfg.Limits)
+ if err != nil {
+ cameraErr <- err
+ return
+ }
+ message, err := decodeFrame(frame, aes, false)
+ if err != nil {
+ cameraErr <- err
+ return
+ }
+ if message.header.Command != commandDeviceInfo && message.header.Command != commandAbility {
+ cameraErr <- fmt.Errorf("unexpected command %d", message.header.Command)
+ return
+ }
+ if _, ok := requests[message.header.Command]; ok {
+ cameraErr <- fmt.Errorf("duplicate command %d", message.header.Command)
+ return
+ }
+ if message.header.Command == commandAbility &&
+ (!strings.Contains(string(message.extension), "admin") ||
+ !strings.Contains(string(message.extension), "")) {
+ cameraErr <- fmt.Errorf("invalid capability extension")
+ return
+ }
+ requests[message.header.Command] = message
+ }
+ for _, command := range []uint32{commandAbility, commandDeviceInfo} {
+ message := requests[command]
+ var payload string
+ if command == commandAbility {
+ payload = `0version_ro`
+ } else {
+ payload = `E1 ZoomE340`
+ }
+ if err = writeTestFrame(cameraConn, header{
+ Command: command, Sequence: message.header.Sequence,
+ ResponseCode: 200, Class: classOffset,
+ }, nil, []byte(payload), aes, false); err != nil {
+ cameraErr <- err
+ return
+ }
+ }
+ cameraErr <- nil
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ type deviceResult struct {
+ value DeviceInfo
+ err error
+ }
+ type capabilityResult struct {
+ value Capabilities
+ err error
+ }
+ deviceDone := make(chan deviceResult, 1)
+ capabilityDone := make(chan capabilityResult, 1)
+ go func() {
+ value, err := client.DeviceInfo(ctx)
+ deviceDone <- deviceResult{value, err}
+ }()
+ go func() {
+ value, err := client.Capabilities(ctx, 0)
+ capabilityDone <- capabilityResult{value, err}
+ }()
+ device := <-deviceDone
+ if device.err != nil || device.value.DisplayModel() != "E340" {
+ t.Fatalf("device = %+v, %v", device.value, device.err)
+ }
+ capabilities := <-capabilityDone
+ if capabilities.err != nil || !slices.Equal(capabilities.value.ObservedChannels, []uint8{0}) {
+ t.Fatalf("capabilities = %+v, %v", capabilities.value, capabilities.err)
+ }
+ if cached, err := client.DeviceInfo(ctx); err != nil || cached != device.value {
+ t.Fatalf("cached device = %+v, %v", cached, err)
+ }
+ capabilities.value.ObservedChannels[0] = 1
+ if cached, err := client.Capabilities(ctx, 0); err != nil ||
+ !slices.Equal(cached.ObservedChannels, []uint8{0}) {
+ t.Fatalf("cached capabilities = %+v, %v", cached, err)
+ }
+ if err = <-cameraErr; err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestClientDiscoveryFailuresDoNotClosePreview(t *testing.T) {
+ clientConn, cameraConn := net.Pipe()
+ cfg, err := NewConfig("camera.local", "admin", "password").normalized()
+ if err != nil {
+ t.Fatal(err)
+ }
+ client := newClient(context.Background(), cfg, clientConn)
+ t.Cleanup(func() { _ = client.Close() })
+ cameraErr := make(chan error, 1)
+ go func() {
+ defer cameraConn.Close()
+ aes, err := serveLogin(cameraConn, cfg)
+ if err != nil {
+ cameraErr <- err
+ return
+ }
+ requests := make(map[uint32]message, 2)
+ for range 2 {
+ frame, err := readFrame(cameraConn, cfg.Limits)
+ if err != nil {
+ cameraErr <- err
+ return
+ }
+ message, err := decodeFrame(frame, aes, false)
+ if err != nil {
+ cameraErr <- err
+ return
+ }
+ requests[message.header.Command] = message
+ }
+ device, ok := requests[commandDeviceInfo]
+ if !ok || requests[commandAbility].header.Command != commandAbility {
+ cameraErr <- fmt.Errorf("missing discovery requests")
+ return
+ }
+ if err = writeTestFrame(cameraConn, header{
+ Command: commandDeviceInfo, Sequence: device.header.Sequence,
+ ResponseCode: 500, Class: classOffset,
+ }, nil, nil, aes, false); err != nil {
+ cameraErr <- err
+ return
+ }
+ cameraErr <- servePreviewSession(cameraConn, cfg, aes, previewRoute{stream: StreamMain})
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+ deviceDone := make(chan error, 1)
+ capabilityDone := make(chan error, 1)
+ go func() {
+ _, err := client.DeviceInfo(ctx)
+ deviceDone <- err
+ }()
+ go func() {
+ _, err := client.Capabilities(ctx, 0)
+ capabilityDone <- err
+ }()
+ var status *StatusError
+ if err = <-deviceDone; !errors.As(err, &status) || status.Code != 500 {
+ t.Fatalf("unexpected device failure: %v", err)
+ }
+ if err = <-capabilityDone; !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("unexpected capability failure: %v", err)
+ }
+
+ previewCtx, previewCancel := context.WithTimeout(context.Background(), time.Second)
+ defer previewCancel()
+ preview, err := client.StartPreview(previewCtx, 0, StreamMain)
+ if err != nil {
+ t.Fatalf("preview after discovery failure: %v; camera: %v", err, <-cameraErr)
+ }
+ for _, kind := range []MediaKind{MediaInfo, MediaVideoI} {
+ packet, err := preview.Read(previewCtx)
+ if err != nil || packet.Kind != kind {
+ t.Fatalf("unexpected preview packet: %+v, %v", packet, err)
+ }
+ }
+ if err = preview.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err = <-cameraErr; err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/pkg/baichuan/client.go b/pkg/baichuan/client.go
new file mode 100644
index 000000000..a21f4b403
--- /dev/null
+++ b/pkg/baichuan/client.go
@@ -0,0 +1,369 @@
+package baichuan
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "sync"
+ "time"
+)
+
+var ErrPreviewOverflow = errors.New("baichuan: preview queue overflow")
+
+type pendingKey struct {
+ command uint32
+ sequence uint16
+}
+
+type previewKey struct {
+ channel uint8
+ stream uint8
+}
+
+type Client struct {
+ cfg Config
+ conn transport
+
+ ctx context.Context
+ cancel context.CancelFunc
+ done chan struct{}
+ wg sync.WaitGroup
+
+ closeOnce sync.Once
+ errMu sync.Mutex
+ err error
+ closeErr error
+
+ cipherMu sync.RWMutex
+ cipher cipherState
+
+ sendMu sync.Mutex
+ reqMu sync.Mutex
+ seq uint16
+ reqs map[pendingKey]chan message
+
+ previewMu sync.RWMutex
+ previews map[previewKey]*Preview
+
+ loginMu sync.Mutex
+ logged bool
+
+ capabilityMu sync.Mutex
+ capabilities map[uint8]Capabilities
+ deviceMu sync.Mutex
+ device DeviceInfo
+ deviceKnown bool
+}
+
+func (c *Client) Format(state fmt.State, _ rune) {
+ _, _ = fmt.Fprint(state, "baichuan.Client")
+}
+
+func Dial(ctx context.Context, cfg Config) (*Client, error) {
+ cfg, err := cfg.normalized()
+ if err != nil {
+ return nil, err
+ }
+ var conn transport
+ if cfg.uid != "" {
+ var discovery *uidDiscovery
+ discovery, err = discoverUID(ctx, cfg.uid, cfg.UIDLocalAddr, cfg.UIDBroadcastAddr, cfg.Timeout)
+ if err == nil {
+ conn, err = newUIDConn(discovery, cfg.Timeout)
+ }
+ } else {
+ conn, err = dialTCP(ctx, cfg)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("baichuan: dial camera: %w", err)
+ }
+ return newClient(ctx, cfg, conn), nil
+}
+
+func newClient(ctx context.Context, cfg Config, conn transport) *Client {
+ lifetime, cancel := context.WithCancel(context.WithoutCancel(ctx))
+ c := &Client{
+ cfg: cfg, conn: conn, ctx: lifetime, cancel: cancel, done: make(chan struct{}),
+ reqs: make(map[pendingKey]chan message), previews: make(map[previewKey]*Preview),
+ }
+ c.wg.Add(1)
+ go c.readLoop()
+ return c
+}
+
+func (c *Client) Done() <-chan struct{} {
+ return c.done
+}
+
+func (c *Client) Err() error {
+ c.errMu.Lock()
+ defer c.errMu.Unlock()
+ return c.err
+}
+
+func (c *Client) Close() error {
+ c.shutdown(context.Canceled)
+ // Login may add keepalive work; wait for it before waiting on the group.
+ c.loginMu.Lock()
+ c.loginMu.Unlock()
+ c.wg.Wait()
+ c.errMu.Lock()
+ err := c.closeErr
+ c.errMu.Unlock()
+ return err
+}
+
+func (c *Client) shutdown(err error) {
+ c.closeOnce.Do(func() {
+ c.errMu.Lock()
+ c.err = err
+ c.errMu.Unlock()
+ c.cancel()
+ close(c.done)
+ closeErr := c.conn.Close()
+ c.errMu.Lock()
+ c.closeErr = closeErr
+ c.errMu.Unlock()
+ })
+}
+
+func (c *Client) readLoop() {
+ defer c.wg.Done()
+ for {
+ value, err := readFrame(c.conn, c.cfg.Limits)
+ if err != nil {
+ if !errors.Is(err, io.EOF) || c.ctx.Err() == nil {
+ c.shutdown(fmt.Errorf("baichuan: read message: %w", err))
+ }
+ return
+ }
+
+ state := c.cipherState(value.header)
+ key := pendingKey{command: value.header.Command, sequence: value.header.Sequence}
+ pending := c.pending(key)
+ preview := c.getPreview(value.header)
+ binaryHint := value.header.Command == commandPreview && pending == nil && preview != nil
+ msg, err := decodeFrame(value, state, binaryHint)
+ if err != nil {
+ c.shutdown(fmt.Errorf("baichuan: decode message: %w", err))
+ return
+ }
+ if msg.binary {
+ if preview != nil {
+ preview.deliver(msg)
+ }
+ continue
+ }
+ if pending != nil {
+ select {
+ case pending <- msg:
+ default:
+ c.shutdown(fmt.Errorf("baichuan: duplicate response for command %d sequence %d", key.command, key.sequence))
+ return
+ }
+ }
+ }
+}
+
+func (c *Client) cipherState(h header) cipherState {
+ if h.Command != commandLogin {
+ c.cipherMu.RLock()
+ state := c.cipher
+ c.cipherMu.RUnlock()
+ return state
+ }
+ c.cipherMu.Lock()
+ defer c.cipherMu.Unlock()
+ if h.Command == commandLogin {
+ if mode, ok := negotiatedEncryption(h.ResponseCode); ok {
+ c.cipher.mode = mode
+ }
+ }
+ return c.cipher
+}
+
+func (c *Client) pending(key pendingKey) chan message {
+ c.reqMu.Lock()
+ defer c.reqMu.Unlock()
+ return c.reqs[key]
+}
+
+func (c *Client) roundTrip(ctx context.Context, req request) (message, error) {
+ ctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
+ defer cancel()
+ key, ch, err := c.reserve(req.command)
+ if err != nil {
+ return message{}, err
+ }
+ defer c.release(key)
+ req.sequence = key.sequence
+
+ c.cipherMu.RLock()
+ state := c.cipher
+ c.cipherMu.RUnlock()
+ packet, err := encodeRequest(req, c.cfg.Limits, state)
+ if err != nil {
+ return message{}, err
+ }
+ c.sendMu.Lock()
+ err = writeFull(ctx, c.conn, c.cfg.Timeout, packet)
+ c.sendMu.Unlock()
+ if err != nil {
+ err = fmt.Errorf("baichuan: write command %d: %w", req.command, err)
+ c.shutdown(err)
+ return message{}, err
+ }
+
+ select {
+ case msg := <-ch:
+ return checkedResponse(msg)
+ case <-ctx.Done():
+ select {
+ case msg := <-ch:
+ return checkedResponse(msg)
+ default:
+ return message{}, ctx.Err()
+ }
+ case <-c.done:
+ select {
+ case msg := <-ch:
+ return checkedResponse(msg)
+ default:
+ return message{}, c.Err()
+ }
+ }
+}
+
+func checkedResponse(msg message) (message, error) {
+ if err := responseError(msg.header); err != nil {
+ return message{}, err
+ }
+ return msg, nil
+}
+
+func (c *Client) writeRequest(ctx context.Context, req request) error {
+ req.sequence = c.nextSequence(req.command)
+ c.cipherMu.RLock()
+ state := c.cipher
+ c.cipherMu.RUnlock()
+ packet, err := encodeRequest(req, c.cfg.Limits, state)
+ if err != nil {
+ return err
+ }
+ c.sendMu.Lock()
+ err = writeFull(ctx, c.conn, c.cfg.Timeout, packet)
+ c.sendMu.Unlock()
+ if err != nil {
+ err = fmt.Errorf("baichuan: write command %d: %w", req.command, err)
+ c.shutdown(err)
+ }
+ return err
+}
+
+func (c *Client) nextSequence(command uint32) uint16 {
+ c.reqMu.Lock()
+ defer c.reqMu.Unlock()
+ for {
+ sequence := c.seq
+ c.seq++
+ if _, ok := c.reqs[pendingKey{command: command, sequence: sequence}]; !ok {
+ return sequence
+ }
+ }
+}
+
+func (c *Client) reserve(command uint32) (pendingKey, chan message, error) {
+ c.reqMu.Lock()
+ defer c.reqMu.Unlock()
+ if len(c.reqs) >= c.cfg.Limits.MaxPending {
+ return pendingKey{}, nil, fmt.Errorf("baichuan: pending request limit reached")
+ }
+ for i := 0; i < 1<<16; i++ {
+ key := pendingKey{command: command, sequence: c.seq}
+ c.seq++
+ if _, ok := c.reqs[key]; !ok {
+ ch := make(chan message, 1)
+ c.reqs[key] = ch
+ return key, ch, nil
+ }
+ }
+ return pendingKey{}, nil, fmt.Errorf("baichuan: no request sequence available")
+}
+
+func (c *Client) release(key pendingKey) {
+ c.reqMu.Lock()
+ delete(c.reqs, key)
+ c.reqMu.Unlock()
+}
+
+func (c *Client) Login(ctx context.Context) error {
+ c.loginMu.Lock()
+ defer c.loginMu.Unlock()
+ if c.logged {
+ return nil
+ }
+
+ nonceMessage, err := c.roundTrip(ctx, request{command: commandLogin, class: classLegacy, forceBC: true})
+ if err != nil {
+ return fmt.Errorf("baichuan: request login nonce: %w", err)
+ }
+ nonce, err := parseNonce(nonceMessage.payload)
+ if err != nil {
+ return fmt.Errorf("baichuan: parse login nonce: %w", err)
+ }
+ c.cipherMu.Lock()
+ c.cipher.setAESKey(deriveAESKey(nonce, c.cfg.password))
+ c.cipherMu.Unlock()
+
+ body, err := buildLogin(c.cfg.username, c.cfg.password, nonce)
+ if err != nil {
+ return fmt.Errorf("baichuan: build login: %w", err)
+ }
+ if _, err = c.roundTrip(ctx, request{
+ command: commandLogin, class: classOffset, payload: body, forceBC: true,
+ }); err != nil {
+ return fmt.Errorf("baichuan: login: %w", err)
+ }
+ c.cipherMu.Lock()
+ if c.cipher.hasKey {
+ c.cipher.mode = encryptionAES
+ }
+ c.cipherMu.Unlock()
+ c.logged = true
+ // Active UID sessions maintain liveness with transport data and ACKs. The
+ // request/response ping is TCP-only and can fill a UID send window on
+ // firmware that doesn't answer it.
+ if _, ok := c.conn.(*uidConn); ok {
+ return nil
+ }
+ c.wg.Add(1)
+ go c.keepAlive()
+ return nil
+}
+
+func (c *Client) keepAlive() {
+ defer c.wg.Done()
+ ticker := time.NewTicker(5 * time.Second)
+ defer ticker.Stop()
+ failures := 0
+ for {
+ select {
+ case <-ticker.C:
+ ctx, cancel := context.WithTimeout(c.ctx, 4*time.Second)
+ _, err := c.roundTrip(ctx, request{command: commandPing, class: classOffset})
+ cancel()
+ if err == nil {
+ failures = 0
+ continue
+ }
+ failures++
+ if failures == 3 {
+ c.shutdown(fmt.Errorf("baichuan: keepalive failed: %w", err))
+ return
+ }
+ case <-c.done:
+ return
+ }
+ }
+}
diff --git a/pkg/baichuan/client_test.go b/pkg/baichuan/client_test.go
new file mode 100644
index 000000000..9201c8b3a
--- /dev/null
+++ b/pkg/baichuan/client_test.go
@@ -0,0 +1,229 @@
+package baichuan
+
+import (
+ "context"
+ "encoding/binary"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "net"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestClientLoginAndPreview(t *testing.T) {
+ for _, route := range []previewRoute{
+ {channel: 0, stream: StreamMain, wire: 0, handle: 0},
+ {channel: 3, stream: StreamSub, wire: 1, handle: 256},
+ {channel: 7, stream: StreamExtern, wire: 2, handle: 1024},
+ } {
+ t.Run(string(route.stream), func(t *testing.T) {
+ clientConn, cameraConn := net.Pipe()
+ cfg, err := NewConfig("camera.local", "admin", "password").normalized()
+ if err != nil {
+ t.Fatal(err)
+ }
+ client := newClient(context.Background(), cfg, clientConn)
+ defer client.Close()
+ cameraErr := make(chan error, 1)
+ go func() { cameraErr <- servePreview(cameraConn, cfg, route) }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ preview, err := client.StartPreview(ctx, route.channel, route.stream)
+ if err != nil {
+ t.Fatalf("%v; camera: %v", err, <-cameraErr)
+ }
+ packet, err := preview.Read(ctx)
+ if err != nil || packet.Kind != MediaInfo || packet.Width != 3840 || packet.Height != 2160 {
+ t.Fatalf("unexpected info packet: %+v, %v", packet, err)
+ }
+ packet, err = preview.Read(ctx)
+ if err != nil || packet.Kind != MediaVideoI || packet.Codec != "H265" {
+ t.Fatalf("unexpected video packet: %+v, %v", packet, err)
+ }
+ if err = preview.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err = preview.Close(); err != nil {
+ t.Fatalf("second close: %v", err)
+ }
+ if err = <-cameraErr; err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+}
+
+func TestRoundTripUsesDefaultTimeout(t *testing.T) {
+ clientConn, cameraConn := net.Pipe()
+ cfg, err := NewConfig("camera.local", "admin", "password").normalized()
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg.Timeout = 20 * time.Millisecond
+ client := newClient(context.Background(), cfg, clientConn)
+ defer client.Close()
+ read := make(chan error, 1)
+ go func() {
+ _, err := readFrame(cameraConn, cfg.Limits)
+ read <- err
+ }()
+ _, err = client.roundTrip(context.Background(), request{command: commandPing, class: classOffset})
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("unexpected response timeout: %v", err)
+ }
+ if err = <-read; err != nil {
+ t.Fatal(err)
+ }
+ _ = cameraConn.Close()
+}
+
+func TestClientReserveWrapAndLimit(t *testing.T) {
+ c := &Client{cfg: Config{Limits: Limits{MaxPending: 2}}, seq: ^uint16(0), reqs: make(map[pendingKey]chan message)}
+ first, _, err := c.reserve(commandPing)
+ if err != nil || first.sequence != ^uint16(0) {
+ t.Fatalf("unexpected first reservation: %+v %v", first, err)
+ }
+ second, _, err := c.reserve(commandPing)
+ if err != nil || second.sequence != 0 {
+ t.Fatalf("unexpected wrapped reservation: %+v %v", second, err)
+ }
+ if _, _, err = c.reserve(commandPing); err == nil {
+ t.Fatal("accepted reservation beyond pending limit")
+ }
+}
+
+type previewRoute struct {
+ channel uint8
+ stream Stream
+ wire uint8
+ handle uint32
+}
+
+func servePreview(conn net.Conn, cfg Config, route previewRoute) error {
+ defer conn.Close()
+ aes, err := serveLogin(conn, cfg)
+ if err != nil {
+ return err
+ }
+ return servePreviewSession(conn, cfg, aes, route)
+}
+
+func servePreviewSession(conn net.Conn, cfg Config, aes cipherState, route previewRoute) error {
+ previewRequest, err := readFrame(conn, cfg.Limits)
+ if err != nil {
+ return err
+ }
+ preview, err := decodeFrame(previewRequest, aes, false)
+ if err != nil {
+ return err
+ }
+ var body previewEnvelope
+ if err = xml.Unmarshal(preview.payload, &body); err != nil ||
+ preview.header.Command != commandPreview || preview.header.Channel != route.channel ||
+ preview.header.Stream != route.wire || body.Preview.Channel != route.channel ||
+ body.Preview.Stream != route.stream || body.Preview.Handle != route.handle {
+ return fmt.Errorf("invalid preview request: %+v %s", preview.header, preview.payload)
+ }
+ if err = writeTestFrame(conn, header{
+ Command: commandPreview, Channel: route.channel, Stream: route.wire, Sequence: preview.header.Sequence,
+ ResponseCode: 200, Class: classOffset,
+ }, nil, nil, aes, false); err != nil {
+ return err
+ }
+
+ media := append(infoFixture(), videoFixture()...)
+ media = append(media, infoFixture()...)
+ if err = writeTestFrame(conn, header{
+ Command: commandPreview, Channel: route.channel, Stream: route.wire, Sequence: preview.header.Sequence,
+ ResponseCode: 200, Class: classOffset,
+ }, []byte("1"), media, aes, true); err != nil {
+ return err
+ }
+
+ stopRequest, err := readFrame(conn, cfg.Limits)
+ if err != nil {
+ return err
+ }
+ stop, err := decodeFrame(stopRequest, aes, false)
+ if err != nil {
+ return err
+ }
+ var stopBody stopPreviewEnvelope
+ if err = xml.Unmarshal(stop.payload, &stopBody); err != nil ||
+ stop.header.Command != commandStopPreview || stop.header.Channel != route.channel ||
+ stop.header.Stream != route.wire || stopBody.Preview.Channel != route.channel ||
+ stopBody.Preview.Handle != route.handle {
+ return fmt.Errorf("invalid stop request: %+v %s", stop.header, stop.payload)
+ }
+ return writeTestFrame(conn, header{
+ Command: commandStopPreview, Channel: route.channel, Stream: route.wire, Sequence: stop.header.Sequence,
+ ResponseCode: 200, Class: classOffset,
+ }, nil, nil, aes, false)
+}
+
+func serveLogin(conn net.Conn, cfg Config) (cipherState, error) {
+
+ nonceRequest, err := readFrame(conn, cfg.Limits)
+ if err != nil {
+ return cipherState{}, err
+ }
+ if nonceRequest.header.Command != commandLogin || nonceRequest.header.ResponseCode != 0xdc12 ||
+ nonceRequest.header.Class != classLegacy || len(nonceRequest.body) != 0 {
+ return cipherState{}, fmt.Errorf("invalid nonce request: %+v", nonceRequest.header)
+ }
+ const nonce = "0123456789ABCDEF"
+ bc := cipherState{mode: encryptionBC}
+ if err = writeTestFrame(conn, header{
+ Command: commandLogin, Sequence: nonceRequest.header.Sequence,
+ ResponseCode: 0xdd12, Class: classOffset,
+ }, nil, []byte(""+nonce+""), bc, false); err != nil {
+ return cipherState{}, err
+ }
+
+ loginRequest, err := readFrame(conn, cfg.Limits)
+ if err != nil {
+ return cipherState{}, err
+ }
+ login, err := decodeFrame(loginRequest, bc, false)
+ if err != nil {
+ return cipherState{}, err
+ }
+ loginXML := string(login.payload)
+ if !strings.Contains(loginXML, "admin<") || strings.Contains(loginXML, ">password<") {
+ return cipherState{}, fmt.Errorf("invalid login XML: %s", loginXML)
+ }
+ if err = writeTestFrame(conn, header{
+ Command: commandLogin, Sequence: loginRequest.header.Sequence,
+ ResponseCode: 200, Class: classOffset,
+ }, nil, nil, bc, false); err != nil {
+ return cipherState{}, err
+ }
+
+ aes := cipherState{mode: encryptionAES, aesKey: deriveAESKey(nonce, cfg.password), hasKey: true}
+ return aes, nil
+}
+
+func writeTestFrame(conn net.Conn, h header, ext, payload []byte, state cipherState, binaryPayload bool) error {
+ ext = testCrypt(state, h.Channel, ext, true)
+ if !binaryPayload {
+ payload = testCrypt(state, h.Channel, payload, true)
+ }
+ bodyLen := len(ext) + len(payload)
+ packet := make([]byte, 24+bodyLen)
+ binary.LittleEndian.PutUint32(packet, wireMagic)
+ binary.LittleEndian.PutUint32(packet[4:], h.Command)
+ binary.LittleEndian.PutUint32(packet[8:], uint32(bodyLen))
+ packet[12] = h.Channel
+ packet[13] = h.Stream
+ binary.LittleEndian.PutUint16(packet[14:], h.Sequence)
+ binary.LittleEndian.PutUint16(packet[16:], h.ResponseCode)
+ binary.LittleEndian.PutUint16(packet[18:], classOffset)
+ binary.LittleEndian.PutUint32(packet[20:], uint32(len(ext)))
+ copy(packet[24:], ext)
+ copy(packet[24+len(ext):], payload)
+ _, err := conn.Write(packet)
+ return err
+}
diff --git a/pkg/baichuan/config.go b/pkg/baichuan/config.go
new file mode 100644
index 000000000..6b7b369ea
--- /dev/null
+++ b/pkg/baichuan/config.go
@@ -0,0 +1,192 @@
+package baichuan
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/netip"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ DefaultPort = 9000
+ DefaultTimeout = 10 * time.Second
+)
+
+const (
+ defaultMaxBody = 16 << 20
+ defaultMaxExtension = 64 << 10
+ defaultMaxPending = 64
+ defaultMaxMediaFrame = 16 << 20
+ defaultMaxResync = 64 << 10
+ defaultMaxMediaBuffer = defaultMaxMediaFrame + defaultMaxResync + mediaVideoHeaderSize
+)
+
+// Limits bounds memory controlled by a camera or slow peer.
+type Limits struct {
+ MaxBody uint32
+ MaxExtension uint32
+ MaxPending int
+ MaxMediaFrame uint32
+ MaxMediaBuffer uint32
+ MaxResync uint32
+}
+
+func (l Limits) normalized() (Limits, error) {
+ if l.MaxBody == 0 {
+ l.MaxBody = defaultMaxBody
+ }
+ if l.MaxExtension == 0 {
+ l.MaxExtension = defaultMaxExtension
+ }
+ if l.MaxPending == 0 {
+ l.MaxPending = defaultMaxPending
+ }
+ if l.MaxMediaFrame == 0 {
+ l.MaxMediaFrame = defaultMaxMediaFrame
+ }
+ if l.MaxMediaBuffer == 0 {
+ l.MaxMediaBuffer = defaultMaxMediaBuffer
+ }
+ if l.MaxResync == 0 {
+ l.MaxResync = defaultMaxResync
+ }
+
+ if l.MaxBody > 64<<20 || l.MaxExtension > 1<<20 || l.MaxPending > 1024 ||
+ l.MaxMediaFrame > 64<<20 || l.MaxMediaBuffer > 128<<20 || l.MaxResync > 1<<20 {
+ return Limits{}, fmt.Errorf("baichuan: limits exceed hard ceiling")
+ }
+ if l.MaxPending < 1 || l.MaxExtension > l.MaxBody || l.MaxResync > l.MaxMediaBuffer ||
+ uint64(l.MaxMediaFrame)+uint64(l.MaxResync)+mediaVideoHeaderSize > uint64(l.MaxMediaBuffer) {
+ return Limits{}, fmt.Errorf("baichuan: inconsistent limits")
+ }
+ return l, nil
+}
+
+// Config contains a private camera endpoint and credentials.
+type Config struct {
+ Host string
+ Port uint16
+ Timeout time.Duration
+ Limits Limits
+ // UIDLocalAddr restricts UID discovery to the interface owning this IPv4 address.
+ UIDLocalAddr string
+ // UIDBroadcastAddr overrides the destination used for UID discovery.
+ UIDBroadcastAddr string
+
+ username string
+ password string
+ uid string
+}
+
+func NewConfig(host, username, password string) Config {
+ return Config{Host: host, username: username, password: password}
+}
+
+func NewUIDConfig(uid, username, password string) Config {
+ return Config{uid: uid, username: username, password: password}
+}
+
+func (c Config) normalized() (Config, error) {
+ if c.uid != "" {
+ if c.Host != "" || c.Port != 0 || !validUID(c.uid) {
+ return Config{}, fmt.Errorf("baichuan: invalid UID endpoint")
+ }
+ if c.UIDLocalAddr != "" {
+ address, err := netip.ParseAddr(strings.TrimSpace(c.UIDLocalAddr))
+ if err != nil || !uidUnicastAddr(address) {
+ return Config{}, fmt.Errorf("baichuan: invalid UID local address")
+ }
+ c.UIDLocalAddr = address.String()
+ }
+ if c.UIDBroadcastAddr != "" {
+ address, err := netip.ParseAddr(strings.TrimSpace(c.UIDBroadcastAddr))
+ if err != nil || !uidDiscoveryAddr(address) {
+ return Config{}, fmt.Errorf("baichuan: invalid UID broadcast address")
+ }
+ c.UIDBroadcastAddr = address.String()
+ }
+ } else {
+ if c.UIDLocalAddr != "" {
+ return Config{}, fmt.Errorf("baichuan: UID local address requires UID transport")
+ }
+ if c.UIDBroadcastAddr != "" {
+ return Config{}, fmt.Errorf("baichuan: UID broadcast address requires UID transport")
+ }
+ c.Host = strings.TrimSpace(c.Host)
+ if c.Host == "" {
+ return Config{}, fmt.Errorf("baichuan: host is required")
+ }
+ if strings.ContainsAny(c.Host, "/?#@") {
+ return Config{}, fmt.Errorf("baichuan: host must not contain URL components")
+ }
+ if c.Port == 0 {
+ c.Port = DefaultPort
+ }
+ }
+ if c.Timeout == 0 {
+ c.Timeout = DefaultTimeout
+ }
+ if c.Timeout < 0 {
+ return Config{}, fmt.Errorf("baichuan: timeout must be positive")
+ }
+
+ var err error
+ if c.Limits, err = c.Limits.normalized(); err != nil {
+ return Config{}, err
+ }
+ return c, nil
+}
+
+func (c Config) address() string {
+ return net.JoinHostPort(c.Host, strconv.Itoa(int(c.Port)))
+}
+
+func (c Config) Format(s fmt.State, _ rune) {
+ if c.uid != "" {
+ _, _ = fmt.Fprintf(s, "baichuan.Config{Mode:%q, LocalAddr:%q, BroadcastAddr:%q, Timeout:%s}",
+ "uid", c.UIDLocalAddr, c.UIDBroadcastAddr, c.Timeout)
+ return
+ }
+ _, _ = fmt.Fprintf(s, "baichuan.Config{Host:%q, Port:%d, Timeout:%s}", c.Host, c.Port, c.Timeout)
+}
+
+func (c Config) MarshalJSON() ([]byte, error) {
+ if c.uid != "" {
+ return json.Marshal(struct {
+ Mode string `json:"mode"`
+ LocalAddr string `json:"local_addr,omitempty"`
+ BroadcastAddr string `json:"broadcast_addr,omitempty"`
+ Timeout time.Duration `json:"timeout"`
+ }{"uid", c.UIDLocalAddr, c.UIDBroadcastAddr, c.Timeout})
+ }
+ return json.Marshal(struct {
+ Host string `json:"host"`
+ Port uint16 `json:"port"`
+ Timeout time.Duration `json:"timeout"`
+ }{c.Host, c.Port, c.Timeout})
+}
+
+// Stream selects one preview profile exposed by the camera.
+type Stream string
+
+const (
+ StreamMain Stream = "mainStream"
+ StreamSub Stream = "subStream"
+ StreamExtern Stream = "externStream"
+)
+
+func (s Stream) params() (uint8, uint32, error) {
+ switch s {
+ case StreamMain:
+ return 0, 0, nil
+ case StreamSub:
+ return 1, 256, nil
+ case StreamExtern:
+ return 2, 1024, nil
+ default:
+ return 0, 0, fmt.Errorf("baichuan: unsupported stream %q", s)
+ }
+}
diff --git a/pkg/baichuan/config_test.go b/pkg/baichuan/config_test.go
new file mode 100644
index 000000000..fa538b923
--- /dev/null
+++ b/pkg/baichuan/config_test.go
@@ -0,0 +1,116 @@
+package baichuan
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+)
+
+func TestConfigRedaction(t *testing.T) {
+ uid := NewUIDConfig("SentinelCameraUID1", "secret-user", "secret-password")
+ uid.UIDLocalAddr = "192.0.2.28"
+ uid.UIDBroadcastAddr = "198.51.100.255"
+ for _, test := range []struct {
+ name string
+ cfg Config
+ secrets []string
+ }{
+ {name: "tcp", cfg: NewConfig("camera.local", "secret-user", "secret-password"), secrets: []string{"secret-user", "secret-password"}},
+ {name: "uid", cfg: uid, secrets: []string{"SentinelCameraUID1", "secret-user", "secret-password"}},
+ } {
+ data, err := json.Marshal(test.cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ values := []string{
+ fmt.Sprintf("%v", test.cfg), fmt.Sprintf("%+v", test.cfg), fmt.Sprintf("%#v", test.cfg),
+ fmt.Sprintf("%s", test.cfg), fmt.Sprintf("%q", test.cfg), string(data),
+ }
+ for _, value := range values {
+ for _, secret := range test.secrets {
+ if strings.Contains(value, secret) {
+ t.Fatalf("%s config leaked %q", test.name, secret)
+ }
+ }
+ }
+ if _, err = test.cfg.normalized(); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestUIDLocalAddress(t *testing.T) {
+ cfg := NewUIDConfig("CameraUID1", "user", "password")
+ cfg.UIDLocalAddr = " 192.0.2.28 "
+ normalized, err := cfg.normalized()
+ if err != nil || normalized.UIDLocalAddr != "192.0.2.28" {
+ t.Fatalf("unexpected local address: %q %v", normalized.UIDLocalAddr, err)
+ }
+ for _, value := range []string{"loopback", "127.0.0.1", "::1"} {
+ cfg.UIDLocalAddr = value
+ if _, err = cfg.normalized(); err == nil {
+ t.Fatalf("accepted invalid UID local address %q", value)
+ }
+ }
+ tcp := NewConfig("camera", "user", "password")
+ tcp.UIDLocalAddr = "192.0.2.28"
+ if _, err = tcp.normalized(); err == nil {
+ t.Fatal("accepted UID local address for TCP transport")
+ }
+}
+
+func TestUIDBroadcastAddress(t *testing.T) {
+ cfg := NewUIDConfig("CameraUID1", "user", "password")
+ cfg.UIDBroadcastAddr = " 198.51.100.255 "
+ normalized, err := cfg.normalized()
+ if err != nil || normalized.UIDBroadcastAddr != "198.51.100.255" {
+ t.Fatalf("unexpected broadcast address: %q %v", normalized.UIDBroadcastAddr, err)
+ }
+ for _, value := range []string{"broadcast", "0.0.0.0", "127.0.0.1", "224.0.0.1", "::1"} {
+ cfg.UIDBroadcastAddr = value
+ if _, err = cfg.normalized(); err == nil {
+ t.Fatalf("accepted invalid UID broadcast address %q", value)
+ }
+ }
+ tcp := NewConfig("camera", "user", "password")
+ tcp.UIDBroadcastAddr = "198.51.100.255"
+ if _, err = tcp.normalized(); err == nil {
+ t.Fatal("accepted UID broadcast address for TCP transport")
+ }
+}
+
+func TestAggregateFormattingRedactsCredentials(t *testing.T) {
+ cfg := NewConfig("camera.local", "sentinel-user", "sentinel-pass")
+ client := &Client{cfg: cfg}
+ preview := &Preview{client: client, key: previewKey{channel: 2}, stream: StreamMain}
+ talk := &Talk{
+ client: client, channel: 2,
+ format: TalkFormat{SampleRate: 16000, SamplePrecision: 16, SamplesPerBlock: 1016},
+ }
+ for _, value := range []any{client, preview, talk} {
+ for _, format := range []string{"%v", "%+v", "%#v", "%s", "%q"} {
+ text := fmt.Sprintf(format, value)
+ if strings.Contains(text, "sentinel-user") || strings.Contains(text, "sentinel-pass") {
+ t.Fatalf("credential leaked from %T with %s: %s", value, format, text)
+ }
+ }
+ }
+}
+
+func TestLimits(t *testing.T) {
+ limits, err := (Limits{}).normalized()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if limits.MaxBody != defaultMaxBody || limits.MaxPending != defaultMaxPending ||
+ limits.MaxMediaBuffer != defaultMaxMediaBuffer {
+ t.Fatalf("unexpected defaults: %+v", limits)
+ }
+ if _, err = (Limits{MaxBody: 65 << 20}).normalized(); err == nil {
+ t.Fatal("expected hard ceiling error")
+ }
+ if _, err = (Limits{MaxBody: 1024, MaxExtension: 2048}).normalized(); err == nil {
+ t.Fatal("expected inconsistent limits error")
+ }
+}
diff --git a/pkg/baichuan/crypto.go b/pkg/baichuan/crypto.go
new file mode 100644
index 000000000..62195d475
--- /dev/null
+++ b/pkg/baichuan/crypto.go
@@ -0,0 +1,103 @@
+package baichuan
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/md5"
+)
+
+type encryption uint8
+
+const (
+ encryptionNone encryption = iota
+ encryptionBC
+ encryptionAES
+)
+
+var (
+ bcKey = [...]byte{0x1f, 0x2d, 0x3c, 0x4b, 0x5a, 0x69, 0x78, 0xff}
+ aesIV = []byte("0123456789abcdef")
+)
+
+type cipherState struct {
+ mode encryption
+ aesKey [aes.BlockSize]byte
+ block cipher.Block
+ hasKey bool
+}
+
+func (s *cipherState) setAESKey(key [aes.BlockSize]byte) {
+ s.aesKey = key
+ s.block, _ = aes.NewCipher(key[:])
+ s.hasKey = true
+}
+
+func modernMD5(value string) string {
+ sum := md5.Sum([]byte(value)) // Baichuan uses the first 31 uppercase hex digits.
+ const digits = "0123456789ABCDEF"
+
+ out := make([]byte, 31)
+ for i := range out {
+ b := sum[i/2]
+ if i&1 == 0 {
+ out[i] = digits[b>>4]
+ } else {
+ out[i] = digits[b&0x0f]
+ }
+ }
+ return string(out)
+}
+
+func deriveAESKey(nonce, password string) [aes.BlockSize]byte {
+ value := modernMD5(nonce + "-" + password)
+ var key [aes.BlockSize]byte
+ copy(key[:], value)
+ return key
+}
+
+func (s cipherState) decryptInPlace(channel uint8, b []byte) {
+ s.cryptInPlace(channel, b, false)
+}
+
+func (s cipherState) cryptInPlace(channel uint8, b []byte, encrypt bool) {
+ switch s.mode {
+ case encryptionBC:
+ for i := range b {
+ b[i] ^= bcKey[(int(channel)+i)%len(bcKey)] ^ channel
+ }
+ case encryptionAES:
+ if !s.hasKey {
+ for i := range b {
+ b[i] ^= bcKey[(int(channel)+i)%len(bcKey)] ^ channel
+ }
+ return
+ }
+ block := s.block
+ if block == nil {
+ block, _ = aes.NewCipher(s.aesKey[:])
+ }
+ var stream cipher.Stream
+ if encrypt {
+ stream = cipher.NewCFBEncrypter(block, aesIV)
+ } else {
+ stream = cipher.NewCFBDecrypter(block, aesIV)
+ }
+ stream.XORKeyStream(b, b)
+ }
+}
+
+func negotiatedEncryption(code uint16) (encryption, bool) {
+ if code>>8 != 0xdd {
+ return 0, false
+ }
+ switch byte(code) {
+ case 0:
+ return encryptionNone, true
+ case 1, 0x12:
+ return encryptionBC, true
+ case 2, 3:
+ return encryptionAES, true
+ default:
+ return 0, false
+ }
+}
diff --git a/pkg/baichuan/crypto_test.go b/pkg/baichuan/crypto_test.go
new file mode 100644
index 000000000..6098ea062
--- /dev/null
+++ b/pkg/baichuan/crypto_test.go
@@ -0,0 +1,41 @@
+package baichuan
+
+import (
+ "encoding/hex"
+ "testing"
+)
+
+func TestModernMD5(t *testing.T) {
+ if actual := modernMD5("test"); actual != "098F6BCD4621D373CADE4E832627B4F" {
+ t.Fatalf("unexpected digest: %s", actual)
+ }
+}
+
+func TestBC(t *testing.T) {
+ state := cipherState{mode: encryptionBC}
+ encoded := testCrypt(state, 2, []byte("abc"), true)
+ if actual := hex.EncodeToString(encoded); actual != "5f2b3b" {
+ t.Fatalf("unexpected ciphertext: %s", actual)
+ }
+ if actual := string(testCrypt(state, 2, encoded, false)); actual != "abc" {
+ t.Fatalf("unexpected plaintext: %s", actual)
+ }
+}
+
+func TestAESKnownVector(t *testing.T) {
+ state := cipherState{mode: encryptionAES}
+ state.setAESKey(deriveAESKey("nonce", "password"))
+ encoded := testCrypt(state, 0, []byte("camera XML"), true)
+ if actual := hex.EncodeToString(encoded); actual != "db73b881b9082f4c67b5" {
+ t.Fatalf("unexpected ciphertext: %s", actual)
+ }
+ if actual := string(testCrypt(state, 0, encoded, false)); actual != "camera XML" {
+ t.Fatalf("unexpected plaintext: %s", actual)
+ }
+}
+
+func testCrypt(state cipherState, channel uint8, src []byte, encrypt bool) []byte {
+ dst := append([]byte(nil), src...)
+ state.cryptInPlace(channel, dst, encrypt)
+ return dst
+}
diff --git a/pkg/baichuan/device.go b/pkg/baichuan/device.go
new file mode 100644
index 000000000..562e92c24
--- /dev/null
+++ b/pkg/baichuan/device.go
@@ -0,0 +1,88 @@
+package baichuan
+
+import (
+ "context"
+ "encoding/xml"
+ "fmt"
+ "strings"
+)
+
+const (
+ maxDeviceInfoBody = 64 << 10
+ maxDeviceInfoField = 128
+)
+
+// DeviceInfo contains non-secret camera identity reported by the firmware.
+type DeviceInfo struct {
+ Type string
+ Model string
+ Hardware string
+ Firmware string
+}
+
+func (d DeviceInfo) DisplayModel() string {
+ if d.Model != "" {
+ return d.Model
+ }
+ return d.Type
+}
+
+type deviceInfoEnvelope struct {
+ Info *struct {
+ Type string `xml:"type"`
+ Model string `xml:"itemNo"`
+ Hardware string `xml:"hardwareVersion"`
+ Firmware string `xml:"firmwareVersion"`
+ } `xml:"VersionInfo"`
+}
+
+func (c *Client) DeviceInfo(ctx context.Context) (DeviceInfo, error) {
+ // Serialize the first query so concurrent setup cannot issue or cache duplicates.
+ c.deviceMu.Lock()
+ defer c.deviceMu.Unlock()
+ if c.deviceKnown {
+ return c.device, nil
+ }
+ if err := c.Login(ctx); err != nil {
+ return DeviceInfo{}, err
+ }
+ response, err := c.roundTrip(ctx, request{command: commandDeviceInfo, class: classOffset})
+ if err != nil {
+ return DeviceInfo{}, fmt.Errorf("baichuan: query device information: %w", err)
+ }
+ value, err := parseDeviceInfo(response.payload)
+ if err != nil {
+ return DeviceInfo{}, fmt.Errorf("baichuan: parse device information: %w", err)
+ }
+ c.device = value
+ c.deviceKnown = true
+ return value, nil
+}
+
+func parseDeviceInfo(body []byte) (DeviceInfo, error) {
+ if len(body) > maxDeviceInfoBody {
+ return DeviceInfo{}, fmt.Errorf("response exceeds %d bytes", maxDeviceInfoBody)
+ }
+ var envelope deviceInfoEnvelope
+ if err := xml.Unmarshal(body, &envelope); err != nil {
+ return DeviceInfo{}, err
+ }
+ if envelope.Info == nil {
+ return DeviceInfo{}, fmt.Errorf("VersionInfo missing from response")
+ }
+ value := DeviceInfo{
+ Type: strings.TrimSpace(envelope.Info.Type),
+ Model: strings.TrimSpace(envelope.Info.Model),
+ Hardware: strings.TrimSpace(envelope.Info.Hardware),
+ Firmware: strings.TrimSpace(envelope.Info.Firmware),
+ }
+ for _, field := range []string{value.Type, value.Model, value.Hardware, value.Firmware} {
+ if len(field) > maxDeviceInfoField {
+ return DeviceInfo{}, fmt.Errorf("device information field exceeds %d bytes", maxDeviceInfoField)
+ }
+ }
+ if value.DisplayModel() == "" {
+ return DeviceInfo{}, fmt.Errorf("camera model missing from response")
+ }
+ return value, nil
+}
diff --git a/pkg/baichuan/device_test.go b/pkg/baichuan/device_test.go
new file mode 100644
index 000000000..04fe32e94
--- /dev/null
+++ b/pkg/baichuan/device_test.go
@@ -0,0 +1,41 @@
+package baichuan
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestParseDeviceInfo(t *testing.T) {
+ body := []byte(` E1 Zoom E340` +
+ `IPC_NT14v3.2.0` +
+ `private camera nameprivate serial`)
+ value, err := parseDeviceInfo(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if value.Type != "E1 Zoom" || value.Model != "E340" || value.DisplayModel() != "E340" ||
+ value.Hardware != "IPC_NT14" || value.Firmware != "v3.2.0" {
+ t.Fatalf("unexpected device information: %+v", value)
+ }
+ if strings.Contains(value.Type+value.Model+value.Hardware+value.Firmware, "private") {
+ t.Fatalf("private fields escaped parser: %+v", value)
+ }
+}
+
+func TestParseDeviceInfoFallbackAndLimits(t *testing.T) {
+ value, err := parseDeviceInfo([]byte(`TrackFlex`))
+ if err != nil || value.DisplayModel() != "TrackFlex" {
+ t.Fatalf("fallback = %+v, %v", value, err)
+ }
+ for _, body := range [][]byte{
+ make([]byte, maxDeviceInfoBody+1),
+ []byte(``),
+ []byte(``),
+ []byte(`` + strings.Repeat("x", maxDeviceInfoField+1) +
+ ``),
+ } {
+ if _, err = parseDeviceInfo(body); err == nil {
+ t.Fatalf("accepted invalid device information of length %d", len(body))
+ }
+ }
+}
diff --git a/pkg/baichuan/media.go b/pkg/baichuan/media.go
new file mode 100644
index 000000000..b9d660f25
--- /dev/null
+++ b/pkg/baichuan/media.go
@@ -0,0 +1,362 @@
+package baichuan
+
+import (
+ "encoding/binary"
+ "fmt"
+ "time"
+)
+
+const (
+ mediaVideoHeaderSize = 24
+
+ mediaInfoV1 = 0x31303031
+ mediaInfoV2 = 0x32303031
+ mediaIFrameMin = 0x63643030
+ mediaIFrameMax = 0x63643039
+ mediaPFrameMin = 0x63643130
+ mediaPFrameMax = 0x63643139
+ mediaAAC = 0x62773530
+ mediaAACV2 = 0x62773531
+ mediaADPCM = 0x62773130
+ mediaAlignment = 8
+ codecH264 = 0x34363248
+ codecH265 = 0x35363248
+)
+
+type MediaKind uint8
+
+const (
+ MediaInfo MediaKind = iota + 1
+ MediaVideoI
+ MediaVideoP
+ MediaAAC
+ MediaADPCM
+)
+
+type MediaPacket struct {
+ Kind MediaKind
+ Codec string
+ Data []byte
+ Timestamp uint32
+ WallTime time.Time
+ Width uint32
+ Height uint32
+ FPS uint8
+ InfoV2 bool
+}
+
+type MediaParser struct {
+ limits Limits
+ buf []byte
+ scan int
+}
+
+func NewMediaParser(limits Limits) (*MediaParser, error) {
+ limits, err := limits.normalized()
+ if err != nil {
+ return nil, err
+ }
+ return &MediaParser{limits: limits}, nil
+}
+
+func (p *MediaParser) Append(data []byte) ([]MediaPacket, error) {
+ if uint64(len(p.buf))+uint64(len(data)) > uint64(p.limits.MaxMediaBuffer) {
+ return nil, fmt.Errorf("baichuan: media buffer exceeds limit %d", p.limits.MaxMediaBuffer)
+ }
+ return p.appendOwned(append([]byte(nil), data...))
+}
+
+// appendOwned may retain data and is only for payloads exclusively owned by the caller.
+func (p *MediaParser) appendOwned(data []byte) ([]MediaPacket, error) {
+ return p.appendOwnedTo(data, nil)
+}
+
+func (p *MediaParser) appendOwnedTo(data []byte, packets []MediaPacket) ([]MediaPacket, error) {
+ if uint64(len(p.buf))+uint64(len(data)) > uint64(p.limits.MaxMediaBuffer) {
+ return nil, fmt.Errorf("baichuan: media buffer exceeds limit %d", p.limits.MaxMediaBuffer)
+ }
+ if len(p.buf) == 0 {
+ p.buf = data
+ } else {
+ p.buf = append(p.buf, data...)
+ }
+
+ packets = packets[:0]
+ for len(p.buf) >= 4 {
+ packet, size, complete, err := p.parsePacket(p.buf)
+ if err != nil {
+ return packets, err
+ }
+ if !complete {
+ break
+ }
+ p.scan = 0
+ p.buf = p.buf[size:]
+ packets = append(packets, packet)
+ }
+ if len(p.buf) == 0 {
+ p.buf = nil
+ }
+ return packets, nil
+}
+
+func knownMediaMagic(magic uint32) bool {
+ return magic == mediaInfoV1 || magic == mediaInfoV2 ||
+ magic >= mediaIFrameMin && magic <= mediaIFrameMax ||
+ magic >= mediaPFrameMin && magic <= mediaPFrameMax ||
+ magic == mediaAAC || magic == mediaAACV2 || magic == mediaADPCM
+}
+
+func (p *MediaParser) parsePacket(b []byte) (MediaPacket, int, bool, error) {
+ magic := binary.LittleEndian.Uint32(b)
+ switch {
+ case magic == mediaInfoV1 || magic == mediaInfoV2:
+ if len(b) < 32 {
+ return MediaPacket{}, 0, false, nil
+ }
+ if size := binary.LittleEndian.Uint32(b[4:8]); size != 32 {
+ return MediaPacket{}, 0, false, fmt.Errorf("baichuan: invalid media info size %d", size)
+ }
+ return MediaPacket{
+ Kind: MediaInfo, Width: binary.LittleEndian.Uint32(b[8:12]),
+ Height: binary.LittleEndian.Uint32(b[12:16]), FPS: b[17], InfoV2: magic == mediaInfoV2,
+ }, 32, true, nil
+ case magic >= mediaIFrameMin && magic <= mediaIFrameMax:
+ return p.parseVideo(b, true)
+ case magic >= mediaPFrameMin && magic <= mediaPFrameMax:
+ return p.parseVideo(b, false)
+ case magic == mediaAAC || magic == mediaAACV2:
+ return p.parseAudio(b, MediaAAC)
+ case magic == mediaADPCM:
+ return p.parseAudio(b, MediaADPCM)
+ default:
+ return MediaPacket{}, 0, false, fmt.Errorf("baichuan: unknown media magic %#x", magic)
+ }
+}
+
+func (p *MediaParser) parseVideo(b []byte, keyframe bool) (MediaPacket, int, bool, error) {
+ const headerSize = mediaVideoHeaderSize
+ if len(b) < headerSize {
+ return MediaPacket{}, 0, false, nil
+ }
+ var codec string
+ switch binary.LittleEndian.Uint32(b[4:8]) {
+ case codecH264:
+ codec = "H264"
+ case codecH265:
+ codec = "H265"
+ default:
+ return MediaPacket{}, 0, false, fmt.Errorf("baichuan: unsupported video codec %q", b[4:8])
+ }
+ payload := binary.LittleEndian.Uint32(b[8:12])
+ extra := binary.LittleEndian.Uint32(b[12:16])
+ if keyframe {
+ return p.parseIFrame(b, codec, payload, extra)
+ }
+ // Observed P-frames honor their declared size; only I-frames require bounded boundary recovery.
+ dataStart := uint64(headerSize) + uint64(extra)
+ plain := dataStart + uint64(payload)
+ total := plain + uint64(padding(payload))
+ if total > uint64(p.limits.MaxMediaFrame) {
+ return MediaPacket{}, 0, false, fmt.Errorf("baichuan: video frame %d exceeds limit %d", total, p.limits.MaxMediaFrame)
+ }
+ size, complete, err := mediaBoundary(b, plain, total, p.limits.MaxResync)
+ if err != nil {
+ return MediaPacket{}, 0, false, fmt.Errorf("baichuan: video payload %d extra header %d: %w", payload, extra, err)
+ }
+ if !complete {
+ return MediaPacket{}, 0, false, nil
+ }
+ packet := MediaPacket{
+ Kind: MediaVideoP, Codec: codec, Data: b[int(dataStart):int(plain)],
+ Timestamp: binary.LittleEndian.Uint32(b[16:20]),
+ }
+ return packet, size, true, nil
+}
+
+func (p *MediaParser) parseIFrame(b []byte, codec string, payload, extra uint32) (MediaPacket, int, bool, error) {
+ const headerSize = mediaVideoHeaderSize
+ dataStart := uint64(headerSize) + uint64(extra)
+ plain := dataStart + uint64(payload)
+ declared := plain + uint64(padding(payload))
+ if declared > uint64(p.limits.MaxMediaFrame) {
+ return MediaPacket{}, 0, false, fmt.Errorf("baichuan: video frame %d exceeds limit %d", declared, p.limits.MaxMediaFrame)
+ }
+ size, complete := p.findVideoBoundary(b, int(dataStart), declared)
+ if !complete {
+ if uint64(len(b)) >= declared+uint64(p.limits.MaxResync)+mediaVideoHeaderSize {
+ return MediaPacket{}, 0, false, fmt.Errorf("baichuan: no keyframe boundary near declared size %d extra header %d", declared, extra)
+ }
+ return MediaPacket{}, 0, false, nil
+ }
+ if uint64(size) > uint64(p.limits.MaxMediaFrame) {
+ return MediaPacket{}, 0, false, fmt.Errorf("baichuan: video frame %d exceeds limit %d", size, p.limits.MaxMediaFrame)
+ }
+ payloadEnd := size
+ if uint64(size) == declared {
+ payloadEnd = int(plain)
+ }
+ var wallTime time.Time
+ if extra >= 4 {
+ wallTime = time.Unix(int64(binary.LittleEndian.Uint32(b[24:28])), 0).UTC()
+ }
+ return MediaPacket{
+ Kind: MediaVideoI, Codec: codec, Data: b[int(dataStart):payloadEnd],
+ Timestamp: binary.LittleEndian.Uint32(b[16:20]),
+ WallTime: wallTime,
+ }, size, true, nil
+}
+
+func (p *MediaParser) findVideoBoundary(b []byte, headerSize int, declared uint64) (int, bool) {
+ if offset := int(declared); offset <= len(b)-8 && plausibleMediaHeader(b[offset:]) {
+ return offset, true
+ }
+ start := int(declared) - int(p.limits.MaxResync)
+ if start < headerSize {
+ start = headerSize
+ }
+ if p.scan < start {
+ p.scan = start
+ }
+ end := int(declared) + int(p.limits.MaxResync)
+ if end > len(b)-8 {
+ end = len(b) - 8
+ }
+ best, pending := -1, -1
+ for i := p.scan; i <= end; i++ {
+ if plausibleMediaHeader(b[i:]) && (best < 0 || abs(i-int(declared)) < abs(best-int(declared))) {
+ best = i
+ } else if pending < 0 && partialVideoHeader(b[i:]) {
+ pending = i
+ }
+ }
+ if pending >= 0 {
+ p.scan = pending
+ } else if end >= p.scan {
+ p.scan = end + 1
+ }
+ return best, best >= 0
+}
+
+func partialVideoHeader(b []byte) bool {
+ if len(b) >= mediaVideoHeaderSize {
+ return false
+ }
+ magic := binary.LittleEndian.Uint32(b)
+ return magic >= mediaIFrameMin && magic <= mediaIFrameMax || magic >= mediaPFrameMin && magic <= mediaPFrameMax
+}
+
+func plausibleMediaHeader(b []byte) bool {
+ if len(b) < 8 {
+ return false
+ }
+ magic := binary.LittleEndian.Uint32(b)
+ switch {
+ case magic == mediaInfoV1 || magic == mediaInfoV2:
+ return binary.LittleEndian.Uint32(b[4:8]) == 32
+ case magic >= mediaIFrameMin && magic <= mediaIFrameMax || magic >= mediaPFrameMin && magic <= mediaPFrameMax:
+ if len(b) < mediaVideoHeaderSize {
+ return false
+ }
+ codec := binary.LittleEndian.Uint32(b[4:8])
+ return codec == codecH264 || codec == codecH265
+ case magic == mediaAAC || magic == mediaAACV2 || magic == mediaADPCM:
+ return binary.LittleEndian.Uint16(b[4:6]) == binary.LittleEndian.Uint16(b[6:8])
+ default:
+ return false
+ }
+}
+
+func abs(value int) int {
+ if value < 0 {
+ return -value
+ }
+ return value
+}
+
+func (p *MediaParser) parseAudio(b []byte, kind MediaKind) (MediaPacket, int, bool, error) {
+ if len(b) < 8 {
+ return MediaPacket{}, 0, false, nil
+ }
+ payload16 := binary.LittleEndian.Uint16(b[4:6])
+ if payload16 != binary.LittleEndian.Uint16(b[6:8]) {
+ return MediaPacket{}, 0, false, fmt.Errorf("baichuan: inconsistent audio payload sizes")
+ }
+ payload := uint32(payload16)
+ plain := uint64(8) + uint64(payload)
+ total := plain + uint64(padding(payload))
+ if total > uint64(p.limits.MaxMediaFrame) {
+ return MediaPacket{}, 0, false, fmt.Errorf("baichuan: audio frame %d exceeds limit %d", total, p.limits.MaxMediaFrame)
+ }
+ size, complete, err := mediaBoundary(b, plain, total, p.limits.MaxResync)
+ if err != nil {
+ return MediaPacket{}, 0, false, err
+ }
+ if !complete {
+ return MediaPacket{}, 0, false, nil
+ }
+ start := 8
+ if kind == MediaADPCM {
+ if payload < 4 {
+ return MediaPacket{}, 0, false, fmt.Errorf("baichuan: invalid ADPCM payload size %d", payload)
+ }
+ if binary.LittleEndian.Uint16(b[8:10]) != 0x0100 {
+ return MediaPacket{}, 0, false, fmt.Errorf("baichuan: invalid ADPCM marker")
+ }
+ start += 4
+ payload -= 4
+ }
+ return MediaPacket{Kind: kind, Data: b[start : start+int(payload)]}, size, true, nil
+}
+
+func mediaBoundary(b []byte, plain, padded uint64, resync uint32) (int, bool, error) {
+ if uint64(len(b)) < plain {
+ return 0, false, nil
+ }
+ if plain == padded {
+ return int(plain), true, nil
+ }
+ if uint64(len(b)) >= plain+4 && hasMediaMagic(b[plain:]) {
+ return int(plain), true, nil
+ }
+ if uint64(len(b)) < padded {
+ return 0, false, nil
+ }
+ if uint64(len(b)) == padded {
+ for _, value := range b[plain:padded] {
+ if value != 0 {
+ return 0, false, nil
+ }
+ }
+ return int(padded), true, nil
+ }
+ if uint64(len(b)) >= padded+4 && hasMediaMagic(b[padded:]) {
+ return int(padded), true, nil
+ }
+ if uint64(len(b)) < padded+4 {
+ return 0, false, nil
+ }
+
+ start := int(plain) - int(resync)
+ if start < 4 {
+ start = 4
+ }
+ end := int(padded) + int(resync)
+ if end > len(b)-3 {
+ end = len(b) - 3
+ }
+ for i := start; i < end; i++ {
+ if hasMediaMagic(b[i:]) {
+ return 0, false, fmt.Errorf("baichuan: media boundary differs from declared size by %d bytes (next magic %#x)",
+ i-int(padded), binary.LittleEndian.Uint32(b[i:]))
+ }
+ }
+ return 0, false, fmt.Errorf("baichuan: no media boundary after declared size %d", padded)
+}
+
+func padding(size uint32) uint32 {
+ if rem := size % mediaAlignment; rem != 0 {
+ return mediaAlignment - rem
+ }
+ return 0
+}
diff --git a/pkg/baichuan/media_test.go b/pkg/baichuan/media_test.go
new file mode 100644
index 000000000..c5118f8e3
--- /dev/null
+++ b/pkg/baichuan/media_test.go
@@ -0,0 +1,328 @@
+package baichuan
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+)
+
+func videoFixture() []byte {
+ b := make([]byte, 40)
+ binary.LittleEndian.PutUint32(b, mediaIFrameMin)
+ copy(b[4:8], "H265")
+ binary.LittleEndian.PutUint32(b[8:12], 3)
+ binary.LittleEndian.PutUint32(b[12:16], 8)
+ binary.LittleEndian.PutUint32(b[16:20], 123456)
+ binary.LittleEndian.PutUint32(b[24:28], 1000)
+ copy(b[32:35], []byte{1, 2, 3})
+ return b
+}
+
+func TestMediaParserIFrameExtraHeader(t *testing.T) {
+ for _, extra := range []uint32{0, 4, 8, 12} {
+ t.Run(fmt.Sprint(extra), func(t *testing.T) {
+ const payload = 3
+ start := 24 + int(extra)
+ video := make([]byte, start+8)
+ binary.LittleEndian.PutUint32(video, mediaIFrameMin)
+ copy(video[4:8], "H265")
+ binary.LittleEndian.PutUint32(video[8:12], payload)
+ binary.LittleEndian.PutUint32(video[12:16], extra)
+ binary.LittleEndian.PutUint32(video[16:20], 123456)
+ if extra >= 4 {
+ binary.LittleEndian.PutUint32(video[24:28], 1000)
+ }
+ copy(video[start:start+payload], []byte{1, 2, 3})
+
+ p, _ := NewMediaParser(Limits{})
+ packets, err := p.Append(append(video, infoFixture()...))
+ if err != nil || len(packets) != 2 {
+ t.Fatalf("unexpected result: packets=%d err=%v", len(packets), err)
+ }
+ packet := packets[0]
+ if !bytes.Equal(packet.Data, []byte{1, 2, 3}) || packet.Timestamp != 123456 {
+ t.Fatalf("unexpected packet: %+v", packet)
+ }
+ if extra >= 4 && packet.WallTime.Unix() != 1000 || extra < 4 && !packet.WallTime.IsZero() {
+ t.Fatalf("unexpected wall time: %v", packet.WallTime)
+ }
+ })
+ }
+}
+
+func infoFixture() []byte {
+ b := make([]byte, 32)
+ binary.LittleEndian.PutUint32(b, mediaInfoV2)
+ binary.LittleEndian.PutUint32(b[4:8], 32)
+ binary.LittleEndian.PutUint32(b[8:12], 3840)
+ binary.LittleEndian.PutUint32(b[12:16], 2160)
+ b[17] = 20
+ return b
+}
+
+func TestMediaParserSplitAndOwnership(t *testing.T) {
+ p, err := NewMediaParser(Limits{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ fixture := videoFixture()
+ packets, err := p.Append(fixture[:19])
+ if err != nil || len(packets) != 0 {
+ t.Fatalf("unexpected partial result: %d, %v", len(packets), err)
+ }
+ packets, err = p.Append(append(fixture[19:], infoFixture()...))
+ if err != nil || len(packets) != 2 {
+ t.Fatalf("unexpected complete result: %d, %v", len(packets), err)
+ }
+ packet := packets[0]
+ if packet.Kind != MediaVideoI || packet.Codec != "H265" || packet.Timestamp != 123456 ||
+ !packet.WallTime.Equal(packet.WallTime.UTC()) || !bytes.Equal(packet.Data, []byte{1, 2, 3}) {
+ t.Fatalf("unexpected packet: %+v", packet)
+ }
+ fixture[32] = 9
+ if packet.Data[0] != 1 {
+ t.Fatal("packet data aliases parser input")
+ }
+}
+
+func TestMediaParserRejectsUnknownPrefix(t *testing.T) {
+ p, _ := NewMediaParser(Limits{})
+ if _, err := p.Append(append([]byte{9, 8, 7, 6}, infoFixture()...)); err == nil || !strings.Contains(err.Error(), "unknown media magic") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestMediaParserAdjustsBrokenKeyframeLength(t *testing.T) {
+ p, _ := NewMediaParser(Limits{})
+ video := videoFixture()[:35]
+ binary.LittleEndian.PutUint32(video[8:12], 100)
+ packets, err := p.Append(append(video, infoFixture()...))
+ if err != nil || len(packets) != 2 {
+ t.Fatalf("unexpected result: packets=%d err=%v", len(packets), err)
+ }
+ if !bytes.Equal(packets[0].Data, []byte{1, 2, 3}) {
+ t.Fatalf("unexpected keyframe data: %x", packets[0].Data)
+ }
+}
+
+func TestPreviewBurstPreservesPacketBytes(t *testing.T) {
+ limits := testLimits(t)
+ parser, _ := NewMediaParser(limits)
+ client := &Client{previews: make(map[previewKey]*Preview)}
+ preview := &Preview{
+ client: client, messages: make(chan message, previewQueueMessages),
+ queueLimit: int64(limits.MaxMediaBuffer), done: make(chan struct{}), parser: parser,
+ }
+ const count = 32
+ for i := range count {
+ frame := make([]byte, 24+(32<<10))
+ binary.LittleEndian.PutUint32(frame, mediaPFrameMin)
+ copy(frame[4:8], "H264")
+ binary.LittleEndian.PutUint32(frame[8:12], 32<<10)
+ frame[24] = byte(i)
+ preview.deliver(message{payload: frame})
+ }
+ if queued := preview.queueBytes.Load(); queued == 0 {
+ t.Fatal("preview burst was not queued")
+ }
+ for i := range count {
+ packet, err := preview.Read(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if packet.Kind != MediaVideoP || len(packet.Data) != 32<<10 || packet.Data[0] != byte(i) {
+ t.Fatalf("packet %d corrupted: kind=%d size=%d first=%d", i, packet.Kind, len(packet.Data), packet.Data[0])
+ }
+ }
+ if queued := preview.queueBytes.Load(); queued != 0 {
+ t.Fatalf("preview retained %d queued bytes", queued)
+ }
+}
+
+func TestPreviewQueueByteBudget(t *testing.T) {
+ client := &Client{previews: make(map[previewKey]*Preview)}
+ preview := &Preview{
+ client: client, messages: make(chan message, previewQueueMessages),
+ queueLimit: 32, done: make(chan struct{}),
+ }
+ preview.deliver(message{payload: make([]byte, 20)})
+ preview.deliver(message{payload: make([]byte, 20)})
+ if queued := preview.queueBytes.Load(); !errors.Is(preview.Err(), ErrPreviewOverflow) || queued != 20 {
+ t.Fatalf("unexpected overflow state: err=%v queued=%d", preview.Err(), queued)
+ }
+}
+
+func TestPreviewQueueMessageBudget(t *testing.T) {
+ client := &Client{previews: make(map[previewKey]*Preview)}
+ preview := &Preview{
+ client: client, messages: make(chan message, previewQueueMessages),
+ queueLimit: 32, done: make(chan struct{}),
+ }
+ for range previewQueueMessages + 1 {
+ preview.deliver(message{})
+ }
+ if queued := preview.queueBytes.Load(); !errors.Is(preview.Err(), ErrPreviewOverflow) || queued != 0 {
+ t.Fatalf("unexpected message overflow state: err=%v queued=%d", preview.Err(), queued)
+ }
+}
+
+func TestMediaParserPFrameExtraHeader(t *testing.T) {
+ p, _ := NewMediaParser(Limits{})
+ b := make([]byte, 36)
+ binary.LittleEndian.PutUint32(b, mediaPFrameMin)
+ copy(b[4:8], "H265")
+ binary.LittleEndian.PutUint32(b[8:12], 3)
+ binary.LittleEndian.PutUint32(b[12:16], 4)
+ copy(b[28:31], []byte{1, 2, 3})
+ packets, err := p.Append(b)
+ if err != nil || len(packets) != 1 || !bytes.Equal(packets[0].Data, []byte{1, 2, 3}) {
+ t.Fatalf("unexpected P-frame result: %+v err=%v", packets, err)
+ }
+}
+
+func TestMediaParserRejectsADPCMUnderflow(t *testing.T) {
+ p, _ := NewMediaParser(Limits{})
+ b := make([]byte, 16)
+ binary.LittleEndian.PutUint32(b, mediaADPCM)
+ binary.LittleEndian.PutUint16(b[4:6], 2)
+ binary.LittleEndian.PutUint16(b[6:8], 2)
+ if _, err := p.Append(b); err == nil || !strings.Contains(err.Error(), "ADPCM payload") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestMediaParserRejectsInconsistentAudioSize(t *testing.T) {
+ p, _ := NewMediaParser(Limits{})
+ b := make([]byte, 16)
+ binary.LittleEndian.PutUint32(b, mediaAAC)
+ binary.LittleEndian.PutUint16(b[4:6], 2)
+ binary.LittleEndian.PutUint16(b[6:8], 3)
+ if _, err := p.Append(b); err == nil || !strings.Contains(err.Error(), "inconsistent audio") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestMediaParserUnpaddedFragmentDoesNotConsumeNextHeader(t *testing.T) {
+ audio := make([]byte, 13)
+ binary.LittleEndian.PutUint32(audio, mediaAAC)
+ binary.LittleEndian.PutUint16(audio[4:6], 5)
+ binary.LittleEndian.PutUint16(audio[6:8], 5)
+ copy(audio[8:], "audio")
+ info := infoFixture()
+ p, _ := NewMediaParser(Limits{})
+ packets, err := p.Append(append(audio, info[:3]...))
+ if err != nil || len(packets) != 0 {
+ t.Fatalf("unexpected first fragment: packets=%d err=%v", len(packets), err)
+ }
+ packets, err = p.Append(info[3:])
+ if err != nil || len(packets) != 2 || string(packets[0].Data) != "audio" || packets[1].Kind != MediaInfo {
+ t.Fatalf("unexpected completed packets: %+v err=%v", packets, err)
+ }
+}
+
+func TestMediaParserFrameLimit(t *testing.T) {
+ p, err := NewMediaParser(Limits{MaxMediaFrame: 64, MaxMediaBuffer: 160, MaxResync: 64})
+ if err != nil {
+ t.Fatal(err)
+ }
+ b := make([]byte, 24)
+ binary.LittleEndian.PutUint32(b, mediaPFrameMin)
+ copy(b[4:8], "H264")
+ binary.LittleEndian.PutUint32(b[8:12], 100)
+ if _, err = p.Append(b); err == nil || !strings.Contains(err.Error(), "exceeds limit") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ p, _ = NewMediaParser(Limits{MaxMediaFrame: 64, MaxMediaBuffer: 160, MaxResync: 64})
+ binary.LittleEndian.PutUint32(b, mediaIFrameMin)
+ binary.LittleEndian.PutUint32(b[8:12], 0)
+ binary.LittleEndian.PutUint32(b[12:16], ^uint32(0))
+ if _, err = p.Append(b); err == nil || !strings.Contains(err.Error(), "exceeds limit") {
+ t.Fatalf("unexpected I-frame error: %v", err)
+ }
+
+ p, _ = NewMediaParser(Limits{MaxMediaFrame: 64, MaxMediaBuffer: 160, MaxResync: 64})
+ b = make([]byte, 72)
+ binary.LittleEndian.PutUint32(b, mediaIFrameMin)
+ copy(b[4:8], "H264")
+ binary.LittleEndian.PutUint32(b[8:12], 40)
+ if _, err = p.Append(append(b, infoFixture()...)); err == nil ||
+ !strings.Contains(err.Error(), "video frame 72 exceeds limit 64") {
+ t.Fatalf("accepted adjusted I-frame beyond limit: %v", err)
+ }
+}
+
+func TestMediaParserScansFragmentedKeyframeLinearly(t *testing.T) {
+ const resync = 4096
+ p, err := NewMediaParser(Limits{MaxMediaFrame: 64, MaxMediaBuffer: 8192, MaxResync: resync})
+ if err != nil {
+ t.Fatal(err)
+ }
+ header := make([]byte, 24)
+ binary.LittleEndian.PutUint32(header, mediaIFrameMin)
+ copy(header[4:8], "H264")
+ if _, err = p.Append(header); err != nil {
+ t.Fatal(err)
+ }
+ previous := len(header)
+ for range resync + 24 {
+ _, err = p.Append([]byte{0})
+ if p.scan != 0 {
+ if p.scan-previous > 1 {
+ t.Fatalf("rescanned %d keyframe offsets", p.scan-previous)
+ }
+ previous = p.scan
+ }
+ }
+ if err == nil || !strings.Contains(err.Error(), "no keyframe boundary") {
+ t.Fatalf("unexpected fragmented keyframe result: %v", err)
+ }
+}
+
+func TestMediaParserAcceptsKeyframeBoundaryAtResyncLimit(t *testing.T) {
+ const resync = 64
+ p, err := NewMediaParser(Limits{MaxMediaFrame: 128, MaxMediaBuffer: 216, MaxResync: resync})
+ if err != nil {
+ t.Fatal(err)
+ }
+ b := make([]byte, 24+resync+24)
+ binary.LittleEndian.PutUint32(b, mediaIFrameMin)
+ copy(b[4:8], "H264")
+ next := 24 + resync
+ binary.LittleEndian.PutUint32(b[next:], mediaPFrameMin)
+ copy(b[next+4:next+8], "H264")
+ packets, err := p.Append(b[:next+8])
+ if err != nil || len(packets) != 0 {
+ t.Fatalf("rejected partial boundary: packets=%d err=%v", len(packets), err)
+ }
+ packets, err = p.Append(b[next+8:])
+ if err != nil || len(packets) != 2 {
+ t.Fatalf("unexpected boundary result: packets=%d err=%v", len(packets), err)
+ }
+}
+
+func TestMediaParserAppendLimit(t *testing.T) {
+ p, err := NewMediaParser(Limits{MaxMediaFrame: 64, MaxMediaBuffer: 160, MaxResync: 64})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = p.Append(make([]byte, 161)); err == nil || !strings.Contains(err.Error(), "media buffer exceeds limit 160") {
+ t.Fatalf("unexpected append error: %v", err)
+ }
+ if p.buf != nil {
+ t.Fatal("oversized append changed parser state")
+ }
+}
+
+func FuzzMediaParser(f *testing.F) {
+ f.Add(videoFixture())
+ f.Add(infoFixture())
+ f.Fuzz(func(t *testing.T, data []byte) {
+ p, _ := NewMediaParser(Limits{MaxMediaFrame: 4096, MaxMediaBuffer: 8192, MaxResync: 1024})
+ _, _ = p.Append(data)
+ })
+}
diff --git a/pkg/baichuan/message.go b/pkg/baichuan/message.go
new file mode 100644
index 000000000..0f7b624e0
--- /dev/null
+++ b/pkg/baichuan/message.go
@@ -0,0 +1,245 @@
+package baichuan
+
+import (
+ "encoding/binary"
+ "fmt"
+ "io"
+)
+
+const (
+ wireMagic = 0x0abcdef0
+
+ classLegacy = 0x6514
+ classModern = 0x6614
+ classOffset = 0x6414
+ classAlt = 0x0000
+
+ commandLogin = 1
+ commandPreview = 3
+ commandStopPreview = 4
+ commandTalkAbility = 10
+ commandStopTalk = 11
+ commandDeviceInfo = 80
+ commandPing = 93
+ commandAbility = 151
+ commandTalkConfig = 201
+ commandTalkData = 202
+)
+
+type header struct {
+ Command uint32
+ BodyLen uint32
+ Channel uint8
+ Stream uint8
+ Sequence uint16
+ ResponseCode uint16
+ Class uint16
+ PayloadOffset uint32
+}
+
+func (h header) hasOffset() bool {
+ return h.Class == classOffset || h.Class == classAlt
+}
+
+type frame struct {
+ header header
+ body []byte
+}
+
+type message struct {
+ header header
+ extension []byte
+ payload []byte
+ binary bool
+ encrypt int
+ hasEncryptLen bool
+ checkPos int
+ hasCheckPos bool
+}
+
+type request struct {
+ command uint32
+ channel uint8
+ stream uint8
+ sequence uint16
+ class uint16
+ extension []byte
+ payload []byte
+ binary bool
+ forceBC bool
+}
+
+func readFrame(r io.Reader, limits Limits) (frame, error) {
+ var base [20]byte
+ if _, err := io.ReadFull(r, base[:]); err != nil {
+ return frame{}, err
+ }
+ if magic := binary.LittleEndian.Uint32(base[:4]); magic != wireMagic {
+ return frame{}, fmt.Errorf("baichuan: invalid message magic %#x", magic)
+ }
+
+ h := header{
+ Command: binary.LittleEndian.Uint32(base[4:8]),
+ BodyLen: binary.LittleEndian.Uint32(base[8:12]),
+ Channel: base[12],
+ Stream: base[13],
+ Sequence: binary.LittleEndian.Uint16(base[14:16]),
+ ResponseCode: binary.LittleEndian.Uint16(base[16:18]),
+ Class: binary.LittleEndian.Uint16(base[18:20]),
+ }
+ if h.Class != classLegacy && h.Class != classModern && h.Class != classOffset && h.Class != classAlt {
+ return frame{}, fmt.Errorf("baichuan: unsupported message class %#x", h.Class)
+ }
+ if h.BodyLen > limits.MaxBody {
+ return frame{}, fmt.Errorf("baichuan: message body %d exceeds limit %d", h.BodyLen, limits.MaxBody)
+ }
+ if h.hasOffset() {
+ var offset [4]byte
+ if _, err := io.ReadFull(r, offset[:]); err != nil {
+ return frame{}, err
+ }
+ h.PayloadOffset = binary.LittleEndian.Uint32(offset[:])
+ if h.PayloadOffset > h.BodyLen || h.PayloadOffset > limits.MaxExtension {
+ return frame{}, fmt.Errorf("baichuan: invalid payload offset %d for body %d", h.PayloadOffset, h.BodyLen)
+ }
+ }
+ payload := h.BodyLen - h.PayloadOffset
+ if limit := payloadLimit(h.Command, limits); payload > limit {
+ return frame{}, fmt.Errorf("baichuan: command %d payload %d exceeds limit %d", h.Command, payload, limit)
+ }
+
+ body := make([]byte, h.BodyLen)
+ if _, err := io.ReadFull(r, body); err != nil {
+ return frame{}, err
+ }
+ return frame{header: h, body: body}, nil
+}
+
+func payloadLimit(command uint32, limits Limits) uint32 {
+ var limit uint32
+ switch command {
+ case commandLogin:
+ limit = maxNonceBody
+ case commandTalkAbility:
+ limit = maxTalkAbilityBody
+ case commandDeviceInfo:
+ limit = maxDeviceInfoBody
+ case commandAbility:
+ limit = maxCapabilityBody
+ default:
+ return limits.MaxBody
+ }
+ if limit > limits.MaxBody {
+ return limits.MaxBody
+ }
+ return limit
+}
+
+func decodeFrame(value frame, cipher cipherState, binaryHint bool) (message, error) {
+ if value.header.PayloadOffset > uint32(len(value.body)) {
+ return message{}, fmt.Errorf("baichuan: invalid payload offset %d for body %d",
+ value.header.PayloadOffset, len(value.body))
+ }
+ offset := int(value.header.PayloadOffset)
+ extensionBody := value.body[:offset]
+ cipher.decryptInPlace(value.header.Channel, extensionBody)
+ extensionMeta, err := parseExtension(extensionBody)
+ if err != nil {
+ return message{}, err
+ }
+ binaryPayload := binaryHint || extensionMeta.BinaryData != nil && *extensionMeta.BinaryData == 1
+
+ payload := value.body[offset:]
+ encryptLen := 0
+ if !binaryPayload {
+ cipher.decryptInPlace(value.header.Channel, payload)
+ } else {
+ if extensionMeta.EncryptLen != nil {
+ n := *extensionMeta.EncryptLen
+ if n < 0 || n > len(payload) {
+ return message{}, fmt.Errorf("baichuan: invalid encrypted prefix %d for payload %d", n, len(payload))
+ }
+ if n > 0 {
+ cipher.decryptInPlace(value.header.Channel, payload[:n])
+ }
+ encryptLen = n
+ }
+ }
+ msg := message{
+ header: value.header, extension: extensionBody, payload: payload,
+ binary: binaryPayload, encrypt: encryptLen, hasEncryptLen: extensionMeta.EncryptLen != nil,
+ }
+ if extensionMeta.CheckPos != nil {
+ msg.checkPos = *extensionMeta.CheckPos
+ msg.hasCheckPos = true
+ }
+ return msg, nil
+}
+
+func hasMediaMagic(b []byte) bool {
+ return len(b) >= 4 && knownMediaMagic(binary.LittleEndian.Uint32(b))
+}
+
+func encodeRequest(value request, limits Limits, cipher cipherState) ([]byte, error) {
+ if value.class != classLegacy && value.class != classModern && value.class != classOffset && value.class != classAlt {
+ return nil, fmt.Errorf("baichuan: unsupported request class %#x", value.class)
+ }
+ if value.forceBC {
+ cipher.mode = encryptionBC
+ }
+ bodyLen := uint64(len(value.extension)) + uint64(len(value.payload))
+ if bodyLen > uint64(limits.MaxBody) || len(value.extension) > int(limits.MaxExtension) {
+ return nil, fmt.Errorf("baichuan: request exceeds configured limits")
+ }
+
+ headerLen := 20
+ if value.class == classOffset || value.class == classAlt {
+ headerLen = 24
+ }
+ packet := make([]byte, headerLen+int(bodyLen))
+ binary.LittleEndian.PutUint32(packet[0:4], wireMagic)
+ binary.LittleEndian.PutUint32(packet[4:8], value.command)
+ binary.LittleEndian.PutUint32(packet[8:12], uint32(bodyLen))
+ packet[12] = value.channel
+ packet[13] = value.stream
+ binary.LittleEndian.PutUint16(packet[14:16], value.sequence)
+ if value.class == classLegacy && value.command == commandLogin && len(value.payload) == 0 {
+ binary.LittleEndian.PutUint16(packet[16:18], 0xdc12)
+ }
+ binary.LittleEndian.PutUint16(packet[18:20], value.class)
+ if headerLen == 24 {
+ binary.LittleEndian.PutUint32(packet[20:24], uint32(len(value.extension)))
+ }
+ extension := packet[headerLen : headerLen+len(value.extension)]
+ payload := packet[headerLen+len(value.extension):]
+ copy(extension, value.extension)
+ copy(payload, value.payload)
+ if value.class != classLegacy {
+ cipher.cryptInPlace(value.channel, extension, true)
+ if !value.binary {
+ cipher.cryptInPlace(value.channel, payload, true)
+ }
+ }
+ return packet, nil
+}
+
+func responseError(h header) error {
+ if _, ok := negotiatedEncryption(h.ResponseCode); ok {
+ return nil
+ }
+ switch h.ResponseCode {
+ case 0, 200, 201, 300:
+ return nil
+ default:
+ return &StatusError{Command: h.Command, Code: h.ResponseCode}
+ }
+}
+
+type StatusError struct {
+ Command uint32
+ Code uint16
+}
+
+func (e *StatusError) Error() string {
+ return fmt.Sprintf("baichuan: command %d failed with status %d", e.Command, e.Code)
+}
diff --git a/pkg/baichuan/message_test.go b/pkg/baichuan/message_test.go
new file mode 100644
index 000000000..01e0814c9
--- /dev/null
+++ b/pkg/baichuan/message_test.go
@@ -0,0 +1,100 @@
+package baichuan
+
+import (
+ "bytes"
+ "encoding/binary"
+ "encoding/hex"
+ "strings"
+ "testing"
+)
+
+func testLimits(t *testing.T) Limits {
+ t.Helper()
+ limits, err := (Limits{}).normalized()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return limits
+}
+
+func TestEncodeNonceRequest(t *testing.T) {
+ packet, err := encodeRequest(request{
+ command: commandLogin, sequence: 7, class: classLegacy, forceBC: true,
+ }, testLimits(t), cipherState{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ const expected = "f0debc0a01000000000000000000070012dc1465"
+ if actual := hex.EncodeToString(packet); actual != expected {
+ t.Fatalf("unexpected packet:\n%s\n%s", actual, expected)
+ }
+}
+
+func TestReadFrameBounds(t *testing.T) {
+ limits := testLimits(t)
+ header := make([]byte, 24)
+ binary.LittleEndian.PutUint32(header, wireMagic)
+ binary.LittleEndian.PutUint32(header[8:], limits.MaxBody+1)
+ binary.LittleEndian.PutUint16(header[18:], classOffset)
+ if _, err := readFrame(bytes.NewReader(header), limits); err == nil || !strings.Contains(err.Error(), "exceeds limit") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ binary.LittleEndian.PutUint32(header[8:], 1)
+ binary.LittleEndian.PutUint32(header[20:], 2)
+ if _, err := readFrame(bytes.NewReader(header), limits); err == nil || !strings.Contains(err.Error(), "payload offset") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestReadFrameRejectsControlPayloadBeforeRead(t *testing.T) {
+ limits := testLimits(t)
+ for command, limit := range map[uint32]uint32{
+ commandLogin: maxNonceBody, commandTalkAbility: maxTalkAbilityBody,
+ commandDeviceInfo: maxDeviceInfoBody, commandAbility: maxCapabilityBody,
+ } {
+ header := make([]byte, 20)
+ binary.LittleEndian.PutUint32(header, wireMagic)
+ binary.LittleEndian.PutUint32(header[4:], command)
+ binary.LittleEndian.PutUint32(header[8:], limit+1)
+ binary.LittleEndian.PutUint16(header[18:], classLegacy)
+ if _, err := readFrame(bytes.NewReader(header), limits); err == nil ||
+ !strings.Contains(err.Error(), "payload") {
+ t.Fatalf("command %d read oversized payload: %v", command, err)
+ }
+ }
+}
+
+func TestDecodeFrame(t *testing.T) {
+ state := cipherState{mode: encryptionBC}
+ ext := []byte("1")
+ body := append(testCrypt(state, 1, ext, true), []byte{1, 2, 3}...)
+ msg, err := decodeFrame(frame{
+ header: header{Channel: 1, BodyLen: uint32(len(body)), PayloadOffset: uint32(len(ext))},
+ body: body,
+ }, state, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !msg.binary || !bytes.Equal(msg.payload, []byte{1, 2, 3}) {
+ t.Fatalf("unexpected message: %+v", msg)
+ }
+}
+
+func TestDecodeFrameRejectsInvalidOffset(t *testing.T) {
+ _, err := decodeFrame(frame{
+ header: header{PayloadOffset: 2}, body: []byte{1},
+ }, cipherState{}, false)
+ if err == nil || !strings.Contains(err.Error(), "invalid payload offset") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func FuzzReadFrame(f *testing.F) {
+ f.Add([]byte{0xf0, 0xde, 0xbc, 0x0a})
+ f.Add(make([]byte, 24))
+ limits, _ := (Limits{MaxBody: 4096, MaxExtension: 1024}).normalized()
+ f.Fuzz(func(t *testing.T, data []byte) {
+ _, _ = readFrame(bytes.NewReader(data), limits)
+ })
+}
diff --git a/pkg/baichuan/preview.go b/pkg/baichuan/preview.go
new file mode 100644
index 000000000..ada33927d
--- /dev/null
+++ b/pkg/baichuan/preview.go
@@ -0,0 +1,218 @@
+package baichuan
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "sync"
+ "sync/atomic"
+)
+
+const previewQueueMessages = 64
+
+func (c *Client) StartPreview(ctx context.Context, channel uint8, stream Stream) (*Preview, error) {
+ if err := c.Login(ctx); err != nil {
+ return nil, err
+ }
+ streamType, handle, err := stream.params()
+ if err != nil {
+ return nil, err
+ }
+ body, err := buildPreview(channel, stream, handle)
+ if err != nil {
+ return nil, fmt.Errorf("baichuan: build preview request: %w", err)
+ }
+ parser, err := NewMediaParser(c.cfg.Limits)
+ if err != nil {
+ return nil, err
+ }
+ p := &Preview{
+ client: c, key: previewKey{channel: channel, stream: streamType}, stream: stream,
+ handle: handle, messages: make(chan message, previewQueueMessages),
+ queueLimit: int64(c.cfg.Limits.MaxMediaBuffer), done: make(chan struct{}), parser: parser,
+ }
+ if err = c.addPreview(p); err != nil {
+ return nil, err
+ }
+ if _, err = c.roundTrip(ctx, request{
+ command: commandPreview, channel: channel, stream: streamType, class: classOffset, payload: body,
+ }); err != nil {
+ p.closeLocal(err)
+ return nil, fmt.Errorf("baichuan: start preview: %w", err)
+ }
+ return p, nil
+}
+
+func (c *Client) addPreview(p *Preview) error {
+ c.previewMu.Lock()
+ defer c.previewMu.Unlock()
+ if _, ok := c.previews[p.key]; ok {
+ return fmt.Errorf("baichuan: preview already active for channel %d stream %d", p.key.channel, p.key.stream)
+ }
+ c.previews[p.key] = p
+ return nil
+}
+
+func (c *Client) removePreview(p *Preview) {
+ c.previewMu.Lock()
+ if c.previews[p.key] == p {
+ delete(c.previews, p.key)
+ }
+ c.previewMu.Unlock()
+}
+
+func (c *Client) getPreview(h header) *Preview {
+ c.previewMu.RLock()
+ p := c.previews[previewKey{channel: h.Channel, stream: h.Stream}]
+ c.previewMu.RUnlock()
+ return p
+}
+
+type Preview struct {
+ client *Client
+ key previewKey
+ stream Stream
+ handle uint32
+
+ messages chan message
+ queueLimit int64
+ queueBytes atomic.Int64
+ done chan struct{}
+ close sync.Once
+ stop sync.Once
+ stopErr error
+ errMu sync.Mutex
+ err error
+
+ readMu sync.Mutex
+ parser *MediaParser
+ packets []MediaPacket
+ next int
+}
+
+func (p *Preview) Format(state fmt.State, _ rune) {
+ _, _ = fmt.Fprintf(state, "baichuan.Preview{Channel:%d, Stream:%q}", p.key.channel, p.stream)
+}
+
+func (p *Preview) deliver(msg message) {
+ select {
+ case <-p.done:
+ return
+ default:
+ }
+ size := int64(len(msg.extension) + len(msg.payload))
+ if !p.reserveQueue(size) {
+ p.closeLocal(ErrPreviewOverflow)
+ return
+ }
+ select {
+ case p.messages <- msg:
+ case <-p.done:
+ p.queueBytes.Add(-size)
+ default:
+ p.queueBytes.Add(-size)
+ p.closeLocal(ErrPreviewOverflow)
+ }
+}
+
+func (p *Preview) reserveQueue(size int64) bool {
+ for {
+ queued := p.queueBytes.Load()
+ if size > p.queueLimit-queued {
+ return false
+ }
+ if !p.queueBytes.CompareAndSwap(queued, queued+size) {
+ continue
+ }
+ return true
+ }
+}
+
+func (p *Preview) Read(ctx context.Context) (MediaPacket, error) {
+ p.readMu.Lock()
+ defer p.readMu.Unlock()
+ for {
+ select {
+ case <-p.done:
+ return MediaPacket{}, p.Err()
+ case <-p.client.done:
+ return MediaPacket{}, p.client.Err()
+ default:
+ }
+ if p.next < len(p.packets) {
+ packet := p.packets[p.next]
+ p.packets[p.next] = MediaPacket{}
+ p.next++
+ return packet, nil
+ }
+ select {
+ case msg := <-p.messages:
+ p.queueBytes.Add(-int64(len(msg.extension) + len(msg.payload)))
+ select {
+ case <-p.done:
+ return MediaPacket{}, p.Err()
+ default:
+ }
+ residue := len(p.parser.buf)
+ packets, err := p.parser.appendOwnedTo(msg.payload, p.packets)
+ if err != nil {
+ err = fmt.Errorf("baichuan: parse media with residue %d, extension %d, encrypted prefix %d (present %t), check position %d (present %t), payload %d, parsed %d, remaining %d: %w",
+ residue, len(msg.extension), msg.encrypt, msg.hasEncryptLen, msg.checkPos, msg.hasCheckPos,
+ len(msg.payload), len(packets), len(p.parser.buf), err)
+ p.closeLocal(err)
+ return MediaPacket{}, err
+ }
+ p.packets = packets
+ p.next = 0
+ case <-p.done:
+ return MediaPacket{}, p.Err()
+ case <-p.client.done:
+ return MediaPacket{}, p.client.Err()
+ case <-ctx.Done():
+ return MediaPacket{}, ctx.Err()
+ }
+ }
+}
+
+func (p *Preview) Err() error {
+ p.errMu.Lock()
+ defer p.errMu.Unlock()
+ if p.err == nil {
+ return io.EOF
+ }
+ return p.err
+}
+
+func (p *Preview) closeLocal(err error) {
+ p.close.Do(func() {
+ p.errMu.Lock()
+ p.err = err
+ p.errMu.Unlock()
+ p.client.removePreview(p)
+ close(p.done)
+ })
+}
+
+func (p *Preview) Close() error {
+ p.stop.Do(func() {
+ p.closeLocal(context.Canceled)
+ if p.client.ctx.Err() != nil {
+ return
+ }
+ body, err := buildStopPreview(p.key.channel, p.handle)
+ if err != nil {
+ p.stopErr = err
+ return
+ }
+ ctx, cancel := context.WithTimeout(p.client.ctx, p.client.cfg.Timeout)
+ defer cancel()
+ _, err = p.client.roundTrip(ctx, request{
+ command: commandStopPreview, channel: p.key.channel, stream: p.key.stream,
+ class: classOffset, payload: body,
+ })
+ if err != nil {
+ p.stopErr = fmt.Errorf("baichuan: stop preview: %w", err)
+ }
+ })
+ return p.stopErr
+}
diff --git a/pkg/baichuan/talk.go b/pkg/baichuan/talk.go
new file mode 100644
index 000000000..c411a7293
--- /dev/null
+++ b/pkg/baichuan/talk.go
@@ -0,0 +1,221 @@
+package baichuan
+
+import (
+ "context"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "sync"
+)
+
+type UnsupportedTalkError struct {
+ Reason string
+}
+
+func (e *UnsupportedTalkError) Error() string {
+ return "baichuan: talkback unsupported: " + e.Reason
+}
+
+type TalkFormat struct {
+ SampleRate uint32
+ SamplePrecision uint32
+ SamplesPerBlock uint32
+}
+
+func (f TalkFormat) BytesPerBlock() int {
+ return 4 + int(f.SamplesPerBlock)/2
+}
+
+func (c *Client) ProbeTalk(ctx context.Context, channel uint8) (TalkFormat, error) {
+ if err := c.Login(ctx); err != nil {
+ return TalkFormat{}, err
+ }
+ config, err := c.talkConfig(ctx, channel)
+ if err != nil {
+ return TalkFormat{}, err
+ }
+ return formatOf(config), nil
+}
+
+func (c *Client) talkConfig(ctx context.Context, channel uint8) (talkConfig, error) {
+ extension, err := buildTalkExtension(channel, false)
+ if err != nil {
+ return talkConfig{}, err
+ }
+ msg, err := c.roundTrip(ctx, request{
+ command: commandTalkAbility, channel: channel, class: classOffset, extension: extension,
+ })
+ if err != nil {
+ var status *StatusError
+ if errors.As(err, &status) && unsupportedTalkStatus(status.Code) {
+ return talkConfig{}, &UnsupportedTalkError{Reason: fmt.Sprintf("camera status %d", status.Code)}
+ }
+ return talkConfig{}, fmt.Errorf("baichuan: query talk ability: %w", err)
+ }
+ ability, err := decodeTalkAbility(msg.payload)
+ if err != nil {
+ return talkConfig{}, fmt.Errorf("baichuan: decode talk ability: %w", err)
+ }
+ return selectTalkConfig(channel, ability)
+}
+
+func unsupportedTalkStatus(code uint16) bool {
+ switch code {
+ case 400, 404, 405, 501:
+ return true
+ default:
+ return false
+ }
+}
+
+func formatOf(config talkConfig) TalkFormat {
+ return TalkFormat{
+ SampleRate: config.Audio.SampleRate, SamplePrecision: config.Audio.SamplePrecision,
+ SamplesPerBlock: config.Audio.SamplesPerBlock,
+ }
+}
+
+type Talk struct {
+ client *Client
+ channel uint8
+ format TalkFormat
+ extension []byte
+
+ mu sync.Mutex
+ closed bool
+ sequence uint16
+ closeOnce sync.Once
+ closeDone chan struct{}
+ closeErr error
+}
+
+func (t *Talk) String() string {
+ return fmt.Sprintf("baichuan.Talk{Channel:%d, Audio:%+v}", t.channel, t.format)
+}
+
+func (t *Talk) GoString() string {
+ return t.String()
+}
+
+func (c *Client) StartTalk(ctx context.Context, channel uint8) (*Talk, error) {
+ if err := c.Login(ctx); err != nil {
+ return nil, err
+ }
+ config, err := c.talkConfig(ctx, channel)
+ if err != nil {
+ return nil, err
+ }
+ if err = c.startTalk(ctx, config); err != nil {
+ return nil, err
+ }
+ extension, err := buildTalkExtension(channel, true)
+ if err != nil {
+ return nil, errors.Join(err, c.stopTalk(ctx, channel))
+ }
+ return &Talk{
+ client: c, channel: channel, format: formatOf(config), extension: extension, closeDone: make(chan struct{}),
+ }, nil
+}
+
+func (c *Client) startTalk(ctx context.Context, config talkConfig) error {
+ extension, err := buildTalkExtension(config.Channel, false)
+ if err != nil {
+ return err
+ }
+ body, err := marshalDocument(talkConfigEnvelope{Config: config})
+ if err != nil {
+ return err
+ }
+ req := request{
+ command: commandTalkConfig, channel: config.Channel, class: classOffset,
+ extension: extension, payload: body,
+ }
+ if _, err = c.roundTrip(ctx, req); err != nil {
+ var status *StatusError
+ if !errors.As(err, &status) || status.Code != 422 {
+ return fmt.Errorf("baichuan: configure talk: %w", err)
+ }
+ if resetErr := c.stopTalk(ctx, config.Channel); resetErr != nil {
+ return errors.Join(fmt.Errorf("baichuan: reset stale talk: %w", err), resetErr)
+ }
+ if _, err = c.roundTrip(ctx, req); err != nil {
+ return fmt.Errorf("baichuan: configure talk after reset: %w", err)
+ }
+ }
+ return nil
+}
+
+func (c *Client) stopTalk(ctx context.Context, channel uint8) error {
+ extension, err := buildTalkExtension(channel, false)
+ if err != nil {
+ return err
+ }
+ _, err = c.roundTrip(ctx, request{
+ command: commandStopTalk, channel: channel, class: classOffset, extension: extension,
+ })
+ var status *StatusError
+ if errors.As(err, &status) && status.Code == 422 {
+ return nil
+ }
+ return err
+}
+
+func (t *Talk) Format() TalkFormat {
+ return t.format
+}
+
+func (t *Talk) WriteBlock(ctx context.Context, block []byte) error {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.closed {
+ return fmt.Errorf("baichuan: write talk: %w", context.Canceled)
+ }
+ if len(block) != t.format.BytesPerBlock() {
+ return fmt.Errorf("baichuan: ADPCM block size %d, want %d", len(block), t.format.BytesPerBlock())
+ }
+ payload, err := buildTalkPayload(block, t.sequence)
+ if err != nil {
+ return err
+ }
+ t.sequence++
+ return t.client.writeRequest(ctx, request{
+ command: commandTalkData, channel: t.channel, class: classOffset,
+ extension: t.extension, payload: payload, binary: true,
+ })
+}
+
+func (t *Talk) Close(ctx context.Context) error {
+ t.closeOnce.Do(func() {
+ t.mu.Lock()
+ t.closed = true
+ t.mu.Unlock()
+ go func() {
+ closeCtx, cancel := context.WithTimeout(t.client.ctx, t.client.cfg.Timeout)
+ t.closeErr = t.client.stopTalk(closeCtx, t.channel)
+ cancel()
+ close(t.closeDone)
+ }()
+ })
+ select {
+ case <-t.closeDone:
+ return t.closeErr
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+func buildTalkPayload(block []byte, sequence uint16) ([]byte, error) {
+ if len(block) > int(^uint16(0))-4 {
+ return nil, fmt.Errorf("baichuan: ADPCM block too large: %d", len(block))
+ }
+ size := len(block) + 4
+ total := 8 + size + int(padding(uint32(size)))
+ b := make([]byte, total)
+ binary.LittleEndian.PutUint32(b, mediaADPCM)
+ binary.LittleEndian.PutUint16(b[4:6], uint16(size))
+ binary.LittleEndian.PutUint16(b[6:8], uint16(size))
+ binary.LittleEndian.PutUint16(b[8:10], 0x0100)
+ binary.LittleEndian.PutUint16(b[10:12], sequence)
+ copy(b[12:], block)
+ return b, nil
+}
diff --git a/pkg/baichuan/talk_test.go b/pkg/baichuan/talk_test.go
new file mode 100644
index 000000000..a342014fd
--- /dev/null
+++ b/pkg/baichuan/talk_test.go
@@ -0,0 +1,247 @@
+package baichuan
+
+import (
+ "context"
+ "encoding/binary"
+ "fmt"
+ "net"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestClientTalk(t *testing.T) {
+ clientConn, cameraConn := net.Pipe()
+ cfg, err := NewConfig("camera.local", "admin", "password").normalized()
+ if err != nil {
+ t.Fatal(err)
+ }
+ client := newClient(context.Background(), cfg, clientConn)
+ defer client.Close()
+
+ cameraErr := make(chan error, 1)
+ go func() {
+ cameraErr <- serveTalk(cameraConn, cfg)
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ talk, err := client.StartTalk(ctx, 2)
+ if err != nil {
+ t.Fatalf("%v; camera: %v", err, <-cameraErr)
+ }
+ format := talk.Format()
+ if format.SampleRate != 16000 || format.SamplePrecision != 16 || format.SamplesPerBlock != 1016 {
+ t.Fatalf("unexpected format: %+v", format)
+ }
+ encoder := ADPCMEncoder{}
+ block, err := encoder.EncodeBlock(make([]int16, format.SamplesPerBlock))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = talk.WriteBlock(ctx, block); err != nil {
+ t.Fatal(err)
+ }
+ if err = talk.Close(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if err = talk.Close(ctx); err != nil {
+ t.Fatalf("second close: %v", err)
+ }
+ if err = <-cameraErr; err != nil {
+ t.Fatal(err)
+ }
+}
+
+func serveTalk(conn net.Conn, cfg Config) error {
+ defer conn.Close()
+ aes, err := serveLogin(conn, cfg)
+ if err != nil {
+ return err
+ }
+ abilityRequest, err := readFrame(conn, cfg.Limits)
+ if err != nil {
+ return err
+ }
+ ability, err := decodeFrame(abilityRequest, aes, false)
+ if err != nil {
+ return err
+ }
+ if ability.header.Command != commandTalkAbility || ability.header.Channel != 2 ||
+ !strings.Contains(string(ability.extension), "2") {
+ return fmt.Errorf("invalid talk ability request: %+v %s", ability.header, ability.extension)
+ }
+ body := []byte(`fullDuplexspeakeradpcm16000161016mono`)
+ if err = writeTestFrame(conn, header{
+ Command: commandTalkAbility, Channel: 2, Sequence: ability.header.Sequence,
+ ResponseCode: 200, Class: classOffset,
+ }, nil, body, aes, false); err != nil {
+ return err
+ }
+
+ configRequest, err := readFrame(conn, cfg.Limits)
+ if err != nil {
+ return err
+ }
+ config, err := decodeFrame(configRequest, aes, false)
+ if err != nil {
+ return err
+ }
+ if config.header.Command != commandTalkConfig ||
+ !strings.Contains(string(config.payload), "1016") {
+ return fmt.Errorf("invalid talk config: %+v %s", config.header, config.payload)
+ }
+ if err = writeTestFrame(conn, header{
+ Command: commandTalkConfig, Channel: 2, Sequence: config.header.Sequence,
+ ResponseCode: 200, Class: classOffset,
+ }, nil, nil, aes, false); err != nil {
+ return err
+ }
+
+ dataFrame, err := readFrame(conn, cfg.Limits)
+ if err != nil {
+ return err
+ }
+ data, err := decodeFrame(dataFrame, aes, true)
+ if err != nil {
+ return err
+ }
+ if data.header.Command != commandTalkData || len(data.payload) != 528 ||
+ binary.LittleEndian.Uint32(data.payload) != mediaADPCM ||
+ binary.LittleEndian.Uint16(data.payload[4:6]) != 516 {
+ return fmt.Errorf("invalid talk data: %+v payload=%d", data.header, len(data.payload))
+ }
+
+ stopRequest, err := readFrame(conn, cfg.Limits)
+ if err != nil {
+ return err
+ }
+ stop, err := decodeFrame(stopRequest, aes, false)
+ if err != nil {
+ return err
+ }
+ if stop.header.Command != commandStopTalk || stop.header.Channel != 2 {
+ return fmt.Errorf("invalid stop talk: %+v", stop.header)
+ }
+ return writeTestFrame(conn, header{
+ Command: commandStopTalk, Channel: 2, Sequence: stop.header.Sequence,
+ ResponseCode: 200, Class: classOffset,
+ }, nil, nil, aes, false)
+}
+
+func TestSelectTalkConfigSkipsInvalidADPCMProfile(t *testing.T) {
+ ability := &talkAbility{
+ Duplexes: []talkDuplex{{Value: "fullDuplex"}},
+ Modes: []talkMode{{Value: "speaker"}},
+ }
+ for _, samples := range []uint32{1015, 1016} {
+ ability.Configs = append(ability.Configs, struct {
+ Value talkAudioConfig `xml:"audioConfig"`
+ }{Value: talkAudioConfig{
+ AudioType: "adpcm", SampleRate: 16000, SamplePrecision: 16,
+ SamplesPerBlock: samples, SoundTrack: "mono",
+ }})
+ }
+ config, err := selectTalkConfig(0, ability)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if config.Audio.SamplesPerBlock != 1016 {
+ t.Fatalf("selected unsafe profile: %+v", config.Audio)
+ }
+}
+
+func TestDecodeTalkAbilityLimits(t *testing.T) {
+ for _, body := range [][]byte{
+ []byte(strings.Repeat(" ", maxTalkAbilityBody+1)),
+ []byte(`` + strings.Repeat(`fullDuplex`,
+ maxTalkAbilityOptions+1) + ``),
+ []byte(``),
+ } {
+ if _, err := decodeTalkAbility(body); err == nil {
+ t.Fatal("accepted oversized talk ability")
+ }
+ }
+}
+
+func TestSelectTalkConfigRejectsIncompleteAbility(t *testing.T) {
+ valid := talkAudioConfig{
+ AudioType: "adpcm", SampleRate: 16000, SamplePrecision: 16,
+ SamplesPerBlock: 1016, SoundTrack: "mono",
+ }
+ for _, test := range []struct {
+ name string
+ ability talkAbility
+ }{
+ {name: "duplex", ability: talkAbility{Modes: []talkMode{{Value: "speaker"}}}},
+ {name: "mode", ability: talkAbility{Duplexes: []talkDuplex{{Value: "fullDuplex"}}}},
+ {name: "ADPCM", ability: talkAbility{
+ Duplexes: []talkDuplex{{Value: "fullDuplex"}}, Modes: []talkMode{{Value: "speaker"}},
+ }},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ if test.name != "ADPCM" {
+ test.ability.Configs = append(test.ability.Configs, struct {
+ Value talkAudioConfig `xml:"audioConfig"`
+ }{Value: valid})
+ }
+ if _, err := selectTalkConfig(0, &test.ability); err == nil {
+ t.Fatal("accepted incomplete talk ability")
+ }
+ })
+ }
+}
+
+func TestUnsupportedTalkStatus(t *testing.T) {
+ for _, code := range []uint16{400, 404, 405, 501} {
+ if !unsupportedTalkStatus(code) {
+ t.Fatalf("status %d should mean unsupported", code)
+ }
+ }
+ for _, code := range []uint16{401, 403, 422, 500, 503} {
+ if unsupportedTalkStatus(code) {
+ t.Fatalf("status %d should remain an operational error", code)
+ }
+ }
+}
+
+func TestADPCMEncoder(t *testing.T) {
+ encoder := ADPCMEncoder{}
+ block, err := encoder.EncodeBlock([]int16{0, 0})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(block) != 5 || string(block) != "\x00\x00\x00\x00\x00" {
+ t.Fatalf("unexpected silence block: %x", block)
+ }
+ for _, samples := range [][]int16{nil, {1}, {1, 2, 3}} {
+ if _, err = encoder.EncodeBlock(samples); err == nil {
+ t.Fatalf("accepted %d samples", len(samples))
+ }
+ }
+}
+
+func TestDecodeADPCMBlock(t *testing.T) {
+ samples, err := DecodeADPCMBlock([]byte{0, 0, 0, 0, 0x10})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(samples) != 2 || samples[0] != 1 || samples[1] != 1 {
+ t.Fatalf("unexpected samples: %v", samples)
+ }
+ for _, block := range [][]byte{nil, {0, 0, 0, 0}, {0, 0, 89, 0, 0}} {
+ if _, err = DecodeADPCMBlock(block); err == nil {
+ t.Fatalf("accepted invalid block: %x", block)
+ }
+ }
+}
+
+func FuzzDecodeADPCMBlock(f *testing.F) {
+ f.Add([]byte{0, 0, 0, 0, 0x10})
+ f.Add([]byte{0, 0, 89, 0, 0})
+ f.Fuzz(func(t *testing.T, block []byte) {
+ _, _ = DecodeADPCMBlock(block)
+ })
+}
diff --git a/pkg/baichuan/talk_xml.go b/pkg/baichuan/talk_xml.go
new file mode 100644
index 000000000..306a5278f
--- /dev/null
+++ b/pkg/baichuan/talk_xml.go
@@ -0,0 +1,159 @@
+package baichuan
+
+import (
+ "encoding/xml"
+ "fmt"
+ "strings"
+)
+
+const (
+ maxTalkAbilityBody = 64 << 10
+ maxTalkAbilityOptions = 32
+ maxTalkAbilityText = 64
+)
+
+type talkAudioConfig struct {
+ Priority *uint32 `xml:"priority,omitempty"`
+ AudioType string `xml:"audioType"`
+ SampleRate uint32 `xml:"sampleRate"`
+ SamplePrecision uint32 `xml:"samplePrecision"`
+ SamplesPerBlock uint32 `xml:"lengthPerEncoder"`
+ SoundTrack string `xml:"soundTrack"`
+}
+
+type talkAbility struct {
+ Version string `xml:"version,attr"`
+ Duplexes []talkDuplex `xml:"duplexList"`
+ Modes []talkMode `xml:"audioStreamModeList"`
+ Configs []struct {
+ Value talkAudioConfig `xml:"audioConfig"`
+ } `xml:"audioConfigList"`
+}
+
+type talkDuplex struct {
+ Value string `xml:"duplex"`
+}
+
+type talkMode struct {
+ Value string `xml:"audioStreamMode"`
+}
+
+type talkAbilityEnvelope struct {
+ XMLName xml.Name `xml:"body"`
+ Ability *talkAbility `xml:"TalkAbility"`
+}
+
+type talkConfig struct {
+ Version string `xml:"version,attr"`
+ Channel uint8 `xml:"channelId"`
+ Duplex string `xml:"duplex"`
+ Mode string `xml:"audioStreamMode"`
+ Audio talkAudioConfig `xml:"audioConfig"`
+}
+
+type talkConfigEnvelope struct {
+ XMLName xml.Name `xml:"body"`
+ Config talkConfig `xml:"TalkConfig"`
+}
+
+type talkExtension struct {
+ XMLName xml.Name `xml:"Extension"`
+ Version string `xml:"version,attr"`
+ Channel uint8 `xml:"channelId"`
+ Binary *uint8 `xml:"binaryData,omitempty"`
+}
+
+func decodeTalkAbility(b []byte) (*talkAbility, error) {
+ if len(b) > maxTalkAbilityBody {
+ return nil, fmt.Errorf("talk ability XML exceeds %d bytes", maxTalkAbilityBody)
+ }
+ var envelope talkAbilityEnvelope
+ if err := xml.Unmarshal(b, &envelope); err != nil {
+ return nil, err
+ }
+ if envelope.Ability == nil {
+ return nil, &UnsupportedTalkError{Reason: "ability missing"}
+ }
+ ability := envelope.Ability
+ if len(ability.Duplexes) > maxTalkAbilityOptions || len(ability.Modes) > maxTalkAbilityOptions ||
+ len(ability.Configs) > maxTalkAbilityOptions || len(ability.Version) > maxTalkAbilityText {
+ return nil, fmt.Errorf("talk ability exceeds semantic limits")
+ }
+ for _, option := range ability.Duplexes {
+ if len(option.Value) > maxTalkAbilityText {
+ return nil, fmt.Errorf("talk ability exceeds semantic limits")
+ }
+ }
+ for _, option := range ability.Modes {
+ if len(option.Value) > maxTalkAbilityText {
+ return nil, fmt.Errorf("talk ability exceeds semantic limits")
+ }
+ }
+ for _, option := range ability.Configs {
+ if len(option.Value.AudioType) > maxTalkAbilityText || len(option.Value.SoundTrack) > maxTalkAbilityText {
+ return nil, fmt.Errorf("talk ability exceeds semantic limits")
+ }
+ }
+ return envelope.Ability, nil
+}
+
+func selectTalkConfig(channel uint8, ability *talkAbility) (talkConfig, error) {
+ if len(ability.Duplexes) == 0 || len(ability.Modes) == 0 {
+ return talkConfig{}, &UnsupportedTalkError{Reason: "incomplete ability"}
+ }
+ var audio talkAudioConfig
+ var adpcm bool
+ for _, option := range ability.Configs {
+ if !strings.EqualFold(option.Value.AudioType, "adpcm") {
+ continue
+ }
+ adpcm = true
+ if validTalkAudio(option.Value) {
+ audio = option.Value
+ break
+ }
+ }
+ if !adpcm {
+ return talkConfig{}, &UnsupportedTalkError{Reason: "ADPCM profile missing"}
+ }
+ if audio.AudioType == "" {
+ return talkConfig{}, &UnsupportedTalkError{Reason: "invalid ADPCM profile"}
+ }
+ audio.Priority = nil
+ version := ability.Version
+ if version == "" {
+ version = "1.1"
+ }
+ duplex := ability.Duplexes[0].Value
+ for _, option := range ability.Duplexes {
+ if strings.EqualFold(option.Value, "fullDuplex") {
+ duplex = option.Value
+ break
+ }
+ }
+ mode := ability.Modes[0].Value
+ for _, option := range ability.Modes {
+ if strings.EqualFold(option.Value, "speaker") {
+ mode = option.Value
+ break
+ }
+ }
+ return talkConfig{
+ Version: version, Channel: channel, Duplex: duplex, Mode: mode, Audio: audio,
+ }, nil
+}
+
+func validTalkAudio(audio talkAudioConfig) bool {
+ return audio.SampleRate >= 8000 && audio.SampleRate <= 48000 && audio.SamplePrecision == 16 &&
+ audio.SamplesPerBlock >= 2 && audio.SamplesPerBlock <= 8192 && audio.SamplesPerBlock&1 == 0 &&
+ (audio.SoundTrack == "" || strings.EqualFold(audio.SoundTrack, "mono"))
+}
+
+func buildTalkExtension(channel uint8, binaryData bool) ([]byte, error) {
+ extension := talkExtension{Version: "1.1", Channel: channel}
+ if binaryData {
+ value := uint8(1)
+ extension.Binary = &value
+ }
+ return marshalDocument(extension)
+}
diff --git a/pkg/baichuan/transport.go b/pkg/baichuan/transport.go
new file mode 100644
index 000000000..908685d56
--- /dev/null
+++ b/pkg/baichuan/transport.go
@@ -0,0 +1,81 @@
+package baichuan
+
+import (
+ "context"
+ "io"
+ "net"
+ "sync"
+ "time"
+)
+
+type transport interface {
+ io.Reader
+ io.Writer
+ SetWriteDeadline(time.Time) error
+ Close() error
+}
+
+func dialTCP(ctx context.Context, cfg Config) (transport, error) {
+ dialer := net.Dialer{Timeout: cfg.Timeout}
+ conn, err := dialer.DialContext(ctx, "tcp", cfg.address())
+ if err != nil {
+ return nil, err
+ }
+ return conn, nil
+}
+
+func writeFull(ctx context.Context, conn transport, timeout time.Duration, b []byte) error {
+ deadline := time.Now().Add(timeout)
+ if value, ok := ctx.Deadline(); ok && value.Before(deadline) {
+ deadline = value
+ }
+ if err := conn.SetWriteDeadline(deadline); err != nil {
+ return err
+ }
+ var interrupt sync.WaitGroup
+ interrupt.Add(1)
+ stop := context.AfterFunc(ctx, func() {
+ _ = conn.SetWriteDeadline(time.Now())
+ interrupt.Done()
+ })
+ defer func() {
+ if stop() {
+ interrupt.Done()
+ }
+ interrupt.Wait()
+ _ = conn.SetWriteDeadline(time.Time{})
+ }()
+
+ for len(b) > 0 {
+ n, err := conn.Write(b)
+ if n > 0 {
+ b = b[n:]
+ }
+ if err != nil {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ return err
+ }
+ if n == 0 {
+ return io.ErrShortWrite
+ }
+ }
+ return nil
+}
+
+func interruptDeadline(ctx context.Context, set func(time.Time) error) func() {
+ var done sync.WaitGroup
+ done.Add(1)
+ stop := context.AfterFunc(ctx, func() {
+ _ = set(time.Now())
+ done.Done()
+ })
+ return func() {
+ if stop() {
+ done.Done()
+ }
+ done.Wait()
+ _ = set(time.Time{})
+ }
+}
diff --git a/pkg/baichuan/transport_test.go b/pkg/baichuan/transport_test.go
new file mode 100644
index 000000000..0da6aff4c
--- /dev/null
+++ b/pkg/baichuan/transport_test.go
@@ -0,0 +1,144 @@
+package baichuan
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "net"
+ "sync"
+ "testing"
+ "time"
+)
+
+type shortTransport struct {
+ bytes.Buffer
+ step int
+}
+
+type failingTransport struct {
+ closed chan struct{}
+ once sync.Once
+}
+
+type cancelTransport struct {
+ shortTransport
+ cancel context.CancelFunc
+ started, release, wrote chan struct{}
+ mu sync.Mutex
+ deadlines int
+}
+
+func (t *failingTransport) Read([]byte) (int, error) {
+ <-t.closed
+ return 0, io.EOF
+}
+
+func (t *failingTransport) Write([]byte) (int, error) { return 1, io.ErrUnexpectedEOF }
+func (t *failingTransport) SetWriteDeadline(time.Time) error { return nil }
+func (t *failingTransport) Close() error {
+ t.once.Do(func() { close(t.closed) })
+ return nil
+}
+
+func (t *shortTransport) Write(b []byte) (int, error) {
+ if len(b) > t.step {
+ b = b[:t.step]
+ }
+ return t.Buffer.Write(b)
+}
+
+func (t *shortTransport) SetWriteDeadline(time.Time) error { return nil }
+func (t *shortTransport) Close() error { return nil }
+
+func (t *cancelTransport) Write(b []byte) (int, error) {
+ t.cancel()
+ <-t.started
+ close(t.wrote)
+ return len(b), nil
+}
+
+func (t *cancelTransport) SetWriteDeadline(deadline time.Time) error {
+ if deadline.IsZero() {
+ return nil
+ }
+ t.mu.Lock()
+ t.deadlines++
+ interrupt := t.deadlines == 2
+ t.mu.Unlock()
+ if interrupt {
+ close(t.started)
+ <-t.release
+ }
+ return nil
+}
+
+func TestWriteFullHandlesShortWrites(t *testing.T) {
+ conn := &shortTransport{step: 2}
+ if err := writeFull(context.Background(), conn, time.Second, []byte("abcdef")); err != nil {
+ t.Fatal(err)
+ }
+ if actual := conn.String(); actual != "abcdef" {
+ t.Fatalf("unexpected payload: %s", actual)
+ }
+}
+
+func TestWriteFullWaitsForCancellationCallback(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ conn := &cancelTransport{
+ cancel: cancel, started: make(chan struct{}), release: make(chan struct{}), wrote: make(chan struct{}),
+ }
+ released := false
+ defer func() {
+ if !released {
+ close(conn.release)
+ }
+ }()
+ done := make(chan error, 1)
+ go func() { done <- writeFull(ctx, conn, time.Second, []byte("payload")) }()
+ <-conn.wrote
+ select {
+ case err := <-done:
+ t.Fatalf("write returned while deadline callback was active: %v", err)
+ case <-time.After(20 * time.Millisecond):
+ }
+ close(conn.release)
+ released = true
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestClientCloseInterruptsFrameRead(t *testing.T) {
+ conn, peer := net.Pipe()
+ defer peer.Close()
+ client := newClient(context.Background(), Config{}, conn)
+ done := make(chan error, 1)
+ go func() { done <- client.Close() }()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("client close did not interrupt frame read")
+ }
+}
+
+func TestClientPartialWriteShutsDown(t *testing.T) {
+ cfg, err := NewConfig("camera.local", "admin", "password").normalized()
+ if err != nil {
+ t.Fatal(err)
+ }
+ client := newClient(context.Background(), cfg, &failingTransport{closed: make(chan struct{})})
+ _, err = client.roundTrip(context.Background(), request{command: commandPing, class: classOffset})
+ if !errors.Is(err, io.ErrUnexpectedEOF) {
+ t.Fatalf("unexpected write error: %v", err)
+ }
+ select {
+ case <-client.Done():
+ default:
+ t.Fatal("client remained active after partial write")
+ }
+ client.wg.Wait()
+}
diff --git a/pkg/baichuan/udp.go b/pkg/baichuan/udp.go
new file mode 100644
index 000000000..4d9f9337b
--- /dev/null
+++ b/pkg/baichuan/udp.go
@@ -0,0 +1,157 @@
+package baichuan
+
+import (
+ "encoding/binary"
+ "fmt"
+ "hash/crc32"
+)
+
+const (
+ uidMagicDiscovery uint32 = 0x2A87CF3A
+ uidMagicAck uint32 = 0x2A87CF20
+ uidMagicData uint32 = 0x2A87CF10
+
+ uidDiscoveryHeader = 20
+ uidAckHeader = 28
+ uidDataHeader = 20
+ uidMaxDatagram = 65507
+)
+
+var uidXMLKey = [...]uint32{
+ 0x1f2d3c4b, 0x5a6c7f8d, 0x38172e4b, 0x8271635a,
+ 0x863f1a2b, 0xa5c6f7d8, 0x8371e1b4, 0x17f2d3a5,
+}
+
+type uidPacket struct {
+ magic uint32
+ connectionID int32
+ packetID uint32
+ transaction uint32
+ payload []byte
+}
+
+func marshalUIDDiscovery(transaction uint32, payload []byte) ([]byte, error) {
+ if err := checkUIDPayload(len(payload), uidDiscoveryHeader, uidMaxDatagram-uidDiscoveryHeader); err != nil {
+ return nil, err
+ }
+ b := make([]byte, uidDiscoveryHeader+len(payload))
+ binary.LittleEndian.PutUint32(b, uidMagicDiscovery)
+ binary.LittleEndian.PutUint32(b[4:], uint32(len(payload)))
+ binary.LittleEndian.PutUint32(b[8:], 1)
+ binary.LittleEndian.PutUint32(b[12:], transaction)
+ binary.LittleEndian.PutUint32(b[16:], uidChecksum(payload))
+ copy(b[uidDiscoveryHeader:], payload)
+ return b, nil
+}
+
+func marshalUIDData(connectionID int32, packetID uint32, payload []byte, maxPayload int) ([]byte, error) {
+ b := make([]byte, uidDataHeader+len(payload))
+ return encodeUIDData(b, connectionID, packetID, payload, maxPayload)
+}
+
+func encodeUIDData(b []byte, connectionID int32, packetID uint32, payload []byte, maxPayload int) ([]byte, error) {
+ if err := checkUIDPayload(len(payload), uidDataHeader, maxPayload); err != nil {
+ return nil, err
+ }
+ if len(b) < uidDataHeader+len(payload) {
+ return nil, fmt.Errorf("baichuan: short UID data buffer")
+ }
+ b = b[:uidDataHeader+len(payload)]
+ clear(b[:uidDataHeader])
+ binary.LittleEndian.PutUint32(b, uidMagicData)
+ binary.LittleEndian.PutUint32(b[4:], uint32(connectionID))
+ binary.LittleEndian.PutUint32(b[12:], packetID)
+ binary.LittleEndian.PutUint32(b[16:], uint32(len(payload)))
+ copy(b[uidDataHeader:], payload)
+ return b, nil
+}
+
+func marshalUIDAck(connectionID int32, packetID uint32, payload []byte, maxPayload int) ([]byte, error) {
+ b := make([]byte, uidAckHeader+len(payload))
+ return encodeUIDAck(b, connectionID, packetID, payload, maxPayload)
+}
+
+func encodeUIDAck(b []byte, connectionID int32, packetID uint32, payload []byte, maxPayload int) ([]byte, error) {
+ if err := checkUIDPayload(len(payload), uidAckHeader, maxPayload); err != nil {
+ return nil, err
+ }
+ if len(b) < uidAckHeader+len(payload) {
+ return nil, fmt.Errorf("baichuan: short UID ACK buffer")
+ }
+ b = b[:uidAckHeader+len(payload)]
+ clear(b[:uidAckHeader])
+ binary.LittleEndian.PutUint32(b, uidMagicAck)
+ binary.LittleEndian.PutUint32(b[4:], uint32(connectionID))
+ binary.LittleEndian.PutUint32(b[16:], packetID)
+ binary.LittleEndian.PutUint32(b[24:], uint32(len(payload)))
+ copy(b[uidAckHeader:], payload)
+ return b, nil
+}
+
+func parseUIDPacket(b []byte, maxPayload int) (uidPacket, error) {
+ if len(b) < 4 {
+ return uidPacket{}, fmt.Errorf("baichuan: short UID packet")
+ }
+ if len(b) > uidMaxDatagram || maxPayload < 0 {
+ return uidPacket{}, fmt.Errorf("baichuan: UID packet exceeds limit")
+ }
+ p := uidPacket{magic: binary.LittleEndian.Uint32(b)}
+ var header, sizeAt int
+ switch p.magic {
+ case uidMagicDiscovery:
+ header, sizeAt = uidDiscoveryHeader, 4
+ if len(b) >= header {
+ p.transaction = binary.LittleEndian.Uint32(b[12:])
+ }
+ case uidMagicAck:
+ header, sizeAt = uidAckHeader, 24
+ if len(b) >= header {
+ p.connectionID = int32(binary.LittleEndian.Uint32(b[4:]))
+ p.packetID = binary.LittleEndian.Uint32(b[16:])
+ }
+ case uidMagicData:
+ header, sizeAt = uidDataHeader, 16
+ if len(b) >= header {
+ p.connectionID = int32(binary.LittleEndian.Uint32(b[4:]))
+ p.packetID = binary.LittleEndian.Uint32(b[12:])
+ }
+ default:
+ return uidPacket{}, fmt.Errorf("baichuan: unknown UID packet")
+ }
+ if len(b) < header {
+ return uidPacket{}, fmt.Errorf("baichuan: short UID packet")
+ }
+ size := binary.LittleEndian.Uint32(b[sizeAt:])
+ if uint64(size) > uint64(maxPayload) || uint64(size) != uint64(len(b)-header) {
+ return uidPacket{}, fmt.Errorf("baichuan: invalid UID payload length")
+ }
+ p.payload = b[header:]
+ if p.magic == uidMagicDiscovery && uidChecksum(p.payload) != binary.LittleEndian.Uint32(b[16:]) {
+ return uidPacket{}, fmt.Errorf("baichuan: UID checksum mismatch")
+ }
+ return p, nil
+}
+
+func checkUIDPayload(size, header, maxPayload int) error {
+ if size < 0 || maxPayload < 0 || size > maxPayload || size > uidMaxDatagram-header {
+ return fmt.Errorf("baichuan: UID payload exceeds limit")
+ }
+ return nil
+}
+
+func xorUID(dst, src []byte, transaction uint32) {
+ dst = dst[:len(src)]
+ for i, value := range src {
+ key := uidXMLKey[(i>>2)%len(uidXMLKey)] + transaction
+ dst[i] = value ^ byte(key>>((i&3)*8))
+ }
+}
+
+func uidChecksum(b []byte) uint32 {
+ table := crc32.IEEETable
+ var sum uint32
+ for _, value := range b {
+ sum = table[byte(sum)^value] ^ sum>>8
+ }
+ return sum
+}
diff --git a/pkg/baichuan/udp_test.go b/pkg/baichuan/udp_test.go
new file mode 100644
index 000000000..cd598130c
--- /dev/null
+++ b/pkg/baichuan/udp_test.go
@@ -0,0 +1,68 @@
+package baichuan
+
+import (
+ "encoding/binary"
+ "strings"
+ "testing"
+)
+
+func TestUIDPacketRejectsMalformedInput(t *testing.T) {
+ valid, err := marshalUIDDiscovery(1, []byte("payload"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ tests := []struct {
+ name string
+ data []byte
+ max int
+ want string
+ }{
+ {name: "short", data: valid[:3], max: 128, want: "short"},
+ {name: "header", data: valid[:12], max: 128, want: "short"},
+ {name: "unknown", data: append([]byte{1, 2, 3, 4}, valid[4:]...), max: 128, want: "unknown"},
+ {name: "truncated", data: valid[:len(valid)-1], max: 128, want: "length"},
+ {name: "trailing", data: append(append([]byte(nil), valid...), 0), max: 128, want: "length"},
+ {name: "limited", data: valid, max: 3, want: "length"},
+ {name: "negative limit", data: valid, max: -1, want: "limit"},
+ {name: "datagram limit", data: make([]byte, uidMaxDatagram+1), max: uidMaxDatagram, want: "limit"},
+ {name: "checksum", data: append([]byte(nil), valid...), max: 128, want: "checksum"},
+ }
+ tests[len(tests)-1].data[len(valid)-1] ^= 1
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ if _, err := parseUIDPacket(test.data, test.max); err == nil || !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ }
+}
+
+func TestUIDPacketMarshalLimits(t *testing.T) {
+ payload := make([]byte, 9)
+ if _, err := marshalUIDData(1, 1, payload, 8); err == nil {
+ t.Fatal("data payload exceeded limit")
+ }
+ if _, err := marshalUIDAck(1, 1, payload, 8); err == nil {
+ t.Fatal("ack payload exceeded limit")
+ }
+ if _, err := marshalUIDDiscovery(1, make([]byte, uidMaxDatagram)); err == nil {
+ t.Fatal("discovery payload exceeded datagram limit")
+ }
+}
+
+func TestUIDChecksumKnownVector(t *testing.T) {
+ if sum := uidChecksum([]byte("123456789")); sum != 0x2dfd2d88 {
+ t.Fatalf("unexpected checksum: %08x", sum)
+ }
+}
+
+func FuzzUIDPacket(f *testing.F) {
+ for _, magic := range []uint32{uidMagicDiscovery, uidMagicAck, uidMagicData} {
+ b := make([]byte, uidAckHeader)
+ binary.LittleEndian.PutUint32(b, magic)
+ f.Add(b)
+ }
+ f.Fuzz(func(t *testing.T, b []byte) {
+ _, _ = parseUIDPacket(b, 2048)
+ })
+}
diff --git a/pkg/baichuan/uid_conn.go b/pkg/baichuan/uid_conn.go
new file mode 100644
index 000000000..dd471b363
--- /dev/null
+++ b/pkg/baichuan/uid_conn.go
@@ -0,0 +1,293 @@
+package baichuan
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/netip"
+ "os"
+ "sync"
+ "time"
+)
+
+const (
+ uidSendWindow = 64
+ uidReceiveWindow = 128
+ uidReadQueue = 512
+ uidMaintenance = 25 * time.Millisecond
+ uidRetransmit = 100 * time.Millisecond
+ uidMaxRetransmit = time.Second
+ uidSocketReadBuffer = 1 << 20
+)
+
+var errUIDReadOverflow = errors.New("baichuan: UID read queue overflow")
+
+type uidBuffer [uidMTU]byte
+
+type uidChunk struct {
+ buffer *uidBuffer
+ data []byte
+}
+
+type uidSendSlot struct {
+ buffer *uidBuffer
+ packet []byte
+ packetID uint32
+ firstSend time.Time
+ lastSend time.Time
+ interval time.Duration
+ used bool
+}
+
+type uidReceiveSlot struct {
+ chunk uidChunk
+ packetID uint32
+ used bool
+}
+
+type uidConn struct {
+ conn *net.UDPConn
+ remote netip.AddrPort
+ clientID int32
+ cameraID int32
+ timeout time.Duration
+
+ done chan struct{}
+ closeOnce sync.Once
+ wg sync.WaitGroup
+ errMu sync.Mutex
+ err error
+
+ readMu sync.Mutex
+ readCurrent uidChunk
+ readQueue chan uidChunk
+ readDeadline uidDeadline
+
+ writeMu sync.Mutex
+ writeDeadline uidDeadline
+ socketWriteMu sync.Mutex
+
+ sendMu sync.Mutex
+ sendSlots [uidSendWindow]uidSendSlot
+ sendCount int
+ nextSend uint32
+ sendWake chan struct{}
+
+ receiveMu sync.Mutex
+ receiveSlots [uidReceiveWindow]uidReceiveSlot
+ nextReceive uint32
+ received bool
+ ackDirty bool
+
+ pool sync.Pool
+}
+
+func newUIDConn(discovery *uidDiscovery, timeout time.Duration) (*uidConn, error) {
+ if discovery == nil || discovery.conn == nil || !discovery.remote.IsValid() ||
+ !discovery.remote.Addr().Is4() || discovery.remote.Port() == 0 ||
+ discovery.clientID <= 0 || discovery.cameraID <= 0 || timeout <= 0 {
+ return nil, errors.New("baichuan: invalid UID connection")
+ }
+ if err := discovery.conn.SetReadBuffer(uidSocketReadBuffer); err != nil {
+ _ = discovery.conn.Close()
+ return nil, fmt.Errorf("baichuan: set UID receive buffer: %w", err)
+ }
+ _ = discovery.conn.SetReadDeadline(time.Time{})
+ s := &uidConn{
+ conn: discovery.conn, remote: discovery.remote,
+ clientID: discovery.clientID, cameraID: discovery.cameraID, timeout: timeout,
+ done: make(chan struct{}), readQueue: make(chan uidChunk, uidReadQueue),
+ sendWake: make(chan struct{}, 1),
+ }
+ s.readDeadline.init()
+ s.writeDeadline.init()
+ s.pool.New = func() any { return new(uidBuffer) }
+ s.wg.Add(2)
+ go s.readLoop()
+ go s.maintenanceLoop()
+ return s, nil
+}
+
+func (s *uidConn) Read(p []byte) (int, error) {
+ if len(p) == 0 {
+ return 0, nil
+ }
+ s.readMu.Lock()
+ defer s.readMu.Unlock()
+ for {
+ if len(s.readCurrent.data) != 0 {
+ n := copy(p, s.readCurrent.data)
+ s.readCurrent.data = s.readCurrent.data[n:]
+ if len(s.readCurrent.data) == 0 {
+ s.putBuffer(s.readCurrent.buffer)
+ s.readCurrent = uidChunk{}
+ }
+ return n, nil
+ }
+ select {
+ case <-s.done:
+ return 0, s.readError()
+ default:
+ }
+ select {
+ case s.readCurrent = <-s.readQueue:
+ continue
+ default:
+ }
+ deadline, changed, timeout := s.readDeadline.snapshot()
+ expired := !deadline.IsZero() && !time.Now().Before(deadline)
+ if expired {
+ return 0, os.ErrDeadlineExceeded
+ }
+ select {
+ case chunk := <-s.readQueue:
+ s.readCurrent = chunk
+ case <-changed:
+ case <-s.done:
+ return 0, s.readError()
+ case <-timeout:
+ return 0, os.ErrDeadlineExceeded
+ }
+ }
+}
+
+func (s *uidConn) Write(p []byte) (int, error) {
+ if len(p) == 0 {
+ return 0, nil
+ }
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+ written := 0
+ for len(p) != 0 {
+ n := len(p)
+ if n > uidMTU-uidDataHeader {
+ n = uidMTU - uidDataHeader
+ }
+ if err := s.writeChunk(p[:n]); err != nil {
+ return written, err
+ }
+ written += n
+ p = p[n:]
+ }
+ return written, nil
+}
+
+func (s *uidConn) SetReadDeadline(value time.Time) error {
+ s.readDeadline.set(value)
+ return nil
+}
+
+func (s *uidConn) SetWriteDeadline(value time.Time) error {
+ s.writeDeadline.set(value)
+ // A later datagram installs a fresh bounded socket deadline. Keeping the
+ // current one here prevents a concurrent clear from unbounding that write.
+ if value.IsZero() {
+ return nil
+ }
+ return s.conn.SetWriteDeadline(value)
+}
+
+func (s *uidConn) Close() error {
+ s.shutdown(net.ErrClosed)
+ s.readDeadline.set(time.Time{})
+ s.writeDeadline.set(time.Time{})
+ s.wg.Wait()
+ s.writeMu.Lock()
+ s.releaseBuffers()
+ s.writeMu.Unlock()
+ return nil
+}
+
+func (s *uidConn) shutdown(err error) {
+ s.closeOnce.Do(func() {
+ s.errMu.Lock()
+ s.err = err
+ s.errMu.Unlock()
+ close(s.done)
+ _ = s.conn.Close()
+ s.signalSend()
+ })
+}
+
+func (s *uidConn) readError() error {
+ s.errMu.Lock()
+ err := s.err
+ s.errMu.Unlock()
+ if errors.Is(err, net.ErrClosed) {
+ return io.EOF
+ }
+ if err == nil {
+ return io.EOF
+ }
+ return err
+}
+
+func (s *uidConn) writeError() error {
+ if err := s.readError(); err != io.EOF {
+ return err
+ }
+ return io.ErrClosedPipe
+}
+
+func (s *uidConn) getBuffer() *uidBuffer {
+ return s.pool.Get().(*uidBuffer)
+}
+
+func (s *uidConn) putBuffer(b *uidBuffer) {
+ if b != nil {
+ s.pool.Put(b)
+ }
+}
+
+func (s *uidConn) signalSend() {
+ select {
+ case s.sendWake <- struct{}{}:
+ default:
+ }
+}
+
+func (s *uidConn) writeDatagram(packet []byte) error {
+ s.socketWriteMu.Lock()
+ deadline := time.Now().Add(s.timeout)
+ if value, _, _ := s.writeDeadline.snapshot(); !value.IsZero() && value.Before(deadline) {
+ deadline = value
+ }
+ if err := s.conn.SetWriteDeadline(deadline); err != nil {
+ s.socketWriteMu.Unlock()
+ return err
+ }
+ _, err := s.conn.WriteToUDPAddrPort(packet, s.remote)
+ s.socketWriteMu.Unlock()
+ return err
+}
+
+func (s *uidConn) releaseBuffers() {
+ s.readMu.Lock()
+ if s.readCurrent.buffer != nil {
+ s.putBuffer(s.readCurrent.buffer)
+ s.readCurrent = uidChunk{}
+ }
+ for len(s.readQueue) != 0 {
+ chunk := <-s.readQueue
+ s.putBuffer(chunk.buffer)
+ }
+ s.readMu.Unlock()
+ s.sendMu.Lock()
+ for i := range s.sendSlots {
+ if s.sendSlots[i].used {
+ s.putBuffer(s.sendSlots[i].buffer)
+ s.sendSlots[i] = uidSendSlot{}
+ }
+ }
+ s.sendCount = 0
+ s.sendMu.Unlock()
+ s.receiveMu.Lock()
+ for i := range s.receiveSlots {
+ if s.receiveSlots[i].used {
+ s.putBuffer(s.receiveSlots[i].chunk.buffer)
+ s.receiveSlots[i] = uidReceiveSlot{}
+ }
+ }
+ s.receiveMu.Unlock()
+}
diff --git a/pkg/baichuan/uid_conn_test.go b/pkg/baichuan/uid_conn_test.go
new file mode 100644
index 000000000..a39c89858
--- /dev/null
+++ b/pkg/baichuan/uid_conn_test.go
@@ -0,0 +1,241 @@
+package baichuan
+
+import (
+ "errors"
+ "io"
+ "math"
+ "net"
+ "os"
+ "testing"
+ "time"
+)
+
+func TestUIDConnOrdersDataAndAcknowledges(t *testing.T) {
+ conn, camera := newTestUIDConn(t, time.Second)
+ sendUIDData(t, camera, conn, 1, []byte("B"))
+ sendUIDData(t, camera, conn, 0, []byte("A"))
+ b := make([]byte, 2)
+ if _, err := io.ReadFull(conn, b); err != nil {
+ t.Fatal(err)
+ }
+ if string(b) != "AB" {
+ t.Fatalf("unexpected ordered data: %q", b)
+ }
+ packet := readUIDPacket(t, camera, time.Second)
+ if packet.magic != uidMagicAck || packet.connectionID != conn.cameraID ||
+ packet.packetID != 1 || len(packet.payload) != 0 {
+ t.Fatalf("unexpected ACK: %+v", packet)
+ }
+}
+
+func TestUIDConnDoesNotRedeliverDuplicateData(t *testing.T) {
+ conn, camera := newTestUIDConn(t, time.Second)
+ sendUIDData(t, camera, conn, 0, []byte("A"))
+ b := make([]byte, 1)
+ if _, err := io.ReadFull(conn, b); err != nil || string(b) != "A" {
+ t.Fatalf("unexpected first delivery: %q %v", b, err)
+ }
+ sendUIDData(t, camera, conn, 0, []byte("A"))
+ if err := conn.SetReadDeadline(time.Now().Add(50 * time.Millisecond)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := conn.Read(b); !errors.Is(err, os.ErrDeadlineExceeded) {
+ t.Fatalf("duplicate reached reader: %v", err)
+ }
+}
+
+func TestUIDConnConsumesEmptyData(t *testing.T) {
+ conn, _ := newTestUIDConn(t, time.Second)
+ b := conn.getBuffer()
+ if !conn.handleUIDData(uidPacket{packetID: 0, payload: b[:0]}, b) {
+ t.Fatal("empty packet buffer was not consumed")
+ }
+ conn.receiveMu.Lock()
+ next := conn.nextReceive
+ conn.receiveMu.Unlock()
+ if next != 1 || len(conn.readQueue) != 0 {
+ t.Fatalf("empty packet reached reader: next=%d queued=%d", next, len(conn.readQueue))
+ }
+}
+
+func TestUIDConnDeadlinesAndClose(t *testing.T) {
+ conn, _ := newTestUIDConn(t, time.Second)
+ if err := conn.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := conn.Read(make([]byte, 1)); !errors.Is(err, os.ErrDeadlineExceeded) {
+ t.Fatalf("unexpected read deadline error: %v", err)
+ }
+ if err := conn.SetWriteDeadline(time.Now().Add(20 * time.Millisecond)); err != nil {
+ t.Fatal(err)
+ }
+ payload := make([]byte, (uidMTU-uidDataHeader)*(uidSendWindow+1))
+ n, err := conn.Write(payload)
+ if !errors.Is(err, os.ErrDeadlineExceeded) || n != (uidMTU-uidDataHeader)*uidSendWindow {
+ t.Fatalf("unexpected bounded write: n=%d err=%v", n, err)
+ }
+ if err = conn.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = conn.Read(make([]byte, 1)); !errors.Is(err, io.EOF) {
+ t.Fatalf("unexpected closed read: %v", err)
+ }
+ if _, err = conn.Write([]byte{1}); !errors.Is(err, io.ErrClosedPipe) {
+ t.Fatalf("unexpected closed write: %v", err)
+ }
+}
+
+func TestUIDConnIgnoresWrongEndpoint(t *testing.T) {
+ conn, camera := newTestUIDConn(t, time.Second)
+ spoof, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer spoof.Close()
+ packet, err := marshalUIDData(conn.clientID, 0, []byte("spoof"), uidMTU-uidDataHeader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = spoof.WriteToUDPAddrPort(packet, conn.conn.LocalAddr().(*net.UDPAddr).AddrPort()); err != nil {
+ t.Fatal(err)
+ }
+ writeUIDPacket(t, camera, conn, []byte{1, 2, 3, 4})
+ _ = conn.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
+ if _, err = conn.Read(make([]byte, 1)); !errors.Is(err, os.ErrDeadlineExceeded) {
+ t.Fatalf("spoofed packet reached reader: %v", err)
+ }
+}
+
+func TestUIDConnSequenceWrap(t *testing.T) {
+ conn, camera := newTestUIDConn(t, time.Second)
+ conn.sendMu.Lock()
+ conn.nextSend = math.MaxUint32
+ conn.sendMu.Unlock()
+ payload := make([]byte, uidMTU-uidDataHeader+1)
+ if _, err := conn.Write(payload); err != nil {
+ t.Fatal(err)
+ }
+ first := readUIDPacket(t, camera, time.Second)
+ second := readUIDPacket(t, camera, time.Second)
+ if first.packetID != math.MaxUint32 || second.packetID != 0 {
+ t.Fatalf("send sequence did not wrap: %d %d", first.packetID, second.packetID)
+ }
+ ack, err := marshalUIDAck(conn.clientID, 0, nil, uidSendWindow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeUIDPacket(t, camera, conn, ack)
+ waitFor(t, time.Second, func() bool {
+ conn.sendMu.Lock()
+ count := conn.sendCount
+ conn.sendMu.Unlock()
+ return count == 0
+ })
+
+ conn.receiveMu.Lock()
+ conn.nextReceive = math.MaxUint32
+ conn.receiveMu.Unlock()
+ sendUIDData(t, camera, conn, 0, []byte("B"))
+ sendUIDData(t, camera, conn, math.MaxUint32, []byte("A"))
+ b := make([]byte, 2)
+ if _, err = io.ReadFull(conn, b); err != nil || string(b) != "AB" {
+ t.Fatalf("receive sequence did not wrap: %q %v", b, err)
+ }
+}
+
+func TestUIDConnReadQueueOverflowIsTerminal(t *testing.T) {
+ conn, _ := newTestUIDConn(t, time.Second)
+ for packetID := uint32(0); packetID <= uidReadQueue; packetID++ {
+ b := conn.getBuffer()
+ packet, err := encodeUIDData(b[:], conn.clientID, packetID, []byte{1}, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ parsed, err := parseUIDPacket(packet, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !conn.handleUIDData(parsed, b) {
+ conn.putBuffer(b)
+ }
+ }
+ select {
+ case <-conn.done:
+ case <-time.After(time.Second):
+ t.Fatal("overflow did not close connection")
+ }
+ if !errors.Is(conn.readError(), errUIDReadOverflow) {
+ t.Fatalf("unexpected overflow error: %v", conn.readError())
+ }
+}
+
+func newTestUIDConn(t *testing.T, timeout time.Duration) (*uidConn, *net.UDPConn) {
+ t.Helper()
+ client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ camera, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ client.Close()
+ t.Fatal(err)
+ }
+ discovery := &uidDiscovery{
+ conn: client, remote: camera.LocalAddr().(*net.UDPAddr).AddrPort(),
+ clientID: 1001, cameraID: 2002,
+ }
+ conn, err := newUIDConn(discovery, timeout)
+ if err != nil {
+ camera.Close()
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = conn.Close()
+ _ = camera.Close()
+ })
+ return conn, camera
+}
+
+func sendUIDData(t *testing.T, camera *net.UDPConn, conn *uidConn, packetID uint32, payload []byte) {
+ t.Helper()
+ packet, err := marshalUIDData(conn.clientID, packetID, payload, uidMTU-uidDataHeader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeUIDPacket(t, camera, conn, packet)
+}
+
+func writeUIDPacket(t *testing.T, camera *net.UDPConn, conn *uidConn, packet []byte) {
+ t.Helper()
+ if _, err := camera.WriteToUDPAddrPort(packet, conn.conn.LocalAddr().(*net.UDPAddr).AddrPort()); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func readUIDPacket(t *testing.T, camera *net.UDPConn, timeout time.Duration) uidPacket {
+ t.Helper()
+ if err := camera.SetReadDeadline(time.Now().Add(timeout)); err != nil {
+ t.Fatal(err)
+ }
+ b := make([]byte, uidMTU)
+ n, _, err := camera.ReadFromUDPAddrPort(b)
+ if err != nil {
+ t.Fatal(err)
+ }
+ packet, err := parseUIDPacket(b[:n], uidMTU-uidDataHeader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return packet
+}
+
+func waitFor(t *testing.T, timeout time.Duration, ready func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(timeout)
+ for !ready() {
+ if !time.Now().Before(deadline) {
+ t.Fatal("condition timed out")
+ }
+ time.Sleep(time.Millisecond)
+ }
+}
diff --git a/pkg/baichuan/uid_deadline.go b/pkg/baichuan/uid_deadline.go
new file mode 100644
index 000000000..db6eeac46
--- /dev/null
+++ b/pkg/baichuan/uid_deadline.go
@@ -0,0 +1,51 @@
+package baichuan
+
+import (
+ "sync"
+ "time"
+)
+
+type uidDeadline struct {
+ mu sync.Mutex
+ value time.Time
+ changed chan struct{}
+ timer *time.Timer
+}
+
+func (d *uidDeadline) set(value time.Time) {
+ d.mu.Lock()
+ if !d.timer.Stop() {
+ select {
+ case <-d.timer.C:
+ default:
+ }
+ }
+ close(d.changed)
+ d.value = value
+ d.changed = make(chan struct{})
+ if !value.IsZero() {
+ duration := time.Until(value)
+ if duration < 0 {
+ duration = 0
+ }
+ d.timer.Reset(duration)
+ }
+ d.mu.Unlock()
+}
+
+func (d *uidDeadline) init() {
+ d.changed = make(chan struct{})
+ d.timer = time.NewTimer(time.Hour)
+ d.timer.Stop()
+}
+
+func (d *uidDeadline) snapshot() (time.Time, <-chan struct{}, <-chan time.Time) {
+ d.mu.Lock()
+ value, changed := d.value, d.changed
+ var timeout <-chan time.Time
+ if !value.IsZero() {
+ timeout = d.timer.C
+ }
+ d.mu.Unlock()
+ return value, changed, timeout
+}
diff --git a/pkg/baichuan/uid_discovery.go b/pkg/baichuan/uid_discovery.go
new file mode 100644
index 000000000..7e3f5d4d9
--- /dev/null
+++ b/pkg/baichuan/uid_discovery.go
@@ -0,0 +1,278 @@
+package baichuan
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/binary"
+ "encoding/xml"
+ "fmt"
+ "net"
+ "net/netip"
+ "strings"
+ "time"
+)
+
+const (
+ uidMTU = 1350
+ uidDiscoveryInterval = 500 * time.Millisecond
+)
+
+var uidDiscoveryPorts = [...]uint16{2015, 2018}
+
+type uidEnvelope struct {
+ XMLName xml.Name `xml:"P2P"`
+ Connect *uidConnect `xml:"C2D_C,omitempty"`
+ Response *uidResponse `xml:"D2C_C_R,omitempty"`
+}
+
+type uidConnect struct {
+ UID string `xml:"uid"`
+ Client uidPortList `xml:"cli"`
+ CID int32 `xml:"cid"`
+ MTU int `xml:"mtu"`
+ Debug int `xml:"debug"`
+ OS string `xml:"p"`
+}
+
+type uidPortList struct {
+ Port int `xml:"port"`
+}
+
+type uidResponse struct {
+ CID int32 `xml:"cid"`
+ DID int32 `xml:"did"`
+ Rsp int `xml:"rsp"`
+ Timer string `xml:"timer"`
+}
+
+type uidDiscovery struct {
+ conn *net.UDPConn
+ remote netip.AddrPort
+ clientID int32
+ cameraID int32
+}
+
+func discoverUID(ctx context.Context, uid, local, broadcast string, timeout time.Duration) (*uidDiscovery, error) {
+ if !validUID(uid) {
+ return nil, fmt.Errorf("baichuan: invalid UID")
+ }
+ if timeout <= 0 {
+ return nil, fmt.Errorf("baichuan: UID discovery timeout must be positive")
+ }
+ var err error
+ var localAddr netip.Addr
+ if local != "" {
+ localAddr, err = netip.ParseAddr(strings.TrimSpace(local))
+ if err != nil || !uidUnicastAddr(localAddr) {
+ return nil, fmt.Errorf("baichuan: invalid UID local address")
+ }
+ }
+ var broadcastAddr netip.Addr
+ if broadcast != "" {
+ broadcastAddr, err = netip.ParseAddr(strings.TrimSpace(broadcast))
+ if err != nil || !uidDiscoveryAddr(broadcastAddr) {
+ return nil, fmt.Errorf("baichuan: invalid UID broadcast address")
+ }
+ }
+ broadcasts, err := uidBroadcasts(localAddr, broadcastAddr)
+ if err != nil {
+ return nil, err
+ }
+
+ listenIP := net.IPv4zero
+ if localAddr.IsValid() {
+ listenIP = net.IP(localAddr.AsSlice())
+ }
+ conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: listenIP})
+ if err != nil {
+ return nil, fmt.Errorf("baichuan: listen for UID discovery: %w", err)
+ }
+ defer interruptDeadline(ctx, conn.SetDeadline)()
+ if err = enableUIDBroadcast(conn); err != nil {
+ _ = conn.Close()
+ return nil, fmt.Errorf("baichuan: enable UID broadcast: %w", err)
+ }
+
+ clientID, transaction, err := uidRandomIDs()
+ if err != nil {
+ _ = conn.Close()
+ return nil, err
+ }
+ payload, err := xml.Marshal(uidEnvelope{Connect: &uidConnect{
+ UID: uid, Client: uidPortList{Port: conn.LocalAddr().(*net.UDPAddr).Port},
+ CID: clientID, MTU: uidMTU, OS: "MAC",
+ }})
+ if err != nil {
+ _ = conn.Close()
+ return nil, fmt.Errorf("baichuan: marshal UID discovery: %w", err)
+ }
+ payload = append([]byte(xml.Header), payload...)
+ xorUID(payload, payload, transaction)
+ packet, err := marshalUIDDiscovery(transaction, payload)
+ if err != nil {
+ _ = conn.Close()
+ return nil, err
+ }
+
+ deadline := time.Now().Add(timeout)
+ if value, ok := ctx.Deadline(); ok && value.Before(deadline) {
+ deadline = value
+ }
+ if err = conn.SetWriteDeadline(deadline); err != nil {
+ _ = conn.Close()
+ return nil, fmt.Errorf("baichuan: set UID discovery write deadline: %w", err)
+ }
+ for {
+ if err = ctx.Err(); err != nil {
+ _ = conn.Close()
+ return nil, err
+ }
+ if !time.Now().Before(deadline) {
+ _ = conn.Close()
+ return nil, fmt.Errorf("baichuan: UID discovery timed out")
+ }
+ var sendErr error
+ sent := false
+ for _, address := range broadcasts {
+ for _, port := range uidDiscoveryPorts {
+ if _, writeErr := conn.WriteToUDPAddrPort(packet, netip.AddrPortFrom(address, port)); writeErr != nil {
+ sendErr = writeErr
+ } else {
+ sent = true
+ }
+ }
+ }
+ if !sent {
+ _ = conn.Close()
+ return nil, fmt.Errorf("baichuan: broadcast UID discovery: %w", sendErr)
+ }
+ if discovery, ok := readUIDDiscovery(conn, deadline, clientID, transaction); ok {
+ return discovery, nil
+ }
+ }
+}
+
+func validUID(uid string) bool {
+ return len(uid) != 0 && len(uid) <= 64 && strings.IndexFunc(uid, func(r rune) bool {
+ return !('0' <= r && r <= '9' || 'A' <= r && r <= 'Z' || 'a' <= r && r <= 'z')
+ }) < 0
+}
+
+func uidUnicastAddr(address netip.Addr) bool {
+ return address.Is4() && !address.IsLoopback() &&
+ (address.IsGlobalUnicast() || address.IsLinkLocalUnicast())
+}
+
+func uidDiscoveryAddr(address netip.Addr) bool {
+ return address.Is4() && !address.IsUnspecified() && !address.IsLoopback() && !address.IsMulticast()
+}
+
+func readUIDDiscovery(conn *net.UDPConn, deadline time.Time, clientID int32, transaction uint32) (*uidDiscovery, bool) {
+ readDeadline := time.Now().Add(uidDiscoveryInterval)
+ if deadline.Before(readDeadline) {
+ readDeadline = deadline
+ }
+ _ = conn.SetReadDeadline(readDeadline)
+ b := make([]byte, uidMTU)
+ for {
+ n, remote, err := conn.ReadFromUDPAddrPort(b)
+ if err != nil {
+ return nil, false
+ }
+ cameraID, ok := parseUIDResponse(b[:n], remote, clientID, transaction)
+ if !ok {
+ continue
+ }
+ return &uidDiscovery{
+ conn: conn, remote: remote, clientID: clientID, cameraID: cameraID,
+ }, true
+ }
+}
+
+func parseUIDResponse(b []byte, remote netip.AddrPort, clientID int32, transaction uint32) (int32, bool) {
+ packet, err := parseUIDPacket(b, uidMTU-uidDiscoveryHeader)
+ if err != nil || packet.magic != uidMagicDiscovery || packet.transaction != transaction ||
+ !uidUnicastAddr(remote.Addr()) || remote.Port() == 0 {
+ return 0, false
+ }
+ xorUID(packet.payload, packet.payload, packet.transaction)
+ var envelope uidEnvelope
+ if xml.Unmarshal(packet.payload, &envelope) != nil || envelope.Response == nil ||
+ envelope.Response.CID != clientID || envelope.Response.DID <= 0 || envelope.Response.Rsp != 0 {
+ return 0, false
+ }
+ return envelope.Response.DID, true
+}
+
+func uidBroadcasts(local, broadcast netip.Addr) ([]netip.Addr, error) {
+ if broadcast.IsValid() {
+ return []netip.Addr{broadcast}, nil
+ }
+ interfaces, err := net.Interfaces()
+ if err != nil {
+ return nil, fmt.Errorf("baichuan: list UID interfaces: %w", err)
+ }
+ set := make(map[netip.Addr]struct{})
+ for _, iface := range interfaces {
+ if iface.Flags&(net.FlagUp|net.FlagBroadcast) != net.FlagUp|net.FlagBroadcast ||
+ iface.Flags&net.FlagLoopback != 0 {
+ continue
+ }
+ addresses, err := iface.Addrs()
+ if err != nil {
+ return nil, fmt.Errorf("baichuan: list addresses for UID interface: %w", err)
+ }
+ for _, address := range addresses {
+ prefix, err := netip.ParsePrefix(address.String())
+ if err != nil || !prefix.Addr().Is4() || prefix.Bits() >= 31 {
+ continue
+ }
+ if local.IsValid() && prefix.Addr() != local {
+ continue
+ }
+ base := prefix.Masked().Addr().As4()
+ bits := prefix.Bits()
+ value := binary.BigEndian.Uint32(base[:]) | uint32(1<<(32-bits)-1)
+ var broadcast [4]byte
+ binary.BigEndian.PutUint32(broadcast[:], value)
+ set[netip.AddrFrom4(broadcast)] = struct{}{}
+ }
+ }
+ if len(set) == 0 {
+ if local.IsValid() {
+ return nil, fmt.Errorf("baichuan: UID local address has no broadcast interface")
+ }
+ return nil, fmt.Errorf("baichuan: no IPv4 broadcast interface")
+ }
+ broadcasts := make([]netip.Addr, 0, len(set))
+ for address := range set {
+ broadcasts = append(broadcasts, address)
+ }
+ return broadcasts, nil
+}
+
+func uidRandomIDs() (int32, uint32, error) {
+ var b [8]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return 0, 0, fmt.Errorf("baichuan: generate UID identifiers: %w", err)
+ }
+ clientID := int32(binary.LittleEndian.Uint32(b[:4]) & 0x7fffffff)
+ if clientID == 0 {
+ clientID = 1
+ }
+ return clientID, binary.LittleEndian.Uint32(b[4:]), nil
+}
+
+func enableUIDBroadcast(conn *net.UDPConn) error {
+ raw, err := conn.SyscallConn()
+ if err != nil {
+ return err
+ }
+ var optionErr error
+ if err = raw.Control(func(fd uintptr) {
+ optionErr = setUIDBroadcast(fd)
+ }); err != nil {
+ return err
+ }
+ return optionErr
+}
diff --git a/pkg/baichuan/uid_discovery_test.go b/pkg/baichuan/uid_discovery_test.go
new file mode 100644
index 000000000..614d43f6b
--- /dev/null
+++ b/pkg/baichuan/uid_discovery_test.go
@@ -0,0 +1,78 @@
+package baichuan
+
+import (
+ "context"
+ "encoding/xml"
+ "net"
+ "net/netip"
+ "testing"
+ "time"
+)
+
+func TestUIDDiscoveryRejectsInvalidConfig(t *testing.T) {
+ for _, uid := range []string{"", "bad uid", string(make([]byte, 65))} {
+ if _, err := discoverUID(context.Background(), uid, "", "", time.Second); err == nil {
+ t.Fatalf("accepted UID of length %d", len(uid))
+ }
+ }
+ if _, err := discoverUID(context.Background(), "valid", "", "", 0); err == nil {
+ t.Fatal("accepted zero timeout")
+ }
+ if _, err := discoverUID(context.Background(), "valid", "not-an-address", "", time.Second); err == nil {
+ t.Fatal("accepted invalid local address")
+ }
+ if _, err := discoverUID(context.Background(), "valid", "", "127.0.0.1", time.Second); err == nil {
+ t.Fatal("accepted invalid broadcast address")
+ }
+}
+
+func TestUIDBroadcastSocketOption(t *testing.T) {
+ conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer conn.Close()
+ if err = enableUIDBroadcast(conn); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestUIDBroadcastSelectionRejectsInactiveAddress(t *testing.T) {
+ if _, err := uidBroadcasts(netip.MustParseAddr("192.0.2.1"), netip.Addr{}); err == nil {
+ t.Fatal("accepted inactive UID local address")
+ }
+}
+
+func TestUIDRoutedBroadcastOverride(t *testing.T) {
+ local := netip.MustParseAddr("192.0.2.211")
+ want := netip.MustParseAddr("198.51.100.255")
+ broadcasts, err := uidBroadcasts(local, want)
+ if err != nil || len(broadcasts) != 1 || broadcasts[0] != want {
+ t.Fatalf("unexpected UID broadcast override: %v %v", broadcasts, err)
+ }
+}
+
+func TestUIDDiscoveryResponseMatchesTransaction(t *testing.T) {
+ const clientID int32 = 71
+ const transaction uint32 = 29
+ payload, err := xml.Marshal(uidEnvelope{Response: &uidResponse{CID: clientID, DID: 83, Rsp: 0}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ payload = append([]byte(xml.Header), payload...)
+ xorUID(payload, payload, transaction)
+ packet, err := marshalUIDDiscovery(transaction, payload)
+ if err != nil {
+ t.Fatal(err)
+ }
+ remote := netip.MustParseAddrPort("203.0.113.67:2015")
+ if _, ok := parseUIDResponse(append([]byte(nil), packet...), remote, clientID, transaction+1); ok {
+ t.Fatal("accepted UID discovery response for another transaction")
+ }
+ if _, ok := parseUIDResponse(append([]byte(nil), packet...), remote, clientID+1, transaction); ok {
+ t.Fatal("accepted UID discovery response for another client")
+ }
+ if cameraID, ok := parseUIDResponse(packet, remote, clientID, transaction); !ok || cameraID != 83 {
+ t.Fatalf("valid UID response = %d, %t", cameraID, ok)
+ }
+}
diff --git a/pkg/baichuan/uid_loop.go b/pkg/baichuan/uid_loop.go
new file mode 100644
index 000000000..d478e0299
--- /dev/null
+++ b/pkg/baichuan/uid_loop.go
@@ -0,0 +1,210 @@
+package baichuan
+
+import (
+ "encoding/xml"
+ "fmt"
+ "time"
+)
+
+func (s *uidConn) readLoop() {
+ defer s.wg.Done()
+ for {
+ b := s.getBuffer()
+ n, remote, err := s.conn.ReadFromUDPAddrPort(b[:])
+ if err != nil {
+ s.putBuffer(b)
+ select {
+ case <-s.done:
+ return
+ default:
+ s.shutdown(fmt.Errorf("baichuan: read UID transport: %w", err))
+ return
+ }
+ }
+ if remote != s.remote {
+ s.putBuffer(b)
+ continue
+ }
+ packet, err := parseUIDPacket(b[:n], uidMTU-uidDataHeader)
+ if err != nil {
+ s.putBuffer(b)
+ continue
+ }
+ switch packet.magic {
+ case uidMagicAck:
+ s.handleUIDAck(packet)
+ s.putBuffer(b)
+ case uidMagicData:
+ if packet.connectionID != s.clientID {
+ s.putBuffer(b)
+ } else if !s.handleUIDData(packet, b) {
+ s.putBuffer(b)
+ }
+ case uidMagicDiscovery:
+ s.handleUIDControl(packet)
+ s.putBuffer(b)
+ default:
+ s.putBuffer(b)
+ }
+ }
+}
+
+func (s *uidConn) handleUIDAck(packet uidPacket) {
+ if packet.connectionID != s.clientID || len(packet.payload) > uidSendWindow {
+ return
+ }
+ s.sendMu.Lock()
+ distance := uint32(s.nextSend - packet.packetID)
+ if distance == 0 || distance > uidSendWindow {
+ s.sendMu.Unlock()
+ return
+ }
+ freed := false
+ for i := range s.sendSlots {
+ slot := &s.sendSlots[i]
+ if !slot.used {
+ continue
+ }
+ acked := int32(slot.packetID-packet.packetID) <= 0
+ if !acked {
+ distance := uint32(slot.packetID - packet.packetID - 1)
+ acked = distance < uint32(len(packet.payload)) && packet.payload[distance] != 0
+ }
+ if acked {
+ s.putBuffer(slot.buffer)
+ *slot = uidSendSlot{}
+ s.sendCount--
+ freed = true
+ }
+ }
+ if freed {
+ s.signalSend()
+ }
+ s.sendMu.Unlock()
+}
+
+func (s *uidConn) handleUIDData(packet uidPacket, b *uidBuffer) bool {
+ s.receiveMu.Lock()
+ distance := int32(packet.packetID - s.nextReceive)
+ if distance < 0 {
+ s.ackDirty = s.received
+ s.receiveMu.Unlock()
+ return false
+ }
+ if distance >= uidReceiveWindow {
+ s.ackDirty = s.received
+ s.receiveMu.Unlock()
+ return false
+ }
+ index := packet.packetID % uidReceiveWindow
+ slot := &s.receiveSlots[index]
+ if slot.used {
+ s.ackDirty = s.received
+ s.receiveMu.Unlock()
+ return false
+ }
+ *slot = uidReceiveSlot{
+ packetID: packet.packetID,
+ chunk: uidChunk{buffer: b, data: packet.payload},
+ used: true,
+ }
+ s.ackDirty = true
+ for {
+ slot = &s.receiveSlots[s.nextReceive%uidReceiveWindow]
+ if !slot.used || slot.packetID != s.nextReceive {
+ break
+ }
+ chunk := slot.chunk
+ *slot = uidReceiveSlot{}
+ s.nextReceive++
+ s.received = true
+ if len(chunk.data) == 0 {
+ s.putBuffer(chunk.buffer)
+ continue
+ }
+ select {
+ case s.readQueue <- chunk:
+ case <-s.done:
+ s.putBuffer(chunk.buffer)
+ s.receiveMu.Unlock()
+ return true
+ default:
+ s.putBuffer(chunk.buffer)
+ s.receiveMu.Unlock()
+ s.shutdown(errUIDReadOverflow)
+ return true
+ }
+ }
+ s.receiveMu.Unlock()
+ return true
+}
+
+func (s *uidConn) handleUIDControl(packet uidPacket) {
+ xorUID(packet.payload, packet.payload, packet.transaction)
+ var envelope struct {
+ Disconnect *struct {
+ CID int32 `xml:"cid"`
+ DID int32 `xml:"did"`
+ } `xml:"D2C_DISC"`
+ }
+ if xml.Unmarshal(packet.payload, &envelope) != nil {
+ return
+ }
+ if envelope.Disconnect != nil &&
+ envelope.Disconnect.CID == s.clientID && envelope.Disconnect.DID == s.cameraID {
+ s.shutdown(fmt.Errorf("baichuan: camera closed UID transport"))
+ }
+}
+
+func (s *uidConn) maintenanceLoop() {
+ defer s.wg.Done()
+ ticker := time.NewTicker(uidMaintenance)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-s.done:
+ return
+ case now := <-ticker.C:
+ if err := s.retransmitUID(now); err != nil {
+ s.shutdown(err)
+ return
+ }
+ if err := s.writeUIDAck(); err != nil {
+ s.shutdown(err)
+ return
+ }
+ }
+ }
+}
+
+func (s *uidConn) writeUIDAck() error {
+ s.receiveMu.Lock()
+ if !s.received || !s.ackDirty {
+ s.receiveMu.Unlock()
+ return nil
+ }
+ packetID := s.nextReceive - 1
+ last := -1
+ var payload [uidReceiveWindow]byte
+ for distance := 0; distance < uidReceiveWindow; distance++ {
+ id := s.nextReceive + uint32(distance)
+ slot := &s.receiveSlots[id%uidReceiveWindow]
+ if slot.used && slot.packetID == id {
+ payload[distance] = 1
+ last = distance
+ }
+ }
+ s.ackDirty = false
+ s.receiveMu.Unlock()
+
+ b := s.getBuffer()
+ packet, err := encodeUIDAck(b[:], s.cameraID, packetID, payload[:last+1], uidReceiveWindow)
+ if err == nil {
+ err = s.writeDatagram(packet)
+ }
+ s.putBuffer(b)
+ if err != nil {
+ return fmt.Errorf("baichuan: write UID acknowledgement: %w", err)
+ }
+ return nil
+}
diff --git a/pkg/baichuan/uid_reliability_test.go b/pkg/baichuan/uid_reliability_test.go
new file mode 100644
index 000000000..73c635f0b
--- /dev/null
+++ b/pkg/baichuan/uid_reliability_test.go
@@ -0,0 +1,168 @@
+package baichuan
+
+import (
+ "bytes"
+ "encoding/xml"
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestUIDConnRetransmitsUntilAcknowledged(t *testing.T) {
+ conn, camera := newTestUIDConn(t, time.Second)
+ if _, err := conn.Write([]byte("request")); err != nil {
+ t.Fatal(err)
+ }
+ first := readUIDPacket(t, camera, time.Second)
+ second := readUIDPacket(t, camera, time.Second)
+ if first.magic != uidMagicData || second.magic != uidMagicData ||
+ first.packetID != 0 || second.packetID != 0 ||
+ !bytes.Equal(first.payload, second.payload) {
+ t.Fatalf("unexpected retransmission: first=%+v second=%+v", first, second)
+ }
+ ack, err := marshalUIDAck(conn.clientID, 0, nil, uidSendWindow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeUIDPacket(t, camera, conn, ack)
+ waitFor(t, time.Second, func() bool {
+ conn.sendMu.Lock()
+ count := conn.sendCount
+ conn.sendMu.Unlock()
+ return count == 0
+ })
+}
+
+func TestUIDConnSelectiveAcknowledgement(t *testing.T) {
+ conn, camera := newTestUIDConn(t, time.Second)
+ payload := make([]byte, (uidMTU-uidDataHeader)*2+1)
+ if _, err := conn.Write(payload); err != nil {
+ t.Fatal(err)
+ }
+ for range 3 {
+ _ = readUIDPacket(t, camera, time.Second)
+ }
+ ack, err := marshalUIDAck(conn.clientID, 0, []byte{0, 1}, uidSendWindow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeUIDPacket(t, camera, conn, ack)
+ waitFor(t, time.Second, func() bool {
+ conn.sendMu.Lock()
+ defer conn.sendMu.Unlock()
+ return conn.sendCount == 1 && conn.sendSlots[1].used
+ })
+ ack, err = marshalUIDAck(conn.clientID, 1, nil, uidSendWindow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeUIDPacket(t, camera, conn, ack)
+ waitFor(t, time.Second, func() bool {
+ conn.sendMu.Lock()
+ defer conn.sendMu.Unlock()
+ return conn.sendCount == 0
+ })
+}
+
+func TestUIDConnSendWindowPreservesUnacknowledgedSlot(t *testing.T) {
+ conn := &uidConn{done: make(chan struct{}), sendWake: make(chan struct{}, 1)}
+ conn.writeDeadline.init()
+ conn.nextSend = uidSendWindow + 1
+ conn.sendCount = 1
+ conn.sendSlots[1] = uidSendSlot{packetID: 1, used: true}
+ conn.writeDeadline.set(time.Now().Add(-time.Second))
+ if packetID, err := conn.reserveSend(); err != os.ErrDeadlineExceeded {
+ t.Fatalf("reserved packet %d over unacknowledged slot: %v", packetID, err)
+ }
+ if conn.nextSend != uidSendWindow+1 || conn.sendCount != 1 {
+ t.Fatalf("send window changed: next=%d pending=%d", conn.nextSend, conn.sendCount)
+ }
+ conn.writeDeadline.set(time.Time{})
+ conn.releaseSend(1)
+ packetID, err := conn.reserveSend()
+ if err != nil || packetID != uidSendWindow+1 {
+ t.Fatalf("reservation after release = %d, %v", packetID, err)
+ }
+ conn.cancelSendReservation()
+}
+
+func TestUIDConnRejectsFutureAcknowledgement(t *testing.T) {
+ conn, camera := newTestUIDConn(t, time.Second)
+ if _, err := conn.Write([]byte("request")); err != nil {
+ t.Fatal(err)
+ }
+ _ = readUIDPacket(t, camera, time.Second)
+ ack, err := marshalUIDAck(conn.clientID, 1, nil, uidSendWindow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ parsed, err := parseUIDPacket(ack, uidSendWindow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ conn.handleUIDAck(parsed)
+ conn.sendMu.Lock()
+ pending := conn.sendCount
+ conn.sendMu.Unlock()
+ if pending != 1 {
+ t.Fatalf("future ACK changed send window: pending=%d", pending)
+ }
+ ack, err = marshalUIDAck(conn.clientID, 0, nil, uidSendWindow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeUIDPacket(t, camera, conn, ack)
+ waitFor(t, time.Second, func() bool {
+ conn.sendMu.Lock()
+ defer conn.sendMu.Unlock()
+ return conn.sendCount == 0
+ })
+}
+
+func TestUIDConnAcknowledgementTimeoutIsTerminal(t *testing.T) {
+ conn, _ := newTestUIDConn(t, 120*time.Millisecond)
+ if _, err := conn.Write([]byte("request")); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-conn.done:
+ case <-time.After(time.Second):
+ t.Fatal("missing acknowledgement did not close transport")
+ }
+ if err := conn.readError(); err == nil || !strings.Contains(err.Error(), "acknowledgement timed out") {
+ t.Fatalf("unexpected terminal error: %v", err)
+ }
+}
+
+func TestUIDConnCameraDisconnectIsTerminal(t *testing.T) {
+ conn, camera := newTestUIDConn(t, time.Second)
+ payload, err := xml.Marshal(struct {
+ XMLName xml.Name `xml:"P2P"`
+ Disconnect struct {
+ CID int32 `xml:"cid"`
+ DID int32 `xml:"did"`
+ } `xml:"D2C_DISC"`
+ }{Disconnect: struct {
+ CID int32 `xml:"cid"`
+ DID int32 `xml:"did"`
+ }{conn.clientID, conn.cameraID}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ const transaction = 17
+ xorUID(payload, payload, transaction)
+ packet, err := marshalUIDDiscovery(transaction, payload)
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeUIDPacket(t, camera, conn, packet)
+ select {
+ case <-conn.done:
+ case <-time.After(time.Second):
+ t.Fatal("camera disconnect did not close transport")
+ }
+ if err = conn.readError(); err == nil || !strings.Contains(err.Error(), "camera closed") {
+ t.Fatalf("unexpected terminal error: %v", err)
+ }
+}
diff --git a/pkg/baichuan/uid_send.go b/pkg/baichuan/uid_send.go
new file mode 100644
index 000000000..1ff7d6283
--- /dev/null
+++ b/pkg/baichuan/uid_send.go
@@ -0,0 +1,118 @@
+package baichuan
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "time"
+)
+
+func (s *uidConn) writeChunk(payload []byte) error {
+ packetID, err := s.reserveSend()
+ if err != nil {
+ return err
+ }
+ b := s.getBuffer()
+ packet, err := encodeUIDData(b[:], s.cameraID, packetID, payload, uidMTU-uidDataHeader)
+ if err != nil {
+ s.putBuffer(b)
+ s.cancelSendReservation()
+ return err
+ }
+ now := time.Now()
+ s.sendMu.Lock()
+ slot := &s.sendSlots[packetID%uidSendWindow]
+ *slot = uidSendSlot{
+ buffer: b, packet: packet, packetID: packetID, firstSend: now, lastSend: now,
+ interval: uidRetransmit, used: true,
+ }
+ s.sendMu.Unlock()
+ if err = s.writeDatagram(packet); err != nil {
+ s.releaseSend(packetID)
+ select {
+ case <-s.done:
+ return s.writeError()
+ default:
+ }
+ s.shutdown(err)
+ return err
+ }
+ return nil
+}
+
+func (s *uidConn) cancelSendReservation() {
+ s.sendMu.Lock()
+ s.sendCount--
+ s.signalSend()
+ s.sendMu.Unlock()
+}
+
+func (s *uidConn) reserveSend() (uint32, error) {
+ for {
+ select {
+ case <-s.done:
+ return 0, s.writeError()
+ default:
+ }
+ s.sendMu.Lock()
+ if s.sendCount < uidSendWindow && !s.sendSlots[s.nextSend%uidSendWindow].used {
+ packetID := s.nextSend
+ s.nextSend++
+ s.sendCount++
+ s.sendMu.Unlock()
+ return packetID, nil
+ }
+ s.sendMu.Unlock()
+
+ deadline, changed, timeout := s.writeDeadline.snapshot()
+ if !deadline.IsZero() && !time.Now().Before(deadline) {
+ return 0, os.ErrDeadlineExceeded
+ }
+ select {
+ case <-s.sendWake:
+ case <-changed:
+ case <-s.done:
+ return 0, s.writeError()
+ case <-timeout:
+ return 0, os.ErrDeadlineExceeded
+ }
+ }
+}
+
+func (s *uidConn) releaseSend(packetID uint32) {
+ s.sendMu.Lock()
+ slot := &s.sendSlots[packetID%uidSendWindow]
+ if slot.used && slot.packetID == packetID {
+ s.putBuffer(slot.buffer)
+ *slot = uidSendSlot{}
+ s.sendCount--
+ s.signalSend()
+ }
+ s.sendMu.Unlock()
+}
+
+func (s *uidConn) retransmitUID(now time.Time) error {
+ s.sendMu.Lock()
+ defer s.sendMu.Unlock()
+ for i := range s.sendSlots {
+ slot := &s.sendSlots[i]
+ if !slot.used || now.Sub(slot.lastSend) < slot.interval {
+ continue
+ }
+ if now.Sub(slot.firstSend) >= s.timeout {
+ return fmt.Errorf("baichuan: UID acknowledgement timed out")
+ }
+ if err := s.writeDatagram(slot.packet); err != nil {
+ if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
+ continue
+ }
+ return fmt.Errorf("baichuan: retransmit UID packet: %w", err)
+ }
+ slot.lastSend = now
+ slot.interval *= 2
+ if slot.interval > uidMaxRetransmit {
+ slot.interval = uidMaxRetransmit
+ }
+ }
+ return nil
+}
diff --git a/pkg/baichuan/uid_sockopt.go b/pkg/baichuan/uid_sockopt.go
new file mode 100644
index 000000000..eada994a9
--- /dev/null
+++ b/pkg/baichuan/uid_sockopt.go
@@ -0,0 +1,9 @@
+//go:build !windows
+
+package baichuan
+
+import "syscall"
+
+func setUIDBroadcast(fd uintptr) error {
+ return syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_BROADCAST, 1)
+}
diff --git a/pkg/baichuan/uid_sockopt_windows.go b/pkg/baichuan/uid_sockopt_windows.go
new file mode 100644
index 000000000..bad4eac18
--- /dev/null
+++ b/pkg/baichuan/uid_sockopt_windows.go
@@ -0,0 +1,9 @@
+//go:build windows
+
+package baichuan
+
+import "syscall"
+
+func setUIDBroadcast(fd uintptr) error {
+ return syscall.SetsockoptInt(syscall.Handle(fd), syscall.SOL_SOCKET, syscall.SO_BROADCAST, 1)
+}
diff --git a/pkg/baichuan/xml.go b/pkg/baichuan/xml.go
new file mode 100644
index 000000000..e3a9febb1
--- /dev/null
+++ b/pkg/baichuan/xml.go
@@ -0,0 +1,134 @@
+package baichuan
+
+import (
+ "encoding/xml"
+ "fmt"
+)
+
+const (
+ maxNonceBody = 4 << 10
+ maxNonceSize = 128
+ binaryExtensionXML = "1"
+)
+
+var binaryExtensionValue = 1
+
+type loginEnvelope struct {
+ XMLName xml.Name `xml:"body"`
+ User loginUser `xml:"LoginUser"`
+ Net loginNet `xml:"LoginNet"`
+}
+
+type loginUser struct {
+ Version string `xml:"version,attr"`
+ Username string `xml:"userName"`
+ Password string `xml:"password"`
+ UserVer int `xml:"userVer"`
+}
+
+type loginNet struct {
+ Version string `xml:"version,attr"`
+ Type string `xml:"type"`
+ UDPPort int `xml:"udpPort"`
+}
+
+type nonceEnvelope struct {
+ Encryption *struct {
+ Nonce string `xml:"nonce"`
+ } `xml:"Encryption"`
+}
+
+type extension struct {
+ BinaryData *int `xml:"binaryData"`
+ EncryptLen *int `xml:"encryptLen"`
+ CheckPos *int `xml:"checkPos"`
+}
+
+type previewEnvelope struct {
+ XMLName xml.Name `xml:"body"`
+ Preview struct {
+ Version string `xml:"version,attr"`
+ Channel uint8 `xml:"channelId"`
+ Handle uint32 `xml:"handle"`
+ Stream Stream `xml:"streamType"`
+ } `xml:"Preview"`
+}
+
+type stopPreviewEnvelope struct {
+ XMLName xml.Name `xml:"body"`
+ Preview struct {
+ Version string `xml:"version,attr"`
+ Channel uint8 `xml:"channelId"`
+ Handle uint32 `xml:"handle"`
+ } `xml:"Preview"`
+}
+
+func marshalDocument(value any) ([]byte, error) {
+ body, err := xml.Marshal(value)
+ if err != nil {
+ return nil, err
+ }
+ doc := make([]byte, 0, len(xml.Header)+len(body))
+ doc = append(doc, xml.Header...)
+ return append(doc, body...), nil
+}
+
+func buildLogin(username, password, nonce string) ([]byte, error) {
+ return marshalDocument(loginEnvelope{
+ User: loginUser{
+ Version: "1.1",
+ Username: modernMD5(username + nonce),
+ Password: modernMD5(password + nonce),
+ UserVer: 1,
+ },
+ Net: loginNet{Version: "1.1", Type: "LAN"},
+ })
+}
+
+func parseNonce(body []byte) (string, error) {
+ if len(body) > maxNonceBody {
+ return "", fmt.Errorf("login nonce XML exceeds %d bytes", maxNonceBody)
+ }
+ var value nonceEnvelope
+ if err := xml.Unmarshal(body, &value); err != nil {
+ return "", fmt.Errorf("decode nonce XML: %w", err)
+ }
+ if value.Encryption == nil || value.Encryption.Nonce == "" {
+ return "", fmt.Errorf("nonce missing from login response")
+ }
+ if len(value.Encryption.Nonce) > maxNonceSize {
+ return "", fmt.Errorf("login nonce exceeds %d bytes", maxNonceSize)
+ }
+ return value.Encryption.Nonce, nil
+}
+
+func parseExtension(body []byte) (extension, error) {
+ if len(body) == 0 {
+ return extension{}, nil
+ }
+ if string(body) == binaryExtensionXML {
+ return extension{BinaryData: &binaryExtensionValue}, nil
+ }
+ var value extension
+ if err := xml.Unmarshal(body, &value); err != nil {
+ return value, fmt.Errorf("decode extension XML: %w", err)
+ }
+ return value, nil
+}
+
+func buildPreview(channel uint8, stream Stream, handle uint32) ([]byte, error) {
+ value := previewEnvelope{}
+ value.Preview.Version = "1.1"
+ value.Preview.Channel = channel
+ value.Preview.Handle = handle
+ value.Preview.Stream = stream
+ return marshalDocument(value)
+}
+
+func buildStopPreview(channel uint8, handle uint32) ([]byte, error) {
+ value := stopPreviewEnvelope{}
+ value.Preview.Version = "1.1"
+ value.Preview.Channel = channel
+ value.Preview.Handle = handle
+ return marshalDocument(value)
+}
diff --git a/pkg/baichuan/xml_test.go b/pkg/baichuan/xml_test.go
new file mode 100644
index 000000000..ac897cb55
--- /dev/null
+++ b/pkg/baichuan/xml_test.go
@@ -0,0 +1,32 @@
+package baichuan
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestParseNonceLimits(t *testing.T) {
+ for _, body := range [][]byte{
+ []byte(strings.Repeat(" ", maxNonceBody+1)),
+ []byte(`` + strings.Repeat("n", maxNonceSize+1) +
+ ``),
+ } {
+ if _, err := parseNonce(body); err == nil {
+ t.Fatal("accepted oversized login nonce")
+ }
+ }
+}
+
+func FuzzXMLParsers(f *testing.F) {
+ f.Add([]byte(`nonce`))
+ f.Add([]byte(`18`))
+ f.Add([]byte(`fullDuplex`))
+ f.Add([]byte(`version_ro`))
+ f.Fuzz(func(t *testing.T, body []byte) {
+ _, _ = parseNonce(body)
+ _, _ = parseExtension(body)
+ _, _ = decodeTalkAbility(body)
+ _, _ = parseCapabilities(body)
+ _, _ = parseDeviceInfo(body)
+ })
+}
diff --git a/pkg/reolink/backchannel.go b/pkg/reolink/backchannel.go
new file mode 100644
index 000000000..b235a4616
--- /dev/null
+++ b/pkg/reolink/backchannel.go
@@ -0,0 +1,270 @@
+package reolink
+
+import (
+ "context"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+ "sync"
+ "time"
+
+ "github.com/AlexxIT/go2rtc/pkg/baichuan"
+ "github.com/AlexxIT/go2rtc/pkg/core"
+ "github.com/AlexxIT/go2rtc/pkg/pcm"
+ "github.com/pion/rtp"
+)
+
+const talkIdleTimeout = 5 * time.Second
+
+type talkSession interface {
+ Format() baichuan.TalkFormat
+ WriteBlock(context.Context, []byte) error
+ Close(context.Context) error
+}
+
+type talkDialer func(context.Context, baichuan.Config, uint8) (talkSession, io.Closer, error)
+
+type backchannel struct {
+ producer *Producer
+ format baichuan.TalkFormat
+ dial talkDialer
+
+ mu sync.Mutex
+ closed bool
+ client io.Closer
+ talk talkSession
+ encoder baichuan.ADPCMEncoder
+ codec core.Codec
+ transcode func([]byte) []byte
+ samples []int16
+ idle *time.Timer
+ idleAt time.Time
+ sender *core.Sender
+}
+
+func (p *Producer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
+ if p.talk == nil || media.Kind != core.KindAudio || media.Direction != core.DirectionSendonly ||
+ codec.Channels > 1 || !talkCodecSupported(codec) {
+ return fmt.Errorf("reolink: unsupported talkback track")
+ }
+
+ p.talkMu.Lock()
+ defer p.talkMu.Unlock()
+ if p.ctx.Err() != nil {
+ return p.ctx.Err()
+ }
+ if p.talk.sender != nil {
+ p.talk.sender.Close()
+ p.talk.sender.Wait()
+ }
+ if err := p.talk.setCodec(codec); err != nil {
+ return err
+ }
+ sender := core.NewSender(media, codec)
+ sender.Handler = func(packet *rtp.Packet) {
+ if err := p.talk.Write(packet.Payload); err != nil && p.ctx.Err() == nil {
+ p.fail(fmt.Errorf("reolink: write talkback: %w", err))
+ }
+ }
+ sender.HandleRTP(track)
+ p.talk.sender = sender
+ p.Senders = []*core.Sender{sender}
+ return nil
+}
+
+func talkCodecs() []*core.Codec {
+ codecs := []*core.Codec{
+ {Name: core.CodecPCMA, ClockRate: 8000, Channels: 1, PayloadType: 8},
+ {Name: core.CodecPCMU, ClockRate: 8000, Channels: 1, PayloadType: 0},
+ }
+ for _, codec := range pcm.ProducerCodecs() {
+ if codec.Name != core.CodecPCM && codec.Name != core.CodecPCML ||
+ codec.ClockRate != 8000 && codec.ClockRate != 16000 {
+ continue
+ }
+ codec.Channels = 1
+ codec.PayloadType = core.PayloadTypeRAW
+ codecs = append(codecs, codec)
+ }
+ return codecs
+}
+
+func talkCodecSupported(codec *core.Codec) bool {
+ if codec.ClockRate == 0 {
+ return false
+ }
+ for _, candidate := range talkCodecs() {
+ if candidate.Name == codec.Name && candidate.ClockRate == codec.ClockRate {
+ return true
+ }
+ }
+ return false
+}
+
+func (b *backchannel) setCodec(codec *core.Codec) error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if b.closed {
+ return fmt.Errorf("reolink: talkback closed")
+ }
+ b.codec = *codec
+ target := &core.Codec{Name: core.CodecPCML, ClockRate: b.format.SampleRate, Channels: 1}
+ b.transcode = pcm.Transcode(target, &b.codec)
+ b.samples = b.samples[:0]
+ return nil
+}
+
+func (b *backchannel) Write(payload []byte) error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if b.closed {
+ return fmt.Errorf("reolink: talkback closed")
+ }
+ blockSamples := uint64(b.format.SamplesPerBlock)
+ inputSamples := uint64(len(payload))
+ if b.codec.Name == core.CodecPCM || b.codec.Name == core.CodecPCML {
+ if len(payload)&1 != 0 {
+ return fmt.Errorf("reolink: talkback PCM payload has odd length %d", len(payload))
+ }
+ inputSamples /= 2
+ }
+ expanded := (inputSamples*uint64(b.format.SampleRate) + uint64(b.codec.ClockRate) - 1) /
+ uint64(b.codec.ClockRate)
+ if expanded > blockSamples*4 {
+ return fmt.Errorf("reolink: talkback packet exceeds sample limit")
+ }
+ if err := b.open(); err != nil {
+ return err
+ }
+ pcmBytes := b.transcode(payload)
+ if len(pcmBytes)&1 != 0 {
+ return fmt.Errorf("reolink: talkback produced odd PCM length %d", len(pcmBytes))
+ }
+ blockSize := int(blockSamples)
+ for i := 0; i < len(pcmBytes); i += 2 {
+ b.samples = append(b.samples, int16(binary.LittleEndian.Uint16(pcmBytes[i:])))
+ }
+ for len(b.samples) >= blockSize {
+ block, err := b.encoder.EncodeBlock(b.samples[:blockSize])
+ if err != nil {
+ return err
+ }
+ if err = b.talk.WriteBlock(b.producer.ctx, block); err != nil {
+ return err
+ }
+ b.producer.addSend(len(block))
+ if len(b.samples) == blockSize {
+ b.samples = b.samples[:0]
+ } else {
+ b.samples = b.samples[blockSize:]
+ }
+ }
+ b.idleAt = time.Now().Add(talkIdleTimeout)
+ if b.idle == nil {
+ b.idle = time.AfterFunc(talkIdleTimeout, b.closeIdle)
+ } else {
+ b.idle.Reset(talkIdleTimeout)
+ }
+ return nil
+}
+
+func (b *backchannel) open() error {
+ if b.talk != nil {
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(b.producer.ctx, baichuan.DefaultTimeout)
+ dial := b.dial
+ if dial == nil {
+ dial = dialTalk
+ }
+ talk, client, err := dial(ctx, b.producer.config, b.producer.channel)
+ if err == nil {
+ if format := talk.Format(); format != b.format {
+ err = fmt.Errorf("reolink: talkback format changed from %+v to %+v", b.format, format)
+ } else {
+ b.client = client
+ b.talk = talk
+ b.encoder = baichuan.ADPCMEncoder{}
+ }
+ }
+ cancel()
+ if err != nil {
+ var cleanup error
+ if talk != nil {
+ closeCtx, closeCancel := context.WithTimeout(context.WithoutCancel(b.producer.ctx), baichuan.DefaultTimeout)
+ cleanup = errors.Join(cleanup, talk.Close(closeCtx))
+ closeCancel()
+ }
+ if client != nil {
+ cleanup = errors.Join(cleanup, client.Close())
+ }
+ return fmt.Errorf("reolink: start talkback: %w", errors.Join(err, cleanup))
+ }
+ return nil
+}
+
+func dialTalk(ctx context.Context, config baichuan.Config, channel uint8) (talkSession, io.Closer, error) {
+ client, err := baichuan.Dial(ctx, config)
+ if err != nil {
+ return nil, nil, err
+ }
+ talk, err := client.StartTalk(ctx, channel)
+ if err != nil {
+ return nil, client, err
+ }
+ return talk, client, nil
+}
+
+func (b *backchannel) closeIdle() {
+ b.mu.Lock()
+ if b.closed {
+ b.mu.Unlock()
+ return
+ }
+ if wait := time.Until(b.idleAt); wait > 0 {
+ b.idle.Reset(wait)
+ b.mu.Unlock()
+ return
+ }
+ err := b.closeSession()
+ b.mu.Unlock()
+ if err != nil && b.producer.ctx.Err() == nil {
+ b.producer.fail(fmt.Errorf("reolink: stop idle talkback: %w", err))
+ }
+}
+
+func (b *backchannel) closeSession() error {
+ if b.talk == nil {
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.WithoutCancel(b.producer.ctx), baichuan.DefaultTimeout)
+ err := b.talk.Close(ctx)
+ cancel()
+ err = errors.Join(err, b.client.Close())
+ b.client = nil
+ b.talk = nil
+ b.samples = b.samples[:0]
+ return err
+}
+
+func (b *backchannel) Close() error {
+ b.mu.Lock()
+ if b.closed {
+ b.mu.Unlock()
+ return nil
+ }
+ b.closed = true
+ if b.idle != nil {
+ b.idle.Stop()
+ }
+ b.mu.Unlock()
+
+ if b.sender != nil {
+ b.sender.Close()
+ b.sender.Wait()
+ }
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.closeSession()
+}
diff --git a/pkg/reolink/backchannel_test.go b/pkg/reolink/backchannel_test.go
new file mode 100644
index 000000000..26f62580f
--- /dev/null
+++ b/pkg/reolink/backchannel_test.go
@@ -0,0 +1,252 @@
+package reolink
+
+import (
+ "context"
+ "errors"
+ "io"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/AlexxIT/go2rtc/pkg/baichuan"
+ "github.com/AlexxIT/go2rtc/pkg/core"
+ "github.com/pion/rtp"
+)
+
+type fakeTalk struct {
+ format baichuan.TalkFormat
+ blocks chan []byte
+ writeErr error
+ closes int
+}
+
+func (t *fakeTalk) Format() baichuan.TalkFormat { return t.format }
+func (t *fakeTalk) WriteBlock(_ context.Context, block []byte) error {
+ if t.writeErr != nil {
+ return t.writeErr
+ }
+ t.blocks <- append([]byte(nil), block...)
+ return nil
+}
+func (t *fakeTalk) Close(context.Context) error {
+ t.closes++
+ return nil
+}
+
+type fakeTalkClient struct {
+ closes int
+}
+
+type fakeTalkPair struct {
+ talk *fakeTalk
+ client *fakeTalkClient
+}
+
+func (c *fakeTalkClient) Close() error {
+ c.closes++
+ return nil
+}
+
+func TestBackchannelAddTrackReplacementAndIdleReopen(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ p := &Producer{ctx: ctx, cancel: cancel}
+ format := baichuan.TalkFormat{SampleRate: 16000, SamplePrecision: 16, SamplesPerBlock: 4}
+ opened := make(chan fakeTalkPair, 2)
+ b := &backchannel{producer: p, format: format}
+ b.dial = func(context.Context, baichuan.Config, uint8) (talkSession, io.Closer, error) {
+ talk := &fakeTalk{format: format, blocks: make(chan []byte, 1)}
+ client := &fakeTalkClient{}
+ opened <- fakeTalkPair{talk: talk, client: client}
+ return talk, client, nil
+ }
+ p.talk = b
+
+ codec := &core.Codec{Name: core.CodecPCMA, ClockRate: 8000, Channels: 1}
+ media := &core.Media{Kind: core.KindAudio, Direction: core.DirectionSendonly, Codecs: []*core.Codec{codec}}
+ first := core.NewReceiver(media, codec)
+ if err := p.AddTrack(media, codec, first); err != nil {
+ t.Fatal(err)
+ }
+ first.WriteRTP(&rtp.Packet{Payload: []byte{0xd5, 0xd5}})
+ firstPair := wantTalkOpen(t, opened)
+ wantBlock(t, firstPair.talk, format.BytesPerBlock())
+
+ second := core.NewReceiver(media, codec)
+ if err := p.AddTrack(media, codec, second); err != nil {
+ t.Fatal(err)
+ }
+ second.WriteRTP(&rtp.Packet{Payload: []byte{0xd5, 0xd5}})
+ wantBlock(t, firstPair.talk, format.BytesPerBlock())
+ select {
+ case <-opened:
+ t.Fatal("sender replacement reopened talk session")
+ default:
+ }
+
+ b.mu.Lock()
+ b.idleAt = time.Now().Add(-time.Second)
+ b.mu.Unlock()
+ b.closeIdle()
+ if firstPair.talk.closes != 1 || firstPair.client.closes != 1 {
+ t.Fatalf("idle close did not release session: talk=%d client=%d", firstPair.talk.closes, firstPair.client.closes)
+ }
+ second.WriteRTP(&rtp.Packet{Payload: []byte{0xd5, 0xd5}})
+ secondPair := wantTalkOpen(t, opened)
+ wantBlock(t, secondPair.talk, format.BytesPerBlock())
+
+ cancel()
+ if err := b.Close(); err != nil {
+ t.Fatal(err)
+ }
+ first.Close()
+ second.Close()
+ if secondPair.talk.closes != 1 || secondPair.client.closes != 1 {
+ t.Fatalf("close did not release reopened session: talk=%d client=%d", secondPair.talk.closes, secondPair.client.closes)
+ }
+}
+
+func TestBackchannelWriteFailureCancelsProducer(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ p := &Producer{ctx: ctx, cancel: cancel}
+ format := baichuan.TalkFormat{SampleRate: 16000, SamplePrecision: 16, SamplesPerBlock: 4}
+ b := &backchannel{producer: p, format: format}
+ b.dial = func(context.Context, baichuan.Config, uint8) (talkSession, io.Closer, error) {
+ return &fakeTalk{
+ format: format, blocks: make(chan []byte, 1), writeErr: errors.New("sentinel write"),
+ }, &fakeTalkClient{}, nil
+ }
+ p.talk = b
+ codec := &core.Codec{Name: core.CodecPCMA, ClockRate: 8000, Channels: 1}
+ media := &core.Media{Kind: core.KindAudio, Direction: core.DirectionSendonly, Codecs: []*core.Codec{codec}}
+ track := core.NewReceiver(media, codec)
+ if err := p.AddTrack(media, codec, track); err != nil {
+ t.Fatal(err)
+ }
+ track.WriteRTP(&rtp.Packet{Payload: []byte{0xd5, 0xd5}})
+ select {
+ case <-p.ctx.Done():
+ case <-time.After(time.Second):
+ t.Fatal("talk write failure did not cancel producer")
+ }
+ if err := p.terminalError(context.Canceled); err == nil || !strings.Contains(err.Error(), "sentinel write") {
+ t.Fatalf("unexpected terminal error: %v", err)
+ }
+ if err := b.Close(); err != nil {
+ t.Fatal(err)
+ }
+ track.Close()
+}
+
+func TestBackchannelDialFailureClosesClient(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ p := &Producer{ctx: ctx, cancel: cancel}
+ format := baichuan.TalkFormat{SampleRate: 16000, SamplePrecision: 16, SamplesPerBlock: 4}
+ client := &fakeTalkClient{}
+ b := &backchannel{producer: p, format: format}
+ b.dial = func(context.Context, baichuan.Config, uint8) (talkSession, io.Closer, error) {
+ return nil, client, errors.New("sentinel dial")
+ }
+ if err := b.setCodec(&core.Codec{Name: core.CodecPCMA, ClockRate: 8000, Channels: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if err := b.Write([]byte{0xd5, 0xd5}); err == nil || !strings.Contains(err.Error(), "sentinel dial") {
+ t.Fatalf("unexpected dial error: %v", err)
+ }
+ if client.closes != 1 {
+ t.Fatalf("failed dial client close count: %d", client.closes)
+ }
+}
+
+func TestBackchannelLinearPCM(t *testing.T) {
+ for _, codec := range talkCodecs() {
+ if codec.Name != core.CodecPCM && codec.Name != core.CodecPCML {
+ continue
+ }
+ t.Run(codec.String(), func(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ format := baichuan.TalkFormat{SampleRate: 16000, SamplePrecision: 16, SamplesPerBlock: 4}
+ talk := &fakeTalk{format: format, blocks: make(chan []byte, 1)}
+ client := &fakeTalkClient{}
+ b := &backchannel{
+ producer: &Producer{ctx: ctx, cancel: cancel}, format: format, talk: talk, client: client,
+ }
+ if err := b.setCodec(codec); err != nil {
+ t.Fatal(err)
+ }
+ inputSamples := (uint64(format.SamplesPerBlock)*uint64(codec.ClockRate) +
+ uint64(format.SampleRate) - 1) / uint64(format.SampleRate)
+ if err := b.Write(make([]byte, int(inputSamples)*2)); err != nil {
+ t.Fatal(err)
+ }
+ wantBlock(t, talk, format.BytesPerBlock())
+ if err := b.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if talk.closes != 1 || client.closes != 1 {
+ t.Fatalf("session wasn't closed: talk=%d client=%d", talk.closes, client.closes)
+ }
+ })
+ }
+}
+
+func TestTalkCodecSupport(t *testing.T) {
+ tests := []struct {
+ name string
+ rate uint32
+ }{
+ {core.CodecPCMA, 8000}, {core.CodecPCMU, 8000},
+ {core.CodecPCML, 8000}, {core.CodecPCM, 8000},
+ {core.CodecPCML, 16000}, {core.CodecPCM, 16000},
+ }
+ for _, test := range tests {
+ codec := &core.Codec{Name: test.name, ClockRate: test.rate, Channels: 1}
+ if !talkCodecSupported(codec) {
+ t.Fatalf("unsupported parent PCM codec %s", codec)
+ }
+ }
+ if talkCodecSupported(&core.Codec{Name: core.CodecPCML, ClockRate: 44100, Channels: 1}) {
+ t.Fatal("accepted unadvertised PCM clock rate")
+ }
+}
+
+func TestBackchannelRejectsInvalidPCM(t *testing.T) {
+ b := &backchannel{
+ producer: &Producer{ctx: context.Background()},
+ format: baichuan.TalkFormat{SampleRate: 16000, SamplesPerBlock: 4},
+ }
+ if err := b.setCodec(&core.Codec{Name: core.CodecPCML, ClockRate: 16000, Channels: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if err := b.Write([]byte{0}); err == nil || !strings.Contains(err.Error(), "odd length") {
+ t.Fatalf("unexpected odd PCM result: %v", err)
+ }
+ if talkCodecSupported(&core.Codec{Name: core.CodecPCML}) {
+ t.Fatal("accepted PCM without a clock rate")
+ }
+}
+
+func wantTalkOpen(t *testing.T, opened <-chan fakeTalkPair) fakeTalkPair {
+ t.Helper()
+ select {
+ case pair := <-opened:
+ return pair
+ case <-time.After(time.Second):
+ t.Fatal("talk session was not opened")
+ return fakeTalkPair{}
+ }
+}
+
+func wantBlock(t *testing.T, talk *fakeTalk, size int) {
+ t.Helper()
+ select {
+ case block := <-talk.blocks:
+ if len(block) != size {
+ t.Fatalf("unexpected ADPCM block size: %d", len(block))
+ }
+ case <-time.After(time.Second):
+ t.Fatal("talk block was not written")
+ }
+}
diff --git a/pkg/reolink/camera.go b/pkg/reolink/camera.go
new file mode 100644
index 000000000..f4f4c47a4
--- /dev/null
+++ b/pkg/reolink/camera.go
@@ -0,0 +1,92 @@
+package reolink
+
+import (
+ "fmt"
+
+ "github.com/AlexxIT/go2rtc/pkg/baichuan"
+)
+
+type ProfileKey struct {
+ Channel uint8
+ Stream baichuan.Stream
+}
+
+type Camera struct {
+ registry *Registry
+ key cameraKey
+ remote string
+ profiles map[*Profile]struct{}
+ epochs map[ProfileKey]mediaEpoch
+ refs int
+ generation uint64
+ mainSessions int
+ otherSessions int
+}
+
+type mediaEpoch struct {
+ video uint32
+ audio uint32
+ audioRate uint32
+ videoSet bool
+ audioSet bool
+}
+
+func (c *Camera) nextEpoch(key ProfileKey, now uint32) mediaEpoch {
+ epoch := c.epochs[key]
+ epoch.video = nextTimestampEpoch(now, epoch.video, epoch.videoSet)
+ epoch.videoSet = true
+ return epoch
+}
+
+func (c *Camera) recordEpoch(key ProfileKey, value mediaEpoch) {
+ previous := c.epochs[key]
+ if value.videoSet && (!previous.videoSet || timestampAfter(value.video, previous.video)) {
+ previous.video = value.video
+ previous.videoSet = true
+ }
+ if value.audioSet && (!previous.audioSet || value.audioRate != previous.audioRate ||
+ timestampAfter(value.audio, previous.audio)) {
+ previous.audio = value.audio
+ previous.audioRate = value.audioRate
+ previous.audioSet = true
+ }
+ c.epochs[key] = previous
+}
+
+func timestampAfter(value, previous uint32) bool {
+ delta := value - previous
+ return delta != 0 && delta < 1<<31
+}
+
+func nextTimestampEpoch(now, previous uint32, set bool) uint32 {
+ if set && !timestampAfter(now, previous) {
+ return previous + 1
+ }
+ return now
+}
+
+func (c *Camera) Format(state fmt.State, _ rune) {
+ _, _ = fmt.Fprintf(state, "reolink.Camera{Remote:%q}", c.remote)
+}
+
+func (c *Camera) addSession(stream baichuan.Stream) {
+ if stream == baichuan.StreamMain {
+ c.mainSessions++
+ return
+ }
+ c.otherSessions++
+}
+
+func (c *Camera) removeSession(stream baichuan.Stream) {
+ if stream == baichuan.StreamMain {
+ if c.mainSessions == 0 {
+ panic("reolink: main session accounting underflow")
+ }
+ c.mainSessions--
+ return
+ }
+ if c.otherSessions == 0 {
+ panic("reolink: other session accounting underflow")
+ }
+ c.otherSessions--
+}
diff --git a/pkg/reolink/config.go b/pkg/reolink/config.go
new file mode 100644
index 000000000..22c170b86
--- /dev/null
+++ b/pkg/reolink/config.go
@@ -0,0 +1,193 @@
+package reolink
+
+import (
+ "fmt"
+ "net"
+ "net/netip"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/AlexxIT/go2rtc/pkg/baichuan"
+ "github.com/AlexxIT/go2rtc/pkg/creds"
+)
+
+type source struct {
+ config baichuan.Config
+ channel uint8
+ stream baichuan.Stream
+ remote string
+ backchannel bool
+ suppressVideo bool
+ suppressAudio bool
+ username string
+ password string
+ identity string
+ protocol string
+}
+
+func (s source) Format(state fmt.State, _ rune) {
+ _, _ = fmt.Fprintf(state, "reolink.source{Remote:%q, Channel:%d, Stream:%q, Backchannel:%t}",
+ s.remote, s.channel, s.stream, s.backchannel)
+}
+
+func parseURL(rawURL string) (source, error) {
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return source{}, fmt.Errorf("reolink: invalid source URL")
+ }
+ if u.Scheme != "reolink" || u.Hostname() == "" || u.Fragment != "" || u.User == nil || u.User.Username() == "" {
+ return source{}, fmt.Errorf("reolink: invalid source")
+ }
+ username := u.User.Username()
+ password, _ := u.User.Password()
+ creds.AddSecret(username)
+ creds.AddSecret(password)
+
+ query, err := url.ParseQuery(u.RawQuery)
+ if err != nil {
+ return source{}, fmt.Errorf("reolink: invalid query")
+ }
+ if query.Get("transport") == "uid" {
+ creds.AddSecret(u.Hostname())
+ }
+ for key, values := range query {
+ if key != "channel" && key != "stream" && key != "backchannel" && key != "transport" &&
+ key != "local" && key != "broadcast" && key != "video" && key != "audio" || len(values) != 1 {
+ return source{}, fmt.Errorf("reolink: invalid query parameter")
+ }
+ }
+
+ s := source{
+ username: username, password: password,
+ stream: baichuan.StreamMain, backchannel: true,
+ }
+ switch query.Get("transport") {
+ case "", "tcp":
+ if query.Get("local") != "" || query.Get("broadcast") != "" {
+ return source{}, fmt.Errorf("reolink: discovery addresses require UID transport")
+ }
+ host := canonicalHost(u.Hostname())
+ s.config = baichuan.NewConfig(host, username, password)
+ if value := u.Port(); value != "" {
+ port, err := strconv.ParseUint(value, 10, 16)
+ if err != nil || port == 0 {
+ return source{}, fmt.Errorf("reolink: invalid port")
+ }
+ s.config.Port = uint16(port)
+ }
+ port := s.config.Port
+ if port == 0 {
+ port = baichuan.DefaultPort
+ s.config.Port = port
+ }
+ s.remote = net.JoinHostPort(host, strconv.Itoa(int(port)))
+ s.protocol = "tcp"
+ case "uid":
+ if u.Port() != "" {
+ return source{}, fmt.Errorf("reolink: UID transport does not use a port")
+ }
+ uid := u.Hostname()
+ s.config = baichuan.NewUIDConfig(uid, username, password)
+ s.config.UIDLocalAddr = query.Get("local")
+ if s.config.UIDLocalAddr != "" {
+ address, err := netip.ParseAddr(s.config.UIDLocalAddr)
+ if err != nil || !address.Is4() || address.IsLoopback() ||
+ !address.IsGlobalUnicast() && !address.IsLinkLocalUnicast() {
+ return source{}, fmt.Errorf("reolink: invalid local address")
+ }
+ s.config.UIDLocalAddr = address.String()
+ }
+ s.config.UIDBroadcastAddr = query.Get("broadcast")
+ if s.config.UIDBroadcastAddr != "" {
+ address, err := netip.ParseAddr(s.config.UIDBroadcastAddr)
+ if err != nil || !address.Is4() || address.IsUnspecified() ||
+ address.IsLoopback() || address.IsMulticast() {
+ return source{}, fmt.Errorf("reolink: invalid broadcast address")
+ }
+ s.config.UIDBroadcastAddr = address.String()
+ }
+ s.identity = uid
+ s.remote = "uid"
+ s.protocol = "udp"
+ default:
+ return source{}, fmt.Errorf("reolink: invalid transport")
+ }
+ channel, err := strconv.ParseUint(defaultValue(query.Get("channel"), "0"), 10, 8)
+ if err != nil {
+ return source{}, fmt.Errorf("reolink: invalid channel")
+ }
+ s.channel = uint8(channel)
+
+ path := strings.TrimPrefix(u.Path, "/")
+ if strings.Contains(path, "/") || path != "" && query.Get("stream") != "" {
+ return source{}, fmt.Errorf("reolink: invalid stream")
+ }
+ name := query.Get("stream")
+ if path != "" {
+ name = path
+ }
+ s.stream, err = parseStream(name)
+ if err != nil {
+ return source{}, err
+ }
+ switch query.Get("backchannel") {
+ case "", "1":
+ case "0":
+ s.backchannel = false
+ default:
+ return source{}, fmt.Errorf("reolink: invalid backchannel")
+ }
+ var enabled bool
+ if enabled, err = parseEnabled(query.Get("video")); err != nil {
+ return source{}, fmt.Errorf("reolink: invalid video")
+ }
+ s.suppressVideo = !enabled
+ if enabled, err = parseEnabled(query.Get("audio")); err != nil {
+ return source{}, fmt.Errorf("reolink: invalid audio")
+ }
+ s.suppressAudio = !enabled
+ if s.suppressVideo && s.suppressAudio && !s.backchannel {
+ return source{}, fmt.Errorf("reolink: source has no enabled media")
+ }
+
+ return s, nil
+}
+
+func parseEnabled(value string) (bool, error) {
+ switch value {
+ case "", "1", "true":
+ return true, nil
+ case "0", "false":
+ return false, nil
+ default:
+ return false, fmt.Errorf("invalid boolean")
+ }
+}
+
+func canonicalHost(host string) string {
+ if ip := net.ParseIP(host); ip != nil {
+ return ip.String()
+ }
+ return strings.TrimSuffix(strings.ToLower(host), ".")
+}
+
+func parseStream(value string) (baichuan.Stream, error) {
+ switch strings.ToLower(value) {
+ case "", "main", strings.ToLower(string(baichuan.StreamMain)):
+ return baichuan.StreamMain, nil
+ case "sub", strings.ToLower(string(baichuan.StreamSub)):
+ return baichuan.StreamSub, nil
+ case "extern", "ext", strings.ToLower(string(baichuan.StreamExtern)):
+ return baichuan.StreamExtern, nil
+ default:
+ return "", fmt.Errorf("reolink: invalid stream")
+ }
+}
+
+func defaultValue(value, fallback string) string {
+ if value == "" {
+ return fallback
+ }
+ return value
+}
diff --git a/pkg/reolink/media.go b/pkg/reolink/media.go
new file mode 100644
index 000000000..49f8c004e
--- /dev/null
+++ b/pkg/reolink/media.go
@@ -0,0 +1,360 @@
+package reolink
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/AlexxIT/go2rtc/pkg/aac"
+ "github.com/AlexxIT/go2rtc/pkg/baichuan"
+ "github.com/AlexxIT/go2rtc/pkg/core"
+ "github.com/AlexxIT/go2rtc/pkg/h264"
+ "github.com/AlexxIT/go2rtc/pkg/h264/annexb"
+ "github.com/AlexxIT/go2rtc/pkg/h265"
+ "github.com/AlexxIT/go2rtc/pkg/pcm"
+ "github.com/pion/rtp"
+)
+
+const (
+ maxProbeBytes = 32 << 20
+ maxProbePackets = 4096
+ maxTimestampStep = uint32(30 * time.Second / time.Microsecond)
+)
+
+type mediaError struct {
+ err error
+}
+
+func (e *mediaError) Error() string {
+ return e.err.Error()
+}
+
+func (e *mediaError) Unwrap() error {
+ return e.err
+}
+
+func mediaErrorf(format string, args ...any) error {
+ return &mediaError{err: fmt.Errorf(format, args...)}
+}
+
+type mediaTrack uint8
+
+const (
+ trackVideo mediaTrack = iota
+ trackAudio
+)
+
+type mediaFrame struct {
+ packet *rtp.Packet
+ track mediaTrack
+ samples uint32
+}
+
+func (f mediaFrame) valid() bool {
+ return f.packet != nil
+}
+
+type mediaPipeline struct {
+ medias []*core.Media
+ epoch mediaEpoch
+
+ videoCodec string
+ videoConfig string
+ videoParams string
+ videoCandidate string
+ videoResync bool
+ videoMedia *core.Media
+ videoTS mediaClock
+
+ audioKind baichuan.MediaKind
+ audioConfig uint16
+ audioMedia *core.Media
+ audioTS uint32
+ audioRate uint32
+}
+
+func (m *mediaPipeline) configureVideo(packet baichuan.MediaPacket) error {
+ payload := annexb.EncodeToAVCC(packet.Data)
+ if !validVideoConfig(packet.Codec, payload) {
+ return mediaErrorf("reolink: invalid %s keyframe", packet.Codec)
+ }
+ return m.configureVideoPayload(packet.Codec, payload)
+}
+
+func (m *mediaPipeline) probeVideo(packet baichuan.MediaPacket) error {
+ payload := annexb.EncodeToAVCC(packet.Data)
+ if packet.Codec != "H264" && packet.Codec != "H265" {
+ return mediaErrorf("reolink: unsupported video codec %q", packet.Codec)
+ }
+ if !validVideoConfig(packet.Codec, payload) {
+ return nil
+ }
+ return m.configureVideoPayload(packet.Codec, payload)
+}
+
+func (m *mediaPipeline) configureVideoPayload(name string, payload []byte) error {
+ var codec *core.Codec
+ switch name {
+ case "H264":
+ params, err := h264ParameterSignature(payload)
+ if err != nil {
+ return err
+ }
+ m.videoParams = params
+ codec = &core.Codec{
+ Name: core.CodecH264, ClockRate: 90000, PayloadType: core.PayloadTypeRAW,
+ FmtpLine: h264.GetFmtpLine(payload),
+ }
+ case "H265":
+ codec = h265.AVCCToCodec(payload)
+ if codec == nil {
+ return mediaErrorf("reolink: invalid H265 keyframe")
+ }
+ default:
+ return mediaErrorf("reolink: unsupported video codec %q", name)
+ }
+ m.videoMedia = &core.Media{
+ Kind: core.KindVideo, Direction: core.DirectionRecvonly, Codecs: []*core.Codec{codec},
+ }
+ m.videoCodec = name
+ m.videoConfig = codec.FmtpLine
+ m.videoTS.value = m.epoch.video
+ m.medias = append(m.medias, m.videoMedia)
+ return nil
+}
+
+func (m *mediaPipeline) configureAudio(packet baichuan.MediaPacket) error {
+ var codec *core.Codec
+ switch packet.Kind {
+ case baichuan.MediaAAC:
+ codec = aac.ADTSToCodec(packet.Data)
+ config, ok := aacConfig(packet.Data)
+ if codec == nil || codec.ClockRate == 0 || codec.Channels == 0 || !ok {
+ return mediaErrorf("reolink: invalid AAC header")
+ }
+ codec.PayloadType = core.PayloadTypeRAW
+ m.audioConfig = config
+ case baichuan.MediaADPCM:
+ if _, err := baichuan.DecodeADPCMBlock(packet.Data); err != nil {
+ return mediaErrorf("reolink: invalid ADPCM audio: %w", err)
+ }
+ codec = &core.Codec{
+ Name: core.CodecPCMA, ClockRate: 8000, Channels: 1, PayloadType: core.PayloadTypeRAW,
+ }
+ default:
+ return mediaErrorf("reolink: unsupported audio kind %d", packet.Kind)
+ }
+ m.audioMedia = &core.Media{
+ Kind: core.KindAudio, Direction: core.DirectionRecvonly, Codecs: []*core.Codec{codec},
+ }
+ m.audioKind = packet.Kind
+ m.audioTS = nowTimestamp(codec.ClockRate)
+ if m.epoch.audioSet && m.epoch.audioRate == codec.ClockRate {
+ m.audioTS = nextTimestampEpoch(m.audioTS, m.epoch.audio, true)
+ }
+ m.audioRate = codec.ClockRate
+ m.medias = append(m.medias, m.audioMedia)
+ return nil
+}
+
+func (m *mediaPipeline) frame(packet baichuan.MediaPacket) (mediaFrame, error) {
+ switch packet.Kind {
+ case baichuan.MediaVideoI, baichuan.MediaVideoP:
+ return m.videoFrame(packet)
+ case baichuan.MediaAAC, baichuan.MediaADPCM:
+ return m.audioFrame(packet)
+ default:
+ return mediaFrame{}, nil
+ }
+}
+
+func (m *mediaPipeline) videoFrame(packet baichuan.MediaPacket) (mediaFrame, error) {
+ if packet.Codec != m.videoCodec {
+ return mediaFrame{}, mediaErrorf("reolink: video codec changed from %s to %s", m.videoCodec, packet.Codec)
+ }
+ payload := encodeOwnedToAVCC(packet.Data)
+ config, err := inspectVideoPayload(packet.Codec, payload)
+ if err != nil {
+ m.suppressInvalidVideo()
+ return mediaFrame{}, nil
+ }
+ if packet.Kind == baichuan.MediaVideoI {
+ if !config {
+ m.suppressInvalidVideo()
+ return mediaFrame{}, nil
+ }
+ same, candidate, err := m.videoParameters(packet.Codec, payload)
+ if err != nil {
+ m.suppressInvalidVideo()
+ return mediaFrame{}, nil
+ }
+ if !same {
+ if candidate == m.videoCandidate {
+ return mediaFrame{}, mediaErrorf("reolink: %s parameter sets changed", packet.Codec)
+ }
+ m.videoCandidate = candidate
+ m.videoResync = true
+ return mediaFrame{}, nil
+ }
+ m.videoCandidate = ""
+ if m.videoResync {
+ m.videoResync = false
+ }
+ }
+ if m.videoResync {
+ return mediaFrame{}, nil
+ }
+ timestamp, err := m.videoTS.next(packet.Timestamp, 90000)
+ if err != nil {
+ return mediaFrame{}, mediaErrorf("reolink: video timestamp: %w", err)
+ }
+ return mediaFrame{
+ track: trackVideo,
+ packet: &rtp.Packet{Header: rtp.Header{Timestamp: timestamp}, Payload: payload},
+ }, nil
+}
+
+func (m *mediaPipeline) suppressInvalidVideo() {
+ m.videoCandidate = ""
+ m.videoResync = true
+}
+
+func (m *mediaPipeline) videoParameters(codec string, payload []byte) (same bool, candidate string, err error) {
+ if codec == "H264" {
+ params, sigErr := h264ParameterSignature(payload)
+ if sigErr != nil {
+ return false, "", sigErr
+ }
+ return params == m.videoParams, params, nil
+ }
+ parsed := h265.AVCCToCodec(payload)
+ if parsed == nil {
+ return false, "", mediaErrorf("reolink: invalid H265 keyframe")
+ }
+ return parsed.FmtpLine == m.videoConfig, parsed.FmtpLine, nil
+}
+
+func (m *mediaPipeline) audioFrame(packet baichuan.MediaPacket) (mediaFrame, error) {
+ if m.audioKind == 0 {
+ return mediaFrame{}, nil
+ }
+ if packet.Kind != m.audioKind {
+ return mediaFrame{}, mediaErrorf("reolink: audio kind changed from %d to %d", m.audioKind, packet.Kind)
+ }
+ if packet.Kind == baichuan.MediaAAC {
+ config, ok := aacConfig(packet.Data)
+ if !ok {
+ return mediaFrame{}, mediaErrorf("reolink: invalid AAC packet")
+ }
+ if config != m.audioConfig {
+ return mediaFrame{}, mediaErrorf("reolink: AAC configuration changed")
+ }
+ }
+ payload, samples, err := audioPayload(packet)
+ if err != nil {
+ return mediaFrame{}, err
+ }
+ frame := mediaFrame{
+ track: trackAudio,
+ packet: &rtp.Packet{Header: rtp.Header{Timestamp: m.audioTS}, Payload: payload},
+ samples: samples,
+ }
+ m.audioTS += samples
+ return frame, nil
+}
+
+type mediaProbe struct {
+ packets []baichuan.MediaPacket
+ bytes int
+}
+
+func (p *mediaProbe) retain(packet baichuan.MediaPacket) error {
+ if packet.Kind == baichuan.MediaVideoI {
+ p.reset()
+ }
+ if len(packet.Data) > maxProbeBytes-p.bytes {
+ return mediaErrorf("reolink: codec probe exceeds %d bytes", maxProbeBytes)
+ }
+ if len(p.packets) >= maxProbePackets {
+ return mediaErrorf("reolink: codec probe exceeds %d packets", maxProbePackets)
+ }
+ packet.Data = append([]byte(nil), packet.Data...)
+ p.packets = append(p.packets, packet)
+ p.bytes += len(packet.Data)
+ return nil
+}
+
+func (p *mediaProbe) reset() {
+ clear(p.packets)
+ p.packets = p.packets[:0]
+ p.bytes = 0
+}
+
+func (p *mediaProbe) release() {
+ p.reset()
+ p.packets = nil
+}
+
+func aacConfig(data []byte) (uint16, bool) {
+ if !aac.IsADTS(data) {
+ return 0, false
+ }
+ return uint16(data[2]&0xfd)<<8 | uint16(data[3]&0xc0), true
+}
+
+func audioPayload(packet baichuan.MediaPacket) ([]byte, uint32, error) {
+ if packet.Kind == baichuan.MediaADPCM {
+ samples, err := baichuan.DecodeADPCMBlock(packet.Data)
+ if err != nil {
+ return nil, 0, mediaErrorf("reolink: invalid ADPCM packet: %w", err)
+ }
+ payload := make([]byte, len(samples))
+ for i, sample := range samples {
+ payload[i] = pcm.PCMtoPCMA(sample)
+ }
+ return payload, uint32(len(samples)), nil
+ }
+ if packet.Kind != baichuan.MediaAAC || !aac.IsADTS(packet.Data) {
+ return nil, 0, mediaErrorf("reolink: invalid AAC packet")
+ }
+ size := int(aac.ReadADTSSize(packet.Data))
+ header := aac.ADTSHeaderLen(packet.Data)
+ if size != len(packet.Data) || header >= size {
+ return nil, 0, mediaErrorf("reolink: invalid AAC packet size")
+ }
+ samples := uint32(packet.Data[6]&3+1) * 1024
+ return packet.Data[header:size], samples, nil
+}
+
+type mediaClock struct {
+ set bool
+ last uint32
+ value uint32
+ remainder uint64
+}
+
+func (c *mediaClock) next(timestamp, rate uint32) (uint32, error) {
+ if !c.set {
+ c.set = true
+ c.last = timestamp
+ return c.value, nil
+ }
+ delta := timestamp - c.last
+ if delta >= 1<<31 || delta > maxTimestampStep {
+ return c.value, fmt.Errorf("camera clock discontinuity: %d to %d microseconds", c.last, timestamp)
+ }
+ c.last = timestamp
+ scaled := uint64(delta)*uint64(rate) + c.remainder
+ c.value += uint32(scaled / 1_000_000)
+ c.remainder = scaled % 1_000_000
+ return c.value, nil
+}
+
+func (c *mediaClock) current() (uint32, bool) {
+ return c.value, c.set
+}
+
+func nowTimestamp(rate uint32) uint32 {
+ now := uint64(time.Now().UnixNano())
+ return uint32(now/uint64(time.Second)*uint64(rate) +
+ now%uint64(time.Second)*uint64(rate)/uint64(time.Second))
+}
diff --git a/pkg/reolink/producer.go b/pkg/reolink/producer.go
new file mode 100644
index 000000000..9ff9aa44d
--- /dev/null
+++ b/pkg/reolink/producer.go
@@ -0,0 +1,198 @@
+package reolink
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "sync"
+ "sync/atomic"
+
+ "github.com/AlexxIT/go2rtc/pkg/baichuan"
+ "github.com/AlexxIT/go2rtc/pkg/core"
+)
+
+type Producer struct {
+ core.Connection
+
+ profile *Profile
+ config baichuan.Config
+ channel uint8
+ ctx context.Context
+ cancel context.CancelFunc
+ stop sync.Once
+ stopErr error
+ send atomic.Uint64
+
+ failureMu sync.Mutex
+ failure error
+ talkMu sync.Mutex
+ talk *backchannel
+}
+
+func (p *Producer) Format(state fmt.State, _ rune) {
+ profile := p.profile
+ if profile == nil {
+ _, _ = fmt.Fprintf(state, "reolink.Producer{ID:%d}", p.ID)
+ return
+ }
+ _, _ = fmt.Fprintf(state, "reolink.Producer{ID:%d, Remote:%q, Channel:%d, Stream:%q, Generation:%d}",
+ p.ID, p.RemoteAddr, profile.key.Channel, profile.key.Stream, profile.generation)
+}
+
+var _ core.Producer = (*Producer)(nil)
+var _ core.Consumer = (*Producer)(nil)
+
+func newProducer(s source, profile *Profile, talkFormat *baichuan.TalkFormat) *Producer {
+ ctx, cancel := context.WithCancel(context.Background())
+ p := &Producer{
+ Connection: core.Connection{
+ ID: core.NewID(), FormatName: "baichuan", Protocol: s.protocol, RemoteAddr: s.remote,
+ },
+ profile: profile, config: s.config, channel: s.channel,
+ ctx: ctx, cancel: cancel,
+ }
+ p.Medias = append(p.Medias, profile.pipeline.medias...)
+ p.Receivers = append(p.Receivers, profile.receivers...)
+ if talkFormat != nil {
+ p.talk = &backchannel{producer: p, format: *talkFormat}
+ p.Medias = append(p.Medias, &core.Media{
+ Kind: core.KindAudio, Direction: core.DirectionSendonly,
+ Codecs: talkCodecs(),
+ })
+ }
+ return p
+}
+
+func (p *Producer) Start() error {
+ p.profile.startProfile()
+ select {
+ case <-p.profile.done:
+ if err := p.ctx.Err(); err != nil {
+ return p.terminalError(err)
+ }
+ return p.terminalError(p.profile.err())
+ case <-p.ctx.Done():
+ <-p.profile.done
+ return p.terminalError(p.ctx.Err())
+ }
+}
+
+func (p *Producer) fail(err error) {
+ if err == nil {
+ return
+ }
+ p.failureMu.Lock()
+ if p.failure == nil {
+ p.failure = err
+ p.cancel()
+ if p.profile != nil {
+ p.profile.cancel()
+ }
+ }
+ p.failureMu.Unlock()
+}
+
+func (p *Producer) terminalError(fallback error) error {
+ p.failureMu.Lock()
+ defer p.failureMu.Unlock()
+ if p.failure != nil {
+ return p.failure
+ }
+ if fallback != nil {
+ return fallback
+ }
+ return errors.New("reolink: profile stopped")
+}
+
+func (p *Producer) Stop() error {
+ p.stop.Do(func() {
+ p.cancel()
+ p.profile.camera.registry.release(p.profile)
+ p.talkMu.Lock()
+ if p.talk != nil {
+ p.stopErr = errors.Join(p.stopErr, p.talk.Close())
+ }
+ p.talkMu.Unlock()
+ <-p.profile.done
+ p.stopErr = errors.Join(p.stopErr, p.profile.closeError())
+ p.profile.closeReceivers()
+ })
+ return p.stopErr
+}
+
+func (p *Producer) addSend(size int) {
+ p.send.Add(uint64(size))
+}
+
+type producerDiagnostics struct {
+ ID uint32 `json:"id,omitempty"`
+ FormatName string `json:"format_name,omitempty"`
+ Protocol string `json:"protocol,omitempty"`
+ RemoteAddr string `json:"remote_addr,omitempty"`
+ Medias []*core.Media `json:"medias,omitempty"`
+ Recv uint64 `json:"bytes_recv,omitempty"`
+ Send uint64 `json:"bytes_send,omitempty"`
+ Reolink profileDiagnostics `json:"reolink"`
+}
+
+type profileDiagnostics struct {
+ Channel uint8 `json:"channel"`
+ Profile string `json:"profile"`
+ State string `json:"state"`
+ Generation uint64 `json:"generation"`
+ Profiles int `json:"profiles"`
+ MainSessions int `json:"main_sessions"`
+ OtherSessions int `json:"other_sessions"`
+ VideoCodec string `json:"video_codec,omitempty"`
+ AudioCodec string `json:"audio_codec,omitempty"`
+ CameraType string `json:"camera_type,omitempty"`
+ CameraModel string `json:"camera_model,omitempty"`
+ HardwareVersion string `json:"hardware_version,omitempty"`
+ FirmwareVersion string `json:"firmware_version,omitempty"`
+ DeviceInfo string `json:"device_info_status,omitempty"`
+ CapabilityInfo string `json:"capability_status,omitempty"`
+ Channels []uint16 `json:"observed_channels,omitempty"`
+ LastError string `json:"last_error_class,omitempty"`
+}
+
+func (p *Producer) diagnostics() profileDiagnostics {
+ profile := p.profile
+ registry := profile.camera.registry
+ p.failureMu.Lock()
+ failureClass := errorClass(p.failure)
+ p.failureMu.Unlock()
+ registry.mu.Lock()
+ profile.mu.Lock()
+ channels := make([]uint16, len(profile.discovery.capabilities.ObservedChannels))
+ for i, channel := range profile.discovery.capabilities.ObservedChannels {
+ channels[i] = uint16(channel)
+ }
+ d := profileDiagnostics{
+ Channel: profile.key.Channel, Profile: string(profile.key.Stream),
+ State: profile.state.String(), Generation: profile.generation, Profiles: len(profile.camera.profiles),
+ MainSessions: profile.camera.mainSessions, OtherSessions: profile.camera.otherSessions,
+ VideoCodec: profile.pipeline.videoCodec,
+ CameraType: profile.discovery.device.Type, CameraModel: profile.discovery.device.Model,
+ HardwareVersion: profile.discovery.device.Hardware, FirmwareVersion: profile.discovery.device.Firmware,
+ DeviceInfo: profile.discovery.deviceStatus, CapabilityInfo: profile.discovery.capabilityStatus,
+ Channels: channels,
+ LastError: profile.errClass,
+ }
+ if profile.pipeline.audioMedia != nil {
+ d.AudioCodec = profile.pipeline.audioMedia.Codecs[0].Name
+ }
+ if failureClass != "" {
+ d.LastError = failureClass
+ }
+ profile.mu.Unlock()
+ registry.mu.Unlock()
+ return d
+}
+
+func (p *Producer) MarshalJSON() ([]byte, error) {
+ return json.Marshal(&producerDiagnostics{
+ ID: p.ID, FormatName: p.FormatName, Protocol: p.Protocol, RemoteAddr: p.RemoteAddr,
+ Medias: p.Medias, Recv: p.profile.recvBytes.Load(), Send: p.send.Load(), Reolink: p.diagnostics(),
+ })
+}
diff --git a/pkg/reolink/producer_test.go b/pkg/reolink/producer_test.go
new file mode 100644
index 000000000..b56b9dca5
--- /dev/null
+++ b/pkg/reolink/producer_test.go
@@ -0,0 +1,252 @@
+package reolink
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/AlexxIT/go2rtc/pkg/aac"
+ "github.com/AlexxIT/go2rtc/pkg/baichuan"
+ "github.com/AlexxIT/go2rtc/pkg/core"
+ "github.com/AlexxIT/go2rtc/pkg/creds"
+)
+
+func TestParseURL(t *testing.T) {
+ source, err := parseURL("reolink://admin:p%40ss@[fd00::1]:9001/sub?channel=2&backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if source.config.Host != "fd00::1" || source.config.Port != 9001 || source.channel != 2 ||
+ source.stream != baichuan.StreamSub || source.remote != "[fd00::1]:9001" || source.backchannel ||
+ source.protocol != "tcp" {
+ t.Fatalf("unexpected source: %+v", source)
+ }
+ text := fmt.Sprint(source.config)
+ if strings.Contains(text, "admin") || strings.Contains(text, "p@ss") {
+ t.Fatalf("credentials exposed by config: %s", text)
+ }
+}
+
+func TestParseUIDURL(t *testing.T) {
+ const uid = "AbC1234567890XYZ"
+ source, err := parseURL("reolink://admin:secret@" + uid +
+ "/sub?transport=uid&channel=1&backchannel=0&local=192.0.2.28&broadcast=198.51.100.255")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if source.config.Host != "" || source.config.Port != 0 || source.channel != 1 ||
+ source.stream != baichuan.StreamSub || source.remote != "uid" || source.backchannel ||
+ source.config.UIDLocalAddr != "192.0.2.28" || source.config.UIDBroadcastAddr != "198.51.100.255" ||
+ source.protocol != "udp" {
+ t.Fatalf("unexpected UID source: %+v", source)
+ }
+ for _, value := range []string{fmt.Sprint(source), fmt.Sprint(source.config)} {
+ if strings.Contains(value, uid) || strings.Contains(value, "secret") {
+ t.Fatalf("UID source leaked: %s", value)
+ }
+ }
+}
+
+func TestParseURLRejectsInvalidSource(t *testing.T) {
+ for _, source := range []string{
+ "http://admin:pass@camera", "reolink://camera", "reolink://admin:pass@camera?channel=256",
+ "reolink://admin:pass@camera?stream=fluent", "reolink://admin:pass@camera/main?stream=sub",
+ "reolink://admin:pass@camera?unknown=1", "reolink://admin:pass@camera?backchannel=yes",
+ "reolink://admin:pass@camera?channel=%zz",
+ "reolink://admin:pass@camera:9000?transport=uid", "reolink://admin:pass@camera?transport=cloud",
+ "reolink://admin:pass@camera?local=192.0.2.28",
+ "reolink://admin:pass@camera?transport=uid&local=127.0.0.1",
+ "reolink://admin:pass@camera?broadcast=198.51.100.255",
+ "reolink://admin:pass@camera?transport=uid&broadcast=127.0.0.1",
+ "reolink://admin:pass@camera?transport=uid&broadcast=224.0.0.1",
+ } {
+ if _, err := parseURL(source); err == nil {
+ t.Fatalf("accepted invalid source: %s", source)
+ }
+ }
+}
+
+func TestParseURLErrorRedactsMalformedSource(t *testing.T) {
+ const source = "reolink://sentinel-user:sentinel-pass@camera:%zz"
+ _, err := parseURL(source)
+ if err == nil || strings.Contains(err.Error(), "sentinel") {
+ t.Fatalf("unsafe parse error: %v", err)
+ }
+}
+
+func TestParseUIDErrorRegistersSecret(t *testing.T) {
+ const uid = "MalformedUID1234"
+ const source = "reolink://admin:pass@" + uid + "?transport=uid&unknown=1"
+ if _, err := parseURL(source); err == nil {
+ t.Fatal("accepted invalid UID source")
+ }
+ if redacted := creds.SecretString(source); strings.Contains(redacted, uid) {
+ t.Fatalf("UID source leaked: %s", redacted)
+ }
+}
+
+func TestMediaClock(t *testing.T) {
+ clock := mediaClock{value: 100}
+ if value, err := clock.next(0xfffffff0, 90000); err != nil || value != 100 {
+ t.Fatalf("unexpected initial value: %d, %v", value, err)
+ }
+ if value, err := clock.next(0x0000c340, 90000); err != nil || value != 4600 {
+ t.Fatalf("unexpected wrapped value: %d, %v", value, err)
+ }
+ if value, err := clock.next(0x0000c340, 90000); err != nil || value != 4600 {
+ t.Fatalf("unexpected duplicate value: %d, %v", value, err)
+ }
+}
+
+func TestMediaClockRejectsDiscontinuity(t *testing.T) {
+ for _, timestamps := range [][2]uint32{
+ {1_000_000, 900_000},
+ {1_000_000, 1_000_000 + maxTimestampStep + 1},
+ } {
+ clock := mediaClock{}
+ if _, err := clock.next(timestamps[0], 90000); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := clock.next(timestamps[1], 90000); err == nil {
+ t.Fatalf("accepted timestamp discontinuity %v", timestamps)
+ }
+ }
+}
+
+func TestNextTimestampEpoch(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ now, previous uint32
+ want uint32
+ }{
+ {name: "Forward", now: 200, previous: 100, want: 200},
+ {name: "Backward", now: 90, previous: 100, want: 101},
+ {name: "Duplicate", now: 100, previous: 100, want: 101},
+ {name: "Wrap", now: 20, previous: 0xfffffff0, want: 20},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ if actual := nextTimestampEpoch(test.now, test.previous, true); actual != test.want {
+ t.Fatalf("unexpected epoch: %d != %d", actual, test.want)
+ }
+ })
+ }
+}
+
+func TestAudioPayload(t *testing.T) {
+ header := []byte{0xff, 0xf1, 0x60, 0x40, 0x00, 0x1f, 0xfc}
+ data := append(header, []byte{1, 2, 3}...)
+ aac.WriteADTSSize(data, uint16(len(data)))
+ payload, samples, err := audioPayload(baichuan.MediaPacket{Kind: baichuan.MediaAAC, Data: data})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(payload) != "\x01\x02\x03" || samples != 1024 {
+ t.Fatalf("unexpected AAC packet: %x %d", payload, samples)
+ }
+}
+
+func TestADPCMAudioPayload(t *testing.T) {
+ payload, samples, err := audioPayload(baichuan.MediaPacket{
+ Kind: baichuan.MediaADPCM, Data: []byte{0, 0, 0, 0, 0},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if samples != 2 || len(payload) != 2 || payload[0] != 0xd5 || payload[1] != 0xd5 {
+ t.Fatalf("unexpected PCMA payload: %x samples=%d", payload, samples)
+ }
+}
+
+func TestAddAudioRejectsInvalidAACConfig(t *testing.T) {
+ base := []byte{0xff, 0xf1, 0x60, 0x40, 0, 0, 0xfc}
+ reservedRate := append([]byte(nil), base...)
+ reservedRate[2] = reservedRate[2]&0xc3 | 0x3c
+ noChannels := append([]byte(nil), base...)
+ noChannels[2] &^= 1
+ noChannels[3] &^= 0xc0
+ for _, data := range [][]byte{reservedRate, noChannels} {
+ pipeline := &mediaPipeline{}
+ if err := pipeline.configureAudio(baichuan.MediaPacket{Kind: baichuan.MediaAAC, Data: data}); err == nil {
+ t.Fatalf("accepted invalid AAC config: %x", data)
+ }
+ }
+}
+
+func TestWritePacketRejectsAACConfigChange(t *testing.T) {
+ data := []byte{0xff, 0xf1, 0x60, 0x40, 0, 0, 0xfc}
+ pipeline := &mediaPipeline{}
+ if err := pipeline.configureAudio(baichuan.MediaPacket{Kind: baichuan.MediaAAC, Data: data}); err != nil {
+ t.Fatal(err)
+ }
+ changed := append([]byte(nil), data...)
+ changed[2] += 4
+ if _, err := pipeline.frame(baichuan.MediaPacket{Kind: baichuan.MediaAAC, Data: changed}); err == nil ||
+ !strings.Contains(err.Error(), "configuration changed") {
+ t.Fatalf("unexpected AAC configuration error: %v", err)
+ }
+}
+
+func TestRetainProbePacketKeepsLatestGOP(t *testing.T) {
+ probe := &mediaProbe{}
+ for _, packet := range []baichuan.MediaPacket{
+ {Kind: baichuan.MediaVideoI, Data: make([]byte, 10)},
+ {Kind: baichuan.MediaVideoP, Data: make([]byte, 5)},
+ {Kind: baichuan.MediaVideoI, Data: make([]byte, 7)},
+ } {
+ if err := probe.retain(packet); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if len(probe.packets) != 1 || probe.bytes != 7 || probe.packets[0].Kind != baichuan.MediaVideoI {
+ t.Fatalf("unexpected retained probe: packets=%d bytes=%d", len(probe.packets), probe.bytes)
+ }
+}
+
+func TestRetainProbePacketOwnsPayload(t *testing.T) {
+ backing := make([]byte, 1024)
+ packet := baichuan.MediaPacket{Kind: baichuan.MediaVideoP, Data: backing[len(backing)-1:]}
+ packet.Data[0] = 1
+ probe := &mediaProbe{}
+ if err := probe.retain(packet); err != nil {
+ t.Fatal(err)
+ }
+ packet.Data[0] = 2
+ if probe.packets[0].Data[0] != 1 {
+ t.Fatal("probe retained the source payload allocation")
+ }
+}
+
+func TestRetainProbePacketLimit(t *testing.T) {
+ probe := &mediaProbe{bytes: maxProbeBytes}
+ if err := probe.retain(baichuan.MediaPacket{Kind: baichuan.MediaVideoP, Data: []byte{1}}); err == nil {
+ t.Fatal("accepted probe packet beyond memory limit")
+ }
+}
+
+func TestRetainProbePacketCountLimitAndRelease(t *testing.T) {
+ probe := &mediaProbe{}
+ for range maxProbePackets {
+ if err := probe.retain(baichuan.MediaPacket{Kind: baichuan.MediaInfo}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := probe.retain(baichuan.MediaPacket{Kind: baichuan.MediaInfo}); err == nil {
+ t.Fatal("accepted probe packet beyond count limit")
+ }
+ probe.release()
+ if probe.packets != nil || probe.bytes != 0 {
+ t.Fatalf("probe retained data after release: packets=%d bytes=%d", len(probe.packets), probe.bytes)
+ }
+}
+
+func TestBackchannelRejectsOversizedPacketBeforeDial(t *testing.T) {
+ p := &Producer{ctx: context.Background()}
+ b := &backchannel{producer: p, format: baichuan.TalkFormat{SampleRate: 16000, SamplesPerBlock: 1016}}
+ if err := b.setCodec(&core.Codec{Name: core.CodecPCMA, ClockRate: 8000, Channels: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if err := b.Write(make([]byte, 2033)); err == nil || !strings.Contains(err.Error(), "sample limit") {
+ t.Fatalf("unexpected oversized packet error: %v", err)
+ }
+}
diff --git a/pkg/reolink/profile.go b/pkg/reolink/profile.go
new file mode 100644
index 000000000..9fed47241
--- /dev/null
+++ b/pkg/reolink/profile.go
@@ -0,0 +1,466 @@
+package reolink
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/AlexxIT/go2rtc/pkg/baichuan"
+ "github.com/AlexxIT/go2rtc/pkg/core"
+)
+
+const (
+ profileProbeTimeout = 10 * time.Second
+ profileTrackCheckInterval = time.Second
+ profileTrackStallTimeout = 15 * time.Second
+ profileAudioRateWindow = 60 * time.Second
+)
+
+type profileState uint8
+
+const (
+ profileStarting profileState = iota
+ profileActive
+ profileClosing
+ profileFailed
+ profileClosed
+)
+
+func (s profileState) String() string {
+ switch s {
+ case profileStarting:
+ return "starting"
+ case profileActive:
+ return "active"
+ case profileClosing:
+ return "closing"
+ case profileFailed:
+ return "failed"
+ case profileClosed:
+ return "closed"
+ default:
+ return "unknown"
+ }
+}
+
+type Profile struct {
+ camera *Camera
+ key ProfileKey
+ generation uint64
+ source source
+ open profileOpener
+
+ ctx context.Context
+ cancel context.CancelFunc
+ startCtx context.Context
+ startCancel context.CancelFunc
+ ready chan struct{}
+ done chan struct{}
+ start sync.Once
+ close sync.Once
+
+ mu sync.Mutex
+ state profileState
+ released bool
+ input profileInput
+ terminal error
+ closeErr error
+ errClass string
+ pipeline mediaPipeline
+ receivers []*core.Receiver
+ video *core.Receiver
+ audio *core.Receiver
+ discovery discoverySnapshot
+
+ talkMu sync.Mutex
+ talkKnown bool
+ talkSupported bool
+ talkFormat baichuan.TalkFormat
+
+ recvBytes atomic.Uint64
+ videoFrames atomic.Uint64
+ audioSamples atomic.Uint64
+
+ trackCheckInterval time.Duration
+ trackStallTimeout time.Duration
+ trackRateWindow time.Duration
+}
+
+func (p *Profile) Format(state fmt.State, _ rune) {
+ _, _ = fmt.Fprintf(state, "reolink.Profile{Remote:%q, Channel:%d, Stream:%q, Generation:%d}",
+ p.camera.remote, p.key.Channel, p.key.Stream, p.generation)
+}
+
+func newProfile(
+ camera *Camera, key ProfileKey, generation uint64, s source, open profileOpener,
+) *Profile {
+ ctx, cancel := context.WithCancel(context.Background())
+ startCtx, startCancel := context.WithCancel(ctx)
+ return &Profile{
+ camera: camera, key: key, generation: generation, source: s, open: open,
+ ctx: ctx, cancel: cancel, startCtx: startCtx, startCancel: startCancel,
+ ready: make(chan struct{}), done: make(chan struct{}), state: profileStarting,
+ pipeline: mediaPipeline{epoch: camera.nextEpoch(key, core.Now90000())},
+ trackCheckInterval: profileTrackCheckInterval, trackStallTimeout: profileTrackStallTimeout,
+ trackRateWindow: profileAudioRateWindow,
+ }
+}
+
+func (p *Profile) run() {
+ setup, cancel := context.WithTimeout(p.ctx, p.sourceTimeout())
+ input, err := p.open(setup, p.camera, p.source)
+ cancel()
+ if err != nil {
+ p.fail(fmt.Errorf("reolink: open profile: %w", err))
+ close(p.done)
+ return
+ }
+ p.mu.Lock()
+ p.input = input
+ p.discovery = input.Discovery()
+ p.mu.Unlock()
+
+ var probe mediaProbe
+ if probe, err = p.probe(input); err == nil {
+ p.initReceivers()
+ err = p.markReady()
+ }
+ if err == nil {
+ err = p.prepareStart(input, &probe)
+ }
+ probe.release()
+ if err == nil {
+ err = p.read(input)
+ }
+ failed := p.beginTerminal(err)
+ var closeErr error
+ if failed {
+ closeErr = input.Abort()
+ } else {
+ closeErr = input.Close()
+ }
+ if failed && closeErr != nil {
+ p.joinTerminal(closeErr)
+ }
+ p.finishSession(closeErr)
+ close(p.done)
+}
+
+func (p *Profile) sourceTimeout() time.Duration {
+ if p.source.config.Timeout > 0 {
+ return p.source.config.Timeout
+ }
+ return baichuan.DefaultTimeout
+}
+
+func (p *Profile) probe(input profileInput) (mediaProbe, error) {
+ ctx, cancel := context.WithTimeout(p.ctx, profileProbeTimeout)
+ defer cancel()
+ var probe mediaProbe
+ for {
+ videoReady := p.source.suppressVideo || p.pipeline.videoMedia != nil
+ audioReady := p.source.suppressAudio || p.pipeline.audioMedia != nil
+ if videoReady && audioReady {
+ break
+ }
+ packet, err := input.Read(ctx)
+ if err != nil {
+ return mediaProbe{}, fmt.Errorf("reolink: probe media: %w", err)
+ }
+ p.record(packet)
+ if p.retainPacket(packet) {
+ if err = p.retainProbe(&probe, packet); err != nil {
+ return mediaProbe{}, err
+ }
+ }
+ switch packet.Kind {
+ case baichuan.MediaVideoI:
+ if !p.source.suppressVideo && p.pipeline.videoMedia == nil {
+ if err = p.pipeline.probeVideo(packet); err != nil {
+ return mediaProbe{}, err
+ }
+ }
+ case baichuan.MediaAAC, baichuan.MediaADPCM:
+ if !p.source.suppressAudio && p.pipeline.audioMedia == nil {
+ if err = p.pipeline.configureAudio(packet); err != nil {
+ return mediaProbe{}, err
+ }
+ }
+ }
+ }
+ return probe, nil
+}
+
+func (p *Profile) initReceivers() {
+ for _, media := range p.pipeline.medias {
+ receiver := core.NewReceiver(media, media.Codecs[0])
+ p.receivers = append(p.receivers, receiver)
+ if media.Kind == core.KindVideo {
+ p.video = receiver
+ } else if media.Kind == core.KindAudio {
+ p.audio = receiver
+ }
+ }
+}
+
+func (p *Profile) replay(probe mediaProbe) error {
+ for _, packet := range probe.packets {
+ frame, err := p.pipeline.frame(packet)
+ if err != nil {
+ return err
+ }
+ if frame.valid() {
+ p.writeFrame(frame)
+ }
+ }
+ return nil
+}
+
+func (p *Profile) read(input profileInput) error {
+ ctx, cancel := context.WithCancel(p.ctx)
+ stalled := make(chan error, 1)
+ done := make(chan struct{})
+ go func() {
+ p.watchTracks(ctx, cancel, stalled)
+ close(done)
+ }()
+ defer func() {
+ cancel()
+ <-done
+ }()
+
+ for {
+ packet, err := input.Read(ctx)
+ if err != nil {
+ select {
+ case err = <-stalled:
+ return err
+ default:
+ }
+ return fmt.Errorf("reolink: read media: %w", err)
+ }
+ p.record(packet)
+ if !p.acceptPacket(packet) {
+ continue
+ }
+ frame, err := p.pipeline.frame(packet)
+ if err != nil {
+ return err
+ }
+ if frame.valid() {
+ p.writeFrame(frame)
+ }
+ }
+}
+
+func (p *Profile) writeFrame(frame mediaFrame) {
+ if frame.track == trackVideo && p.video != nil {
+ p.videoFrames.Add(1)
+ p.video.WriteRTP(frame.packet)
+ } else if frame.track == trackAudio && p.audio != nil {
+ p.audioSamples.Add(uint64(frame.samples))
+ p.audio.WriteRTP(frame.packet)
+ }
+}
+
+func (p *Profile) prepareStart(input profileInput, probe *mediaProbe) error {
+ for {
+ select {
+ case <-p.startCtx.Done():
+ if err := p.ctx.Err(); err != nil {
+ return err
+ }
+ return p.replay(*probe)
+ default:
+ }
+ packet, err := input.Read(p.startCtx)
+ if err != nil {
+ if errors.Is(err, context.Canceled) && p.startCtx.Err() != nil && p.ctx.Err() == nil {
+ return p.replay(*probe)
+ }
+ return fmt.Errorf("reolink: wait for start: %w", err)
+ }
+ p.record(packet)
+ if p.retainPacket(packet) {
+ err = p.retainProbe(probe, packet)
+ }
+ if err != nil {
+ return err
+ }
+ }
+}
+
+func (p *Profile) retainProbe(probe *mediaProbe, packet baichuan.MediaPacket) error {
+ if p.source.suppressVideo {
+ probe.reset()
+ }
+ return probe.retain(packet)
+}
+
+func (p *Profile) acceptPacket(packet baichuan.MediaPacket) bool {
+ switch packet.Kind {
+ case baichuan.MediaVideoI, baichuan.MediaVideoP:
+ return !p.source.suppressVideo
+ case baichuan.MediaAAC, baichuan.MediaADPCM:
+ return !p.source.suppressAudio
+ default:
+ return false
+ }
+}
+
+func (p *Profile) retainPacket(packet baichuan.MediaPacket) bool {
+ if !p.source.suppressVideo && !p.source.suppressAudio {
+ return true
+ }
+ return p.acceptPacket(packet)
+}
+
+func (p *Profile) startProfile() {
+ p.start.Do(p.startCancel)
+}
+
+func (p *Profile) closeReceivers() {
+ p.close.Do(func() {
+ for _, receiver := range p.receivers {
+ receiver.Close()
+ }
+ })
+}
+
+func (p *Profile) record(packet baichuan.MediaPacket) {
+ p.recvBytes.Add(uint64(len(packet.Data)))
+}
+
+func (p *Profile) markReady() error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.state != profileStarting {
+ return p.terminalError()
+ }
+ p.state = profileActive
+ close(p.ready)
+ return nil
+}
+
+func (p *Profile) waitReady() error {
+ <-p.ready
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.state != profileActive {
+ return p.terminalError()
+ }
+ return nil
+}
+
+func (p *Profile) terminalError() error {
+ if p.terminal != nil {
+ return p.terminal
+ }
+ return fmt.Errorf("reolink: profile %s", p.state)
+}
+
+func (p *Profile) beginTerminal(err error) bool {
+ p.camera.registry.mu.Lock()
+ defer p.camera.registry.mu.Unlock()
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.state == profileClosing || p.state == profileClosed || p.state == profileFailed {
+ return false
+ }
+ if _, ok := p.camera.profiles[p]; ok {
+ delete(p.camera.profiles, p)
+ }
+ p.state = profileFailed
+ p.terminal = err
+ p.errClass = errorClass(err)
+ p.cancel()
+ select {
+ case <-p.ready:
+ default:
+ close(p.ready)
+ }
+ return true
+}
+
+func (p *Profile) joinTerminal(err error) {
+ p.mu.Lock()
+ p.terminal = errors.Join(p.terminal, err)
+ p.mu.Unlock()
+}
+
+func (p *Profile) err() error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.terminalError()
+}
+
+func (p *Profile) closeError() error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.closeErr
+}
+
+func (p *Profile) finishSession(closeErr error) {
+ registry := p.camera.registry
+ registry.mu.Lock()
+ p.mu.Lock()
+ p.closeErr = closeErr
+ if p.state == profileClosing {
+ p.state = profileClosed
+ if closeErr != nil {
+ p.terminal = closeErr
+ p.errClass = errorClass(closeErr)
+ }
+ }
+ state := p.state.String()
+ errClass := p.errClass
+ epoch := mediaEpoch{
+ audio: p.pipeline.audioTS, audioRate: p.pipeline.audioRate, audioSet: p.pipeline.audioMedia != nil,
+ }
+ if epoch.video, epoch.videoSet = p.pipeline.videoTS.current(); epoch.videoSet || epoch.audioSet {
+ p.camera.recordEpoch(p.key, epoch)
+ }
+ p.mu.Unlock()
+ p.camera.removeSession(p.key.Stream)
+ registry.removeCameraLocked(p.camera)
+ registry.mu.Unlock()
+ event := registry.log.Debug().Str("camera", p.camera.remote).Uint8("channel", p.key.Channel).
+ Str("profile", string(p.key.Stream)).Uint64("generation", p.generation).
+ Str("state", state).Str("error_class", errClass)
+ if closeErr != nil {
+ event = event.Err(closeErr)
+ }
+ event.Msg("reolink profile stopped")
+}
+
+func (p *Profile) fail(err error) {
+ p.beginTerminal(err)
+ p.finishSession(nil)
+}
+
+func errorClass(err error) string {
+ switch {
+ case err == nil:
+ return ""
+ case errors.Is(err, context.Canceled):
+ return "canceled"
+ case errors.Is(err, context.DeadlineExceeded):
+ return "timeout"
+ case errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF):
+ return "eof"
+ }
+ var netErr net.Error
+ if errors.As(err, &netErr) {
+ return "network"
+ }
+ var mediaErr *mediaError
+ if errors.As(err, &mediaErr) {
+ return "media"
+ }
+ return "protocol"
+}
diff --git a/pkg/reolink/profile_input.go b/pkg/reolink/profile_input.go
new file mode 100644
index 000000000..667fa80c5
--- /dev/null
+++ b/pkg/reolink/profile_input.go
@@ -0,0 +1,149 @@
+package reolink
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/AlexxIT/go2rtc/pkg/baichuan"
+)
+
+type profileInput interface {
+ Read(context.Context) (baichuan.MediaPacket, error)
+ ProbeTalk(context.Context, uint8) (baichuan.TalkFormat, error)
+ Discovery() discoverySnapshot
+ Abort() error
+ Close() error
+}
+
+type profileOpener func(context.Context, *Camera, source) (profileInput, error)
+
+const capabilityProbeTimeout = 2 * time.Second
+
+type discoverySnapshot struct {
+ device baichuan.DeviceInfo
+ capabilities baichuan.Capabilities
+ deviceStatus string
+ capabilityStatus string
+}
+
+func (p *Profile) probeTalk() (baichuan.TalkFormat, bool, error) {
+ p.talkMu.Lock()
+ defer p.talkMu.Unlock()
+ if p.talkKnown {
+ return p.talkFormat, p.talkSupported, nil
+ }
+ p.mu.Lock()
+ input := p.input
+ state := p.state
+ terminal := p.terminal
+ p.mu.Unlock()
+ if input == nil || state != profileActive {
+ if terminal == nil {
+ terminal = fmt.Errorf("reolink: profile %s", state)
+ }
+ return baichuan.TalkFormat{}, false, terminal
+ }
+ ctx, cancel := context.WithTimeout(p.ctx, p.sourceTimeout())
+ format, err := input.ProbeTalk(ctx, p.key.Channel)
+ cancel()
+ if err != nil {
+ var unsupported *baichuan.UnsupportedTalkError
+ if !errors.As(err, &unsupported) {
+ return baichuan.TalkFormat{}, false, fmt.Errorf("reolink: probe talkback: %w", err)
+ }
+ p.talkKnown = true
+ return baichuan.TalkFormat{}, false, nil
+ }
+ p.talkKnown = true
+ p.talkSupported = true
+ p.talkFormat = format
+ return format, true, nil
+}
+
+type baichuanInput struct {
+ client *baichuan.Client
+ preview *baichuan.Preview
+ discovery discoverySnapshot
+ once sync.Once
+ err error
+}
+
+func openBaichuan(ctx context.Context, _ *Camera, s source) (profileInput, error) {
+ client, err := baichuan.Dial(ctx, s.config)
+ if err != nil {
+ return nil, err
+ }
+ if err = client.Login(ctx); err != nil {
+ return nil, errors.Join(err, client.Close())
+ }
+ discovery := discover(ctx, client, s.channel)
+ preview, err := client.StartPreview(ctx, s.channel, s.stream)
+ if err != nil {
+ return nil, errors.Join(err, client.Close())
+ }
+ return &baichuanInput{client: client, preview: preview, discovery: discovery}, nil
+}
+
+func discover(ctx context.Context, client *baichuan.Client, channel uint8) discoverySnapshot {
+ ctx, cancel := context.WithTimeout(ctx, capabilityProbeTimeout)
+ defer cancel()
+ var device baichuan.DeviceInfo
+ var capabilities baichuan.Capabilities
+ var deviceErr, capabilityErr error
+ var wg sync.WaitGroup
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ device, deviceErr = client.DeviceInfo(ctx)
+ }()
+ go func() {
+ defer wg.Done()
+ capabilities, capabilityErr = client.Capabilities(ctx, channel)
+ }()
+ wg.Wait()
+ return discoverySnapshot{
+ device: device, capabilities: capabilities,
+ deviceStatus: discoveryStatus(deviceErr), capabilityStatus: discoveryStatus(capabilityErr),
+ }
+}
+
+func discoveryStatus(err error) string {
+ if err == nil {
+ return "available"
+ }
+ return errorClass(err)
+}
+
+func (i *baichuanInput) Discovery() discoverySnapshot {
+ return i.discovery
+}
+
+func (i *baichuanInput) Read(ctx context.Context) (baichuan.MediaPacket, error) {
+ return i.preview.Read(ctx)
+}
+
+func (i *baichuanInput) ProbeTalk(ctx context.Context, channel uint8) (baichuan.TalkFormat, error) {
+ return i.client.ProbeTalk(ctx, channel)
+}
+
+func (i *baichuanInput) Close() error {
+ return i.close(true)
+}
+
+func (i *baichuanInput) Abort() error {
+ return i.close(false)
+}
+
+func (i *baichuanInput) close(graceful bool) error {
+ i.once.Do(func() {
+ if graceful {
+ i.err = errors.Join(i.preview.Close(), i.client.Close())
+ } else {
+ i.err = errors.Join(i.client.Close(), i.preview.Close())
+ }
+ })
+ return i.err
+}
diff --git a/pkg/reolink/profile_liveness.go b/pkg/reolink/profile_liveness.go
new file mode 100644
index 000000000..76823bdc8
--- /dev/null
+++ b/pkg/reolink/profile_liveness.go
@@ -0,0 +1,63 @@
+package reolink
+
+import (
+ "context"
+ "time"
+)
+
+func (p *Profile) watchTracks(ctx context.Context, cancel context.CancelFunc, stalled chan<- error) {
+ if p.trackCheckInterval <= 0 || p.trackStallTimeout <= 0 {
+ return
+ }
+ rawOnly := p.video == nil && p.audio == nil
+ ticker := time.NewTicker(p.trackCheckInterval)
+ defer ticker.Stop()
+ video, audio := p.videoFrames.Load(), p.audioSamples.Load()
+ videoAt, audioAt := time.Now(), time.Now()
+ received, receivedAt := p.recvBytes.Load(), time.Now()
+ rateSamples, rateAt := p.audioSamples.Load(), time.Now()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case now := <-ticker.C:
+ if frames := p.videoFrames.Load(); frames != video {
+ video, videoAt = frames, now
+ }
+ if samples := p.audioSamples.Load(); samples != audio {
+ audio, audioAt = samples, now
+ }
+ if rawOnly {
+ if bytes := p.recvBytes.Load(); bytes != received {
+ received, receivedAt = bytes, now
+ }
+ }
+ var err error
+ if rawOnly && now.Sub(receivedAt) >= p.trackStallTimeout {
+ err = mediaErrorf("reolink: media stream stalled")
+ } else if p.video != nil && now.Sub(videoAt) >= p.trackStallTimeout {
+ err = mediaErrorf("reolink: video track stalled")
+ } else if p.audio != nil && now.Sub(audioAt) >= p.trackStallTimeout {
+ err = mediaErrorf("reolink: audio track stalled")
+ } else if p.audio != nil && p.pipeline.audioRate > 0 && p.trackRateWindow > 0 &&
+ now.Sub(rateAt) >= p.trackRateWindow {
+ samples := p.audioSamples.Load()
+ if audioRateLow(samples-rateSamples, p.pipeline.audioRate, now.Sub(rateAt)) {
+ err = mediaErrorf("reolink: audio track rate below expected")
+ } else {
+ rateSamples, rateAt = samples, now
+ }
+ }
+ if err != nil {
+ stalled <- err
+ cancel()
+ return
+ }
+ }
+ }
+}
+
+func audioRateLow(samples uint64, rate uint32, elapsed time.Duration) bool {
+ expected := uint64(rate) * uint64(elapsed) / uint64(time.Second)
+ return samples < expected-expected/4
+}
diff --git a/pkg/reolink/profile_liveness_test.go b/pkg/reolink/profile_liveness_test.go
new file mode 100644
index 000000000..5f357b16e
--- /dev/null
+++ b/pkg/reolink/profile_liveness_test.go
@@ -0,0 +1,259 @@
+package reolink
+
+import (
+ "context"
+ "errors"
+ "io"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/AlexxIT/go2rtc/pkg/baichuan"
+)
+
+const (
+ testTrackCheckInterval = 10 * time.Millisecond
+ testTrackStallTimeout = 100 * time.Millisecond
+)
+
+func TestProfileFailsWhenAdvertisedTrackStalls(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ packet func(uint32) baichuan.MediaPacket
+ want string
+ closeErr error
+ }{
+ {name: "video", packet: testAAC, want: "video track stalled"},
+ {name: "audio", packet: testH264PFrame, want: "audio track stalled", closeErr: context.Canceled},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ producer.profile.trackCheckInterval = testTrackCheckInterval
+ producer.profile.trackStallTimeout = testTrackStallTimeout
+ done := make(chan error, 1)
+ go func() { done <- producer.Start() }()
+ input := opener.input(0)
+ input.closeErr = test.closeErr
+ ticker := time.NewTicker(testTrackCheckInterval)
+ defer ticker.Stop()
+ deadline := time.NewTimer(time.Second)
+ defer deadline.Stop()
+ for timestamp := uint32(1_050_000); ; timestamp += 5_000 {
+ select {
+ case err = <-done:
+ if err == nil || !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("unexpected terminal error: %v", err)
+ }
+ if class := errorClass(err); test.closeErr == nil && class != "media" {
+ t.Fatalf("track stall classified as %q", class)
+ }
+ if class := producer.diagnostics().LastError; class != "media" {
+ t.Fatalf("profile recorded track stall as %q", class)
+ }
+ if err = producer.Stop(); !errors.Is(err, test.closeErr) {
+ t.Fatalf("unexpected stop error: %v", err)
+ }
+ return
+ case <-ticker.C:
+ input.results <- inputResult{packet: test.packet(timestamp)}
+ case <-deadline.C:
+ t.Fatal("track stall did not stop producer")
+ }
+ }
+ })
+ }
+}
+
+func TestProfileDoesNotMonitorUnadvertisedAudio(t *testing.T) {
+ profile := deliveryTestProfile()
+ profile.trackCheckInterval = testTrackCheckInterval
+ profile.trackStallTimeout = testTrackStallTimeout
+ ctx, cancel := context.WithCancel(context.Background())
+ stalled := make(chan error, 1)
+ done := make(chan struct{})
+ go func() {
+ profile.watchTracks(ctx, cancel, stalled)
+ close(done)
+ }()
+ for range 15 {
+ profile.videoFrames.Add(1)
+ time.Sleep(testTrackCheckInterval)
+ }
+ select {
+ case err := <-stalled:
+ t.Fatalf("unadvertised audio stopped video-only profile: %v", err)
+ default:
+ }
+ cancel()
+ <-done
+}
+
+func TestBackchannelOnlyProfileStallsWithoutMedia(t *testing.T) {
+ format := baichuan.TalkFormat{SampleRate: 16000, SamplesPerBlock: 1016}
+ opener := &fakeProfileOpener{talk: &format}
+ registry := testRegistry(opener.open)
+ producer, err := registry.Dial("reolink://admin:secret@camera/sub?video=0&audio=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ producer.profile.trackCheckInterval = testTrackCheckInterval
+ producer.profile.trackStallTimeout = testTrackStallTimeout
+ done := make(chan error, 1)
+ go func() { done <- producer.Start() }()
+ select {
+ case err = <-done:
+ if err == nil || !strings.Contains(err.Error(), "media stream stalled") {
+ t.Fatalf("unexpected terminal error: %v", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("backchannel-only media stall did not stop producer")
+ }
+ if err = producer.Stop(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestProfileReconnectsAfterLowAudioRate(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ const source = "reolink://admin:secret@camera/main?backchannel=0"
+ first, err := registry.Dial(source)
+ if err != nil {
+ t.Fatal(err)
+ }
+ setTestTrackHealth(first.profile, 300*time.Millisecond, 500*time.Millisecond)
+ firstDone := make(chan error, 1)
+ go func() { firstDone <- first.Start() }()
+ input := opener.input(0)
+ err = runProfileTraffic(input, firstDone, 20*time.Millisecond, 200*time.Millisecond, 2*time.Second)
+ if err == nil || !strings.Contains(err.Error(), "audio track rate below expected") {
+ t.Fatalf("unexpected under-rate result: %v", err)
+ }
+ if class := first.diagnostics().LastError; class != "media" {
+ t.Fatalf("under-rate failure classified as %q", class)
+ }
+
+ replacement, err := registry.Dial(source)
+ if err != nil {
+ t.Fatalf("replacement profile failed: %v", err)
+ }
+ setTestTrackHealth(replacement.profile, 300*time.Millisecond, 300*time.Millisecond)
+ replacementDone := make(chan error, 1)
+ go func() { replacementDone <- replacement.Start() }()
+ input = opener.input(1)
+ if err = runProfileTraffic(input, replacementDone, 20*time.Millisecond, 50*time.Millisecond, time.Second); err != nil {
+ t.Fatalf("full-rate replacement stopped: %v", err)
+ }
+ if err = first.Stop(); err != nil {
+ t.Fatalf("failed to stop terminal profile: %v", err)
+ }
+ input.results <- inputResult{err: io.ErrUnexpectedEOF}
+ if err = <-replacementDone; !errors.Is(err, io.ErrUnexpectedEOF) {
+ t.Fatalf("unexpected replacement terminal error: %v", err)
+ }
+ if err = replacement.Stop(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func setTestTrackHealth(profile *Profile, stall, rateWindow time.Duration) {
+ profile.trackCheckInterval = 5 * time.Millisecond
+ profile.trackStallTimeout = stall
+ profile.trackRateWindow = rateWindow
+}
+
+func runProfileTraffic(
+ input *fakeProfileInput, done <-chan error, videoEvery, audioEvery, duration time.Duration,
+) error {
+ video := time.NewTicker(videoEvery)
+ defer video.Stop()
+ audio := time.NewTicker(audioEvery)
+ defer audio.Stop()
+ deadline := time.NewTimer(duration)
+ defer deadline.Stop()
+ var timestamp uint32 = 1_050_000
+ for {
+ select {
+ case err := <-done:
+ return err
+ case <-video.C:
+ input.results <- inputResult{packet: testH264PFrame(timestamp)}
+ timestamp += 5_000
+ case <-audio.C:
+ input.results <- inputResult{packet: testAAC(timestamp)}
+ case <-deadline.C:
+ return nil
+ }
+ }
+}
+
+func TestProfileIgnoresStalledSuppressedTrack(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ producer, err := registry.Dial("reolink://admin:secret@camera/main?video=0&backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ producer.profile.trackCheckInterval = testTrackCheckInterval
+ producer.profile.trackStallTimeout = testTrackStallTimeout
+ done := make(chan error, 1)
+ go func() { done <- producer.Start() }()
+ input := opener.input(0)
+ ticker := time.NewTicker(testTrackCheckInterval)
+ defer ticker.Stop()
+ stable := time.NewTimer(250 * time.Millisecond)
+ defer stable.Stop()
+ for timestamp := uint32(1_050_000); ; timestamp += 5_000 {
+ select {
+ case err = <-done:
+ t.Fatalf("suppressed video stall stopped producer: %v", err)
+ case <-ticker.C:
+ input.results <- inputResult{packet: testH264PFrame(timestamp)}
+ input.results <- inputResult{packet: testAAC(timestamp)}
+ case <-stable.C:
+ goto passed
+ }
+ }
+passed:
+ input.results <- inputResult{err: io.ErrUnexpectedEOF}
+ if err = <-done; !errors.Is(err, io.ErrUnexpectedEOF) {
+ t.Fatalf("unexpected terminal error: %v", err)
+ }
+ if err = producer.Stop(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestProfileStallsOnEnabledTrackOnly(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ producer, err := registry.Dial("reolink://admin:secret@camera/main?video=0&backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ producer.profile.trackCheckInterval = testTrackCheckInterval
+ producer.profile.trackStallTimeout = testTrackStallTimeout
+ done := make(chan error, 1)
+ go func() { done <- producer.Start() }()
+ deadline := time.NewTimer(time.Second)
+ defer deadline.Stop()
+ for {
+ select {
+ case err = <-done:
+ if err == nil || !strings.Contains(err.Error(), "audio track stalled") {
+ t.Fatalf("unexpected terminal error: %v", err)
+ }
+ if err = producer.Stop(); err != nil {
+ t.Fatalf("unexpected stop error: %v", err)
+ }
+ return
+ case <-deadline.C:
+ t.Fatal("audio stall did not stop producer")
+ }
+ }
+}
diff --git a/pkg/reolink/profile_test.go b/pkg/reolink/profile_test.go
new file mode 100644
index 000000000..0238d4813
--- /dev/null
+++ b/pkg/reolink/profile_test.go
@@ -0,0 +1,382 @@
+package reolink
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/AlexxIT/go2rtc/pkg/baichuan"
+ "github.com/AlexxIT/go2rtc/pkg/core"
+ "github.com/pion/rtp"
+)
+
+func deliveryTestProfile() *Profile {
+ media := &core.Media{Kind: core.KindVideo, Direction: core.DirectionRecvonly}
+ codec := &core.Codec{Name: core.CodecH265, ClockRate: 90000}
+ media.Codecs = []*core.Codec{codec}
+ receiver := core.NewReceiver(media, codec)
+ return &Profile{video: receiver, receivers: []*core.Receiver{receiver}}
+}
+
+func TestProducerDiagnosticsAreRedacted(t *testing.T) {
+ opener := &fakeProfileOpener{discovery: discoverySnapshot{
+ device: baichuan.DeviceInfo{
+ Type: "E1 Zoom", Model: "E340", Hardware: "IPC_NT14", Firmware: "v3.2.0",
+ },
+ capabilities: baichuan.Capabilities{
+ ObservedChannels: []uint8{0},
+ },
+ deviceStatus: "available", capabilityStatus: "available",
+ }}
+ registry := testRegistry(opener.open)
+ producer, err := registry.Dial("reolink://sentinel-user:sentinel-pass@camera/main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ producer.Source = "reolink://sentinel-user:sentinel-pass@camera"
+ producer.URL = producer.Source
+ data, err := json.Marshal(producer)
+ if err != nil {
+ t.Fatal(err)
+ }
+ text := string(data)
+ if strings.Contains(text, "sentinel-user") || strings.Contains(text, "sentinel-pass") {
+ t.Fatalf("credentials exposed in diagnostics: %s", text)
+ }
+ for _, expected := range []string{
+ `"remote_addr":"camera:9000"`, `"generation":1`, `"main_sessions":1`,
+ `"profiles":1`, `"camera_type":"E1 Zoom"`,
+ `"camera_model":"E340"`, `"hardware_version":"IPC_NT14"`,
+ `"firmware_version":"v3.2.0"`, `"device_info_status":"available"`,
+ `"capability_status":"available"`, `"observed_channels":[0]`,
+ } {
+ if !strings.Contains(text, expected) {
+ t.Fatalf("missing %s in diagnostics: %s", expected, text)
+ }
+ }
+ _ = producer.Stop()
+ waitFor(t, opener.input(0).closed.Load)
+}
+
+func TestProducerDiagnosticsDuringDelivery(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ done := make(chan error, 1)
+ go func() { done <- producer.Start() }()
+ input := opener.input(0)
+ go func() {
+ for i := range 1000 {
+ input.results <- inputResult{packet: testH264PFrame(uint32(1_050_000 + i*50_000))}
+ }
+ input.results <- inputResult{err: io.ErrUnexpectedEOF}
+ }()
+ for range 1000 {
+ if _, err = json.Marshal(producer); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err = <-done; !errors.Is(err, io.ErrUnexpectedEOF) {
+ t.Fatalf("unexpected terminal error: %v", err)
+ }
+ if !input.aborted.Load() {
+ t.Fatal("terminal failure closed input gracefully")
+ }
+ if class := producer.diagnostics().LastError; class != "eof" {
+ t.Fatalf("truncated network frame classified as %q", class)
+ }
+ if video, audio := producer.profile.videoFrames.Load(), producer.profile.audioSamples.Load(); video != 1001 || audio == 0 {
+ t.Fatalf("unexpected liveness state: video=%d audio_samples=%d", video, audio)
+ }
+ _ = producer.Stop()
+}
+
+func TestProducerResyncsWithoutRestartAfterInvalidVideo(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ done := make(chan error, 1)
+ go func() { done <- producer.Start() }()
+ waitFor(t, func() bool { return producer.profile.startCtx.Err() != nil })
+ input := opener.input(0)
+ frames := producer.profile.videoFrames.Load()
+ input.results <- inputResult{packet: baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoP, Codec: "H264", Timestamp: 1_050_000,
+ Data: []byte{0, 0, 0, 1, 0xc1, 1},
+ }}
+ input.results <- inputResult{packet: testH264PFrame(1_100_000)}
+ input.results <- inputResult{packet: testH264Keyframe(1_150_000)}
+ waitFor(t, func() bool { return producer.profile.videoFrames.Load() == frames+1 })
+ select {
+ case err = <-done:
+ t.Fatalf("video repair stopped producer: %v", err)
+ default:
+ }
+ input.results <- inputResult{err: io.ErrUnexpectedEOF}
+ if err = <-done; !errors.Is(err, io.ErrUnexpectedEOF) {
+ t.Fatalf("unexpected terminal error: %v", err)
+ }
+ if err = producer.Stop(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestProfileWritesFrameDirectlyToReceiver(t *testing.T) {
+ profile := deliveryTestProfile()
+ var received *rtp.Packet
+ (&core.Node{Input: func(packet *rtp.Packet) {
+ received = packet
+ }}).WithParent(&profile.video.Node)
+ frame := mediaFrame{track: trackVideo, packet: &rtp.Packet{
+ Header: rtp.Header{Timestamp: 7}, Payload: []byte{1, 2, 3},
+ }}
+ profile.writeFrame(frame)
+ if received != frame.packet {
+ t.Fatal("profile copied the RTP packet")
+ }
+}
+
+func TestProducerStartReplaysProbeAfterTrackAttachment(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ packets := make(chan *rtp.Packet, 1)
+ (&core.Node{Input: func(packet *rtp.Packet) { packets <- packet }}).WithParent(&producer.Receivers[0].Node)
+ done := make(chan error, 1)
+ go func() { done <- producer.Start() }()
+ select {
+ case packet := <-packets:
+ if packet.Timestamp == 0 || len(packet.Payload) == 0 {
+ t.Fatalf("unexpected probe frame: timestamp=%d size=%d", packet.Timestamp, len(packet.Payload))
+ }
+ case <-time.After(time.Second):
+ t.Fatal("probe frame was not replayed after start")
+ }
+ select {
+ case err = <-done:
+ t.Fatalf("producer stopped before cancellation: %v", err)
+ default:
+ }
+ if err = producer.Stop(); err != nil {
+ t.Fatal(err)
+ }
+ if err = <-done; !errors.Is(err, context.Canceled) {
+ t.Fatalf("unexpected stop error: %v", err)
+ }
+ input := opener.input(0)
+ if !input.closed.Load() {
+ t.Fatal("producer stop returned before its input closed")
+ }
+ if input.aborted.Load() {
+ t.Fatal("producer stop aborted input")
+ }
+}
+
+func TestProfileProbeSkipsInvalidInitialVideoKeyframe(t *testing.T) {
+ input := &fakeProfileInput{results: make(chan inputResult, 3), done: make(chan struct{})}
+ invalid := testVideoKeyframe("H265", 1_000_000)
+ invalid.Data[4] |= 0x80
+ input.results <- inputResult{packet: invalid}
+ input.results <- inputResult{packet: testVideoKeyframe("H265", 1_050_000)}
+ input.results <- inputResult{packet: testAAC(1_050_000)}
+
+ profile := &Profile{ctx: context.Background()}
+ probe, err := profile.probe(input)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if profile.pipeline.videoMedia == nil || profile.pipeline.videoCodec != "H265" {
+ t.Fatal("probe did not configure the valid replacement keyframe")
+ }
+ if len(probe.packets) != 2 || probe.packets[0].Kind != baichuan.MediaVideoI {
+ t.Fatalf("unexpected retained probe: packets=%d", len(probe.packets))
+ }
+}
+
+func TestProfileProbeRequiresEnabledAudio(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ defer cancel()
+ input := &fakeProfileInput{results: make(chan inputResult, 1), done: make(chan struct{})}
+ input.results <- inputResult{packet: testH264Keyframe(1_000_000)}
+ profile := &Profile{ctx: ctx}
+ if _, err := profile.probe(input); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("missing audio did not fail probe: %v", err)
+ }
+}
+
+func TestProducerRetainsReceiversForReconnect(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ receiver := producer.Receivers[0]
+ recording := make(chan *rtp.Packet, 2)
+ live := make(chan *rtp.Packet, 2)
+ (&core.Node{Input: func(packet *rtp.Packet) { recording <- packet }}).WithParent(&receiver.Node)
+ (&core.Node{Input: func(packet *rtp.Packet) { live <- packet }}).WithParent(&receiver.Node)
+ wantPacket := func(name string, packets <-chan *rtp.Packet) *rtp.Packet {
+ t.Helper()
+ select {
+ case packet := <-packets:
+ return packet
+ case <-time.After(time.Second):
+ t.Fatalf("%s consumer did not receive a packet", name)
+ return nil
+ }
+ }
+ done := make(chan error, 1)
+ go func() { done <- producer.Start() }()
+ wantPacket("recording", recording)
+ wantPacket("live", live)
+ opener.input(0).results <- inputResult{err: io.ErrUnexpectedEOF}
+ if err = <-done; !errors.Is(err, io.ErrUnexpectedEOF) {
+ t.Fatalf("unexpected terminal error: %v", err)
+ }
+
+ replacement := core.NewReceiver(receiver.Media, receiver.Codec)
+ receiver.Replace(replacement)
+ packet := &rtp.Packet{Header: rtp.Header{Timestamp: 42}, Payload: []byte{1}}
+ replacement.WriteRTP(packet)
+ for name, packets := range map[string]<-chan *rtp.Packet{"recording": recording, "live": live} {
+ if got := wantPacket(name, packets); got != packet {
+ t.Fatalf("replacement copied the RTP packet for %s", name)
+ }
+ }
+ if err = producer.Stop(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestProducerReconnectUsesForwardTimestampEpoch(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ first, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ videoReceiver := first.Receivers[0]
+ audioReceiver := first.Receivers[1]
+ videoPackets := make(chan *rtp.Packet, 4)
+ audioPackets := make(chan *rtp.Packet, 4)
+ (&core.Node{Input: func(packet *rtp.Packet) { videoPackets <- packet }}).WithParent(&videoReceiver.Node)
+ (&core.Node{Input: func(packet *rtp.Packet) { audioPackets <- packet }}).WithParent(&audioReceiver.Node)
+ firstDone := make(chan error, 1)
+ go func() { firstDone <- first.Start() }()
+ lastVideo := (<-videoPackets).Timestamp
+ lastAudio := (<-audioPackets).Timestamp
+ opener.input(0).results <- inputResult{packet: testH264PFrame(1_050_000)}
+ opener.input(0).results <- inputResult{packet: testAAC(1_050_000)}
+ lastVideo = (<-videoPackets).Timestamp
+ lastAudio = (<-audioPackets).Timestamp
+ opener.input(0).results <- inputResult{err: io.ErrUnexpectedEOF}
+ if err = <-firstDone; !errors.Is(err, io.ErrUnexpectedEOF) {
+ t.Fatalf("unexpected first terminal error: %v", err)
+ }
+
+ replacement, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ videoReceiver.Replace(replacement.Receivers[0])
+ audioReceiver.Replace(replacement.Receivers[1])
+ replacementDone := make(chan error, 1)
+ go func() { replacementDone <- replacement.Start() }()
+ nextVideo := (<-videoPackets).Timestamp
+ nextAudio := (<-audioPackets).Timestamp
+ if !timestampAfter(nextVideo, lastVideo) || !timestampAfter(nextAudio, lastAudio) {
+ t.Fatalf("replacement timestamps did not advance: video=%d->%d audio=%d->%d",
+ lastVideo, nextVideo, lastAudio, nextAudio)
+ }
+ if err = first.Stop(); err != nil {
+ t.Fatal(err)
+ }
+ if err = replacement.Stop(); err != nil {
+ t.Fatal(err)
+ }
+ if err = <-replacementDone; !errors.Is(err, context.Canceled) {
+ t.Fatalf("unexpected replacement terminal error: %v", err)
+ }
+}
+
+func TestProducerStopPropagatesInputCloseError(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ closeErr := errors.New("sentinel input close")
+ opener.input(0).closeErr = closeErr
+ if err = producer.Stop(); !errors.Is(err, closeErr) {
+ t.Fatalf("input close error was suppressed: %v", err)
+ }
+ if err = producer.Stop(); !errors.Is(err, closeErr) {
+ t.Fatalf("repeated stop lost close error: %v", err)
+ }
+}
+
+func TestProducerFailureStopsProfileBeforeReconnect(t *testing.T) {
+ format := baichuan.TalkFormat{SampleRate: 16000, SamplePrecision: 16, SamplesPerBlock: 4}
+ opener := &fakeProfileOpener{talk: &format}
+ registry := testRegistry(opener.open)
+ producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ producer.talk.dial = func(context.Context, baichuan.Config, uint8) (talkSession, io.Closer, error) {
+ return &fakeTalk{
+ format: format, blocks: make(chan []byte, 1), writeErr: errors.New("sentinel producer failure"),
+ }, &fakeTalkClient{}, nil
+ }
+ done := make(chan error, 1)
+ go func() { done <- producer.Start() }()
+ codec := &core.Codec{Name: core.CodecPCMA, ClockRate: 8000, Channels: 1}
+ media := &core.Media{
+ Kind: core.KindAudio, Direction: core.DirectionSendonly, Codecs: []*core.Codec{codec},
+ }
+ track := core.NewReceiver(media, codec)
+ if err = producer.AddTrack(media, codec, track); err != nil {
+ t.Fatal(err)
+ }
+ track.WriteRTP(&rtp.Packet{Payload: []byte{0xd5, 0xd5}})
+ select {
+ case err = <-done:
+ if err == nil || !strings.Contains(err.Error(), "sentinel producer failure") {
+ t.Fatalf("unexpected terminal error: %v", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("talkback failure did not stop the producer")
+ }
+ data, err := json.Marshal(producer)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(data), `"last_error_class":"protocol"`) {
+ t.Fatalf("diagnostics lost producer failure class: %s", data)
+ }
+ replacement, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=1")
+ if err != nil {
+ t.Fatalf("replacement rejected after terminal failure: %v", err)
+ }
+ if err = producer.Stop(); err != nil {
+ t.Fatal(err)
+ }
+ if err = replacement.Stop(); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/pkg/reolink/registry.go b/pkg/reolink/registry.go
new file mode 100644
index 000000000..25c3bc9b8
--- /dev/null
+++ b/pkg/reolink/registry.go
@@ -0,0 +1,160 @@
+package reolink
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/binary"
+ "fmt"
+ "hash"
+ "sync"
+
+ "github.com/AlexxIT/go2rtc/pkg/baichuan"
+ "github.com/rs/zerolog"
+)
+
+type cameraKey struct {
+ endpoint string
+ credential [sha256.Size]byte
+}
+
+type Registry struct {
+ mu sync.Mutex
+ cameras map[cameraKey]*Camera
+ secret [sha256.Size]byte
+ open profileOpener
+ log zerolog.Logger
+}
+
+func (r *Registry) Format(state fmt.State, _ rune) {
+ _, _ = fmt.Fprint(state, "reolink.Registry")
+}
+
+func NewRegistry(log zerolog.Logger) *Registry {
+ var secret [sha256.Size]byte
+ if _, err := rand.Read(secret[:]); err != nil {
+ panic(fmt.Errorf("reolink: initialize credential identity: %w", err))
+ }
+ return newRegistry(log, secret, openBaichuan)
+}
+
+func newRegistry(log zerolog.Logger, secret [sha256.Size]byte, open profileOpener) *Registry {
+ return &Registry{
+ cameras: make(map[cameraKey]*Camera), secret: secret,
+ open: open, log: log,
+ }
+}
+
+func (r *Registry) Dial(rawURL string) (*Producer, error) {
+ s, err := parseURL(rawURL)
+ if err != nil {
+ return nil, err
+ }
+ key := r.cameraKey(s)
+ s.username, s.password, s.identity = "", "", ""
+
+ profile, err := r.acquire(key, s)
+ if err != nil {
+ return nil, err
+ }
+ if err = profile.waitReady(); err != nil {
+ r.abort(profile)
+ return nil, err
+ }
+
+ var talk *baichuan.TalkFormat
+ if s.backchannel {
+ format, supported, probeErr := profile.probeTalk()
+ if probeErr != nil {
+ r.abort(profile)
+ return nil, probeErr
+ }
+ if supported {
+ talk = &format
+ }
+ }
+ if s.suppressVideo && s.suppressAudio && talk == nil {
+ r.abort(profile)
+ return nil, fmt.Errorf("reolink: backchannel-only source requires camera talkback")
+ }
+ return newProducer(s, profile, talk), nil
+}
+
+func (r *Registry) abort(profile *Profile) {
+ r.release(profile)
+ <-profile.done
+ profile.closeReceivers()
+}
+
+func (r *Registry) cameraKey(s source) cameraKey {
+ h := hmac.New(sha256.New, r.secret[:])
+ writeIdentityField(h, s.username)
+ writeIdentityField(h, s.password)
+ writeIdentityField(h, s.identity)
+ var credential [sha256.Size]byte
+ copy(credential[:], h.Sum(nil))
+ return cameraKey{endpoint: s.remote, credential: credential}
+}
+
+func writeIdentityField(w hash.Hash, value string) {
+ var size [8]byte
+ binary.BigEndian.PutUint64(size[:], uint64(len(value)))
+ _, _ = w.Write(size[:])
+ _, _ = w.Write([]byte(value))
+}
+
+func (r *Registry) acquire(key cameraKey, s source) (*Profile, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ camera := r.cameras[key]
+ if camera == nil {
+ camera = &Camera{
+ registry: r, key: key, remote: s.remote,
+ profiles: make(map[*Profile]struct{}), epochs: make(map[ProfileKey]mediaEpoch),
+ }
+ r.cameras[key] = camera
+ }
+ profileKey := ProfileKey{Channel: s.channel, Stream: s.stream}
+ camera.addSession(s.stream)
+ camera.generation++
+ profile := newProfile(camera, profileKey, camera.generation, s, r.open)
+ camera.refs++
+ camera.profiles[profile] = struct{}{}
+ r.log.Debug().Str("camera", s.remote).Uint8("channel", s.channel).
+ Str("profile", string(s.stream)).Uint64("generation", profile.generation).
+ Msg("reolink profile started")
+ go profile.run()
+ return profile, nil
+}
+
+func (r *Registry) release(profile *Profile) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ profile.mu.Lock()
+ if profile.released {
+ profile.mu.Unlock()
+ return
+ }
+ profile.released = true
+ if profile.camera.refs == 0 {
+ panic("reolink: camera reference accounting underflow")
+ }
+ profile.camera.refs--
+ if _, ok := profile.camera.profiles[profile]; ok {
+ delete(profile.camera.profiles, profile)
+ if profile.state == profileStarting || profile.state == profileActive {
+ profile.state = profileClosing
+ profile.cancel()
+ }
+ }
+ profile.mu.Unlock()
+ r.removeCameraLocked(profile.camera)
+}
+
+func (r *Registry) removeCameraLocked(camera *Camera) {
+ if camera.refs == 0 && len(camera.profiles) == 0 && camera.mainSessions == 0 && camera.otherSessions == 0 &&
+ r.cameras[camera.key] == camera {
+ delete(r.cameras, camera.key)
+ }
+}
diff --git a/pkg/reolink/registry_test.go b/pkg/reolink/registry_test.go
new file mode 100644
index 000000000..c2aa2c87f
--- /dev/null
+++ b/pkg/reolink/registry_test.go
@@ -0,0 +1,418 @@
+package reolink
+
+import (
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/AlexxIT/go2rtc/pkg/aac"
+ "github.com/AlexxIT/go2rtc/pkg/baichuan"
+ "github.com/rs/zerolog"
+)
+
+type inputResult struct {
+ packet baichuan.MediaPacket
+ err error
+}
+
+type fakeProfileInput struct {
+ results chan inputResult
+ done chan struct{}
+ once sync.Once
+ talk baichuan.TalkFormat
+ talkErr error
+ talks atomic.Uint32
+ closed atomic.Bool
+ aborted atomic.Bool
+ closeErr error
+ discovery discoverySnapshot
+}
+
+func newFakeProfileInput() *fakeProfileInput {
+ i := &fakeProfileInput{
+ results: make(chan inputResult, 16), done: make(chan struct{}),
+ talkErr: &baichuan.UnsupportedTalkError{Reason: "test camera"},
+ }
+ i.results <- inputResult{packet: testH264Keyframe(1_000_000)}
+ i.results <- inputResult{packet: testAAC(1_000_000)}
+ return i
+}
+
+func (i *fakeProfileInput) Read(ctx context.Context) (baichuan.MediaPacket, error) {
+ select {
+ case result := <-i.results:
+ return result.packet, result.err
+ case <-i.done:
+ return baichuan.MediaPacket{}, context.Canceled
+ case <-ctx.Done():
+ return baichuan.MediaPacket{}, ctx.Err()
+ }
+}
+
+func (i *fakeProfileInput) ProbeTalk(context.Context, uint8) (baichuan.TalkFormat, error) {
+ i.talks.Add(1)
+ return i.talk, i.talkErr
+}
+
+func (i *fakeProfileInput) Discovery() discoverySnapshot {
+ return i.discovery
+}
+
+func (i *fakeProfileInput) Close() error {
+ return i.close(false)
+}
+
+func (i *fakeProfileInput) Abort() error {
+ return i.close(true)
+}
+
+func (i *fakeProfileInput) close(aborted bool) error {
+ i.once.Do(func() {
+ i.aborted.Store(aborted)
+ i.closed.Store(true)
+ close(i.done)
+ })
+ return i.closeErr
+}
+
+type fakeProfileOpener struct {
+ mu sync.Mutex
+ inputs []*fakeProfileInput
+ talk *baichuan.TalkFormat
+ discovery discoverySnapshot
+}
+
+func (o *fakeProfileOpener) open(context.Context, *Camera, source) (profileInput, error) {
+ input := newFakeProfileInput()
+ if o.talk != nil {
+ input.talk = *o.talk
+ input.talkErr = nil
+ }
+ input.discovery = o.discovery
+ o.mu.Lock()
+ o.inputs = append(o.inputs, input)
+ o.mu.Unlock()
+ return input, nil
+}
+
+func (o *fakeProfileOpener) count() int {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ return len(o.inputs)
+}
+
+func (o *fakeProfileOpener) input(index int) *fakeProfileInput {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ return o.inputs[index]
+}
+
+func testRegistry(open profileOpener) *Registry {
+ secret := sha256.Sum256([]byte("test registry secret"))
+ return newRegistry(zerolog.Nop(), secret, open)
+}
+
+func testH264Keyframe(timestamp uint32) baichuan.MediaPacket {
+ return baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoI, Codec: "H264", Timestamp: timestamp,
+ Data: []byte{
+ 0, 0, 0, 1, 0x67, 0x64, 0, 0x29,
+ 0, 0, 0, 1, 0x68, 0,
+ 0, 0, 0, 1, 0x65, 0,
+ },
+ }
+}
+
+func testAAC(timestamp uint32) baichuan.MediaPacket {
+ data := []byte{0xff, 0xf1, 0x60, 0x40, 0, 0, 0xfc, 1, 2, 3}
+ aac.WriteADTSSize(data, uint16(len(data)))
+ return baichuan.MediaPacket{Kind: baichuan.MediaAAC, Timestamp: timestamp, Data: data}
+}
+
+func testH264PFrame(timestamp uint32) baichuan.MediaPacket {
+ return baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoP, Codec: "H264", Timestamp: timestamp,
+ Data: []byte{0, 0, 0, 1, 0x41, 1, 2, 3},
+ }
+}
+
+func waitFor(t *testing.T, condition func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for !condition() {
+ if time.Now().After(deadline) {
+ t.Fatal("condition did not become true")
+ }
+ time.Sleep(time.Millisecond)
+ }
+}
+
+func TestRegistryKeepsConfiguredSourcesIndependent(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ p1, err := registry.Dial("reolink://admin:secret@CAMERA.EXAMPLE./main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ p2, err := registry.Dial("reolink://admin:secret@camera.example:9000?stream=main&backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if opener.count() != 2 || p1.profile == p2.profile || p1.profile.camera != p2.profile.camera {
+ t.Fatalf("sources not isolated within one camera: opens=%d same_profile=%t same_camera=%t",
+ opener.count(), p1.profile == p2.profile, p1.profile.camera == p2.profile.camera)
+ }
+ if p1.Receivers[0] == p2.Receivers[0] {
+ t.Fatal("configured sources shared a core receiver")
+ }
+ if err = p1.Stop(); err != nil {
+ t.Fatal(err)
+ }
+ if !opener.input(0).closed.Load() {
+ t.Fatal("producer stop returned before its input closed")
+ }
+ if opener.input(1).closed.Load() {
+ t.Fatal("one source closed another source")
+ }
+ if err = p2.Stop(); err != nil {
+ t.Fatal(err)
+ }
+ if !opener.input(1).closed.Load() {
+ t.Fatal("second producer stop returned before its input closed")
+ }
+}
+
+func TestRegistryIsolatesCredentials(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ p1, err := registry.Dial("reolink://admin:first@camera/main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ p2, err := registry.Dial("reolink://admin:second@camera/main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if opener.count() != 2 || p1.profile == p2.profile {
+ t.Fatal("different credentials shared camera state")
+ }
+ _ = p1.Stop()
+ _ = p2.Stop()
+ waitFor(t, func() bool { return opener.input(0).closed.Load() && opener.input(1).closed.Load() })
+}
+
+func TestRegistryCameraRejectionDoesNotDisturbActiveProfile(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ rejected := &baichuan.StatusError{Code: 430}
+ opens := 0
+ registry := testRegistry(func(ctx context.Context, camera *Camera, source source) (profileInput, error) {
+ opens++
+ if opens == 2 {
+ return nil, rejected
+ }
+ return opener.open(ctx, camera, source)
+ })
+ producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = registry.Dial("reolink://admin:secret@camera/main?backchannel=0")
+ var status *baichuan.StatusError
+ if !errors.As(err, &status) || status.Code != rejected.Code {
+ t.Fatalf("unexpected camera rejection: %v", err)
+ }
+ if opener.input(0).closed.Load() {
+ t.Fatal("camera rejection closed the active profile")
+ }
+ registry.mu.Lock()
+ mainSessions, profiles := producer.profile.camera.mainSessions, len(producer.profile.camera.profiles)
+ registry.mu.Unlock()
+ if mainSessions != 1 || profiles != 1 {
+ t.Fatalf("active camera state after rejection: sessions=%d profiles=%d", mainSessions, profiles)
+ }
+ if err = producer.Stop(); err != nil {
+ t.Fatal(err)
+ }
+ waitFor(t, func() bool {
+ registry.mu.Lock()
+ defer registry.mu.Unlock()
+ return len(registry.cameras) == 0
+ })
+}
+
+func TestRegistryFailureCreatesNewGeneration(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ p1, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ startErr := make(chan error, 1)
+ go func() { startErr <- p1.Start() }()
+ opener.input(0).results <- inputResult{err: io.ErrUnexpectedEOF}
+ if err = <-startErr; !errors.Is(err, io.ErrUnexpectedEOF) {
+ t.Fatalf("unexpected terminal error: %v", err)
+ }
+ registry.mu.Lock()
+ mainSessions := p1.profile.camera.mainSessions
+ registry.mu.Unlock()
+ if mainSessions != 0 {
+ t.Fatalf("producer start returned with %d main sessions active", mainSessions)
+ }
+ p2, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p2.profile.generation != p1.profile.generation+1 || opener.count() != 2 {
+ t.Fatalf("replacement generation=%d opens=%d", p2.profile.generation, opener.count())
+ }
+ if err = p1.Stop(); err != nil {
+ t.Fatal(err)
+ }
+ if opener.input(1).closed.Load() {
+ t.Fatal("old generation release closed replacement")
+ }
+ if err = p2.Stop(); err != nil {
+ t.Fatal(err)
+ }
+ waitFor(t, opener.input(1).closed.Load)
+}
+
+func TestRegistryConcurrentDial(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ const count = 32
+ producers := make(chan *Producer, count)
+ errs := make(chan error, count)
+ var wg sync.WaitGroup
+ for range count {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0")
+ if err != nil {
+ errs <- err
+ return
+ }
+ producers <- producer
+ }()
+ }
+ wg.Wait()
+ close(producers)
+ close(errs)
+ accepted := 0
+ var camera *Camera
+ for producer := range producers {
+ accepted++
+ camera = producer.profile.camera
+ if err := producer.Stop(); err != nil {
+ t.Error(err)
+ }
+ }
+ for err := range errs {
+ t.Error(err)
+ }
+ if accepted != count || opener.count() != count {
+ t.Fatalf("accepted=%d opens=%d", accepted, opener.count())
+ }
+ waitFor(t, func() bool {
+ for i := range count {
+ if !opener.input(i).closed.Load() {
+ return false
+ }
+ }
+ registry.mu.Lock()
+ defer registry.mu.Unlock()
+ return camera.mainSessions == 0 && camera.otherSessions == 0 && len(registry.cameras) == 0
+ })
+}
+
+func TestRegistryScopesTalkProbeToConfiguredSource(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ const base = "reolink://admin:secret@camera/main"
+ withoutTalk, err := registry.Dial(base + "?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ first, err := registry.Dial(base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := registry.Dial(base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if opener.input(0).talks.Load() != 0 || opener.input(1).talks.Load() != 1 ||
+ opener.input(2).talks.Load() != 1 || withoutTalk.talk != nil || first.talk != nil || second.talk != nil {
+ t.Fatalf("unexpected talk probes: disabled=%d first=%d second=%d",
+ opener.input(0).talks.Load(), opener.input(1).talks.Load(), opener.input(2).talks.Load())
+ }
+ _ = withoutTalk.Stop()
+ _ = first.Stop()
+ _ = second.Stop()
+ waitFor(t, func() bool {
+ return opener.input(0).closed.Load() && opener.input(1).closed.Load() && opener.input(2).closed.Load()
+ })
+}
+
+func TestUIDCameraIdentityIsSecretAndRouteIndependent(t *testing.T) {
+ secret := [sha256.Size]byte{1}
+ registry := newRegistry(zerolog.Nop(), secret, nil)
+ first, err := parseURL("reolink://admin:pass@ABC1234567890001/main?transport=uid")
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := parseURL("reolink://admin:pass@ABC1234567890002/main?transport=uid")
+ if err != nil {
+ t.Fatal(err)
+ }
+ firstKey, secondKey := registry.cameraKey(first), registry.cameraKey(second)
+ if firstKey == secondKey || firstKey.endpoint != "uid" || secondKey.endpoint != "uid" {
+ t.Fatal("UID camera identities were not isolated")
+ }
+ selected, err := parseURL("reolink://admin:pass@ABC1234567890001/main?transport=uid&local=192.0.2.28")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if firstKey != registry.cameraKey(selected) {
+ t.Fatal("UID interface selection changed camera identity")
+ }
+ routed, err := parseURL("reolink://admin:pass@ABC1234567890001/main?transport=uid&broadcast=198.51.100.255")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if firstKey != registry.cameraKey(routed) {
+ t.Fatal("UID broadcast selection changed camera identity")
+ }
+}
+
+func TestAggregateFormattingRedactsCredentials(t *testing.T) {
+ source, err := parseURL("reolink://sentinel-user:sentinel-pass@camera/main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ producer, err := registry.Dial("reolink://sentinel-user:sentinel-pass@camera/main?backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer producer.Stop()
+
+ for _, value := range []any{source, registry, producer.profile.camera, producer.profile, producer} {
+ for _, format := range []string{"%v", "%+v", "%#v", "%s", "%q"} {
+ text := fmt.Sprintf(format, value)
+ if strings.Contains(text, "sentinel-user") || strings.Contains(text, "sentinel-pass") {
+ t.Fatalf("credentials exposed by %T with %s: %s", value, format, text)
+ }
+ }
+ }
+}
diff --git a/pkg/reolink/track_test.go b/pkg/reolink/track_test.go
new file mode 100644
index 000000000..b183492a7
--- /dev/null
+++ b/pkg/reolink/track_test.go
@@ -0,0 +1,189 @@
+package reolink
+
+import (
+ "errors"
+ "io"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/AlexxIT/go2rtc/pkg/baichuan"
+ "github.com/AlexxIT/go2rtc/pkg/core"
+ "github.com/pion/rtp"
+)
+
+func TestParseURLAcceptsTrackSuppression(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ url string
+ wantVideo bool
+ wantAudio bool
+ }{
+ {name: "default_both", url: "reolink://u:p@cam/main", wantVideo: true, wantAudio: true},
+ {name: "video_zero", url: "reolink://u:p@cam/main?video=0", wantAudio: true},
+ {name: "audio_zero", url: "reolink://u:p@cam/main?audio=0", wantVideo: true},
+ {name: "video_false", url: "reolink://u:p@cam/main?video=false", wantAudio: true},
+ {name: "audio_false", url: "reolink://u:p@cam/main?audio=false", wantVideo: true},
+ {name: "video_one", url: "reolink://u:p@cam/main?video=1", wantVideo: true, wantAudio: true},
+ {name: "audio_one", url: "reolink://u:p@cam/main?audio=1", wantVideo: true, wantAudio: true},
+ {name: "video_true", url: "reolink://u:p@cam/main?video=true", wantVideo: true, wantAudio: true},
+ {name: "audio_true", url: "reolink://u:p@cam/main?audio=true", wantVideo: true, wantAudio: true},
+ {name: "combined", url: "reolink://u:p@cam/main?video=0&audio=1", wantAudio: true},
+ {name: "backchannel_only", url: "reolink://u:p@cam/main?video=0&audio=false"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ s, err := parseURL(tc.url)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !s.suppressVideo != tc.wantVideo || !s.suppressAudio != tc.wantAudio {
+ t.Fatalf("unexpected tracks: video=%t audio=%t", !s.suppressVideo, !s.suppressAudio)
+ }
+ })
+ }
+}
+
+func TestParseURLRejectsInvalidTrackSelection(t *testing.T) {
+ for _, source := range []string{
+ "reolink://u:p@cam/main?video=0&audio=0&backchannel=0",
+ "reolink://u:p@cam/main?video=2",
+ "reolink://u:p@cam/main?audio=yes",
+ "reolink://u:p@cam/main?video=0&video=1",
+ } {
+ if _, err := parseURL(source); err == nil {
+ t.Fatalf("accepted invalid source: %s", source)
+ }
+ }
+}
+
+func TestAudioOnlyProfileRetainsLatestStartupPacket(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ producer, err := registry.Dial("reolink://admin:secret@camera/main?video=0&backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(producer.Medias) != 1 || producer.Medias[0].Kind != core.KindAudio ||
+ len(producer.Receivers) != 1 || producer.profile.video != nil || producer.profile.audio == nil {
+ t.Fatal("video-suppressed profile advertised unexpected tracks")
+ }
+ input := opener.input(0)
+ bytes := producer.profile.recvBytes.Load()
+ const packets = maxProbePackets + 16
+ for i := range packets {
+ packet := testAAC(uint32(i) * 64_000)
+ packet.Data[len(packet.Data)-1] = byte(i)
+ input.results <- inputResult{packet: packet}
+ }
+ waitFor(t, func() bool {
+ return producer.profile.recvBytes.Load() == bytes+packets*uint64(len(testAAC(0).Data))
+ })
+ select {
+ case <-producer.profile.done:
+ t.Fatal("audio-only profile failed before start")
+ default:
+ }
+
+ audio := make(chan *rtp.Packet, 2)
+ (&core.Node{Input: func(packet *rtp.Packet) { audio <- packet }}).WithParent(&producer.Receivers[0].Node)
+ done := make(chan error, 1)
+ go func() { done <- producer.Start() }()
+ select {
+ case packet := <-audio:
+ want := byte((packets - 1) % 256)
+ if got := packet.Payload[len(packet.Payload)-1]; got != want {
+ t.Fatalf("replayed audio payload = %d, want %d", got, want)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("latest startup audio packet was not replayed")
+ }
+ select {
+ case <-audio:
+ t.Fatal("replayed stale startup audio")
+ case <-time.After(10 * time.Millisecond):
+ }
+ input.results <- inputResult{err: io.ErrUnexpectedEOF}
+ if err = <-done; !errors.Is(err, io.ErrUnexpectedEOF) {
+ t.Fatalf("unexpected terminal error: %v", err)
+ }
+ if err = producer.Stop(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestProfileDeliversOnlyEnabledTracks(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ producer, err := registry.Dial("reolink://admin:secret@camera/main?audio=0&backchannel=0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(producer.Medias) != 1 || producer.Medias[0].Kind != core.KindVideo ||
+ len(producer.Receivers) != 1 || producer.profile.video == nil || producer.profile.audio != nil {
+ t.Fatal("audio-suppressed profile advertised unexpected tracks")
+ }
+ videoPackets := make(chan *rtp.Packet, 11)
+ (&core.Node{Input: func(packet *rtp.Packet) { videoPackets <- packet }}).WithParent(&producer.Receivers[0].Node)
+ done := make(chan error, 1)
+ go func() { done <- producer.Start() }()
+ input := opener.input(0)
+ for i := uint32(0); i < 10; i++ {
+ input.results <- inputResult{packet: testH264PFrame(1_000_000 + i*50_000)}
+ input.results <- inputResult{packet: testAAC(1_000_000 + i*50_000)}
+ }
+ for range 11 {
+ select {
+ case <-videoPackets:
+ case <-time.After(time.Second):
+ t.Fatal("video packet not delivered")
+ }
+ }
+ if video, audio := producer.profile.videoFrames.Load(), producer.profile.audioSamples.Load(); video != 11 || audio != 0 {
+ t.Fatalf("unexpected liveness state: video=%d audio_samples=%d", video, audio)
+ }
+ input.results <- inputResult{err: io.ErrUnexpectedEOF}
+ if err = <-done; !errors.Is(err, io.ErrUnexpectedEOF) {
+ t.Fatalf("unexpected terminal error: %v", err)
+ }
+ if err = producer.Stop(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestRegistryBackchannelOnlySource(t *testing.T) {
+ format := baichuan.TalkFormat{SampleRate: 16000, SamplesPerBlock: 1016}
+ opener := &fakeProfileOpener{talk: &format}
+ registry := testRegistry(opener.open)
+ producer, err := registry.Dial("reolink://admin:secret@camera/sub?video=0&audio=false")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(producer.Receivers) != 0 || len(producer.Medias) != 1 ||
+ producer.Medias[0].Direction != core.DirectionSendonly {
+ t.Fatalf("unexpected backchannel-only medias=%v receivers=%d", producer.Medias, len(producer.Receivers))
+ }
+ if err = producer.Stop(); err != nil {
+ t.Fatal(err)
+ }
+ if !opener.input(0).closed.Load() {
+ t.Fatal("backchannel-only stop returned before preview closed")
+ }
+}
+
+func TestRegistryRejectsUnsupportedBackchannelOnlySource(t *testing.T) {
+ opener := &fakeProfileOpener{}
+ registry := testRegistry(opener.open)
+ _, err := registry.Dial("reolink://admin:secret@camera/sub?video=0&audio=0")
+ if err == nil || !strings.Contains(err.Error(), "requires camera talkback") {
+ t.Fatalf("unexpected unsupported backchannel result: %v", err)
+ }
+ if opener.count() != 1 || !opener.input(0).closed.Load() {
+ t.Fatal("unsupported backchannel-only source leaked its preview")
+ }
+ registry.mu.Lock()
+ cameras := len(registry.cameras)
+ registry.mu.Unlock()
+ if cameras != 0 {
+ t.Fatalf("unsupported backchannel-only source retained %d cameras", cameras)
+ }
+}
diff --git a/pkg/reolink/video.go b/pkg/reolink/video.go
new file mode 100644
index 000000000..719629213
--- /dev/null
+++ b/pkg/reolink/video.go
@@ -0,0 +1,171 @@
+package reolink
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/binary"
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/AlexxIT/go2rtc/pkg/h264"
+ "github.com/AlexxIT/go2rtc/pkg/h264/annexb"
+ "github.com/AlexxIT/go2rtc/pkg/h265"
+)
+
+// encodeOwnedToAVCC reuses access units with nonempty NALs, four-byte start codes, and no AUD.
+// Preview owns packet data; every other layout uses the parent converter.
+func encodeOwnedToAVCC(data []byte) []byte {
+ if !canEncodeToAVCCInPlace(data) {
+ return annexb.EncodeToAVCC(data)
+ }
+ header, nalu := 0, 4
+ for {
+ offset := bytes.Index(data[nalu:], []byte{0, 0, 1})
+ if offset < 0 {
+ binary.BigEndian.PutUint32(data[header:], uint32(len(data)-nalu))
+ return data
+ }
+ next := nalu + offset - 1
+ binary.BigEndian.PutUint32(data[header:], uint32(next-nalu))
+ header, nalu = next, next+4
+ }
+}
+
+func canEncodeToAVCCInPlace(data []byte) bool {
+ if len(data) < 5 || binary.BigEndian.Uint32(data) != 1 {
+ return false
+ }
+ for nalu := 4; ; {
+ if nalu >= len(data) || data[nalu]&0x1f == 9 || data[nalu]&0x7e == 35<<1 {
+ return false
+ }
+ offset := bytes.Index(data[nalu:], []byte{0, 0, 1})
+ if offset < 0 {
+ return true
+ }
+ marker := nalu + offset
+ if marker <= nalu+1 || data[marker-1] != 0 {
+ return false
+ }
+ nalu = marker + 3
+ }
+}
+
+func validVideoConfig(codec string, data []byte) bool {
+ valid, err := inspectVideoPayload(codec, data)
+ return err == nil && valid
+}
+
+func inspectVideoPayload(codec string, data []byte) (bool, error) {
+ minNALU := 1
+ if codec == "H265" {
+ minNALU = 2
+ } else if codec != "H264" {
+ return false, fmt.Errorf("unsupported codec %q", codec)
+ }
+ if len(data) < 4+minNALU {
+ return false, fmt.Errorf("invalid AVCC framing")
+ }
+ var first, second, third, keyframe bool
+ index := 0
+ for len(data) != 0 {
+ if len(data) < 4 {
+ return false, fmt.Errorf("invalid AVCC framing")
+ }
+ size := binary.BigEndian.Uint32(data)
+ if size < uint32(minNALU) || uint64(size) > uint64(len(data)-4) {
+ return false, fmt.Errorf("invalid AVCC framing")
+ }
+ nalu := data[4 : 4+int(size)]
+ switch codec {
+ case "H264":
+ typeID := nalu[0] & 0x1f
+ if nalu[0]&0x80 != 0 {
+ return false, fmt.Errorf("NAL %d has forbidden bit", index)
+ }
+ if typeID == 0 || typeID >= 24 {
+ return false, fmt.Errorf("NAL %d has type %d", index, typeID)
+ }
+ switch typeID {
+ case h264.NALUTypeSPS:
+ if len(nalu) < 4 {
+ return false, nil
+ }
+ first = true
+ case h264.NALUTypePPS:
+ second = true
+ case h264.NALUTypeIFrame:
+ keyframe = true
+ }
+ case "H265":
+ typeID := (nalu[0] >> 1) & 0x3f
+ if nalu[0]&0x80 != 0 {
+ return false, fmt.Errorf("NAL %d has forbidden bit", index)
+ }
+ if typeID >= 48 {
+ return false, fmt.Errorf("NAL %d has type %d", index, typeID)
+ }
+ if nalu[1]&7 == 0 {
+ return false, fmt.Errorf("NAL %d has temporal ID zero", index)
+ }
+ switch typeID {
+ case h265.NALUTypeVPS:
+ first = true
+ case h265.NALUTypeSPS:
+ second = true
+ case h265.NALUTypePPS:
+ third = true
+ case h265.NALUTypeIFrame, h265.NALUTypeIFrame2, h265.NALUTypeIFrame3:
+ keyframe = true
+ }
+ }
+ data = data[4+int(size):]
+ index++
+ }
+ if codec == "H264" {
+ return first && second && keyframe, nil
+ }
+ return first && second && third && keyframe, nil
+}
+
+func h264ParameterSignature(data []byte) (string, error) {
+ const maxSets = 64
+ sets := make([]string, 0, 2)
+ count := 0
+ for len(data) >= 4 {
+ size := int(binary.BigEndian.Uint32(data))
+ if size <= 0 || size > len(data)-4 {
+ return "", mediaErrorf("reolink: invalid H264 parameter sets")
+ }
+ nalu := data[4 : 4+size]
+ typeID := nalu[0] & 0x1f
+ if typeID == h264.NALUTypeSPS || typeID == h264.NALUTypePPS {
+ if count == maxSets {
+ return "", mediaErrorf("reolink: H264 parameter sets exceed %d", maxSets)
+ }
+ count++
+ digest := sha256.Sum256(nalu)
+ var raw [sha256.Size + 1]byte
+ raw[0] = typeID
+ copy(raw[1:], digest[:])
+ key := string(raw[:])
+ duplicate := false
+ for _, existing := range sets {
+ if existing == key {
+ duplicate = true
+ break
+ }
+ }
+ if !duplicate {
+ sets = append(sets, key)
+ }
+ }
+ data = data[4+size:]
+ }
+ if len(data) != 0 || len(sets) < 2 {
+ return "", mediaErrorf("reolink: invalid H264 parameter sets")
+ }
+ sort.Strings(sets)
+ return strings.Join(sets, ""), nil
+}
diff --git a/pkg/reolink/video_test.go b/pkg/reolink/video_test.go
new file mode 100644
index 000000000..4232df9ba
--- /dev/null
+++ b/pkg/reolink/video_test.go
@@ -0,0 +1,335 @@
+package reolink
+
+import (
+ "bytes"
+ "encoding/base64"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/AlexxIT/go2rtc/pkg/baichuan"
+ "github.com/AlexxIT/go2rtc/pkg/h264/annexb"
+)
+
+func TestWritePacketRejectsVideoCodecChange(t *testing.T) {
+ pipeline := &mediaPipeline{videoCodec: "H265"}
+ if _, err := pipeline.frame(baichuan.MediaPacket{Kind: baichuan.MediaVideoP, Codec: "H264"}); err == nil {
+ t.Fatal("accepted video codec change")
+ }
+}
+
+func TestWritePacketAcceptsDuplicateH264ParameterSets(t *testing.T) {
+ data := h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 1)
+ pipeline := &mediaPipeline{}
+ if err := pipeline.configureVideo(baichuan.MediaPacket{Kind: baichuan.MediaVideoI, Codec: "H264", Data: data}); err != nil {
+ t.Fatal(err)
+ }
+ config := pipeline.videoConfig
+ changed := h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 2)
+ if frame, err := pipeline.frame(baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoI, Codec: "H264", Data: changed,
+ }); err != nil || !frame.valid() {
+ t.Fatalf("duplicate parameter sets failed: frame=%v err=%v", frame, err)
+ }
+ if pipeline.videoConfig != config {
+ t.Fatalf("parameter variant changed configuration: %q", pipeline.videoConfig)
+ }
+ if _, err := pipeline.frame(baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoI, Codec: "H264",
+ Data: h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 2),
+ }); err != nil {
+ t.Fatalf("repeated parameter variant failed: %v", err)
+ }
+}
+
+func TestWritePacketAcceptsReorderedH264ParameterSets(t *testing.T) {
+ sps, err := base64.StdEncoding.DecodeString("Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pps := []byte{0x68, 0xee, 0x3c, 0x80}
+ idr := []byte{0x65, 0}
+ pipeline := &mediaPipeline{}
+ if err = pipeline.configureVideo(baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoI, Codec: "H264", Data: h264AccessUnit(sps, pps, idr),
+ }); err != nil {
+ t.Fatal(err)
+ }
+ frame, err := pipeline.frame(baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoI, Codec: "H264", Data: h264AccessUnit(pps, sps, idr),
+ })
+ if err != nil || !frame.valid() {
+ t.Fatalf("reordered parameter sets failed: frame=%v err=%v", frame, err)
+ }
+}
+
+func TestWritePacketRejectsH264ParameterSetChange(t *testing.T) {
+ for _, name := range []string{"sps", "pps"} {
+ t.Run(name, func(t *testing.T) {
+ pipeline := &mediaPipeline{}
+ data := h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 1)
+ if err := pipeline.configureVideo(baichuan.MediaPacket{Kind: baichuan.MediaVideoI, Codec: "H264", Data: data}); err != nil {
+ t.Fatal(err)
+ }
+ changed := h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 1)
+ if name == "sps" {
+ changed[10] ^= 1
+ } else {
+ changed[len(changed)-7] ^= 1
+ }
+ packet := baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoI, Codec: "H264", Data: changed,
+ }
+ if frame, err := pipeline.frame(packet); err != nil || frame.valid() {
+ t.Fatalf("first parameter-set candidate was not suppressed: frame=%v err=%v", frame, err)
+ }
+ packet.Data = h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 1)
+ if name == "sps" {
+ packet.Data[10] ^= 1
+ } else {
+ packet.Data[len(packet.Data)-7] ^= 1
+ }
+ if _, err := pipeline.frame(packet); err == nil || !strings.Contains(err.Error(), "parameter sets changed") {
+ t.Fatalf("unexpected parameter-set error: %v", err)
+ }
+ })
+ }
+}
+
+func TestWritePacketRejectsH265ParameterSetChange(t *testing.T) {
+ pipeline := &mediaPipeline{}
+ if err := pipeline.configureVideo(testVideoKeyframe("H265", 0)); err != nil {
+ t.Fatal(err)
+ }
+ changed := testVideoKeyframe("H265", 1_000_000)
+ changed.Data[5] ^= 8 // change nuh_layer_id without invalidating the NAL header
+ if frame, err := pipeline.videoFrame(changed); err != nil || frame.valid() {
+ t.Fatalf("first parameter-set candidate was not suppressed: frame=%v err=%v", frame, err)
+ }
+ changed = testVideoKeyframe("H265", 1_050_000)
+ changed.Data[5] ^= 8
+ if _, err := pipeline.videoFrame(changed); err == nil || !strings.Contains(err.Error(), "parameter sets changed") {
+ t.Fatalf("unexpected parameter-set error: %v", err)
+ }
+}
+
+func TestWritePacketRecoversFromSingleH264ParameterCandidate(t *testing.T) {
+ pipeline := &mediaPipeline{}
+ base := h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 1)
+ if err := pipeline.configureVideo(baichuan.MediaPacket{Kind: baichuan.MediaVideoI, Codec: "H264", Data: base}); err != nil {
+ t.Fatal(err)
+ }
+ changed := h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 1)
+ changed[10] ^= 1
+ if frame, err := pipeline.frame(baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoI, Codec: "H264", Timestamp: 1_000_000, Data: changed,
+ }); err != nil || frame.valid() {
+ t.Fatalf("parameter candidate was not suppressed: frame=%v err=%v", frame, err)
+ }
+ if frame, err := pipeline.frame(baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoI, Codec: "H264", Timestamp: 1_050_000,
+ Data: h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 1),
+ }); err != nil || !frame.valid() {
+ t.Fatalf("original parameters did not recover: frame=%v err=%v", frame, err)
+ }
+}
+
+func TestWritePacketResyncsAfterExcessH264ParameterSets(t *testing.T) {
+ pipeline := &mediaPipeline{}
+ base := h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 1)
+ if err := pipeline.configureVideo(baichuan.MediaPacket{Kind: baichuan.MediaVideoI, Codec: "H264", Data: base}); err != nil {
+ t.Fatal(err)
+ }
+ if frame, err := pipeline.videoFrame(baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoI, Codec: "H264", Timestamp: 1_000_000,
+ Data: h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 64),
+ }); err != nil || frame.valid() {
+ t.Fatalf("excess parameter sets were not suppressed: frame=%v err=%v", frame, err)
+ }
+ if frame, err := pipeline.videoFrame(baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoI, Codec: "H264", Timestamp: 1_050_000, Data: base,
+ }); err != nil || !frame.valid() {
+ t.Fatalf("valid keyframe did not resync: frame=%v err=%v", frame, err)
+ }
+}
+
+func TestWritePacketInvalidVideoDoesNotPoisonClock(t *testing.T) {
+ pipeline := &mediaPipeline{}
+ base := testH264Keyframe(1_000_000)
+ if err := pipeline.configureVideo(base); err != nil {
+ t.Fatal(err)
+ }
+ if frame, err := pipeline.videoFrame(base); err != nil || !frame.valid() || frame.packet.Timestamp != 0 {
+ t.Fatalf("unexpected initial frame: frame=%v err=%v", frame, err)
+ }
+ if frame, err := pipeline.videoFrame(baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoP, Codec: "H264", Timestamp: 21_000_000,
+ Data: []byte{0, 0, 0, 1, 0xc1, 1},
+ }); err != nil || frame.valid() {
+ t.Fatalf("invalid frame was not suppressed: frame=%v err=%v", frame, err)
+ }
+ recovery := testH264Keyframe(1_100_000)
+ frame, err := pipeline.videoFrame(recovery)
+ if err != nil || !frame.valid() {
+ t.Fatalf("invalid timestamp poisoned recovery: frame=%v err=%v", frame, err)
+ }
+ if frame.packet.Timestamp != 9000 {
+ t.Fatalf("recovery timestamp = %d, want 9000", frame.packet.Timestamp)
+ }
+}
+
+func TestConfigureVideoBoundsH264ParameterSets(t *testing.T) {
+ data := h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 64)
+ err := (&mediaPipeline{}).configureVideo(baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoI, Codec: "H264", Data: data,
+ })
+ if err == nil || !strings.Contains(err.Error(), "parameter sets exceed 64") {
+ t.Fatalf("unexpected parameter-set limit error: %v", err)
+ }
+}
+
+func h264Keyframe(t *testing.T, encodedSPS string, ppsTail byte, spsCopies int) []byte {
+ t.Helper()
+ sps, err := base64.StdEncoding.DecodeString(encodedSPS)
+ if err != nil {
+ t.Fatal(err)
+ }
+ nalus := make([][]byte, 0, spsCopies+2)
+ for range spsCopies {
+ nalus = append(nalus, sps)
+ }
+ nalus = append(nalus, []byte{0x68, 0xee, 0x3c, ppsTail}, []byte{0x65, 0})
+ return h264AccessUnit(nalus...)
+}
+
+func h264AccessUnit(nalus ...[]byte) []byte {
+ var data []byte
+ for _, nalu := range nalus {
+ data = append(data, 0, 0, 0, 1)
+ data = append(data, nalu...)
+ }
+ return data
+}
+
+func TestEncodeOwnedToAVCC(t *testing.T) {
+ for _, input := range [][]byte{
+ {0, 0, 0, 1, 0x41, 1, 2, 0, 0, 0, 1, 0x41, 3},
+ {0, 0, 1, 0x41, 1, 2, 0, 0, 1, 0x41, 3},
+ {0, 0, 0, 1, 0x09, 0xf0, 0, 0, 0, 1, 0x41, 3},
+ {0, 0, 0, 1, 0x41, 1, 2, 0, 0, 1, 0x41, 3},
+ {0, 0, 0, 1, 0, 0, 0, 1, 0x41, 3},
+ } {
+ original := append([]byte(nil), input...)
+ want := annexb.EncodeToAVCC(original)
+ if got := encodeOwnedToAVCC(input); !bytes.Equal(got, want) {
+ t.Fatalf("unexpected conversion for %x: %x != %x", original, got, want)
+ }
+ }
+ input := []byte{0, 0, 0, 1, 0x41, 1, 2}
+ if got := encodeOwnedToAVCC(input); &got[0] != &input[0] {
+ t.Fatal("four-byte access unit was copied")
+ }
+}
+
+func FuzzEncodeOwnedToAVCC(f *testing.F) {
+ for _, input := range [][]byte{
+ {0, 0, 0, 1, 0x41, 1, 2, 0, 0, 0, 1, 0x41, 3},
+ {0, 0, 1, 0x41, 1, 2},
+ {0, 0, 0, 1, 0x09, 0xf0, 0, 0, 0, 1, 0x41, 3},
+ {0, 0, 0, 1, 0, 0, 0, 1, 0x41, 3},
+ } {
+ f.Add(input)
+ }
+ f.Fuzz(func(t *testing.T, input []byte) {
+ if len(input) > 1<<20 {
+ t.Skip()
+ }
+ original := append([]byte(nil), input...)
+ want := annexb.EncodeToAVCC(original)
+ if got := encodeOwnedToAVCC(input); !bytes.Equal(got, want) {
+ t.Fatalf("unexpected conversion for %x: %x != %x", original, got, want)
+ }
+ })
+}
+
+func TestAddVideoRejectsMalformedKeyframe(t *testing.T) {
+ for _, test := range []struct {
+ codec string
+ data []byte
+ }{
+ {codec: "H264"},
+ {codec: "H264", data: []byte{0, 0, 0, 1, 0x67, 0x64}},
+ {codec: "H264", data: []byte{
+ 0, 0, 0, 1, 0x67, 0x64,
+ 0, 0, 0, 1, 0x67, 0x64, 0, 0x29,
+ 0, 0, 0, 1, 0x68, 0,
+ 0, 0, 0, 1, 0x65, 0,
+ }},
+ {codec: "H264", data: []byte{0, 0, 0, 1, 0x65, 0}},
+ {codec: "H265", data: []byte{0, 0, 0, 1, 0x40}},
+ } {
+ pipeline := &mediaPipeline{}
+ if err := pipeline.configureVideo(baichuan.MediaPacket{Codec: test.codec, Data: test.data}); err == nil {
+ t.Fatalf("accepted malformed %s keyframe: %x", test.codec, test.data)
+ }
+ }
+}
+
+func TestWritePacketResyncsAfterInvalidNALHeaders(t *testing.T) {
+ for _, test := range []struct {
+ codec string
+ nalu []byte
+ }{
+ {codec: "H264", nalu: []byte{0, 1}},
+ {codec: "H264", nalu: []byte{0x78, 1}},
+ {codec: "H264", nalu: []byte{0xc1, 1}},
+ {codec: "H265", nalu: []byte{48 << 1, 1}},
+ {codec: "H265", nalu: []byte{0x80, 1}},
+ {codec: "H265", nalu: []byte{1 << 1, 0}},
+ } {
+ t.Run(fmt.Sprintf("%s_%x", test.codec, test.nalu), func(t *testing.T) {
+ pipeline := &mediaPipeline{}
+ keyframe := testVideoKeyframe(test.codec, 1_000_000)
+ if err := pipeline.configureVideo(keyframe); err != nil {
+ t.Fatal(err)
+ }
+ data := append([]byte{0, 0, 0, 1}, test.nalu...)
+ if frame, err := pipeline.videoFrame(baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoP, Codec: test.codec, Timestamp: 1_050_000, Data: data,
+ }); err != nil || frame.valid() {
+ t.Fatalf("invalid NAL was not suppressed: frame=%v err=%v", frame, err)
+ }
+ if frame, err := pipeline.videoFrame(testVideoPFrame(test.codec, 1_100_000)); err != nil || frame.valid() {
+ t.Fatalf("intervening frame was not suppressed: frame=%v err=%v", frame, err)
+ }
+ if frame, err := pipeline.videoFrame(testVideoKeyframe(test.codec, 1_150_000)); err != nil || !frame.valid() {
+ t.Fatalf("valid keyframe did not resync: frame=%v err=%v", frame, err)
+ }
+ })
+ }
+}
+
+func testVideoKeyframe(codec string, timestamp uint32) baichuan.MediaPacket {
+ if codec == "H264" {
+ return testH264Keyframe(timestamp)
+ }
+ return baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoI, Codec: "H265", Timestamp: timestamp,
+ Data: []byte{
+ 0, 0, 0, 1, 0x40, 1,
+ 0, 0, 0, 1, 0x42, 1,
+ 0, 0, 0, 1, 0x44, 1,
+ 0, 0, 0, 1, 0x26, 1,
+ },
+ }
+}
+
+func testVideoPFrame(codec string, timestamp uint32) baichuan.MediaPacket {
+ if codec == "H264" {
+ return testH264PFrame(timestamp)
+ }
+ return baichuan.MediaPacket{
+ Kind: baichuan.MediaVideoP, Codec: "H265", Timestamp: timestamp,
+ Data: []byte{0, 0, 0, 1, 2, 1, 0},
+ }
+}
diff --git a/website/.vitepress/config.js b/website/.vitepress/config.js
index 792f2e75e..357e3d774 100644
--- a/website/.vitepress/config.js
+++ b/website/.vitepress/config.js
@@ -26,7 +26,7 @@ export default defineConfig({
// second line of Telegram card (black bold), autodetect from site description
['meta', { property: 'og:title', content: 'go2rtc - Ultimate camera streaming application' }],
// third line of Telegram card, autodetect from site description
- ['meta', { property: 'og:description', content: 'Support alsa, doorbird, dvrip, eseecloud, ffmpeg, gopro, hass, hls, homekit, mjpeg, mp4, mpegts, nest, onvif, ring, roborock, rtmp, rtsp, tapo, vigi, tuya, v4l2, webrtc, wyze, xiaomi.' }],
+ ['meta', { property: 'og:description', content: 'Support alsa, doorbird, dvrip, eseecloud, ffmpeg, gopro, hass, hls, homekit, mjpeg, mp4, mpegts, nest, onvif, reolink, ring, roborock, rtmp, rtsp, tapo, vigi, tuya, v4l2, webrtc, wyze, xiaomi.' }],
['meta', { property: 'og:url', content: 'https://go2rtc.org/' }],
['meta', { property: 'og:image', content: 'https://go2rtc.org/images/logo.png' }],
// important for Telegram - the image will be at the bottom and large
@@ -138,6 +138,7 @@ export default defineConfig({
{text: 'mpeg', link: '/internal/mpeg/'},
{text: 'multitrans', link: '/internal/multitrans/'},
{text: 'nest', link: '/internal/nest/'},
+ {text: 'reolink', link: '/internal/reolink/'},
{text: 'ring', link: '/internal/ring/'},
{text: 'roborock', link: '/internal/roborock/'},
{text: 'tapo', link: '/internal/tapo/'},
diff --git a/www/schema.json b/www/schema.json
index 27fee57d7..17dde385c 100644
--- a/www/schema.json
+++ b/www/schema.json
@@ -174,6 +174,7 @@
"kasa",
"mpeg",
"nest",
+ "reolink",
"ring",
"roborock",
"tapo",