Skip to content
Open
44 changes: 43 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 @@ -586,7 +588,7 @@ Support import camera links from [Home Assistant](https://www.home-assistant.io/

- [Generic Camera](https://www.home-assistant.io/integrations/generic/), setup via GUI
- [HomeKit Camera](https://www.home-assistant.io/integrations/homekit_controller/)
- [ONVIF](https://www.home-assistant.io/integrations/onvif/)
- [ONVIF](https://www.home-assistant.io/integrations/onvif/) via [Module: ONVIF](#module-onvif)
Copy link
Contributor

Choose a reason for hiding this comment

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

This is the wrong section, this one is talking about importing cameras FROM Home Assistant, not TO Home Assistant. Please revert this.

Here is where I meant:

https://github.com/AlexxIT/go2rtc#module-hass:~:text=Integrations%20%3E%20Add%20Integration-,%3E%20ONVIF%20%3E,-Host%3A%20127.0.0.1

image

- [Roborock](https://github.com/humbertogontijo/homeassistant-roborock) vacuums with camera

```yaml
Expand Down Expand Up @@ -1208,6 +1210,46 @@ 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:
- name: Camera 1
main_stream: camera1
sub_stream: camera1_lq
Copy link
Contributor

@felipecrs felipecrs Mar 14, 2025

Choose a reason for hiding this comment

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

I think we should think on how this config will look like in the future. How about:

onvif:
  - name: Camera 1
    streams:
      - camera1
      - camera1_lq

The first stream would always be mainstream, while the second would be substream if present.

By the way some cameras have additional streams, how are they usually exposed in this case? Maybe we should support it too.

This approach is better because it doesn't require to document third_stream, fourth_stream, options. Also, it's less things to type.

As per resolution, maybe this?

onvif:
  - name: Camera 1
    streams:
      - camera1#res=1920x1080
      - camera1_lq#res=1270x720

Very go2rtc-style. :)

Copy link
Contributor

@felipecrs felipecrs Mar 14, 2025

Choose a reason for hiding this comment

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

Another thing is, you are adding onvif at the top as an array of cameras. But what if in the future we may want to other introduce onvif global configurations?

To protect against this, I think you should make it a nested option within the top onvif like this:

onvif:
  cameras: # or maybe "profiles"?
    - name: Camera 1
      # ...

Copy link
Author

Choose a reason for hiding this comment

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

Thanks for feedback.
I changed OnvifCameras to OnvifProfiles and added function to parse stream arguments (res, codec).

New config:

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

By default:

  • res=1920x1080
  • codec=H264

- name: Camera 2
main_stream: camera2
sub_stream: camera2_lq

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

**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
53 changes: 49 additions & 4 deletions internal/onvif/onvif.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,16 @@ import (
"github.com/rs/zerolog"
)

var OnvifCameras []onvif.OnvifCamera

func Init() {
var cfg struct {
OnvifCameras []onvif.OnvifCamera `yaml:"onvif"`
}

app.LoadConfig(&cfg)
OnvifCameras = cfg.OnvifCameras

log = app.GetLogger("onvif")

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

var log zerolog.Logger

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

var streamsList []string
for _, cam := range OnvifCameras {
streamsList = append(streamsList, cam.MainStream)
if cam.SubStream != "" {
streamsList = append(streamsList, cam.SubStream)
}
}

return streamsList
}

func GetCameraNameByMainStream(mainStream string) string {
for _, cam := range OnvifCameras {
if cam.MainStream == mainStream || cam.SubStream == mainStream {
return cam.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 +120,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, GetCameraNameByMainStream(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 +144,23 @@ 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(OnvifCameras)

case onvif.MediaGetProfile:
token := onvif.FindTagValue(b, "ProfileToken")
b = onvif.GetProfileResponse(token)
for _, cam := range OnvifCameras {
if(cam.MainStream == token || cam.SubStream == token){
b = onvif.GetProfileResponse(cam)
}
}

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

case onvif.MediaGetVideoSourceConfiguration:
token := onvif.FindTagValue(b, "ConfigurationToken")
Expand Down
91 changes: 75 additions & 16 deletions pkg/onvif/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import (
"time"
)

type OnvifCamera struct {
Name string `yaml:"name"`
MainStream string `yaml:"main_stream"`
SubStream string `yaml:"sub_stream,omitempty"`
}

const ServiceGetServiceCapabilities = "GetServiceCapabilities"

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

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

func GetProfileResponse(name string) []byte {
func GetProfileResponse(cam OnvifCamera) []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, `">
func appendProfile(e *Envelope, tag string, cam OnvifCamera) {
e.Append(`<trt:`, tag, ` token="`, cam.MainStream, `" fixed="true">
<tt:Name>`, cam.MainStream, `</tt:Name>
<tt:VideoSourceConfiguration token="`, cam.MainStream, `">
<tt:Name>VSC</tt:Name>
<tt:SourceToken>`, name, `</tt:SourceToken>
<tt:SourceToken>`, cam.MainStream, `</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:VideoEncoderConfiguration token="`, cam.MainStream, `">
<tt:Name>MainStream</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, `>
`)

if cam.SubStream != "" {
e.Append(`<trt:`, tag, ` token="`, cam.SubStream, `" fixed="true">
<tt:Name>`, cam.SubStream, `</tt:Name>
<tt:VideoSourceConfiguration token="`, cam.MainStream, `">
<tt:Name>VSC</tt:Name>
<tt:SourceToken>`, cam.MainStream, `</tt:SourceToken>
<tt:Bounds x="0" y="0" width="640" height="360"></tt:Bounds>
</tt:VideoSourceConfiguration>
<tt:VideoEncoderConfiguration token="`, cam.SubStream, `">
<tt:Name>SubStream</tt:Name>
<tt:Encoding>H264</tt:Encoding>
<tt:Resolution><tt:Width>640</tt:Width><tt:Height>360</tt:Height></tt:Resolution>
<tt:RateControl />
</tt:VideoEncoderConfiguration>
</trt:`, tag, `>
`)
}
}

func GetVideoSourceConfigurationsResponse(names []string) []byte {
func GetVideoSourceConfigurationsResponse(OnvifCameras []OnvifCamera) []byte {
e := NewEnvelope()
e.Append(`<trt:GetVideoSourceConfigurationsResponse>
`)
for _, name := range names {
appendProfile(e, "Configurations", name)
for _, cam := range OnvifCameras {
appendProfile(e, "Configurations", cam)
}
e.Append(`</trt:GetVideoSourceConfigurationsResponse>`)
return e.Bytes()
Expand Down Expand Up @@ -235,6 +260,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