Skip to content
Open
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg
* [Module: MP4](#module-mp4)
* [Module: HLS](#module-hls)
* [Module: MJPEG](#module-mjpeg)
* [Module: ONVIF](#module-onvif)
* [Module: Log](#module-log)
* [Security](#security)
* [Codecs filters](#codecs-filters)
Expand Down Expand Up @@ -175,6 +176,7 @@ Available modules:
- [mp4](#module-mp4) - MSE, MP4 stream and MP4 snapshot Server
- [hls](#module-hls) - HLS TS or fMP4 stream Server
- [mjpeg](#module-mjpeg) - MJPEG Server
- [onvif](#module-onvif) - ONVIF server
- [ffmpeg](#source-ffmpeg) - FFmpeg integration
- [ngrok](#module-ngrok) - ngrok integration (external access for private network)
- [hass](#module-hass) - Home Assistant integration
Expand Down Expand Up @@ -1112,7 +1114,7 @@ You have several options on how to add a camera to Home Assistant:
2. Camera [any source](#module-streams) => [go2rtc config](#configuration) => [Generic Camera](https://www.home-assistant.io/integrations/generic/)
- Install any [go2rtc](#fast-start)
- Add your stream to [go2rtc config](#configuration)
- Hass > Settings > Integrations > Add Integration > [ONVIF](https://my.home-assistant.io/redirect/config_flow_start/?domain=onvif) > Host: `127.0.0.1`, Port: `1984`
- Hass > Settings > Integrations > Add Integration > [ONVIF](https://my.home-assistant.io/redirect/config_flow_start/?domain=onvif) > Host: `127.0.0.1`, Port: `1984` (using [Module: ONVIF](#module-onvif))
- Hass > Settings > Integrations > Add Integration > [Generic Camera](https://my.home-assistant.io/redirect/config_flow_start/?domain=generic) > Stream Source URL: `rtsp://127.0.0.1:8554/camera1` (change to your stream name, leave everything else as is)

You have several options on how to watch the stream from the cameras in Home Assistant:
Expand Down Expand Up @@ -1208,6 +1210,53 @@ API examples:

[![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M)

### Module: ONVIF

This module provides an **ONVIF server** that allows go2rtc to act as an ONVIF-compatible device, making it easier to integrate cameras with ONVIF-supported software like Dahua NVRs or Home Assistant.

With ONVIF support, go2rtc can:
- Expose configured streams as ONVIF profiles.
- Provide additional ONVIF functionalities like `GetOSDs` to show camera name in Dahua NVR.
- Maintain a **consistent camera order** to prevent issues with NVRs that rely on `GetProfilesResponse` for identification.

**Example Configuration**

```yaml
onvif:
profiles:
- name: Camera 1
streams:
- camera1#res=1920x1080
- camera1_lq#res=1270x720#codec=H265
- name: Camera 2
streams:
- camera2
- camera2_lq#res=640x360

streams:
camera1:
- rtsp://admin:[email protected]/cam/realmonitor?channel=1&subtype=0&unicast=true
camera1_lq:
- ffmpeg:camera1#video=h265#height=360
camera2:
- rtsp://admin:[email protected]/cam/realmonitor?channel=1&subtype=0&unicast=true
camera2_lq:
- ffmpeg:camera2#video=h264#height=360
```

Default params for `streams`:
- `res=1920x1080`
- `codec=H264`

**Example Dahua NVR configuration:**
- **Channel**: <camera channel on NVR>
- **Manufacturer**: ONVIF
- **IP Address**: <go2rtc IP>
- **RTSP Port**: Self-adaptive
- **HTTP Port**: <go2rtc http api port, eg. 1984>
- **Username / Password**: Currently auth is not supported by go2rtc
- **Remote CH No.**: <camera index from onvif array, counting from 1>

### Module: Log

You can set different log levels for different modules.
Expand Down
62 changes: 58 additions & 4 deletions internal/onvif/onvif.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,18 @@ import (
"github.com/rs/zerolog"
)

var OnvifProfiles []onvif.OnvifProfile

func Init() {
var cfg struct {
Onvif struct {
OnvifProfiles []onvif.OnvifProfile `yaml:"profiles"`
} `yaml:"onvif"`
}

app.LoadConfig(&cfg)
OnvifProfiles = cfg.Onvif.OnvifProfiles

log = app.GetLogger("onvif")

streams.HandleFunc("onvif", streamOnvif)
Expand All @@ -32,6 +43,34 @@ func Init() {

var log zerolog.Logger

func GetConfiguredStreams() []string {
if len(OnvifProfiles) == 0 {
return streams.GetAllNames()
}

var streamsList []string
for _, profile := range OnvifProfiles {
for _, stream := range profile.Streams {
name, _, _, _ := onvif.ParseStream(stream)
streamsList = append(streamsList, name)
}
}

return streamsList
}

func GetCameraNameByStream(streamName string) string {
for _, profile := range OnvifProfiles {
for _, stream := range profile.Streams {
name, _, _, _ := onvif.ParseStream(stream)
if name == streamName {
return profile.Name
}
}
}
return "Unknown Camera"
}

func streamOnvif(rawURL string) (core.Producer, error) {
client, err := onvif.NewClient(rawURL)
if err != nil {
Expand Down Expand Up @@ -86,6 +125,13 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
case onvif.DeviceGetServices:
b = onvif.GetServicesResponse(r.Host)

case onvif.DeviceGetOSDs:
token := onvif.FindTagValue(b, "ConfigurationToken")
b = onvif.GetOSDsResponse(token, GetCameraNameByStream(token))

case onvif.DeviceGetOSDOptions:
b = onvif.GetOSDOptionsResponse()

case onvif.DeviceGetDeviceInformation:
// important for Hass: SerialNumber (unique server ID)
b = onvif.GetDeviceInformationResponse("", "go2rtc", app.Version, r.Host)
Expand All @@ -103,19 +149,27 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
})

case onvif.MediaGetVideoSources:
b = onvif.GetVideoSourcesResponse(streams.GetAllNames())
b = onvif.GetVideoSourcesResponse(GetConfiguredStreams())

case onvif.MediaGetProfiles:
// important for Hass: H264 codec, width, height
b = onvif.GetProfilesResponse(streams.GetAllNames())
b = onvif.GetProfilesResponse(OnvifProfiles)

case onvif.MediaGetProfile:
token := onvif.FindTagValue(b, "ProfileToken")
b = onvif.GetProfileResponse(token)
for _, profile := range OnvifProfiles {
for _, stream := range profile.Streams {
name, _, _, _ := onvif.ParseStream(stream)
if name == token {
b = onvif.GetProfileResponse(profile)
break
}
}
}

case onvif.MediaGetVideoSourceConfigurations:
// important for Happytime Onvif Client
b = onvif.GetVideoSourceConfigurationsResponse(streams.GetAllNames())
b = onvif.GetVideoSourceConfigurationsResponse(OnvifProfiles)

case onvif.MediaGetVideoSourceConfiguration:
token := onvif.FindTagValue(b, "ConfigurationToken")
Expand Down
127 changes: 102 additions & 25 deletions pkg/onvif/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@ import (
"bytes"
"regexp"
"time"
"strconv"
"strings"
)

type OnvifProfile struct {
Name string `yaml:"name"`
Streams []string `yaml:"streams"`
}

const ServiceGetServiceCapabilities = "GetServiceCapabilities"

const (
Expand All @@ -18,6 +25,8 @@ const (
DeviceGetNetworkInterfaces = "GetNetworkInterfaces"
DeviceGetNetworkProtocols = "GetNetworkProtocols"
DeviceGetNTP = "GetNTP"
DeviceGetOSDs = "GetOSDs"
DeviceGetOSDOptions = "GetOSDOptions"
DeviceGetScopes = "GetScopes"
DeviceGetServices = "GetServices"
DeviceGetSystemDateAndTime = "GetSystemDateAndTime"
Expand Down Expand Up @@ -140,51 +149,85 @@ func GetMediaServiceCapabilitiesResponse() []byte {
return e.Bytes()
}

func GetProfilesResponse(names []string) []byte {
func GetProfilesResponse(OnvifProfiles []OnvifProfile) []byte {
e := NewEnvelope()
e.Append(`<trt:GetProfilesResponse>
`)
for _, name := range names {
appendProfile(e, "Profiles", name)
for _, cam := range OnvifProfiles {
appendProfile(e, "Profiles", cam)
}
e.Append(`</trt:GetProfilesResponse>`)
return e.Bytes()
}

func GetProfileResponse(name string) []byte {
func GetProfileResponse(cam OnvifProfile) []byte {
e := NewEnvelope()
e.Append(`<trt:GetProfileResponse>
`)
appendProfile(e, "Profile", name)
appendProfile(e, "Profile", cam)
e.Append(`</trt:GetProfileResponse>`)
return e.Bytes()
}

func appendProfile(e *Envelope, tag, name string) {
// empty `RateControl` important for UniFi Protect
e.Append(`<trt:`, tag, ` token="`, name, `" fixed="true">
<tt:Name>`, name, `</tt:Name>
<tt:VideoSourceConfiguration token="`, name, `">
<tt:Name>VSC</tt:Name>
<tt:SourceToken>`, name, `</tt:SourceToken>
<tt:Bounds x="0" y="0" width="1920" height="1080"></tt:Bounds>
</tt:VideoSourceConfiguration>
<tt:VideoEncoderConfiguration token="vec">
<tt:Name>VEC</tt:Name>
<tt:Encoding>H264</tt:Encoding>
<tt:Resolution><tt:Width>1920</tt:Width><tt:Height>1080</tt:Height></tt:Resolution>
<tt:RateControl />
</tt:VideoEncoderConfiguration>
</trt:`, tag, `>
`)
// Parsing stream name to: name, width, height, codec
func ParseStream(stream string) (string, int, int, string) {
parts := strings.Split(stream, "#")
name := parts[0]
width, height := 1920, 1080 // default resolution
codec := "H264" // default codec

resRegex := regexp.MustCompile(`res=(\d+)x(\d+)`)
codecRegex := regexp.MustCompile(`codec=([a-zA-Z0-9]+)`)

for _, part := range parts[1:] {
if matches := resRegex.FindStringSubmatch(part); len(matches) == 3 {
width, _ = strconv.Atoi(matches[1])
height, _ = strconv.Atoi(matches[2])
}
if matches := codecRegex.FindStringSubmatch(part); len(matches) == 2 {
codec = matches[1]
}
}

return name, width, height, codec
}

func GetVideoSourceConfigurationsResponse(names []string) []byte {
func appendProfile(e *Envelope, tag string, profile OnvifProfile) {
if len(profile.Streams) == 0 {
return
}

// Pierwszy stream jako główny
firstStream := profile.Streams[0]
firstName, firstWidth, firstHeight, _ := ParseStream(firstStream)

for _, stream := range profile.Streams {
streamName, width, height, codec := ParseStream(stream)

e.Append(`<trt:`, tag, ` token="`, streamName, `" fixed="true">
<tt:Name>`, streamName, `</tt:Name>
<tt:VideoSourceConfiguration token="`, firstName, `">
<tt:Name>VSC</tt:Name>
<tt:SourceToken>`, firstName, `</tt:SourceToken>
<tt:Bounds x="0" y="0" width="`, strconv.Itoa(firstWidth), `" height="`, strconv.Itoa(firstHeight), `"></tt:Bounds>
</tt:VideoSourceConfiguration>
<tt:VideoEncoderConfiguration token="`, streamName, `">
<tt:Name>SubStream</tt:Name>
<tt:Encoding>`, codec, `</tt:Encoding>
<tt:Resolution><tt:Width>`, strconv.Itoa(width), `</tt:Width><tt:Height>`, strconv.Itoa(height), `</tt:Height></tt:Resolution>
<tt:RateControl />
</tt:VideoEncoderConfiguration>
</trt:`, tag, `>
`)
}
}

func GetVideoSourceConfigurationsResponse(OnvifProfiles []OnvifProfile) []byte {
e := NewEnvelope()
e.Append(`<trt:GetVideoSourceConfigurationsResponse>
`)
for _, name := range names {
appendProfile(e, "Configurations", name)
for _, cam := range OnvifProfiles {
appendProfile(e, "Configurations", cam)
}
e.Append(`</trt:GetVideoSourceConfigurationsResponse>`)
return e.Bytes()
Expand Down Expand Up @@ -235,6 +278,40 @@ func GetSnapshotUriResponse(uri string) []byte {
return e.Bytes()
}

func GetOSDOptionsResponse() []byte {
e := NewEnvelope()
e.Append(`<trt:GetOSDOptionsResponse>
<trt:OSDOptions>
<tt:MaximumNumberOfOSDs Total="1" Image="0" PlainText="1" Date="0" Time="0" DateAndTime="0"/>
<tt:Type>Text</tt:Type>
<tt:PositionOption>Custom</tt:PositionOption>
<tt:TextOption>
<tt:Type>Plain</tt:Type>
</tt:TextOption>
</trt:OSDOptions>
</trt:GetOSDOptionsResponse>`)
return e.Bytes()
}

func GetOSDsResponse(configurationToken string, cameraName string) []byte {
e := NewEnvelope()
e.Append(`<trt:GetOSDsResponse>
<trt:OSDs token="OSD00000">
<tt:VideoSourceConfigurationToken>`, configurationToken, `</tt:VideoSourceConfigurationToken>
<tt:Type>Text</tt:Type>
<tt:Position>
<tt:Type>Custom</tt:Type>
<tt:Pos x="0" y="0"/>
</tt:Position>
<tt:TextString>
<tt:Type>Plain</tt:Type>
<tt:PlainText>`, cameraName, `</tt:PlainText>
</tt:TextString>
</trt:OSDs>
</trt:GetOSDsResponse>`)
return e.Bytes()
}

func StaticResponse(operation string) []byte {
switch operation {
case DeviceGetSystemDateAndTime:
Expand Down