Files
Strix/internal/camera/discovery/probe.go
T
eduard256 3fec89be7f Add HTTP prober, optimize mDNS timeout, add Trassir/ZOSI to OUI
- Add HTTPProber: parallel HEAD+GET on ports 80/8080, extracts Server header
- Reduce mDNS timeout from 1s to 100ms using context wrapper around
  mdns.Query (HomeKit devices respond in 2-10ms, no need to wait 1s)
- Add Trassir (F0:23:B9) and ZOSI (00:05:FE) to camera OUI database
- Probe response time improved from ~1s to ~110ms for reachable devices
2026-03-16 14:46:58 +00:00

185 lines
4.6 KiB
Go

package discovery
import (
"context"
"sync"
"time"
"github.com/eduard256/Strix/internal/models"
)
const (
// ProbeTimeout is the overall timeout for all probes combined.
ProbeTimeout = 3 * time.Second
// ProbeTypeUnreachable indicates the device did not respond to ping.
ProbeTypeUnreachable = "unreachable"
// ProbeTypeStandard indicates a normal IP camera (RTSP/HTTP/ONVIF).
ProbeTypeStandard = "standard"
// ProbeTypeHomeKit indicates an Apple HomeKit camera that needs PIN pairing.
ProbeTypeHomeKit = "homekit"
)
// Prober is an interface for network probe implementations.
// Each prober discovers specific information about a device at a given IP.
// New probers can be added by implementing this interface and registering
// them with ProbeService.
type Prober interface {
// Name returns a unique identifier for this prober (e.g., "dns", "arp", "mdns").
Name() string
// Probe runs the probe against the given IP address.
// Must respect context cancellation/timeout.
// Returns nil result if nothing was found (not an error).
Probe(ctx context.Context, ip string) (any, error)
}
// ProbeService orchestrates multiple probers to gather information about a device.
// It first pings the device, then runs all registered probers in parallel.
type ProbeService struct {
pinger *PingProber
probers []Prober
logger interface {
Debug(string, ...any)
Error(string, error, ...any)
Info(string, ...any)
}
}
// NewProbeService creates a new ProbeService with the given probers.
// The ping prober is always included and runs first.
func NewProbeService(
probers []Prober,
logger interface {
Debug(string, ...any)
Error(string, error, ...any)
Info(string, ...any)
},
) *ProbeService {
return &ProbeService{
pinger: &PingProber{},
probers: probers,
logger: logger,
}
}
// Probe runs ping + all registered probers against the given IP.
// Overall timeout is 3 seconds. Results are collected from whatever
// finishes in time; slow probers are omitted (nil in response).
func (s *ProbeService) Probe(ctx context.Context, ip string) *models.ProbeResponse {
ctx, cancel := context.WithTimeout(ctx, ProbeTimeout)
defer cancel()
response := &models.ProbeResponse{
IP: ip,
Type: ProbeTypeStandard,
}
// Step 1: Ping
s.logger.Debug("probing device", "ip", ip)
pingResult, err := s.pinger.Ping(ctx, ip)
if err != nil || !pingResult.Reachable {
errMsg := "device unreachable"
if err != nil {
errMsg = err.Error()
}
s.logger.Debug("ping failed", "ip", ip, "error", errMsg)
response.Reachable = false
response.Type = ProbeTypeUnreachable
response.Error = errMsg
return response
}
response.Reachable = true
response.LatencyMs = pingResult.LatencyMs
s.logger.Debug("ping OK", "ip", ip, "latency_ms", pingResult.LatencyMs)
// Step 2: Run all probers in parallel
type probeResult struct {
name string
data any
err error
}
results := make(chan probeResult, len(s.probers))
var wg sync.WaitGroup
for _, p := range s.probers {
wg.Add(1)
go func(prober Prober) {
defer wg.Done()
data, err := prober.Probe(ctx, ip)
results <- probeResult{
name: prober.Name(),
data: data,
err: err,
}
}(p)
}
// Close results channel when all probers finish
go func() {
wg.Wait()
close(results)
}()
// Collect results
for r := range results {
if r.err != nil {
s.logger.Debug("prober failed", "prober", r.name, "error", r.err.Error())
continue
}
if r.data == nil {
continue
}
switch r.name {
case "dns":
if v, ok := r.data.(*models.DNSProbeResult); ok {
response.Probes.DNS = v
}
case "arp":
if v, ok := r.data.(*models.ARPProbeResult); ok {
response.Probes.ARP = v
}
case "mdns":
if v, ok := r.data.(*models.MDNSProbeResult); ok {
response.Probes.MDNS = v
}
case "http":
if v, ok := r.data.(*models.HTTPProbeResult); ok {
response.Probes.HTTP = v
}
}
}
// Step 3: Determine type based on probe results
response.Type = s.determineType(response)
s.logger.Info("probe completed",
"ip", ip,
"reachable", response.Reachable,
"type", response.Type,
"latency_ms", response.LatencyMs,
)
return response
}
// determineType decides the device type based on collected probe results.
func (s *ProbeService) determineType(response *models.ProbeResponse) string {
if !response.Reachable {
return ProbeTypeUnreachable
}
// HomeKit camera that is not yet paired
if response.Probes.MDNS != nil && !response.Probes.MDNS.Paired {
category := response.Probes.MDNS.Category
if category == "camera" || category == "doorbell" {
return ProbeTypeHomeKit
}
}
return ProbeTypeStandard
}