diff --git a/.claude/skills/frontend_design_strix/SKILL.md b/.claude/skills/frontend_design_strix/SKILL.md
new file mode 100644
index 0000000..68c529a
--- /dev/null
+++ b/.claude/skills/frontend_design_strix/SKILL.md
@@ -0,0 +1,211 @@
+---
+name: frontend_design_strix
+description: Create or redesign frontend pages for Strix. Use when building new HTML pages, redesigning existing ones, or working on any UI task in the www/ directory. Covers design principles, layout patterns, and component usage.
+disable-model-invocation: true
+---
+
+# Strix Frontend Design
+
+You are creating or modifying a frontend page for Strix. Your goal is to produce a page that looks **identical in quality** to the existing pages, especially `www/homekit.html` which is the design reference.
+
+## Before you start
+
+Read these files completely:
+
+1. **`www/design-system.html`** -- All CSS variables, every component, JS patterns. This is your component library.
+2. **`www/homekit.html`** -- The design reference. This page is the gold standard. Study its structure, spacing, how little text it uses, how the back button is positioned.
+3. **`www/index.html`** -- The entry point. Understand the probe flow and how data is passed between pages via URL params.
+
+If you need to understand backend APIs or the probe system, read:
+- `www/standard.html` -- how probe data flows into a configuration page
+- `www/test.html` -- how polling and real-time updates work
+- `www/config.html` -- complex two-column layout with live preview
+
+## Design Philosophy
+
+### Radical minimalism
+
+Every element on screen must earn its place. If something doesn't help the user complete their task, remove it.
+
+- **10% text, 90% meaning.** A label that says "Pairing Code" with an info-icon is better than a paragraph explaining what a pairing code is.
+- **Hide details behind info-icons.** Long explanations go into tooltips (the `(i)` icon pattern). The user who needs the explanation can hover. The user who doesn't is not bothered.
+- **No decorative elements without function.** No ornamental icons, no badges that don't convey information, no cards-as-decoration.
+- **One action per screen.** Each page should have one primary thing the user does. Everything else is secondary.
+
+### How we think about design decisions
+
+When building homekit.html, we went through this process:
+
+1. **Started with all the data** -- device info table, long descriptions, badges, decorative icons
+2. **Asked "does the user need this?"** for every element
+3. **Removed everything that wasn't essential** -- the device info table (IP, MAC, vendor) was removed because the user doesn't need it to enter a PIN code
+4. **Moved explanations into tooltips** -- "This camera supports Apple HomeKit. Enter the 8-digit pairing code printed on your camera or included in the manual" became just a label "Pairing Code" with a tooltip
+5. **Removed format hints** -- "Format: XXX-XX-XXX" was removed because the input fields themselves make the format obvious
+6. **Made the primary action obvious** -- big button, full width, impossible to miss
+
+Apply this same thinking to every page you create.
+
+### Visual rules
+
+- Dark theme with purple accent -- never deviate from the color palette in `:root`
+- All icons are inline SVG -- never use emoji, never use icon fonts, never use external icon libraries
+- Fonts: system font stack for UI, monospace for technical values (URLs, IPs, codes)
+- Borders are subtle: `rgba(139, 92, 246, 0.15)` -- barely visible purple tint
+- Glow effects on focus and hover, never on static elements (except logos)
+- Animations are fast (150ms) and subtle -- translateY(-2px) on hover, fadeIn on page load
+- No rounded corners larger than 8px (except special cases like toggle switches)
+
+## Layout Patterns
+
+### Pages after probe (like homekit.html) -- TRUE CENTER
+
+This is the most common case for new pages. Content is vertically centered on screen.
+
+```
+.screen {
+ min-height: 100vh;
+ display: flex;
+ align-items: center; /* TRUE CENTER -- not flex-start */
+ justify-content: center;
+}
+.container { max-width: 480px; width: 100%; }
+```
+
+**Back button** is positioned OUTSIDE the container, wider than content, using `.back-wrapper`:
+
+```
+.back-wrapper {
+ position: absolute; top: 1.5rem;
+ left: 50%; transform: translateX(-50%);
+ width: 100%; max-width: 600px; /* wider than container */
+ padding: 0 1.5rem;
+ z-index: 10;
+}
+```
+
+This is MANDATORY for all centered layout pages. The back button must NOT be inside the centered container.
+
+### Entry page (like index.html) -- TOP CENTER
+
+Content is near the top with `margin-top: 8vh`. Used for the main entry point only.
+
+### Content pages (like standard.html, create.html) -- STANDARD
+
+Back button at top, then title, then content flowing down. `max-width: 600px`, no vertical centering.
+
+### Data-heavy pages (like test.html) -- WIDE
+
+`max-width: 1200px` with card grids.
+
+### Two-column (like config.html) -- SPLIT
+
+Settings left, live preview right. Collapses to tabs on mobile.
+
+## Hero Section Pattern
+
+For centered pages, the hero contains a logo/icon + short title:
+
+```html
+
+
+
Short Name
+
+```
+
+- The icon should be recognizable and relevant (Strix owl for main, HomeKit house for HomeKit)
+- The title is SHORT -- one or two words max
+- No subtitles unless absolutely necessary
+- Glow effect on the icon via `filter: drop-shadow()`
+
+## Component Usage
+
+All components are documented with live examples in `www/design-system.html`. Key ones:
+
+- **Buttons**: `.btn .btn-primary .btn-large` for primary action (full width), `.btn-outline` for secondary
+- **Inputs**: `.input` with `.label` and optional `.info-icon` with `.tooltip`
+- **Toast**: Every page needs `` and the `showToast()` function
+- **Error box**: `.error-box` with `.visible` class toggled
+- **Info icon + tooltip**: For hiding explanations -- always prefer this over visible text
+
+## Navigation -- CRITICAL
+
+### Always pass ALL known data forward
+
+When navigating to another page, pass every piece of data you have. This is non-negotiable. Future pages may need any of these values.
+
+```javascript
+function navigateNext() {
+ var p = new URLSearchParams();
+ p.set('primary_data', value);
+ // Pass through EVERYTHING known:
+ if (ip) p.set('ip', ip);
+ if (mac) p.set('mac', mac);
+ if (vendor) p.set('vendor', vendor);
+ if (model) p.set('model', model);
+ if (server) p.set('server', server);
+ if (hostname) p.set('hostname', hostname);
+ if (ports) p.set('ports', ports);
+ if (user) p.set('user', user);
+ if (channel) p.set('channel', channel);
+ // ... any other params from probe
+ window.location.href = 'next.html?' + p.toString();
+}
+```
+
+### Page init always reads all params
+
+```javascript
+var params = new URLSearchParams(location.search);
+var ip = params.get('ip') || '';
+var mac = params.get('mac') || '';
+var vendor = params.get('vendor') || '';
+// ... read ALL possible params even if this page doesn't use them
+// They need to be available for passing to the next page
+```
+
+## JavaScript Rules
+
+- Use `var`, not `let`/`const` -- ES5 compatible
+- Build DOM with `document.createElement`, not innerHTML
+- Use `async function` + `fetch()` for API calls
+- Always handle errors: check `!r.ok`, catch exceptions, show toast
+- Debounce input handlers if they trigger API calls (300ms)
+- Use `addEventListener`, never inline event handlers in HTML
+
+## API Pattern
+
+```javascript
+async function doSomething() {
+ try {
+ var r = await fetch('api/endpoint', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+ if (!r.ok) {
+ var text = await r.text();
+ showToast(text || 'Error ' + r.status);
+ return;
+ }
+ var data = await r.json();
+ // success...
+ } catch (e) {
+ showToast('Connection error: ' + e.message);
+ }
+}
+```
+
+## Checklist before finishing
+
+- [ ] Page uses correct layout pattern for its type
+- [ ] Back button positioned correctly (`.back-wrapper` for centered, inline for standard)
+- [ ] All CSS variables from `:root` -- no hardcoded colors
+- [ ] No unnecessary text -- everything possible hidden behind info-icons
+- [ ] All known URL params are read at init and passed forward on navigation
+- [ ] Toast element present, showToast function included
+- [ ] Error states handled (API errors, validation)
+- [ ] Mobile responsive (test at 375px width)
+- [ ] No emoji anywhere
+- [ ] All icons are inline SVG
+- [ ] Primary action is obvious and full-width
+- [ ] Page looks like it belongs with homekit.html and index.html
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f86588..72e1ca5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,20 @@
# Changelog
+## [2.1.0] - 2026-04-08
+
+### Added
+- ONVIF protocol support: auto-discovery via unicast WS-Discovery, stream resolution through ONVIF profiles
+- ONVIF probe detector: detects ONVIF cameras during network probe (4-7ms response time, no auth required)
+- ONVIF camera page (onvif.html): credentials form with option to also test popular stream patterns
+- ONVIF stream handler: resolves all camera profiles, tests each via RTSP, returns paired results (onvif:// + rtsp://) with shared screenshots
+- Design system reference (design-system.html) with all UI components documented
+
+### Changed
+- ONVIF has highest probe priority (above HomeKit and Standard)
+- JPEG-only streams (no H264/H265) are classified as Alternative in test results
+- HomeKit page redesigned: Apple HomeKit logo, centered layout, floating back button
+- Hardened create.html against undefined/null URL values in query parameters
+
## [2.0.0] - 2025-04-05
### Added
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/pkg/tester/source_onvif.go b/pkg/tester/source_onvif.go
new file mode 100644
index 0000000..99c04b6
--- /dev/null
+++ b/pkg/tester/source_onvif.go
@@ -0,0 +1,104 @@
+package tester
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/AlexxIT/go2rtc/pkg/core"
+ "github.com/AlexxIT/go2rtc/pkg/onvif"
+)
+
+// testOnvif resolves all ONVIF profiles, tests each via RTSP,
+// and adds two Results per profile (onvif:// + rtsp://).
+// ex. "onvif://admin:pass@10.0.20.111" or "onvif://admin:pass@10.0.20.119:2020"
+func testOnvif(s *Session, rawURL string) {
+ client, err := onvif.NewClient(rawURL)
+ if err != nil {
+ return
+ }
+
+ tokens, err := client.GetProfilesTokens()
+ if err != nil {
+ return
+ }
+
+ for _, token := range tokens {
+ profileURL := rawURL + "?subtype=" + token
+
+ pc, err := onvif.NewClient(profileURL)
+ if err != nil {
+ continue
+ }
+
+ rtspURI, err := pc.GetURI()
+ if err != nil {
+ continue
+ }
+
+ testOnvifProfile(s, profileURL, rtspURI)
+ }
+}
+
+// testOnvifProfile tests a single RTSP stream and adds two Results (onvif + rtsp)
+func testOnvifProfile(s *Session, onvifURL, rtspURL string) {
+ start := time.Now()
+
+ prod, err := rtspHandler(rtspURL)
+ if err != nil {
+ return
+ }
+ defer func() { _ = prod.Stop() }()
+
+ latency := time.Since(start).Milliseconds()
+
+ var codecs []string
+ for _, media := range prod.GetMedias() {
+ if media.Direction != core.DirectionRecvonly {
+ continue
+ }
+ for _, codec := range media.Codecs {
+ codecs = append(codecs, codec.Name)
+ }
+ }
+
+ // capture screenshot
+ var screenshotPath string
+ var width, height int
+
+ if raw, codecName := getScreenshot(prod); raw != nil {
+ var jpeg []byte
+
+ switch codecName {
+ case core.CodecH264, core.CodecH265:
+ jpeg = toJPEG(raw)
+ default:
+ jpeg = raw
+ }
+
+ if jpeg != nil {
+ idx := s.AddScreenshot(jpeg)
+ screenshotPath = fmt.Sprintf("api/test/screenshot?id=%s&i=%d", s.ID, idx)
+ width, height = jpegSize(jpeg)
+ }
+ }
+
+ // add onvif:// result
+ s.AddResult(&Result{
+ Source: onvifURL,
+ Screenshot: screenshotPath,
+ Codecs: codecs,
+ Width: width,
+ Height: height,
+ LatencyMs: latency,
+ })
+
+ // add rtsp:// result (same screenshot, same codecs)
+ s.AddResult(&Result{
+ Source: rtspURL,
+ Screenshot: screenshotPath,
+ Codecs: codecs,
+ Width: width,
+ Height: height,
+ LatencyMs: latency,
+ })
+}
diff --git a/pkg/tester/worker.go b/pkg/tester/worker.go
index 0512486..49a6069 100644
--- a/pkg/tester/worker.go
+++ b/pkg/tester/worker.go
@@ -56,6 +56,11 @@ func testURL(s *Session, rawURL string) {
return
}
+ if strings.HasPrefix(rawURL, "onvif://") {
+ testOnvif(s, rawURL)
+ return
+ }
+
handler := GetHandler(rawURL)
if handler == nil {
return
diff --git a/www/create.html b/www/create.html
index 5ba7e4a..c80c3d9 100644
--- a/www/create.html
+++ b/www/create.html
@@ -328,8 +328,9 @@
// Pre-populate custom streams from "url" query parameter (supports multiple)
params.getAll('url').forEach(function(u) {
+ if (!u || typeof u !== 'string') return;
u = u.trim();
- if (u && u.indexOf('://') !== -1 && customStreams.indexOf(u) === -1) {
+ if (u && u !== 'undefined' && u !== 'null' && u.indexOf('://') !== -1 && customStreams.indexOf(u) === -1) {
customStreams.push(u);
}
});
@@ -395,7 +396,7 @@
addInput.type = 'text';
addInput.placeholder = 'rtsp://user:pass@host/path or bubble://...';
addInput.spellcheck = false;
- addInput.value = pendingInput;
+ addInput.value = pendingInput || '';
var addBtn = document.createElement('button');
addBtn.className = 'btn-add';
@@ -404,7 +405,7 @@
function addCustom() {
var v = addInput.value.trim();
- if (!v) return;
+ if (!v || v === 'undefined' || v === 'null') return;
if (v.indexOf('://') === -1) {
showToast('URL must include protocol (rtsp://, http://, bubble://, ...)');
return;
@@ -592,7 +593,7 @@
addInput.type = 'text';
addInput.placeholder = 'rtsp://user:pass@host/path or bubble://...';
addInput.spellcheck = false;
- addInput.value = pendingInput;
+ addInput.value = pendingInput || '';
var addBtn = document.createElement('button');
addBtn.className = 'btn-add';
addBtn.type = 'button';
@@ -600,7 +601,7 @@
function addCustom() {
var v = addInput.value.trim();
- if (!v) return;
+ if (!v || v === 'undefined' || v === 'null') return;
if (v.indexOf('://') === -1) { showToast('URL must include protocol (rtsp://, http://, bubble://, ...)'); return; }
if (customStreams.indexOf(v) !== -1 || dbStreams.indexOf(v) !== -1) { showToast('This URL is already in the list'); return; }
customStreams.push(v);
diff --git a/www/design-system.html b/www/design-system.html
new file mode 100644
index 0000000..739463f
--- /dev/null
+++ b/www/design-system.html
@@ -0,0 +1,2249 @@
+
+
+
+
+
+
+ Strix - Design System
+
+
+
+
+