Add ONVIF probe detector via unicast WS-Discovery

- Add ProbeONVIF() prober: sends unicast WS-Discovery to ip:3702,
  parses XAddrs, Name, Hardware from response (no auth needed)
- Add ONVIFResult struct to probe models
- Register ONVIF detector with highest priority (before HomeKit)
- Fix homekit.html back-wrapper max-width to match design system
This commit is contained in:
eduard256 2026-04-08 10:31:46 +00:00
parent 1291e6a5b6
commit 5be8d4aa00
4 changed files with 150 additions and 2 deletions

View file

@ -33,6 +33,14 @@ func Init() {
}
ports = loadPorts()
// ONVIF detector (highest priority -- auto-discovers all streams)
detectors = append(detectors, func(r *probe.Response) string {
if r.Probes.ONVIF != nil {
return "onvif"
}
return ""
})
// HomeKit detector
detectors = append(detectors, func(r *probe.Response) string {
if r.Probes.MDNS != nil {
@ -115,6 +123,12 @@ func runProbe(parent context.Context, ip string) *probe.Response {
resp.Probes.HTTP = r
mu.Unlock()
})
run(func() {
r, _ := probe.ProbeONVIF(fastCtx, ip)
mu.Lock()
resp.Probes.ONVIF = r
mu.Unlock()
})
wg.Wait()

View file

@ -14,6 +14,7 @@ type Probes struct {
ARP *ARPResult `json:"arp"`
MDNS *MDNSResult `json:"mdns"`
HTTP *HTTPResult `json:"http"`
ONVIF *ONVIFResult `json:"onvif"`
}
type PortsResult struct {
@ -43,3 +44,10 @@ type HTTPResult struct {
StatusCode int `json:"status_code"`
Server string `json:"server"`
}
type ONVIFResult struct {
URL string `json:"url"`
Port int `json:"port"`
Name string `json:"name,omitempty"`
Hardware string `json:"hardware,omitempty"`
}

126
pkg/probe/onvif.go Normal file
View file

@ -0,0 +1,126 @@
package probe
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"net/url"
"regexp"
"strings"
"time"
)
// ProbeONVIF sends unicast WS-Discovery probe to ip:3702.
// Returns nil, nil if the device does not support ONVIF.
func ProbeONVIF(ctx context.Context, ip string) (*ONVIFResult, error) {
conn, err := net.ListenPacket("udp4", ":0")
if err != nil {
return nil, err
}
defer conn.Close()
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(100 * time.Millisecond)
}
_ = conn.SetDeadline(deadline)
// WS-Discovery Probe message
// https://www.onvif.org/wp-content/uploads/2016/12/ONVIF_Feature_Discovery_Specification_16.07.pdf
msg := `<?xml version="1.0" ?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
<s:Header xmlns:a="http://schemas.xmlsoap.org/ws/2004/08/addressing">
<a:Action>http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</a:Action>
<a:MessageID>urn:uuid:` + randUUID() + `</a:MessageID>
<a:To>urn:schemas-xmlsoap-org:ws:2005:04:discovery</a:To>
</s:Header>
<s:Body>
<d:Probe xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery">
<d:Types />
<d:Scopes />
</d:Probe>
</s:Body>
</s:Envelope>`
addr := &net.UDPAddr{IP: net.ParseIP(ip), Port: 3702}
if _, err = conn.WriteTo([]byte(msg), addr); err != nil {
return nil, err
}
buf := make([]byte, 8192)
for {
n, _, err := conn.ReadFrom(buf)
if err != nil {
return nil, nil // timeout -- device doesn't support ONVIF
}
body := string(buf[:n])
if !strings.Contains(body, "onvif") {
continue
}
xaddrs := findXMLTag(body, "XAddrs")
if xaddrs == "" {
continue
}
// fix buggy cameras reporting 0.0.0.0
// ex. <wsdd:XAddrs>http://0.0.0.0:8080/onvif/device_service</wsdd:XAddrs>
if s, ok := strings.CutPrefix(xaddrs, "http://0.0.0.0"); ok {
xaddrs = "http://" + ip + s
}
port := 80
if u, err := url.Parse(xaddrs); err == nil && u.Port() != "" {
fmt.Sscanf(u.Port(), "%d", &port)
}
scopes := findXMLTag(body, "Scopes")
return &ONVIFResult{
URL: xaddrs,
Port: port,
Name: findScope(scopes, "onvif://www.onvif.org/name/"),
Hardware: findScope(scopes, "onvif://www.onvif.org/hardware/"),
}, nil
}
}
// internals
var reXMLTag = map[string]*regexp.Regexp{}
func findXMLTag(s, tag string) string {
re, ok := reXMLTag[tag]
if !ok {
re = regexp.MustCompile(`(?s)<(?:\w+:)?` + tag + `\b[^>]*>([^<]+)`)
reXMLTag[tag] = re
}
m := re.FindStringSubmatch(s)
if len(m) != 2 {
return ""
}
return m[1]
}
func findScope(s, prefix string) string {
i := strings.Index(s, prefix)
if i < 0 {
return ""
}
s = s[i+len(prefix):]
if j := strings.IndexByte(s, ' '); j >= 0 {
s = s[:j]
}
s, _ = url.QueryUnescape(s)
return s
}
func randUUID() string {
b := make([]byte, 16)
rand.Read(b)
s := hex.EncodeToString(b)
return s[:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:]
}

View file

@ -60,13 +60,13 @@
.back-wrapper {
position: absolute; top: 1.5rem;
left: 50%; transform: translateX(-50%);
width: 100%; max-width: 480px;
width: 100%; max-width: 600px;
padding: 0 1.5rem;
z-index: 10;
}
@media (min-width: 768px) {
.back-wrapper { max-width: 540px; }
.back-wrapper { max-width: 660px; }
}
.btn-back {