1193 lines
32 KiB
Go
1193 lines
32 KiB
Go
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
|
package hksv
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
|
"github.com/AlexxIT/go2rtc/pkg/hap"
|
|
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
|
|
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
|
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
|
"github.com/pion/rtp"
|
|
"github.com/rs/zerolog"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// --- Mock implementations ---
|
|
|
|
type mockStreamProvider struct {
|
|
mu sync.Mutex
|
|
consumers map[string][]core.Consumer
|
|
addErr error
|
|
}
|
|
|
|
func newMockStreamProvider() *mockStreamProvider {
|
|
return &mockStreamProvider{consumers: make(map[string][]core.Consumer)}
|
|
}
|
|
|
|
func (m *mockStreamProvider) AddConsumer(streamName string, consumer core.Consumer) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.addErr != nil {
|
|
return m.addErr
|
|
}
|
|
m.consumers[streamName] = append(m.consumers[streamName], consumer)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockStreamProvider) RemoveConsumer(streamName string, consumer core.Consumer) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
cs := m.consumers[streamName]
|
|
for i, c := range cs {
|
|
if c == consumer {
|
|
m.consumers[streamName] = append(cs[:i], cs[i+1:]...)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *mockStreamProvider) count(streamName string) int {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return len(m.consumers[streamName])
|
|
}
|
|
|
|
type mockPairingStore struct {
|
|
mu sync.Mutex
|
|
saved map[string][]string
|
|
err error
|
|
}
|
|
|
|
func newMockPairingStore() *mockPairingStore {
|
|
return &mockPairingStore{saved: make(map[string][]string)}
|
|
}
|
|
|
|
func (m *mockPairingStore) SavePairings(streamName string, pairings []string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
cp := make([]string, len(pairings))
|
|
copy(cp, pairings)
|
|
m.saved[streamName] = cp
|
|
return nil
|
|
}
|
|
|
|
func (m *mockPairingStore) get(streamName string) []string {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.saved[streamName]
|
|
}
|
|
|
|
type mockSnapshotProvider struct {
|
|
data []byte
|
|
err error
|
|
called bool
|
|
width int
|
|
height int
|
|
}
|
|
|
|
func (m *mockSnapshotProvider) GetSnapshot(streamName string, width, height int) ([]byte, error) {
|
|
m.called = true
|
|
m.width = width
|
|
m.height = height
|
|
return m.data, m.err
|
|
}
|
|
|
|
type mockLiveStreamHandler struct {
|
|
setupCalled bool
|
|
startCalled bool
|
|
stopCalled bool
|
|
setupErr error
|
|
startErr error
|
|
endpointsVal any
|
|
}
|
|
|
|
func (m *mockLiveStreamHandler) SetupEndpoints(conn net.Conn, offer *camera.SetupEndpointsRequest) (any, error) {
|
|
m.setupCalled = true
|
|
return "setup-resp", m.setupErr
|
|
}
|
|
func (m *mockLiveStreamHandler) GetEndpointsResponse() any {
|
|
return m.endpointsVal
|
|
}
|
|
func (m *mockLiveStreamHandler) StartStream(streamName string, conf *camera.SelectedStreamConfiguration, connTracker ConnTracker) error {
|
|
m.startCalled = true
|
|
return m.startErr
|
|
}
|
|
func (m *mockLiveStreamHandler) StopStream(sessionID string, connTracker ConnTracker) error {
|
|
m.stopCalled = true
|
|
return nil
|
|
}
|
|
|
|
// --- Test helpers ---
|
|
|
|
func newTestServer(t *testing.T, opts ...func(*Config)) *Server {
|
|
t.Helper()
|
|
streams := newMockStreamProvider()
|
|
cfg := Config{
|
|
StreamName: "test-camera",
|
|
Pin: "27041991",
|
|
HKSV: true,
|
|
Streams: streams,
|
|
Logger: zerolog.Nop(),
|
|
Port: 0,
|
|
}
|
|
for _, opt := range opts {
|
|
opt(&cfg)
|
|
}
|
|
srv, err := NewServer(cfg)
|
|
require.NoError(t, err)
|
|
return srv
|
|
}
|
|
|
|
// ====================================================================
|
|
// NewServer
|
|
// ====================================================================
|
|
|
|
func TestNewServer_MinimalHKSV(t *testing.T) {
|
|
streams := newMockStreamProvider()
|
|
srv, err := NewServer(Config{
|
|
StreamName: "cam1",
|
|
Pin: "27041991",
|
|
HKSV: true,
|
|
Streams: streams,
|
|
Logger: zerolog.Nop(),
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, srv)
|
|
|
|
require.Equal(t, "cam1", srv.StreamName())
|
|
require.NotNil(t, srv.Accessory())
|
|
require.NotNil(t, srv.MDNSEntry())
|
|
|
|
// Verify mDNS entry fields
|
|
mdns := srv.MDNSEntry()
|
|
require.NotEmpty(t, mdns.Name)
|
|
require.Equal(t, hap.CategoryCamera, mdns.Info[hap.TXTCategory])
|
|
require.Equal(t, hap.StatusNotPaired, mdns.Info[hap.TXTStatusFlags])
|
|
}
|
|
|
|
func TestNewServer_DefaultPin(t *testing.T) {
|
|
srv, err := NewServer(Config{
|
|
StreamName: "cam1",
|
|
HKSV: true,
|
|
Streams: newMockStreamProvider(),
|
|
Logger: zerolog.Nop(),
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, srv)
|
|
}
|
|
|
|
func TestNewServer_InvalidPin(t *testing.T) {
|
|
_, err := NewServer(Config{
|
|
StreamName: "cam1",
|
|
Pin: "123", // too short
|
|
HKSV: true,
|
|
Streams: newMockStreamProvider(),
|
|
Logger: zerolog.Nop(),
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "invalid pin")
|
|
}
|
|
|
|
func TestNewServer_DoorbellCategory(t *testing.T) {
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.CategoryID = "doorbell"
|
|
})
|
|
require.Equal(t, hap.CategoryDoorbell, srv.MDNSEntry().Info[hap.TXTCategory])
|
|
|
|
// Doorbell accessory should have ProgrammableSwitchEvent char
|
|
char := srv.accessory.GetCharacter("73")
|
|
require.NotNil(t, char, "doorbell should have ProgrammableSwitchEvent characteristic")
|
|
}
|
|
|
|
func TestNewServer_CameraCategory(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
require.Equal(t, hap.CategoryCamera, srv.MDNSEntry().Info[hap.TXTCategory])
|
|
}
|
|
|
|
func TestNewServer_ProxyMode(t *testing.T) {
|
|
srv, err := NewServer(Config{
|
|
StreamName: "cam1",
|
|
Pin: "27041991",
|
|
ProxyURL: "http://192.168.1.100:51827",
|
|
Streams: newMockStreamProvider(),
|
|
Logger: zerolog.Nop(),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Nil(t, srv.Accessory(), "proxy mode should not create local accessory")
|
|
require.Equal(t, "http://192.168.1.100:51827", srv.proxyURL)
|
|
}
|
|
|
|
func TestNewServer_SpeakerDisabledByDefault(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
// Speaker service type is "113"
|
|
svc := srv.accessory.GetService("113")
|
|
require.Nil(t, svc, "speaker service should be removed by default")
|
|
}
|
|
|
|
func TestNewServer_SpeakerEnabled(t *testing.T) {
|
|
speakerOn := true
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.Speaker = &speakerOn
|
|
})
|
|
svc := srv.accessory.GetService("113")
|
|
require.NotNil(t, svc, "speaker service should be present when enabled")
|
|
}
|
|
|
|
func TestNewServer_CustomName(t *testing.T) {
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.Name = "Living Room Camera"
|
|
})
|
|
require.Equal(t, "Living Room Camera", srv.MDNSEntry().Name)
|
|
}
|
|
|
|
func TestNewServer_CustomDeviceID(t *testing.T) {
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.DeviceID = "AA:BB:CC:DD:EE:FF"
|
|
})
|
|
require.Equal(t, "AA:BB:CC:DD:EE:FF", srv.MDNSEntry().Info[hap.TXTDeviceID])
|
|
}
|
|
|
|
func TestNewServer_MotionThresholdDefault(t *testing.T) {
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.MotionMode = "detect"
|
|
})
|
|
require.Equal(t, defaultThreshold, srv.motionThreshold)
|
|
}
|
|
|
|
func TestNewServer_MotionThresholdCustom(t *testing.T) {
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.MotionMode = "detect"
|
|
c.MotionThreshold = 3.5
|
|
})
|
|
require.Equal(t, 3.5, srv.motionThreshold)
|
|
}
|
|
|
|
func TestNewServer_NonHKSV(t *testing.T) {
|
|
srv, err := NewServer(Config{
|
|
StreamName: "cam1",
|
|
Pin: "27041991",
|
|
HKSV: false,
|
|
Streams: newMockStreamProvider(),
|
|
Logger: zerolog.Nop(),
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, srv.Accessory())
|
|
// Non-HKSV accessory should NOT have motion sensor
|
|
char := srv.accessory.GetCharacter("22")
|
|
require.Nil(t, char, "non-HKSV should not have MotionDetected")
|
|
}
|
|
|
|
// ====================================================================
|
|
// Pairing Management
|
|
// ====================================================================
|
|
|
|
func TestPairing_AddAndGet(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
|
|
pub := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
|
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32}
|
|
srv.AddPair("client-1", pub, hap.PermissionAdmin)
|
|
|
|
got := srv.GetPair("client-1")
|
|
require.Equal(t, pub, got)
|
|
}
|
|
|
|
func TestPairing_GetUnknown(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
require.Nil(t, srv.GetPair("nonexistent"))
|
|
}
|
|
|
|
func TestPairing_Delete(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
pub := []byte{1, 2, 3, 4}
|
|
srv.AddPair("client-1", pub, hap.PermissionAdmin)
|
|
require.NotNil(t, srv.GetPair("client-1"))
|
|
|
|
srv.DelPair("client-1")
|
|
require.Nil(t, srv.GetPair("client-1"))
|
|
}
|
|
|
|
func TestPairing_DeleteNonexistent(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
// Should not panic
|
|
srv.DelPair("nonexistent")
|
|
}
|
|
|
|
func TestPairing_NoDuplicates(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
pub := []byte{1, 2, 3, 4}
|
|
srv.AddPair("client-1", pub, hap.PermissionAdmin)
|
|
srv.AddPair("client-1", pub, hap.PermissionAdmin) // duplicate
|
|
require.Len(t, srv.pairings, 1)
|
|
}
|
|
|
|
func TestPairing_MultiplePairs(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
srv.AddPair("client-1", []byte{1}, hap.PermissionAdmin)
|
|
srv.AddPair("client-2", []byte{2}, hap.PermissionAdmin)
|
|
srv.AddPair("client-3", []byte{3}, hap.PermissionAdmin)
|
|
|
|
require.Len(t, srv.pairings, 3)
|
|
require.NotNil(t, srv.GetPair("client-1"))
|
|
require.NotNil(t, srv.GetPair("client-2"))
|
|
require.NotNil(t, srv.GetPair("client-3"))
|
|
|
|
srv.DelPair("client-2")
|
|
require.Len(t, srv.pairings, 2)
|
|
require.Nil(t, srv.GetPair("client-2"))
|
|
require.NotNil(t, srv.GetPair("client-1"))
|
|
require.NotNil(t, srv.GetPair("client-3"))
|
|
}
|
|
|
|
func TestPairing_Persistence(t *testing.T) {
|
|
store := newMockPairingStore()
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.Store = store
|
|
})
|
|
|
|
srv.AddPair("client-1", []byte{1, 2, 3, 4}, hap.PermissionAdmin)
|
|
|
|
saved := store.get("test-camera")
|
|
require.Len(t, saved, 1)
|
|
require.Contains(t, saved[0], "client_id=client-1")
|
|
|
|
srv.DelPair("client-1")
|
|
saved = store.get("test-camera")
|
|
require.Len(t, saved, 0)
|
|
}
|
|
|
|
func TestPairing_PersistenceError(t *testing.T) {
|
|
store := newMockPairingStore()
|
|
store.err = errors.New("disk full")
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.Store = store
|
|
})
|
|
|
|
// Should not panic, just log the error
|
|
srv.AddPair("client-1", []byte{1}, hap.PermissionAdmin)
|
|
require.Len(t, srv.pairings, 1) // pairing is still added in memory
|
|
}
|
|
|
|
func TestPairing_PreExisting(t *testing.T) {
|
|
srv, err := NewServer(Config{
|
|
StreamName: "cam1",
|
|
Pin: "27041991",
|
|
HKSV: true,
|
|
Pairings: []string{"client_id=pre-existing&client_public=0102&permissions=1"},
|
|
Streams: newMockStreamProvider(),
|
|
Logger: zerolog.Nop(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
got := srv.GetPair("pre-existing")
|
|
require.Equal(t, []byte{1, 2}, got)
|
|
}
|
|
|
|
// ====================================================================
|
|
// UpdateStatus
|
|
// ====================================================================
|
|
|
|
func TestUpdateStatus_NotPaired(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
require.Equal(t, hap.StatusNotPaired, srv.MDNSEntry().Info[hap.TXTStatusFlags])
|
|
}
|
|
|
|
func TestUpdateStatus_Paired(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
srv.AddPair("client-1", []byte{1}, hap.PermissionAdmin)
|
|
require.Equal(t, hap.StatusPaired, srv.MDNSEntry().Info[hap.TXTStatusFlags])
|
|
}
|
|
|
|
func TestUpdateStatus_UnpairedAfterDelete(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
srv.AddPair("client-1", []byte{1}, hap.PermissionAdmin)
|
|
require.Equal(t, hap.StatusPaired, srv.MDNSEntry().Info[hap.TXTStatusFlags])
|
|
|
|
srv.DelPair("client-1")
|
|
require.Equal(t, hap.StatusNotPaired, srv.MDNSEntry().Info[hap.TXTStatusFlags])
|
|
}
|
|
|
|
// ====================================================================
|
|
// Connection Tracking
|
|
// ====================================================================
|
|
|
|
func TestConnTracking_AddDel(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
require.Empty(t, srv.conns)
|
|
|
|
conn1 := "conn1"
|
|
conn2 := "conn2"
|
|
srv.AddConn(conn1)
|
|
srv.AddConn(conn2)
|
|
require.Len(t, srv.conns, 2)
|
|
|
|
srv.DelConn(conn1)
|
|
require.Len(t, srv.conns, 1)
|
|
|
|
srv.DelConn(conn2)
|
|
require.Empty(t, srv.conns)
|
|
}
|
|
|
|
func TestConnTracking_DelNonexistent(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
// Should not panic
|
|
srv.DelConn("never-added")
|
|
require.Empty(t, srv.conns)
|
|
}
|
|
|
|
func TestConnTracking_Concurrent(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
var wg sync.WaitGroup
|
|
|
|
for i := 0; i < 50; i++ {
|
|
wg.Add(1)
|
|
go func(n int) {
|
|
defer wg.Done()
|
|
conn := fmt.Sprintf("conn-%d", n)
|
|
srv.AddConn(conn)
|
|
time.Sleep(time.Millisecond)
|
|
srv.DelConn(conn)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
require.Empty(t, srv.conns)
|
|
}
|
|
|
|
// ====================================================================
|
|
// MarshalJSON
|
|
// ====================================================================
|
|
|
|
func TestMarshalJSON_Unpaired(t *testing.T) {
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.Name = "TestCam"
|
|
})
|
|
|
|
data, err := srv.MarshalJSON()
|
|
require.NoError(t, err)
|
|
|
|
var v map[string]any
|
|
require.NoError(t, json.Unmarshal(data, &v))
|
|
|
|
require.Equal(t, "TestCam", v["name"])
|
|
require.NotEmpty(t, v["device_id"])
|
|
require.NotEmpty(t, v["setup_code"])
|
|
require.NotEmpty(t, v["setup_id"])
|
|
_, hasPaired := v["paired"]
|
|
require.False(t, hasPaired, "paired=0 should be omitted with omitempty")
|
|
}
|
|
|
|
func TestMarshalJSON_Paired(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
srv.AddPair("client-1", []byte{1}, hap.PermissionAdmin)
|
|
|
|
data, err := srv.MarshalJSON()
|
|
require.NoError(t, err)
|
|
|
|
var v map[string]any
|
|
require.NoError(t, json.Unmarshal(data, &v))
|
|
|
|
require.Equal(t, float64(1), v["paired"])
|
|
// Setup code should be hidden when paired
|
|
_, hasSetupCode := v["setup_code"]
|
|
require.False(t, hasSetupCode || v["setup_code"] == "", "setup code should not be in paired JSON")
|
|
}
|
|
|
|
// ====================================================================
|
|
// GetAccessories
|
|
// ====================================================================
|
|
|
|
func TestGetAccessories(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
accs := srv.GetAccessories(nil)
|
|
require.Len(t, accs, 1)
|
|
require.Equal(t, srv.accessory, accs[0])
|
|
}
|
|
|
|
// ====================================================================
|
|
// SetMotionDetected
|
|
// ====================================================================
|
|
|
|
func TestSetMotionDetected(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
|
|
char := srv.accessory.GetCharacter("22") // MotionDetected
|
|
require.NotNil(t, char)
|
|
|
|
srv.SetMotionDetected(true)
|
|
require.Equal(t, true, char.Value)
|
|
|
|
srv.SetMotionDetected(false)
|
|
require.Equal(t, false, char.Value)
|
|
}
|
|
|
|
func TestSetMotionDetected_NoAccessory(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
srv.accessory = nil
|
|
// Should not panic
|
|
srv.SetMotionDetected(true)
|
|
}
|
|
|
|
// ====================================================================
|
|
// TriggerDoorbell
|
|
// ====================================================================
|
|
|
|
func TestTriggerDoorbell(t *testing.T) {
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.CategoryID = "doorbell"
|
|
})
|
|
|
|
char := srv.accessory.GetCharacter("73") // ProgrammableSwitchEvent
|
|
require.NotNil(t, char)
|
|
|
|
srv.TriggerDoorbell()
|
|
require.Equal(t, 0, char.Value) // SINGLE_PRESS
|
|
}
|
|
|
|
func TestTriggerDoorbell_CameraAccessory(t *testing.T) {
|
|
srv := newTestServer(t) // camera, not doorbell
|
|
// Should not panic (GetCharacter returns nil, function returns early)
|
|
srv.TriggerDoorbell()
|
|
}
|
|
|
|
// ====================================================================
|
|
// GetImage (snapshots)
|
|
// ====================================================================
|
|
|
|
func TestGetImage_NoProvider(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
result := srv.GetImage(nil, 640, 480)
|
|
require.Nil(t, result)
|
|
}
|
|
|
|
func TestGetImage_WithProvider(t *testing.T) {
|
|
snapshot := &mockSnapshotProvider{data: []byte("fake-jpeg-data")}
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.Snapshots = snapshot
|
|
})
|
|
|
|
result := srv.GetImage(nil, 1920, 1080)
|
|
require.Equal(t, []byte("fake-jpeg-data"), result)
|
|
require.True(t, snapshot.called)
|
|
require.Equal(t, 1920, snapshot.width)
|
|
require.Equal(t, 1080, snapshot.height)
|
|
}
|
|
|
|
func TestGetImage_ProviderError(t *testing.T) {
|
|
snapshot := &mockSnapshotProvider{err: errors.New("no camera")}
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.Snapshots = snapshot
|
|
})
|
|
|
|
result := srv.GetImage(nil, 640, 480)
|
|
require.Nil(t, result)
|
|
}
|
|
|
|
// ====================================================================
|
|
// GetCharacteristic / SetCharacteristic
|
|
// ====================================================================
|
|
|
|
func TestGetCharacteristic_KnownChar(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
|
|
// MotionDetected (type "22") should be accessible
|
|
char := srv.accessory.GetCharacter("22")
|
|
require.NotNil(t, char)
|
|
|
|
val := srv.GetCharacteristic(nil, 1, char.IID)
|
|
require.Equal(t, char.Value, val)
|
|
}
|
|
|
|
func TestGetCharacteristic_UnknownIID(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
val := srv.GetCharacteristic(nil, 1, 0xFFFFFF)
|
|
require.Nil(t, val)
|
|
}
|
|
|
|
func TestGetCharacteristic_SetupEndpoints_NoLiveStream(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
|
|
char := srv.accessory.GetCharacter(camera.TypeSetupEndpoints)
|
|
require.NotNil(t, char)
|
|
|
|
val := srv.GetCharacteristic(nil, 1, char.IID)
|
|
require.Nil(t, val)
|
|
}
|
|
|
|
func TestGetCharacteristic_SetupEndpoints_WithLiveStream(t *testing.T) {
|
|
handler := &mockLiveStreamHandler{endpointsVal: "test-endpoints"}
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.LiveStream = handler
|
|
})
|
|
|
|
char := srv.accessory.GetCharacter(camera.TypeSetupEndpoints)
|
|
val := srv.GetCharacteristic(nil, 1, char.IID)
|
|
require.Equal(t, "test-endpoints", val)
|
|
}
|
|
|
|
func TestSetCharacteristic_GenericChar(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
|
|
// Active (type "B0") — generic set
|
|
char := srv.accessory.GetCharacter("B0")
|
|
require.NotNil(t, char)
|
|
|
|
srv.SetCharacteristic(nil, 1, char.IID, 0)
|
|
require.Equal(t, 0, char.Value)
|
|
}
|
|
|
|
func TestSetCharacteristic_UnknownIID(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
// Should not panic
|
|
srv.SetCharacteristic(nil, 1, 0xFFFFFF, "value")
|
|
}
|
|
|
|
func TestSetCharacteristic_SetupEndpoints_WithLiveStream(t *testing.T) {
|
|
handler := &mockLiveStreamHandler{}
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.LiveStream = handler
|
|
})
|
|
|
|
char := srv.accessory.GetCharacter(camera.TypeSetupEndpoints)
|
|
require.NotNil(t, char)
|
|
|
|
// Create valid TLV8 base64 data for SetupEndpointsRequest
|
|
req := camera.SetupEndpointsRequest{
|
|
SessionID: "test-session-id-1234",
|
|
}
|
|
encoded, err := tlv8.MarshalBase64(req)
|
|
require.NoError(t, err)
|
|
|
|
srv.SetCharacteristic(nil, 1, char.IID, encoded)
|
|
require.True(t, handler.setupCalled)
|
|
}
|
|
|
|
func TestSetCharacteristic_SetupEndpoints_NoLiveStream(t *testing.T) {
|
|
srv := newTestServer(t) // no live stream handler
|
|
|
|
char := srv.accessory.GetCharacter(camera.TypeSetupEndpoints)
|
|
require.NotNil(t, char)
|
|
|
|
req := camera.SetupEndpointsRequest{SessionID: "test"}
|
|
encoded, _ := tlv8.MarshalBase64(req)
|
|
|
|
// Should not panic
|
|
srv.SetCharacteristic(nil, 1, char.IID, encoded)
|
|
}
|
|
|
|
func TestSetCharacteristic_SetupEndpoints_InvalidTLV8(t *testing.T) {
|
|
handler := &mockLiveStreamHandler{}
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.LiveStream = handler
|
|
})
|
|
|
|
char := srv.accessory.GetCharacter(camera.TypeSetupEndpoints)
|
|
srv.SetCharacteristic(nil, 1, char.IID, "not-valid-base64-tlv8")
|
|
require.False(t, handler.setupCalled, "invalid TLV8 should not call handler")
|
|
}
|
|
|
|
func TestSetCharacteristic_SelectedStream_Start(t *testing.T) {
|
|
handler := &mockLiveStreamHandler{}
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.LiveStream = handler
|
|
})
|
|
|
|
char := srv.accessory.GetCharacter(camera.TypeSelectedStreamConfiguration)
|
|
require.NotNil(t, char)
|
|
|
|
conf := camera.SelectedStreamConfiguration{
|
|
Control: camera.SessionControl{
|
|
SessionID: "session-123",
|
|
Command: camera.SessionCommandStart,
|
|
},
|
|
}
|
|
encoded, err := tlv8.MarshalBase64(conf)
|
|
require.NoError(t, err)
|
|
|
|
srv.SetCharacteristic(nil, 1, char.IID, encoded)
|
|
require.True(t, handler.startCalled)
|
|
}
|
|
|
|
func TestSetCharacteristic_SelectedStream_End(t *testing.T) {
|
|
handler := &mockLiveStreamHandler{}
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.LiveStream = handler
|
|
})
|
|
|
|
char := srv.accessory.GetCharacter(camera.TypeSelectedStreamConfiguration)
|
|
conf := camera.SelectedStreamConfiguration{
|
|
Control: camera.SessionControl{
|
|
SessionID: "session-123",
|
|
Command: camera.SessionCommandEnd,
|
|
},
|
|
}
|
|
encoded, _ := tlv8.MarshalBase64(conf)
|
|
|
|
srv.SetCharacteristic(nil, 1, char.IID, encoded)
|
|
require.True(t, handler.stopCalled)
|
|
}
|
|
|
|
func TestSetCharacteristic_SelectedStream_NoLiveStream(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
|
|
char := srv.accessory.GetCharacter(camera.TypeSelectedStreamConfiguration)
|
|
conf := camera.SelectedStreamConfiguration{
|
|
Control: camera.SessionControl{Command: camera.SessionCommandStart},
|
|
}
|
|
encoded, _ := tlv8.MarshalBase64(conf)
|
|
|
|
// Should not panic
|
|
srv.SetCharacteristic(nil, 1, char.IID, encoded)
|
|
}
|
|
|
|
func TestSetCharacteristic_SelectedRecordingConfig(t *testing.T) {
|
|
streams := newMockStreamProvider()
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.MotionMode = "detect"
|
|
c.Streams = streams
|
|
})
|
|
|
|
char := srv.accessory.GetCharacter(camera.TypeSelectedCameraRecordingConfiguration)
|
|
require.NotNil(t, char)
|
|
|
|
srv.SetCharacteristic(nil, 1, char.IID, "some-config-value")
|
|
require.Equal(t, "some-config-value", char.Value)
|
|
}
|
|
|
|
func TestSetCharacteristic_DataStreamTransport_CloseRequest(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
|
|
char := srv.accessory.GetCharacter(camera.TypeSetupDataStreamTransport)
|
|
require.NotNil(t, char)
|
|
|
|
// Create a close request (SessionCommandType != 0)
|
|
req := camera.SetupDataStreamTransportRequest{
|
|
SessionCommandType: 1, // close
|
|
}
|
|
encoded, err := tlv8.MarshalBase64(req)
|
|
require.NoError(t, err)
|
|
|
|
// Should not panic (no active session)
|
|
srv.SetCharacteristic(nil, 1, char.IID, encoded)
|
|
}
|
|
|
|
func TestSetCharacteristic_DataStreamTransport_InvalidTLV8(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
|
|
char := srv.accessory.GetCharacter(camera.TypeSetupDataStreamTransport)
|
|
// Invalid TLV8 — should log error and return
|
|
srv.SetCharacteristic(nil, 1, char.IID, "bad-data")
|
|
}
|
|
|
|
// ====================================================================
|
|
// prepareHKSVConsumer / takePreparedConsumer
|
|
// ====================================================================
|
|
|
|
func TestTakePreparedConsumer_None(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
require.Nil(t, srv.takePreparedConsumer())
|
|
}
|
|
|
|
func TestTakePreparedConsumer_Available(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
consumer := NewHKSVConsumer(zerolog.Nop())
|
|
srv.preparedConsumer = consumer
|
|
|
|
got := srv.takePreparedConsumer()
|
|
require.Equal(t, consumer, got)
|
|
require.Nil(t, srv.preparedConsumer, "should be cleared after take")
|
|
}
|
|
|
|
func TestTakePreparedConsumer_OnlyOnce(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
srv.preparedConsumer = NewHKSVConsumer(zerolog.Nop())
|
|
|
|
first := srv.takePreparedConsumer()
|
|
require.NotNil(t, first)
|
|
|
|
second := srv.takePreparedConsumer()
|
|
require.Nil(t, second, "second take should return nil")
|
|
}
|
|
|
|
// ====================================================================
|
|
// startMotionDetector
|
|
// ====================================================================
|
|
|
|
func TestStartMotionDetector_AddsAndRemoves(t *testing.T) {
|
|
streams := newMockStreamProvider()
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.MotionMode = "detect"
|
|
c.Streams = streams
|
|
})
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
srv.startMotionDetector()
|
|
}()
|
|
|
|
// Wait for consumer to be added
|
|
require.Eventually(t, func() bool {
|
|
return streams.count("test-camera") == 1
|
|
}, 2*time.Second, 10*time.Millisecond)
|
|
|
|
// Motion detector should be set
|
|
srv.mu.Lock()
|
|
det := srv.motionDetector
|
|
srv.mu.Unlock()
|
|
require.NotNil(t, det)
|
|
|
|
// Stop the detector
|
|
_ = det.Stop()
|
|
<-done
|
|
|
|
// Should be cleaned up
|
|
require.Equal(t, 0, streams.count("test-camera"))
|
|
srv.mu.Lock()
|
|
require.Nil(t, srv.motionDetector)
|
|
srv.mu.Unlock()
|
|
}
|
|
|
|
func TestStartMotionDetector_Idempotent(t *testing.T) {
|
|
streams := newMockStreamProvider()
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.MotionMode = "detect"
|
|
c.Streams = streams
|
|
})
|
|
|
|
// Start first detector
|
|
done1 := make(chan struct{})
|
|
go func() {
|
|
defer close(done1)
|
|
srv.startMotionDetector()
|
|
}()
|
|
|
|
require.Eventually(t, func() bool {
|
|
return streams.count("test-camera") == 1
|
|
}, 2*time.Second, 10*time.Millisecond)
|
|
|
|
// Second start should be no-op
|
|
done2 := make(chan struct{})
|
|
go func() {
|
|
defer close(done2)
|
|
srv.startMotionDetector()
|
|
}()
|
|
<-done2 // returns immediately
|
|
|
|
// Should still have only 1 consumer
|
|
require.Equal(t, 1, streams.count("test-camera"))
|
|
|
|
srv.stopMotionDetector()
|
|
<-done1
|
|
}
|
|
|
|
func TestStartMotionDetector_StreamError(t *testing.T) {
|
|
streams := newMockStreamProvider()
|
|
streams.addErr = errors.New("stream not found")
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.MotionMode = "detect"
|
|
c.Streams = streams
|
|
})
|
|
|
|
srv.startMotionDetector()
|
|
|
|
// Should clean up and not leave a dangling detector
|
|
srv.mu.Lock()
|
|
require.Nil(t, srv.motionDetector)
|
|
srv.mu.Unlock()
|
|
}
|
|
|
|
// ====================================================================
|
|
// isClosedConnErr
|
|
// ====================================================================
|
|
|
|
func TestIsClosedConnErr(t *testing.T) {
|
|
require.False(t, isClosedConnErr(nil))
|
|
require.False(t, isClosedConnErr(errors.New("something")))
|
|
require.True(t, isClosedConnErr(errors.New("use of closed network connection")))
|
|
require.True(t, isClosedConnErr(fmt.Errorf("wrapped: %w",
|
|
errors.New("read: use of closed network connection"))))
|
|
}
|
|
|
|
// ====================================================================
|
|
// Consumer Integration: realistic fMP4 flow via AddTrack
|
|
// ====================================================================
|
|
|
|
func TestConsumer_AddTrack_H264(t *testing.T) {
|
|
c := NewHKSVConsumer(zerolog.Nop())
|
|
|
|
videoMedia := c.Medias[0]
|
|
videoCodec := &core.Codec{
|
|
Name: core.CodecH264,
|
|
ClockRate: 90000,
|
|
FmtpLine: "profile-level-id=42e01f",
|
|
}
|
|
receiver := core.NewReceiver(videoMedia, videoCodec)
|
|
|
|
err := c.AddTrack(videoMedia, videoCodec, receiver)
|
|
require.NoError(t, err)
|
|
require.Len(t, c.Senders, 1)
|
|
}
|
|
|
|
func TestConsumer_AddTrack_H264AndAAC(t *testing.T) {
|
|
c := NewHKSVConsumer(zerolog.Nop())
|
|
|
|
videoCodec := &core.Codec{
|
|
Name: core.CodecH264,
|
|
ClockRate: 90000,
|
|
FmtpLine: "profile-level-id=42e01f",
|
|
}
|
|
audioCodec := &core.Codec{
|
|
Name: core.CodecAAC,
|
|
ClockRate: 16000,
|
|
Channels: 1,
|
|
}
|
|
|
|
vReceiver := core.NewReceiver(c.Medias[0], videoCodec)
|
|
aReceiver := core.NewReceiver(c.Medias[1], audioCodec)
|
|
|
|
err := c.AddTrack(c.Medias[0], videoCodec, vReceiver)
|
|
require.NoError(t, err)
|
|
|
|
err = c.AddTrack(c.Medias[1], audioCodec, aReceiver)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, c.Senders, 2)
|
|
|
|
// Init should be built after both tracks added
|
|
select {
|
|
case <-c.initDone:
|
|
require.NoError(t, c.initErr)
|
|
require.NotEmpty(t, c.initData)
|
|
default:
|
|
t.Fatal("initDone should be closed after both tracks are added")
|
|
}
|
|
}
|
|
|
|
func TestConsumer_AddTrack_UnsupportedCodec(t *testing.T) {
|
|
c := NewHKSVConsumer(zerolog.Nop())
|
|
|
|
codec := &core.Codec{Name: core.CodecVP9, ClockRate: 90000}
|
|
receiver := core.NewReceiver(c.Medias[0], codec)
|
|
|
|
err := c.AddTrack(c.Medias[0], codec, receiver)
|
|
require.NoError(t, err) // returns nil for unsupported
|
|
require.Len(t, c.Senders, 0, "unsupported codec should not add sender")
|
|
}
|
|
|
|
func TestConsumer_AddTrack_LateTrackIgnored(t *testing.T) {
|
|
c := NewHKSVConsumer(zerolog.Nop())
|
|
|
|
// Build init with one track
|
|
videoCodec := &core.Codec{Name: core.CodecH264, ClockRate: 90000}
|
|
vReceiver := core.NewReceiver(c.Medias[0], videoCodec)
|
|
_ = c.AddTrack(c.Medias[0], videoCodec, vReceiver)
|
|
|
|
audioCodec := &core.Codec{Name: core.CodecAAC, ClockRate: 16000, Channels: 1}
|
|
aReceiver := core.NewReceiver(c.Medias[1], audioCodec)
|
|
_ = c.AddTrack(c.Medias[1], audioCodec, aReceiver)
|
|
|
|
// Init is built
|
|
<-c.initDone
|
|
|
|
// Late track should be ignored
|
|
lateCodec := &core.Codec{Name: core.CodecH264, ClockRate: 90000}
|
|
lateReceiver := core.NewReceiver(c.Medias[0], lateCodec)
|
|
err := c.AddTrack(c.Medias[0], lateCodec, lateReceiver)
|
|
require.NoError(t, err)
|
|
require.Len(t, c.Senders, 2, "late track should not add another sender")
|
|
}
|
|
|
|
// ====================================================================
|
|
// Full HKSV Recording Flow (integration)
|
|
// ====================================================================
|
|
|
|
func TestConsumer_FullRecordingFlow(t *testing.T) {
|
|
// This test simulates a realistic HKSV recording:
|
|
// 1. Create consumer with H264+AAC tracks
|
|
// 2. Activate with HDS session
|
|
// 3. Send keyframe + P-frames as GOP
|
|
// 4. Send next keyframe (triggers flush)
|
|
// 5. Verify fragment received on controller side
|
|
|
|
acc, ctrl := newTestSessionPair(t)
|
|
c := NewHKSVConsumer(zerolog.Nop())
|
|
|
|
// Add tracks
|
|
videoCodec := &core.Codec{Name: core.CodecH264, ClockRate: 90000}
|
|
audioCodec := &core.Codec{Name: core.CodecAAC, ClockRate: 16000, Channels: 1}
|
|
vReceiver := core.NewReceiver(c.Medias[0], videoCodec)
|
|
aReceiver := core.NewReceiver(c.Medias[1], audioCodec)
|
|
require.NoError(t, c.AddTrack(c.Medias[0], videoCodec, vReceiver))
|
|
require.NoError(t, c.AddTrack(c.Medias[1], audioCodec, aReceiver))
|
|
|
|
// Read init from controller side
|
|
initDone := make(chan struct{})
|
|
go func() {
|
|
defer close(initDone)
|
|
msg, err := ctrl.ReadMessage()
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "dataSend", msg.Protocol)
|
|
packets := msg.Body["packets"].([]any)
|
|
pkt := packets[0].(map[string]any)
|
|
meta := pkt["metadata"].(map[string]any)
|
|
assert.Equal(t, "mediaInitialization", meta["dataType"])
|
|
}()
|
|
|
|
// Activate
|
|
require.NoError(t, c.Activate(acc, 1))
|
|
<-initDone
|
|
|
|
require.True(t, c.active)
|
|
require.Equal(t, 2, c.seqNum)
|
|
|
|
// Simulate GOP: keyframe + P-frames
|
|
// Send a fake keyframe (IDR NAL type 5)
|
|
keyframePayload := make([]byte, 2000)
|
|
keyframePayload[4] = 0x65 // NAL type 5 = IDR
|
|
c.mu.Lock()
|
|
b := c.muxer.GetPayload(0, &rtp.Packet{
|
|
Header: rtp.Header{Timestamp: 0, SequenceNumber: 1},
|
|
Payload: keyframePayload,
|
|
})
|
|
c.fragBuf = append(c.fragBuf, b...)
|
|
|
|
// Add some P-frames
|
|
for i := 0; i < 5; i++ {
|
|
pFramePayload := make([]byte, 500)
|
|
pFramePayload[4] = 0x41 // NAL type 1 = non-IDR
|
|
b = c.muxer.GetPayload(0, &rtp.Packet{
|
|
Header: rtp.Header{Timestamp: uint32(3000 * (i + 1)), SequenceNumber: uint16(i + 2)},
|
|
Payload: pFramePayload,
|
|
})
|
|
c.fragBuf = append(c.fragBuf, b...)
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
// Read fragment from controller side
|
|
fragDone := make(chan struct{})
|
|
go func() {
|
|
defer close(fragDone)
|
|
msg, err := ctrl.ReadMessage()
|
|
assert.NoError(t, err)
|
|
packets := msg.Body["packets"].([]any)
|
|
pkt := packets[0].(map[string]any)
|
|
meta := pkt["metadata"].(map[string]any)
|
|
assert.Equal(t, "mediaFragment", meta["dataType"])
|
|
assert.Equal(t, int64(2), meta["dataSequenceNumber"].(int64))
|
|
}()
|
|
|
|
// Flush the fragment
|
|
c.mu.Lock()
|
|
c.flushFragment()
|
|
c.mu.Unlock()
|
|
|
|
<-fragDone
|
|
require.Equal(t, 3, c.seqNum)
|
|
|
|
// Stop
|
|
require.NoError(t, c.Stop())
|
|
require.False(t, c.active)
|
|
}
|
|
|
|
// ====================================================================
|
|
// Motion Detector Integration with Server
|
|
// ====================================================================
|
|
|
|
func TestMotionDetector_IntegrationWithServer(t *testing.T) {
|
|
// Simulates: server starts motion detector, detector triggers motion,
|
|
// server updates MotionDetected characteristic
|
|
|
|
streams := newMockStreamProvider()
|
|
srv := newTestServer(t, func(c *Config) {
|
|
c.MotionMode = "detect"
|
|
c.MotionThreshold = 2.0
|
|
c.Streams = streams
|
|
})
|
|
|
|
motionChar := srv.accessory.GetCharacter("22")
|
|
require.NotNil(t, motionChar)
|
|
|
|
// Start motion detector in background
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
srv.startMotionDetector()
|
|
}()
|
|
|
|
// Wait for detector to be registered
|
|
require.Eventually(t, func() bool {
|
|
srv.mu.Lock()
|
|
defer srv.mu.Unlock()
|
|
return srv.motionDetector != nil
|
|
}, 2*time.Second, 10*time.Millisecond)
|
|
|
|
// Manually trigger motion through the detector
|
|
srv.mu.Lock()
|
|
det := srv.motionDetector
|
|
srv.mu.Unlock()
|
|
|
|
// Feed warmup frames
|
|
for i := 0; i < motionWarmupFrames; i++ {
|
|
det.handlePacket(makePFrame(500))
|
|
}
|
|
det.holdBudget = 10
|
|
det.cooldownBudget = 5
|
|
|
|
// Trigger motion with large frame
|
|
det.handlePacket(makePFrame(5000))
|
|
|
|
// MotionDetected characteristic should be true
|
|
require.Equal(t, true, motionChar.Value)
|
|
|
|
// Expire hold
|
|
for i := 0; i < 10; i++ {
|
|
det.handlePacket(makePFrame(500))
|
|
}
|
|
|
|
// MotionDetected should be false
|
|
require.Equal(t, false, motionChar.Value)
|
|
|
|
// Clean up
|
|
_ = det.Stop()
|
|
<-done
|
|
}
|
|
|
|
// ====================================================================
|
|
// connLabel
|
|
// ====================================================================
|
|
|
|
func TestConnLabel(t *testing.T) {
|
|
require.Contains(t, connLabel("hello"), "string")
|
|
require.Contains(t, connLabel(42), "int")
|
|
}
|
|
|
|
// ====================================================================
|
|
// connLabel with HDS conn
|
|
// ====================================================================
|
|
|
|
func TestConnLabel_HDSConn(t *testing.T) {
|
|
key := []byte(core.RandString(16, 0))
|
|
salt := core.RandString(32, 0)
|
|
c1, c2 := net.Pipe()
|
|
defer c1.Close()
|
|
defer c2.Close()
|
|
|
|
hdsConn, err := hds.NewConn(c1, key, salt, false)
|
|
require.NoError(t, err)
|
|
|
|
label := connLabel(hdsConn)
|
|
require.Contains(t, label, "hds")
|
|
}
|