Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions internal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions internal/reolink/README.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions internal/reolink/reolink.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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},
Expand Down
1 change: 1 addition & 0 deletions pkg/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:` |
Expand Down
118 changes: 118 additions & 0 deletions pkg/baichuan/adpcm.go
Original file line number Diff line number Diff line change
@@ -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
}
124 changes: 124 additions & 0 deletions pkg/baichuan/capability.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading