diff --git a/README.md b/README.md index 90a2537fc..bac14e8ed 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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: @@ -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#codec=H265 + - camera1_lq#res=1270x720#codec=H265 + - name: Camera 2 + streams: + - camera2 + - camera2_lq#res=640x360 + +streams: + camera1: + - rtsp://admin:admin@192.168.1.1/cam/realmonitor?channel=1&subtype=0&unicast=true + camera1_lq: + - ffmpeg:camera1#video=h265#height=720 + camera2: + - rtsp://admin:admin@192.168.1.2/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**: +- **Manufacturer**: ONVIF +- **IP Address**: +- **RTSP Port**: Self-adaptive +- **HTTP Port**: +- **Username / Password**: Currently auth is not supported by go2rtc +- **Remote CH No.**: + ### Module: Log You can set different log levels for different modules. diff --git a/internal/onvif/onvif.go b/internal/onvif/onvif.go index 6dfa633a6..9a72fdd34 100644 --- a/internal/onvif/onvif.go +++ b/internal/onvif/onvif.go @@ -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) @@ -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 { @@ -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) @@ -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") diff --git a/pkg/onvif/server.go b/pkg/onvif/server.go index 54272798a..9dc3b8f88 100644 --- a/pkg/onvif/server.go +++ b/pkg/onvif/server.go @@ -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 ( @@ -18,6 +25,8 @@ const ( DeviceGetNetworkInterfaces = "GetNetworkInterfaces" DeviceGetNetworkProtocols = "GetNetworkProtocols" DeviceGetNTP = "GetNTP" + DeviceGetOSDs = "GetOSDs" + DeviceGetOSDOptions = "GetOSDOptions" DeviceGetScopes = "GetScopes" DeviceGetServices = "GetServices" DeviceGetSystemDateAndTime = "GetSystemDateAndTime" @@ -140,51 +149,85 @@ func GetMediaServiceCapabilitiesResponse() []byte { return e.Bytes() } -func GetProfilesResponse(names []string) []byte { +func GetProfilesResponse(OnvifProfiles []OnvifProfile) []byte { e := NewEnvelope() e.Append(` `) - for _, name := range names { - appendProfile(e, "Profiles", name) + for _, cam := range OnvifProfiles { + appendProfile(e, "Profiles", cam) } e.Append(``) return e.Bytes() } -func GetProfileResponse(name string) []byte { +func GetProfileResponse(cam OnvifProfile) []byte { e := NewEnvelope() e.Append(` `) - appendProfile(e, "Profile", name) + appendProfile(e, "Profile", cam) e.Append(``) return e.Bytes() } -func appendProfile(e *Envelope, tag, name string) { - // empty `RateControl` important for UniFi Protect - e.Append(` - `, name, ` - - VSC - `, name, ` - - - - VEC - H264 - 19201080 - - - -`) +// 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 + } + + // get first stream as main stream + firstStream := profile.Streams[0] + firstName, firstWidth, firstHeight, _ := ParseStream(firstStream) + + for _, stream := range profile.Streams { + streamName, width, height, codec := ParseStream(stream) + + e.Append(` + `, streamName, ` + + VSC + `, firstName, ` + + + + SubStream + `, codec, ` + `, strconv.Itoa(width), ``, strconv.Itoa(height), ` + + + + `) + } +} + +func GetVideoSourceConfigurationsResponse(OnvifProfiles []OnvifProfile) []byte { e := NewEnvelope() e.Append(` `) - for _, name := range names { - appendProfile(e, "Configurations", name) + for _, cam := range OnvifProfiles { + appendProfile(e, "Configurations", cam) } e.Append(``) return e.Bytes() @@ -235,6 +278,40 @@ func GetSnapshotUriResponse(uri string) []byte { return e.Bytes() } +func GetOSDOptionsResponse() []byte { + e := NewEnvelope() + e.Append(` + + + Text + Custom + + Plain + + +`) + return e.Bytes() +} + +func GetOSDsResponse(configurationToken string, cameraName string) []byte { + e := NewEnvelope() + e.Append(` + + `, configurationToken, ` + Text + + Custom + + + + Plain + `, cameraName, ` + + +`) + return e.Bytes() +} + func StaticResponse(operation string) []byte { switch operation { case DeviceGetSystemDateAndTime: