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(`