From 5be8d4aa0063388348f102fa04baa0696435f001 Mon Sep 17 00:00:00 2001 From: eduard256 Date: Wed, 8 Apr 2026 10:31:46 +0000 Subject: [PATCH] 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 --- internal/probe/probe.go | 14 +++++ pkg/probe/models.go | 8 +++ pkg/probe/onvif.go | 126 ++++++++++++++++++++++++++++++++++++++++ www/homekit.html | 4 +- 4 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 pkg/probe/onvif.go diff --git a/internal/probe/probe.go b/internal/probe/probe.go index 90e7838..63bd0d7 100644 --- a/internal/probe/probe.go +++ b/internal/probe/probe.go @@ -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() diff --git a/pkg/probe/models.go b/pkg/probe/models.go index a67e6df..42afc12 100644 --- a/pkg/probe/models.go +++ b/pkg/probe/models.go @@ -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"` +} diff --git a/pkg/probe/onvif.go b/pkg/probe/onvif.go new file mode 100644 index 0000000..e7c3517 --- /dev/null +++ b/pkg/probe/onvif.go @@ -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 := ` + + + http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe + urn:uuid:` + randUUID() + ` + urn:schemas-xmlsoap-org:ws:2005:04:discovery + + + + + + + +` + + 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. http://0.0.0.0:8080/onvif/device_service + 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:] +} diff --git a/www/homekit.html b/www/homekit.html index ba5c5b9..390ce77 100644 --- a/www/homekit.html +++ b/www/homekit.html @@ -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 {