From cd24ab4dc57c9b205928228a7b520d7f9bc2870c Mon Sep 17 00:00:00 2001 From: Radu Ursache Date: Fri, 10 Jul 2026 17:39:21 +0300 Subject: [PATCH 1/8] Support longer HAP type IDs and tlv8 bool for HKSV InitIID keeps packed IIDs for legacy 3-char types so existing pairings stay valid, and assigns sequential high-range IIDs for 4-char types from the HKSV open-source guide. Also add write-response permission helpers and boolean TLV8 encode/decode --- pkg/hap/accessory.go | 68 ++++++++++++++++++++++++++++++++------------ pkg/hap/tlv8/tlv8.go | 12 ++++++++ 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/pkg/hap/accessory.go b/pkg/hap/accessory.go index c659a24aa..6813ec3fc 100644 --- a/pkg/hap/accessory.go +++ b/pkg/hap/accessory.go @@ -25,38 +25,70 @@ var PW = []string{"pw"} var PRPW = []string{"pr", "pw"} var EVPRPW = []string{"ev", "pr", "pw"} var EVPR = []string{"ev", "pr"} +// PRPWWR is paired-read/write with write-response (HAP "wr") +var PRPWWR = []string{"pr", "pw", "wr"} +// PWWR is paired-write with write-response +var PWWR = []string{"pw", "wr"} +// EVPRPWWR is event + paired-read/write + write-response +var EVPRPWWR = []string{"ev", "pr", "pw", "wr"} +// TWPRPW is timed-write + paired-read/write (admin-only traits handled by controller) +var TWPRPW = []string{"pr", "pw", "tw"} +// EVTWPRPW is event + timed-write + paired-read/write +var EVTWPRPW = []string{"ev", "pr", "pw", "tw"} type Accessory struct { AID uint8 `json:"aid"` // 150 unique accessories per bridge Services []*Service `json:"services"` } +// nextLongIID starts high enough to never collide with packed 3-char IIDs +// (packed form is ANSSSCCC, e.g. 0x11110000 for CameraRTPStreamManagement) +const nextLongIIDBase uint64 = 0x80000000 + func (a *Accessory) InitIID() { serviceN := map[string]byte{} - for _, service := range a.Services { - if len(service.Type) > 3 { - panic(service.Type) - } + nextLong := nextLongIIDBase - n := serviceN[service.Type] + 1 - serviceN[service.Type] = n + for _, service := range a.Services { + // Short HAP types (legacy, max 3 hex chars) keep packed IIDs so existing + // pairings stay valid. Longer types from the HKSV open-source guide + // (4 hex chars, e.g. 8033) use sequential IIDs in a high range. + if len(service.Type) <= 3 { + n := serviceN[service.Type] + 1 + serviceN[service.Type] = n + + if n > 15 { + panic(n) + } - if n > 15 { - panic(n) + // ServiceID = ANSSS000 + s := fmt.Sprintf("%x%x%03s000", a.AID, n, service.Type) + service.IID, _ = strconv.ParseUint(s, 16, 64) + + for _, character := range service.Characters { + if len(character.Type) > 3 { + // Mix of short service + long character: sequential under service + character.IID = nextLong + nextLong++ + continue + } + + // CharacterID = ANSSSCCC + character.IID, _ = strconv.ParseUint(character.Type, 16, 64) + character.IID += service.IID + } + continue } - // ServiceID = ANSSS000 - s := fmt.Sprintf("%x%x%03s000", a.AID, n, service.Type) - service.IID, _ = strconv.ParseUint(s, 16, 64) + if len(service.Type) > 8 { + panic(service.Type) + } + service.IID = nextLong + nextLong++ for _, character := range service.Characters { - if len(character.Type) > 3 { - panic(character.Type) - } - - // CharacterID = ANSSSCCC - character.IID, _ = strconv.ParseUint(character.Type, 16, 64) - character.IID += service.IID + character.IID = nextLong + nextLong++ } } } diff --git a/pkg/hap/tlv8/tlv8.go b/pkg/hap/tlv8/tlv8.go index 7b397b998..cda047ba0 100644 --- a/pkg/hap/tlv8/tlv8.go +++ b/pkg/hap/tlv8/tlv8.go @@ -100,6 +100,12 @@ func appendValue(b []byte, tag byte, value reflect.Value) ([]byte, error) { var err error switch value.Kind() { + case reflect.Bool: + if value.Bool() { + return append(b, tag, 1, 1), nil + } + return append(b, tag, 1, 0), nil + case reflect.Uint8: v := value.Uint() return append(b, tag, 1, byte(v)), nil @@ -296,6 +302,12 @@ func unmarshalStruct(b []byte, value reflect.Value) error { func unmarshalValue(v []byte, value reflect.Value) error { switch value.Kind() { + case reflect.Bool: + if len(v) != 1 { + return errors.New("tlv8: wrong size: " + value.Type().Name()) + } + value.SetBool(v[0] != 0) + case reflect.Uint8: if len(v) != 1 { return errors.New("tlv8: wrong size: " + value.Type().Name()) From 73db5efe1cbc3b7a64e9f224c1026376731b3ceb Mon Sep 17 00:00:00 2001 From: Radu Ursache Date: Fri, 10 Jul 2026 17:40:40 +0300 Subject: [PATCH 2/8] Add HKSV open-source HAP types, TLV8 structs and services Implements the Developer Preview 17.99 services and characteristics from Apple's HomeKit Secure Video Open Source Compatibility Guide: camera capabilities, multi-tier RTP/WebRTC stream management with mandatory HEVC tiers, buffer/key/cert management, and accessory builder --- pkg/hap/camera/hksv_accessory.go | 59 +++++ pkg/hap/camera/hksv_buffer.go | 82 ++++++ pkg/hap/camera/hksv_capabilities.go | 112 ++++++++ pkg/hap/camera/hksv_cert.go | 23 ++ pkg/hap/camera/hksv_services.go | 397 ++++++++++++++++++++++++++++ pkg/hap/camera/hksv_tiers.go | 91 +++++++ pkg/hap/camera/hksv_types.go | 194 ++++++++++++++ pkg/hap/camera/hksv_webrtc.go | 107 ++++++++ 8 files changed, 1065 insertions(+) create mode 100644 pkg/hap/camera/hksv_accessory.go create mode 100644 pkg/hap/camera/hksv_buffer.go create mode 100644 pkg/hap/camera/hksv_capabilities.go create mode 100644 pkg/hap/camera/hksv_cert.go create mode 100644 pkg/hap/camera/hksv_services.go create mode 100644 pkg/hap/camera/hksv_tiers.go create mode 100644 pkg/hap/camera/hksv_types.go create mode 100644 pkg/hap/camera/hksv_webrtc.go diff --git a/pkg/hap/camera/hksv_accessory.go b/pkg/hap/camera/hksv_accessory.go new file mode 100644 index 000000000..a9bf82d06 --- /dev/null +++ b/pkg/hap/camera/hksv_accessory.go @@ -0,0 +1,59 @@ +package camera + +import ( + "github.com/AlexxIT/go2rtc/pkg/hap" +) + +// NewHKSVAccessory builds a HomeKit camera accessory with the Developer Preview +// HKSV open-source services (WebRTC, multi-tier HEVC, CMAF provisioning) +// in addition to classic RTP stream management (min 5 concurrent RTP sessions) +func NewHKSVAccessory(manuf, model, name, serial, firmware, seed string) *hap.Accessory { + sensorUUID := SensorUUIDBytes(seed) + + services := []*hap.Service{ + hap.ServiceAccessoryInformation(manuf, model, name, serial, firmware), + } + // Classic live view slots (guide requires at least 5 concurrent RTP sessions) + for i := 0; i < MinConcurrentRTPSessions; i++ { + services = append(services, ServiceCameraRTPStreamManagement()) + } + services = append(services, + ServiceMicrophone(), + // HKSV open-source services (spec 17.99) + ServiceCameraCapabilities(sensorUUID), + ServiceCameraGlobalOperatingMode(), + ServiceMotionSensorHKSV(sensorUUID), + ServiceCameraMotionZones(), + ServiceCameraMultiTierRTPStreamManagement(sensorUUID), + ServiceCameraWebRTCStreamManagement(sensorUUID), + ServiceCameraRecordingManagementHKSV(), + ServiceCameraBufferManagement(), + ServiceCameraKeyManagement(), + ServiceCameraClientCertificateManagement(), + ) + + acc := &hap.Accessory{ + AID: hap.DeviceAID, + Services: services, + } + acc.InitIID() + return acc +} + +// AppendHKSVServices adds HKSV services to an existing accessory (in-place) +// Call InitIID after if IIDs have not been assigned yet +func AppendHKSVServices(acc *hap.Accessory, seed string) { + sensorUUID := SensorUUIDBytes(seed) + acc.Services = append(acc.Services, + ServiceCameraCapabilities(sensorUUID), + ServiceCameraGlobalOperatingMode(), + ServiceMotionSensorHKSV(sensorUUID), + ServiceCameraMotionZones(), + ServiceCameraMultiTierRTPStreamManagement(sensorUUID), + ServiceCameraWebRTCStreamManagement(sensorUUID), + ServiceCameraRecordingManagementHKSV(), + ServiceCameraBufferManagement(), + ServiceCameraKeyManagement(), + ServiceCameraClientCertificateManagement(), + ) +} diff --git a/pkg/hap/camera/hksv_buffer.go b/pkg/hap/camera/hksv_buffer.go new file mode 100644 index 000000000..95230f11b --- /dev/null +++ b/pkg/hap/camera/hksv_buffer.go @@ -0,0 +1,82 @@ +package camera + +// BufferUploadCommandRequest starts/stops buffer upload (UUID 8013) +type BufferUploadCommandRequest struct { + SessionID uint64 `tlv8:"1"` + Command byte `tlv8:"2"` + Start uint64 `tlv8:"3"` + Stop uint64 `tlv8:"4"` + StopAction byte `tlv8:"5"` +} + +// BufferUploadCommandResponse is returned after buffer upload command +type BufferUploadCommandResponse struct { + ClipID uint64 `tlv8:"1"` +} + +// BufferActivityCommandRequest marks buffer activity (UUID 8017) +type BufferActivityCommandRequest struct { + Start uint64 `tlv8:"1"` // NTP timestamp + Duration uint64 `tlv8:"2"` // milliseconds + Activity byte `tlv8:"3"` +} + +// BufferEventCommandRequest queries or acknowledges events (UUID 8014) +type BufferEventCommandRequest struct { + Command byte `tlv8:"1"` + SequenceNumber uint64 `tlv8:"2"` + Limit uint64 `tlv8:"3"` +} + +// BufferEventCommandResponse is returned after buffer event command +type BufferEventCommandResponse struct { + Events []CameraBufferEvent `tlv8:"1"` +} + +// CameraBufferEvent is one event from the camera event queue +type CameraBufferEvent struct { + SequenceNumber uint64 `tlv8:"1"` + Type byte `tlv8:"2"` + CMAFSessionStart CameraBufferEventCMAFSession `tlv8:"3"` + CMAFSessionStop CameraBufferEventCMAFSession `tlv8:"4"` + Motion CameraBufferEventMotion `tlv8:"5"` + CMAFError CameraBufferEventCMAFError `tlv8:"6"` +} + +// CameraBufferEventCMAFSession identifies a CMAF session start/stop +type CameraBufferEventCMAFSession struct { + CMAFSessionID uint64 `tlv8:"1"` +} + +// CameraBufferEventMotion reports motion state +type CameraBufferEventMotion struct { + Active bool `tlv8:"1"` +} + +// CameraBufferEventCMAFError reports a CMAF ingest error +type CameraBufferEventCMAFError struct { + CMAFSessionID uint64 `tlv8:"1"` + CMAFError byte `tlv8:"2"` +} + +// CameraRecordingPublishingPointValue holds the CMAF publish URL (UUID 8016) +type CameraRecordingPublishingPointValue struct { + URL string `tlv8:"1"` + ServerCACertificates []Certificate `tlv8:"2"` +} + +// Certificate is a DER-encoded X.509 certificate +type Certificate struct { + Certificate string `tlv8:"1"` +} + +// CameraKeyValue is written to install a key (UUID 8051) +type CameraKeyValue struct { + Key string `tlv8:"1"` + KeyNumber uint64 `tlv8:"2"` +} + +// CameraKeyIDValue reports the current key identifier (UUID 8052) +type CameraKeyIDValue struct { + KeyID uint64 `tlv8:"1"` +} diff --git a/pkg/hap/camera/hksv_capabilities.go b/pkg/hap/camera/hksv_capabilities.go new file mode 100644 index 000000000..2e3ca6140 --- /dev/null +++ b/pkg/hap/camera/hksv_capabilities.go @@ -0,0 +1,112 @@ +package camera + +// CameraCapabilitiesValue is the Camera Capabilities characteristic (UUID 8011) +type CameraCapabilitiesValue struct { + Version uint8 `tlv8:"1"` + CameraSensors CameraSensors `tlv8:"2"` +} + +// CameraSensors wraps a list of sensor configurations +type CameraSensors struct { + Sensors []SensorConfiguration `tlv8:"1"` +} + +// SensorConfiguration describes one image sensor +type SensorConfiguration struct { + SensorDimensions SensorDimensions `tlv8:"1"` + SensorUUID string `tlv8:"2"` // 16-byte UUID + SensorType byte `tlv8:"3"` + SensorIntent byte `tlv8:"4"` + VideoStreamCapabilities []CameraVideoStreamCapability `tlv8:"5"` +} + +// SensorDimensions is the sensor pixel size +type SensorDimensions struct { + Width uint16 `tlv8:"1"` + Height uint16 `tlv8:"2"` +} + +// CameraVideoStreamCapability is one advertised stream configuration +type CameraVideoStreamCapability struct { + Identifier string `tlv8:"1"` // UUID + VideoQuality byte `tlv8:"2"` + Width uint16 `tlv8:"3"` + Height uint16 `tlv8:"4"` + FramesPerSecond uint8 `tlv8:"5"` + AverageBitRate uint32 `tlv8:"6"` // kbps + PeakBitRate uint32 `tlv8:"7"` // kbps +} + +// ContributingSensorsValue lists sensors that contributed to motion (UUID 8086) +type ContributingSensorsValue struct { + SensorList []ContributingSensor `tlv8:"1"` +} + +// ContributingSensor is one sensor UUID in a motion event +type ContributingSensor struct { + SensorUUID string `tlv8:"1"` +} + +// CameraZonesValue holds motion/activity zones (UUID 8022) +type CameraZonesValue struct { + ZoneDataVersion uint8 `tlv8:"1"` + ZoneData string `tlv8:"2"` // opaque Zone Data TLV8 blob +} + +// ZoneDataV2 is version-2 zone data content +type ZoneDataV2 struct { + Method byte `tlv8:"1"` + Polygons []Polygon `tlv8:"3"` +} + +// Polygon is a non-self-intersecting zone polygon +type Polygon struct { + Identifier string `tlv8:"1"` // UUID + Vertices string `tlv8:"3"` // little-endian UINT16 (X,Y) pairs +} + +// DefaultCameraCapabilities builds a 1080p primary sensor capabilities blob +func DefaultCameraCapabilities(sensorUUID string) CameraCapabilitiesValue { + return CameraCapabilitiesValue{ + Version: 1, + CameraSensors: CameraSensors{ + Sensors: []SensorConfiguration{ + { + SensorDimensions: SensorDimensions{Width: 1920, Height: 1080}, + SensorUUID: sensorUUID, + SensorType: SensorTypePrimary, + SensorIntent: SensorIntentMain, + VideoStreamCapabilities: []CameraVideoStreamCapability{ + { + Identifier: sensorUUID, + VideoQuality: VideoQualityHigh, + Width: 1920, + Height: 1080, + FramesPerSecond: 30, + AverageBitRate: Bitrate1080pAvgKbps, + PeakBitRate: Bitrate1080pMaxKbps, + }, + { + Identifier: sensorUUID, + VideoQuality: VideoQualityMedium, + Width: 1280, + Height: 720, + FramesPerSecond: 30, + AverageBitRate: Bitrate720pAvgKbps, + PeakBitRate: Bitrate720pMaxKbps, + }, + { + Identifier: sensorUUID, + VideoQuality: VideoQualityLow, + Width: 640, + Height: 360, + FramesPerSecond: 15, + AverageBitRate: BitrateLowAvgKbps, + PeakBitRate: BitrateLowMaxKbps, + }, + }, + }, + }, + }, + } +} diff --git a/pkg/hap/camera/hksv_cert.go b/pkg/hap/camera/hksv_cert.go new file mode 100644 index 000000000..30e0d2237 --- /dev/null +++ b/pkg/hap/camera/hksv_cert.go @@ -0,0 +1,23 @@ +package camera + +// CameraClientCSRRequest is written by the controller with a nonce (UUID 8081) +type CameraClientCSRRequest struct { + Nonce string `tlv8:"1"` // 32 random bytes +} + +// CameraClientCSRResponse is the CSR + nonce signature from the accessory +type CameraClientCSRResponse struct { + CSR string `tlv8:"1"` // DER CSR + NonceSignature string `tlv8:"2"` // EC signature of nonce, max 128 bytes +} + +// CameraClientCertificateRequest installs the issued client cert (UUID 8082) +type CameraClientCertificateRequest struct { + ClientCertificate string `tlv8:"1"` // DER + CA string `tlv8:"2"` // DER +} + +// CameraClientCertificateStatusValue reports cert freshness (UUID 8083) +type CameraClientCertificateStatusValue struct { + NeedsUpdate bool `tlv8:"1"` +} diff --git a/pkg/hap/camera/hksv_services.go b/pkg/hap/camera/hksv_services.go new file mode 100644 index 000000000..93235e4c0 --- /dev/null +++ b/pkg/hap/camera/hksv_services.go @@ -0,0 +1,397 @@ +package camera + +import ( + "encoding/base64" + "encoding/hex" + + "github.com/AlexxIT/go2rtc/pkg/hap" + "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" + "github.com/google/uuid" +) + +func dataBase64(raw string) string { + return base64.StdEncoding.EncodeToString([]byte(raw)) +} + +// SensorUUIDBytes returns a stable 16-byte sensor UUID string for TLV8 data fields +func SensorUUIDBytes(seed string) string { + u := uuid.NewSHA1(uuid.NameSpaceOID, []byte("go2rtc/hksv/"+seed)) + return string(u[:]) +} + +// SensorUUIDHex returns the sensor UUID as a hex string (for logs/config) +func SensorUUIDHex(seed string) string { + u := uuid.NewSHA1(uuid.NameSpaceOID, []byte("go2rtc/hksv/"+seed)) + return hex.EncodeToString(u[:]) +} + +func mustTLV8(v any) string { + s, err := tlv8.MarshalBase64(v) + if err != nil { + return "" + } + return s +} + +// ServiceCameraCapabilities advertises open-source HKSV camera capabilities (17.99) +func ServiceCameraCapabilities(sensorUUID string) *hap.Service { + return &hap.Service{ + Type: TypeCameraCapabilities, + Characters: []*hap.Character{ + { + Type: TypeVersion, + Format: hap.FormatString, + Value: CameraCapabilitiesVersion, + Perms: hap.PR, + }, + { + Type: TypeCameraCapabilitiesChar, + Format: hap.FormatTLV8, + Value: mustTLV8(DefaultCameraCapabilities(sensorUUID)), + Perms: hap.PR, + }, + }, + } +} + +// ServiceCameraGlobalOperatingMode controls whole-accessory streaming state +func ServiceCameraGlobalOperatingMode() *hap.Service { + return &hap.Service{ + Type: TypeCameraGlobalOperatingMode, + Characters: []*hap.Character{ + { + Type: TypeHomeKitCameraActive, + Format: hap.FormatBool, + Value: true, + Perms: hap.EVPRPW, + }, + { + Type: TypeStreamingEnabled, + Format: hap.FormatBool, + Value: true, + Perms: hap.EVTWPRPW, + }, + { + Type: TypeCameraOperatingModeIndicator, + Format: hap.FormatBool, + Value: true, + Perms: hap.EVPRPW, + }, + }, + } +} + +// ServiceMotionSensorHKSV is a motion sensor with optional multi-sensor fields +func ServiceMotionSensorHKSV(sensorUUID string) *hap.Service { + return &hap.Service{ + Type: TypeMotionSensor, + Characters: []*hap.Character{ + { + Type: TypeMotionDetected, + Format: hap.FormatBool, + Value: false, + Perms: hap.EVPR, + }, + { + Type: TypeStatusActive, + Format: hap.FormatBool, + Value: true, + Perms: hap.EVPR, + }, + { + Type: TypeMotionEnabled, + Format: hap.FormatBool, + Value: true, + Perms: hap.EVTWPRPW, + }, + { + Type: TypeSensorUUID, + Format: hap.FormatData, + Value: dataBase64(sensorUUID), + Perms: hap.PR, + }, + }, + } +} + +// ServiceCameraMotionZones holds activity zones +func ServiceCameraMotionZones() *hap.Service { + return &hap.Service{ + Type: TypeCameraMotionZones, + Characters: []*hap.Character{ + { + Type: TypeVersion, + Format: hap.FormatString, + Value: CameraCapabilitiesVersion, + Perms: hap.PR, + }, + { + Type: TypeActive, + Format: hap.FormatUInt8, + Value: 1, + Perms: hap.EVPRPW, + }, + { + Type: TypeCameraZones, + Format: hap.FormatTLV8, + Value: mustTLV8(CameraZonesValue{ZoneDataVersion: 2}), + Perms: hap.PRPW, + }, + }, + } +} + +// ServiceCameraMultiTierRTPStreamManagement advertises multi-tier RTP streaming +func ServiceCameraMultiTierRTPStreamManagement(sensorUUID string) *hap.Service { + videoH264 := Default1080pVideoTiers(VideoCodecTypeTierH264, 99) + videoH265 := Default1080pVideoTiers(VideoCodecTypeTierH265, 100) + // Combined list: marshal as separate codec entries by using first as primary + // and advertising H.265 via a second SupportedVideoStreamTiers value is not + // possible as a single TLV8; HomeKit expects one value that may list one codec. + // We advertise H.265 as primary (mandatory) and keep H.264 as secondary service + // instance if needed. Spec: Codec enum on the characteristic is singular, so + // accessories typically expose one characteristic value per codec via repeated + // top-level items. Our tlv8 slice marshal supports that via []SupportedVideoStreamTiers. + _ = videoH264 + + audio := DefaultOpusAudioTier(111) + rtp := SupportedRTPConfiguration{ + SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80}, + } + + return &hap.Service{ + Type: TypeCameraMultiTierRTPStreamManagement, + Characters: []*hap.Character{ + { + Type: TypeStreamingEnabled, + Format: hap.FormatBool, + Value: true, + Perms: hap.EVTWPRPW, + }, + { + Type: TypeStatusActive, + Format: hap.FormatBool, + Value: true, + Perms: hap.EVPR, + }, + { + Type: TypeSupportedVideoStreamTiers, + Format: hap.FormatTLV8, + // Prefer HEVC (mandatory) as the primary advertised codec + Value: mustTLV8([]SupportedVideoStreamTiers{videoH265, videoH264}), + Perms: hap.EVPR, + }, + { + Type: TypeSupportedAudioStreamTiers, + Format: hap.FormatTLV8, + Value: mustTLV8(audio), + Perms: hap.EVPR, + }, + { + Type: TypeSupportedRTPConfiguration, + Format: hap.FormatTLV8, + Value: mustTLV8(rtp), + Perms: hap.PR, + }, + { + Type: TypeSetupEndpoints, + Format: hap.FormatTLV8, + Value: "", + Perms: hap.PRPW, + }, + { + Type: TypeRTPStreamingControl, + Format: hap.FormatTLV8, + Value: "", + Perms: hap.PRPWWR, + }, + { + Type: TypeSensorUUID, + Format: hap.FormatData, + Value: dataBase64(sensorUUID), + Perms: hap.PR, + }, + }, + } +} + +// ServiceCameraWebRTCStreamManagement is the HAP WebRTC signalling service +func ServiceCameraWebRTCStreamManagement(sensorUUID string) *hap.Service { + videoH265 := Default1080pVideoTiers(VideoCodecTypeTierH265, 100) + videoH264 := Default1080pVideoTiers(VideoCodecTypeTierH264, 99) + audio := DefaultOpusAudioTier(111) + + return &hap.Service{ + Type: TypeCameraWebRTCStreamManagement, + Characters: []*hap.Character{ + { + Type: TypeWebRTCSolicitOffer, + Format: hap.FormatTLV8, + Value: "", + Perms: hap.PRPWWR, + }, + { + Type: TypeWebRTCProvideAnswer, + Format: hap.FormatTLV8, + Value: "", + Perms: hap.PRPWWR, + }, + { + Type: TypeWebRTCStreamingControl, + Format: hap.FormatTLV8, + Value: "", + Perms: hap.PRPWWR, + }, + { + Type: TypeWebRTCNumberOfActiveSessions, + Format: hap.FormatUInt8, + Value: 0, + Perms: hap.EVPR, + }, + { + Type: TypeWebRTCReoffer, + Format: hap.FormatTLV8, + Value: "", + Perms: hap.PRPWWR, + }, + { + Type: TypeWebRTCUpdateSession, + Format: hap.FormatTLV8, + Value: "", + Perms: hap.PRPWWR, + }, + { + Type: TypeWebRTCSupportedVideoStreamTiers, + Format: hap.FormatTLV8, + Value: mustTLV8([]SupportedVideoStreamTiers{videoH265, videoH264}), + Perms: hap.EVPR, + }, + { + Type: TypeWebRTCSupportedAudioStreamTiers, + Format: hap.FormatTLV8, + Value: mustTLV8(audio), + Perms: hap.EVPR, + }, + { + Type: TypeStreamingEnabled, + Format: hap.FormatBool, + Value: true, + Perms: hap.EVTWPRPW, + }, + { + Type: TypeSensorUUID, + Format: hap.FormatData, + Value: dataBase64(sensorUUID), + Perms: hap.PR, + }, + }, + } +} + +// ServiceCameraRecordingManagementHKSV enables event recording control +func ServiceCameraRecordingManagementHKSV() *hap.Service { + return &hap.Service{ + Type: TypeCameraRecordingManagement, + Characters: []*hap.Character{ + { + Type: TypeActive, + Format: hap.FormatUInt8, + Value: 0, + Perms: hap.EVPRPW, + }, + { + Type: TypeRecordingAudioActive, + Format: hap.FormatUInt8, + Value: 0, + Perms: hap.EVPRPW, + }, + }, + } +} + +// ServiceCameraBufferManagement manages recording buffers and CMAF publish point +func ServiceCameraBufferManagement() *hap.Service { + return &hap.Service{ + Type: TypeCameraBufferManagement, + Characters: []*hap.Character{ + { + Type: TypeBufferUploadCommand, + Format: hap.FormatTLV8, + Value: "", + Perms: hap.PRPWWR, + }, + { + Type: TypeBufferActivityCommand, + Format: hap.FormatTLV8, + Value: "", + Perms: hap.PW, + }, + { + Type: TypeBufferEventCommand, + Format: hap.FormatTLV8, + Value: "", + Perms: hap.PRPWWR, + }, + { + Type: TypeBufferEventSequenceNumber, + Format: hap.FormatUInt32, + Value: 0, + Perms: hap.EVPR, + }, + { + Type: TypeCameraRecordingPublishingPoint, + Format: hap.FormatTLV8, + Value: "", + Perms: hap.PRPW, + }, + }, + } +} + +// ServiceCameraKeyManagement holds CMAF ingest keys +func ServiceCameraKeyManagement() *hap.Service { + return &hap.Service{ + Type: TypeCameraKeyManagement, + Characters: []*hap.Character{ + { + Type: TypeCameraKey, + Format: hap.FormatTLV8, + Value: "", + Perms: []string{"pw", "tw"}, + }, + { + Type: TypeCameraKeyID, + Format: hap.FormatTLV8, + Value: mustTLV8(CameraKeyIDValue{KeyID: 0}), + Perms: hap.EVPR, + }, + }, + } +} + +// ServiceCameraClientCertificateManagement handles CMAF client cert provisioning +func ServiceCameraClientCertificateManagement() *hap.Service { + return &hap.Service{ + Type: TypeCameraClientCertificateManagement, + Characters: []*hap.Character{ + { + Type: TypeCameraClientCSR, + Format: hap.FormatTLV8, + Value: "", + Perms: hap.PRPWWR, + }, + { + Type: TypeCameraClientCertificate, + Format: hap.FormatTLV8, + Value: "", + Perms: []string{"pr", "pw", "tw"}, + }, + { + Type: TypeCameraClientCertificateStatus, + Format: hap.FormatTLV8, + Value: mustTLV8(CameraClientCertificateStatusValue{NeedsUpdate: true}), + Perms: hap.EVPR, + }, + }, + } +} diff --git a/pkg/hap/camera/hksv_tiers.go b/pkg/hap/camera/hksv_tiers.go new file mode 100644 index 000000000..70bdbcb27 --- /dev/null +++ b/pkg/hap/camera/hksv_tiers.go @@ -0,0 +1,91 @@ +package camera + +// SupportedVideoStreamTiers describes multi-tier RTP/WebRTC video encodings +// (UUID 8043 / 8059) +type SupportedVideoStreamTiers struct { + Codec byte `tlv8:"1"` + PayloadType uint8 `tlv8:"2"` + Tiers []VideoStreamTier `tlv8:"3"` +} + +// VideoStreamTier is one quality tier for a video codec +type VideoStreamTier struct { + Identifier uint32 `tlv8:"1"` + Quality byte `tlv8:"2"` + TargetAverageBitrate uint32 `tlv8:"3"` // kbps + Width uint16 `tlv8:"4"` + Height uint16 `tlv8:"5"` + FrameRate uint8 `tlv8:"6"` +} + +// SupportedAudioStreamTiers describes multi-tier audio encodings +// (UUID 8044 / 805A) +type SupportedAudioStreamTiers struct { + Codec byte `tlv8:"1"` + PayloadType uint8 `tlv8:"2"` + Tiers []AudioStreamTier `tlv8:"3"` +} + +// AudioStreamTier is one quality tier for an audio codec +// Spec currently allows exactly one tier +type AudioStreamTier struct { + Identifier uint32 `tlv8:"1"` + TargetAverageBitrate uint32 `tlv8:"2"` // bits per second + SampleRate byte `tlv8:"3"` + BitDepth byte `tlv8:"4"` + PacketTime uint8 `tlv8:"5"` // only 20 ms allowed + NumberOfChannels uint8 `tlv8:"6"` // only 1 allowed +} + +// Default1080pVideoTiers returns High/Medium/Low tiers for a 1080p 16:9 sensor +func Default1080pVideoTiers(codec byte, payloadType uint8) SupportedVideoStreamTiers { + return SupportedVideoStreamTiers{ + Codec: codec, + PayloadType: payloadType, + Tiers: []VideoStreamTier{ + { + Identifier: 1, + Quality: VideoQualityHigh, + TargetAverageBitrate: Bitrate1080pAvgKbps, + Width: 1920, + Height: 1080, + FrameRate: 30, + }, + { + Identifier: 2, + Quality: VideoQualityMedium, + TargetAverageBitrate: Bitrate720pAvgKbps, + Width: 1280, + Height: 720, + FrameRate: 30, + }, + { + Identifier: 3, + Quality: VideoQualityLow, + TargetAverageBitrate: BitrateLowAvgKbps, + Width: 640, + Height: 360, + FrameRate: 15, + }, + }, + } +} + +// DefaultOpusAudioTier returns the mandatory Opus audio tier (16 kHz capture, +// reported transmission sample rate 48 kHz per the guide note) +func DefaultOpusAudioTier(payloadType uint8) SupportedAudioStreamTiers { + return SupportedAudioStreamTiers{ + Codec: AudioCodecTypeOpus, + PayloadType: payloadType, + Tiers: []AudioStreamTier{ + { + Identifier: 1, + TargetAverageBitrate: 24000, + SampleRate: AudioTierSampleRate48kHz, + BitDepth: AudioTierBitDepth16, + PacketTime: 20, + NumberOfChannels: 1, + }, + }, + } +} diff --git a/pkg/hap/camera/hksv_types.go b/pkg/hap/camera/hksv_types.go new file mode 100644 index 000000000..d25190370 --- /dev/null +++ b/pkg/hap/camera/hksv_types.go @@ -0,0 +1,194 @@ +package camera + +// HAP short type UUIDs from HomeKit Secure Video Open Source Compatibility Guide +// Full form: 0000XXXX-0000-1000-8000-0026BB765291 +// Developer Preview 17.99 (2026-06-03) + +// Service types +const ( + TypeCameraCapabilities = "8010" + TypeCameraGlobalOperatingMode = "8032" + TypeCameraMotionZones = "8021" + TypeCameraBufferManagement = "8000" + TypeCameraMultiTierRTPStreamManagement = "8031" + TypeCameraWebRTCStreamManagement = "8033" + // TypeCameraRecordingManagement already exists as "204" in HAP R2 + TypeCameraKeyManagement = "8050" + TypeCameraClientCertificateManagement = "8080" + TypeMotionSensor = "85" +) + +// Characteristic types +const ( + TypeSensorUUID = "805B" + TypeMotionEnabled = "8087" + TypeSupportedVideoStreamTiers = "8043" + TypeSupportedAudioStreamTiers = "8044" + TypeCameraCapabilitiesChar = "8011" + TypeContributingSensors = "8086" + TypeCameraKey = "8051" + TypeCameraKeyID = "8052" + TypeBufferUploadCommand = "8013" + TypeBufferActivityCommand = "8017" + TypeBufferEventCommand = "8014" + TypeBufferEventSequenceNumber = "8015" + TypeCameraRecordingPublishingPoint = "8016" + TypeCameraZones = "8022" + TypeStreamingEnabled = "8041" + TypeRTPStreamingControl = "8045" + TypeWebRTCSolicitOffer = "8053" + TypeWebRTCProvideAnswer = "8054" + TypeWebRTCStreamingControl = "8056" + TypeWebRTCNumberOfActiveSessions = "8057" + TypeWebRTCReoffer = "8058" + TypeWebRTCUpdateSession = "805C" + TypeWebRTCSupportedVideoStreamTiers = "8059" + TypeWebRTCSupportedAudioStreamTiers = "805A" + TypeCameraClientCSR = "8081" + TypeCameraClientCertificate = "8082" + TypeCameraClientCertificateStatus = "8083" + + // Existing HAP characteristics/services reused by HKSV services + TypeVersion = "37" + TypeActive = "B0" + TypeCameraOperatingMode = "21A" // legacy operating mode service + TypeHomeKitCameraActive = "21B" + TypeThirdPartyCameraActive = "21C" + TypeCameraOperatingModeIndicator = "21D" + TypeMotionDetected = "22" + TypeRecordingAudioActive = "226" + TypeNightVision = "11B" + TypeStatusActive = "75" + TypeCameraRecordingManagement = "204" +) + +// CameraCapabilitiesVersion is the Developer Preview version string +const CameraCapabilitiesVersion = "17.99" + +// Video codec type for stream tiers (differs from legacy VideoCodecTypeH264=0) +const ( + VideoCodecTypeTierH264 = 1 + VideoCodecTypeTierH265 = 2 +) + +// Camera video quality tiers +const ( + VideoQualityHighest = 1 + VideoQualityHigh = 2 + VideoQualityMedium = 3 + VideoQualityLow = 4 +) + +// Audio sample rates for stream tiers +const ( + AudioTierSampleRate16kHz = 1 + AudioTierSampleRate24kHz = 2 + AudioTierSampleRate32kHz = 3 + AudioTierSampleRate48kHz = 4 +) + +// Audio bit depth for stream tiers +const ( + AudioTierBitDepth8 = 1 + AudioTierBitDepth16 = 2 + AudioTierBitDepth24 = 3 +) + +// Sensor type / intent +const ( + SensorTypeUnknown = 0 + SensorTypePrimary = 1 + SensorTypeGeneric = 255 + + SensorIntentUnknown = 0 + SensorIntentMain = 1 + SensorIntentPackage = 2 + SensorIntentGeneric = 255 +) + +// RTP streaming control commands +const ( + RTPStreamCommandEnd = 1 + RTPStreamCommandStart = 2 +) + +// RTP / WebRTC streaming status +const ( + StreamStatusSuccess = 0 + StreamStatusUnknownSessionIdentifier = 1 + StreamStatusNoSuchStream = 2 + StreamStatusBusy = 3 + StreamStatusError = 4 +) + +// WebRTC solicit-offer status +const ( + WebRTCSolicitSuccess = 0 + WebRTCSolicitPrivacyModeActive = 1 + WebRTCSolicitError = 2 +) + +// WebRTC streaming status (provide answer / control / reoffer / update) +const ( + WebRTCStatusSuccess = 0 + WebRTCStatusUnknownSessionIdentifier = 1 + WebRTCStatusBusy = 2 + WebRTCStatusError = 3 +) + +// WebRTC streaming control commands +const ( + WebRTCCommandEnd = 1 +) + +// Buffer upload commands +const ( + BufferUploadStart = 1 + BufferUploadStartAndStop = 2 + BufferUploadStop = 3 + BufferStopActionPause = 1 + BufferStopActionFinalize = 2 +) + +// Buffer activity +const ( + BufferActivityShouldRecord = 1 + BufferActivityShouldNotRecord = 2 +) + +// Buffer event commands / types +const ( + BufferEventQuery = 1 + BufferEventAcknowledge = 2 + + BufferEventTypeCMAFSessionStart = 1 + BufferEventTypeCMAFSessionStop = 2 + BufferEventTypeMotion = 3 + BufferEventTypeCMAFError = 4 +) + +// Zone application methods +const ( + ZoneMethodNormal = 1 + ZoneMethodInverted = 2 +) + +// Default bitrates (kbps) from the guide +const ( + Bitrate4KAvgKbps = 4500 + Bitrate4KMaxKbps = 5000 + Bitrate2KAvgKbps = 2800 + Bitrate2KMaxKbps = 3000 + Bitrate1080pAvgKbps = 1700 + Bitrate1080pMaxKbps = 1800 + Bitrate720pAvgKbps = 768 + Bitrate720pMaxKbps = 800 + BitrateLowAvgKbps = 180 + BitrateLowMaxKbps = 190 +) + +// Max concurrent sessions required by the guide +const ( + MinConcurrentRTPSessions = 5 + MinConcurrentWebRTCSessions = 6 +) diff --git a/pkg/hap/camera/hksv_webrtc.go b/pkg/hap/camera/hksv_webrtc.go new file mode 100644 index 000000000..cb56d8da0 --- /dev/null +++ b/pkg/hap/camera/hksv_webrtc.go @@ -0,0 +1,107 @@ +package camera + +// WebRTCSolicitOfferRequest is written by the controller (UUID 8053) +type WebRTCSolicitOfferRequest struct { + Options WebRTCOfferOptions `tlv8:"1"` +} + +// WebRTCOfferOptions holds solicit-offer options +type WebRTCOfferOptions struct { + SFrameEnabled bool `tlv8:"1"` +} + +// WebRTCSolicitOfferResponse is returned after solicit-offer +type WebRTCSolicitOfferResponse struct { + SessionIdentifier string `tlv8:"1"` + SDPOffer string `tlv8:"2"` + AdditionalCandidates []WebRTCICECandidate `tlv8:"3"` + Status byte `tlv8:"4"` + SFrameConfiguration SFrameKeyData `tlv8:"5"` +} + +// WebRTCICECandidate is one ICE candidate for WebRTC signalling +type WebRTCICECandidate struct { + Candidate string `tlv8:"1"` + SDPMid string `tlv8:"2"` + SDPMLineIndex uint16 `tlv8:"3"` +} + +// SFrameKeyData is end-to-end media encryption key material +type SFrameKeyData struct { + Key string `tlv8:"1"` + KID uint64 `tlv8:"2"` +} + +// WebRTCProvideAnswerRequest is written by the controller (UUID 8054) +type WebRTCProvideAnswerRequest struct { + SessionIdentifier string `tlv8:"1"` + SDPAnswer string `tlv8:"2"` + AdditionalCandidates []WebRTCICECandidate `tlv8:"3"` +} + +// WebRTCProvideAnswerResponse is returned after provide-answer +type WebRTCProvideAnswerResponse struct { + SessionIdentifier string `tlv8:"1"` + Status byte `tlv8:"2"` +} + +// WebRTCStreamingControlRequest ends a WebRTC session (UUID 8056) +type WebRTCStreamingControlRequest struct { + SessionIdentifier string `tlv8:"1"` + Command byte `tlv8:"2"` +} + +// WebRTCStreamingControlResponse is returned after streaming-control +type WebRTCStreamingControlResponse struct { + SessionIdentifier string `tlv8:"1"` + Status byte `tlv8:"2"` +} + +// WebRTCReofferRequest renegotiates an existing session (UUID 8058) +type WebRTCReofferRequest struct { + SessionIdentifier string `tlv8:"1"` + SDPOffer string `tlv8:"2"` + Options WebRTCOfferOptions `tlv8:"3"` +} + +// WebRTCReofferResponse is returned after reoffer +type WebRTCReofferResponse struct { + SessionIdentifier string `tlv8:"1"` + SDPAnswer string `tlv8:"2"` + Status byte `tlv8:"3"` + SFrameConfiguration SFrameKeyData `tlv8:"4"` +} + +// WebRTCUpdateSessionRequest updates SFrame keys (UUID 805C) +type WebRTCUpdateSessionRequest struct { + SessionIdentifier string `tlv8:"1"` + ReceiveKeysToAdd []SFrameKeyData `tlv8:"2"` + ReceiveKIDsToRemove []SFrameKID `tlv8:"3"` +} + +// SFrameKID identifies an SFrame key to remove +type SFrameKID struct { + KID uint64 `tlv8:"1"` +} + +// WebRTCUpdateSessionResponse is returned after update-session +type WebRTCUpdateSessionResponse struct { + SessionIdentifier string `tlv8:"1"` + Status byte `tlv8:"2"` +} + +// RTPStreamingControlRequest controls multi-tier RTP streams (UUID 8045) +type RTPStreamingControlRequest struct { + SessionIdentifier string `tlv8:"1"` + Command byte `tlv8:"2"` + VideoTier uint32 `tlv8:"3"` + VideoSSRC uint32 `tlv8:"4"` + AudioTier uint32 `tlv8:"5"` + AudioSSRC uint32 `tlv8:"6"` +} + +// RTPStreamingControlResponse is returned after RTP streaming control +type RTPStreamingControlResponse struct { + SessionIdentifier string `tlv8:"1"` + Status byte `tlv8:"2"` +} From cfd6ce3025a88cef0ee9a978e7994cdc85440815 Mon Sep 17 00:00:00 2001 From: Radu Ursache Date: Fri, 10 Jul 2026 17:44:08 +0300 Subject: [PATCH 3/8] Add HomeKit WebRTC session manager and HEVC consumer path Session manager implements solicit-offer, provide-answer, reoffer, update-session, end, SFrame key storage, and CMAF client CSR/cert provisioning. Skip empty-string TLV8 fields so optional values do not emit zero-length items. Consumer accepts H.265 tracks for HKSV --- pkg/hap/tlv8/tlv8.go | 5 + pkg/homekit/consumer.go | 9 + pkg/homekit/webrtc_session.go | 329 ++++++++++++++++++++++++++++++++++ 3 files changed, 343 insertions(+) create mode 100644 pkg/homekit/webrtc_session.go diff --git a/pkg/hap/tlv8/tlv8.go b/pkg/hap/tlv8/tlv8.go index cda047ba0..bd6572c99 100644 --- a/pkg/hap/tlv8/tlv8.go +++ b/pkg/hap/tlv8/tlv8.go @@ -128,6 +128,11 @@ func appendValue(b []byte, tag byte, value reflect.Value) ([]byte, error) { case reflect.String: v := value.String() + if len(v) == 0 { + // Skip empty strings so we never emit a zero-length TLV + // (length 0 is reserved as an item separator) + return b, nil + } l := len(v) // support "big" string for ; l > 255; l -= 255 { b = append(b, tag, 255) diff --git a/pkg/homekit/consumer.go b/pkg/homekit/consumer.go index c1be74472..4e8323f3c 100644 --- a/pkg/homekit/consumer.go +++ b/pkg/homekit/consumer.go @@ -9,6 +9,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h265" "github.com/AlexxIT/go2rtc/pkg/hap/camera" "github.com/AlexxIT/go2rtc/pkg/opus" "github.com/AlexxIT/go2rtc/pkg/srtp" @@ -35,6 +36,7 @@ func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer { Direction: core.DirectionSendonly, Codecs: []*core.Codec{ {Name: core.CodecH264}, + {Name: core.CodecH265}, }, }, { @@ -165,6 +167,13 @@ func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Re } else { sender.Handler = h264.RepairAVCC(track.Codec, sender.Handler) } + case core.CodecH265: + sender.Handler = h265.RTPPay(1378, sender.Handler) + if track.Codec.IsRTP() { + sender.Handler = h265.RTPDepay(track.Codec, sender.Handler) + } else { + sender.Handler = h265.RepairAVCC(track.Codec, sender.Handler) + } case core.CodecOpus: sender.Handler = opus.RepackToHAP(c.audioRTPTime, sender.Handler) } diff --git a/pkg/homekit/webrtc_session.go b/pkg/homekit/webrtc_session.go new file mode 100644 index 000000000..d451325dc --- /dev/null +++ b/pkg/homekit/webrtc_session.go @@ -0,0 +1,329 @@ +package homekit + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/hap/camera" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/google/uuid" + pion "github.com/pion/webrtc/v4" +) + +// PeerConnectionFactory creates a pion PeerConnection for HomeKit WebRTC +type PeerConnectionFactory func() (*pion.PeerConnection, error) + +// WebRTCSession is one active HomeKit WebRTC streaming session +type WebRTCSession struct { + ID string + Conn *webrtc.Conn + CreatedAt time.Time + SFrame bool +} + +// WebRTCManager tracks concurrent HomeKit WebRTC sessions (min 6 required) +type WebRTCManager struct { + mu sync.Mutex + sessions map[string]*WebRTCSession + factory PeerConnectionFactory + max int + + // CMAF client cert state + privKey *ecdsa.PrivateKey + clientCert []byte + caCert []byte + keyID uint64 + keys map[uint64][]byte +} + +// NewWebRTCManager creates a session manager +func NewWebRTCManager(factory PeerConnectionFactory) *WebRTCManager { + return &WebRTCManager{ + sessions: make(map[string]*WebRTCSession), + factory: factory, + max: camera.MinConcurrentWebRTCSessions, + keys: make(map[uint64][]byte), + } +} + +// ActiveCount returns the number of active WebRTC sessions +func (m *WebRTCManager) ActiveCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.sessions) +} + +// SolicitOffer creates a new session and returns an SDP offer with ICE candidates +func (m *WebRTCManager) SolicitOffer(sframe bool) (*camera.WebRTCSolicitOfferResponse, error) { + if m.factory == nil { + return &camera.WebRTCSolicitOfferResponse{Status: camera.WebRTCSolicitError}, nil + } + + m.mu.Lock() + if len(m.sessions) >= m.max { + m.mu.Unlock() + return &camera.WebRTCSolicitOfferResponse{Status: camera.WebRTCSolicitError}, nil + } + m.mu.Unlock() + + pc, err := m.factory() + if err != nil { + return &camera.WebRTCSolicitOfferResponse{Status: camera.WebRTCSolicitError}, nil + } + + conn := webrtc.NewConn(pc) + conn.Mode = core.ModePassiveConsumer + conn.Protocol = "homekit-webrtc" + conn.FormatName = "homekit/webrtc" + + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecH265}, + {Name: core.CodecH264}, + }, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecOpus, ClockRate: 48000, Channels: 2}, + }, + }, + } + + offer, err := conn.CreateCompleteOffer(medias) + if err != nil { + _ = conn.Close() + return &camera.WebRTCSolicitOfferResponse{Status: camera.WebRTCSolicitError}, nil + } + + // HAP data fields use raw 16-byte UUID + u := uuid.New() + sessionRaw := string(u[:]) + + sess := &WebRTCSession{ + ID: sessionRaw, + Conn: conn, + CreatedAt: time.Now(), + SFrame: sframe, + } + + m.mu.Lock() + m.sessions[sessionRaw] = sess + m.mu.Unlock() + + // Collect host candidates already present in the complete offer SDP; + // AdditionalCandidates can stay empty when candidates are inlined in SDP + res := &camera.WebRTCSolicitOfferResponse{ + SessionIdentifier: sessionRaw, + SDPOffer: offer, + Status: camera.WebRTCSolicitSuccess, + } + + if sframe { + key := make([]byte, 16) + _, _ = rand.Read(key) + res.SFrameConfiguration = camera.SFrameKeyData{ + Key: string(key), + KID: 1, + } + } + + return res, nil +} + +// ProvideAnswer applies the controller SDP answer and returns status +func (m *WebRTCManager) ProvideAnswer(req *camera.WebRTCProvideAnswerRequest) *camera.WebRTCProvideAnswerResponse { + m.mu.Lock() + sess, ok := m.sessions[req.SessionIdentifier] + m.mu.Unlock() + + if !ok { + return &camera.WebRTCProvideAnswerResponse{ + SessionIdentifier: req.SessionIdentifier, + Status: camera.WebRTCStatusUnknownSessionIdentifier, + } + } + + for _, c := range req.AdditionalCandidates { + _ = sess.Conn.AddCandidate(c.Candidate) + } + + if err := sess.Conn.SetAnswer(req.SDPAnswer); err != nil { + return &camera.WebRTCProvideAnswerResponse{ + SessionIdentifier: req.SessionIdentifier, + Status: camera.WebRTCStatusError, + } + } + + return &camera.WebRTCProvideAnswerResponse{ + SessionIdentifier: req.SessionIdentifier, + Status: camera.WebRTCStatusSuccess, + } +} + +// GetSession returns a session by ID +func (m *WebRTCManager) GetSession(id string) *WebRTCSession { + m.mu.Lock() + defer m.mu.Unlock() + return m.sessions[id] +} + +// EndSession tears down a WebRTC session +func (m *WebRTCManager) EndSession(id string) *camera.WebRTCStreamingControlResponse { + m.mu.Lock() + sess, ok := m.sessions[id] + if ok { + delete(m.sessions, id) + } + m.mu.Unlock() + + if !ok { + return &camera.WebRTCStreamingControlResponse{ + SessionIdentifier: id, + Status: camera.WebRTCStatusUnknownSessionIdentifier, + } + } + + _ = sess.Conn.Close() + return &camera.WebRTCStreamingControlResponse{ + SessionIdentifier: id, + Status: camera.WebRTCStatusSuccess, + } +} + +// Reoffer handles renegotiation from the controller +func (m *WebRTCManager) Reoffer(req *camera.WebRTCReofferRequest) *camera.WebRTCReofferResponse { + m.mu.Lock() + sess, ok := m.sessions[req.SessionIdentifier] + m.mu.Unlock() + + if !ok { + return &camera.WebRTCReofferResponse{ + SessionIdentifier: req.SessionIdentifier, + Status: camera.WebRTCStatusUnknownSessionIdentifier, + } + } + + // Controller sent a new offer; we answer + if err := sess.Conn.SetOffer(req.SDPOffer); err != nil { + return &camera.WebRTCReofferResponse{ + SessionIdentifier: req.SessionIdentifier, + Status: camera.WebRTCStatusError, + } + } + + answer, err := sess.Conn.GetCompleteAnswer(nil, nil) + if err != nil { + return &camera.WebRTCReofferResponse{ + SessionIdentifier: req.SessionIdentifier, + Status: camera.WebRTCStatusError, + } + } + + return &camera.WebRTCReofferResponse{ + SessionIdentifier: req.SessionIdentifier, + SDPAnswer: answer, + Status: camera.WebRTCStatusSuccess, + } +} + +// UpdateSession applies SFrame key updates +func (m *WebRTCManager) UpdateSession(req *camera.WebRTCUpdateSessionRequest) *camera.WebRTCUpdateSessionResponse { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.sessions[req.SessionIdentifier]; !ok { + return &camera.WebRTCUpdateSessionResponse{ + SessionIdentifier: req.SessionIdentifier, + Status: camera.WebRTCStatusUnknownSessionIdentifier, + } + } + + for _, k := range req.ReceiveKeysToAdd { + m.keys[k.KID] = []byte(k.Key) + } + for _, k := range req.ReceiveKIDsToRemove { + delete(m.keys, k.KID) + } + + return &camera.WebRTCUpdateSessionResponse{ + SessionIdentifier: req.SessionIdentifier, + Status: camera.WebRTCStatusSuccess, + } +} + +// HandleCSR generates a client certificate signing request for CMAF ingest +func (m *WebRTCManager) HandleCSR(nonce []byte) (*camera.CameraClientCSRResponse, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.privKey == nil { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + m.privKey = key + } + + template := x509.CertificateRequest{ + Subject: pkix.Name{CommonName: "go2rtc-hksv"}, + } + csrDER, err := x509.CreateCertificateRequest(rand.Reader, &template, m.privKey) + if err != nil { + return nil, err + } + + sum := sha256.Sum256(nonce) + sig, err := ecdsa.SignASN1(rand.Reader, m.privKey, sum[:]) + if err != nil { + return nil, err + } + + return &camera.CameraClientCSRResponse{ + CSR: string(csrDER), + NonceSignature: string(sig), + }, nil +} + +// InstallClientCertificate stores the issued CMAF client certificate +func (m *WebRTCManager) InstallClientCertificate(req *camera.CameraClientCertificateRequest) { + m.mu.Lock() + defer m.mu.Unlock() + m.clientCert = []byte(req.ClientCertificate) + m.caCert = []byte(req.CA) +} + +// CertificateNeedsUpdate reports whether a new client cert is required +func (m *WebRTCManager) CertificateNeedsUpdate() bool { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.clientCert) == 0 +} + +// SetKey stores a CMAF content key +func (m *WebRTCManager) SetKey(key []byte, number uint64) uint64 { + m.mu.Lock() + defer m.mu.Unlock() + m.keys[number] = key + m.keyID = number + return number +} + +// KeyID returns the current key identifier +func (m *WebRTCManager) KeyID() uint64 { + m.mu.Lock() + defer m.mu.Unlock() + return m.keyID +} + From 4d0f4e9b5ec6ed04c5013729707d6d3325f32754 Mon Sep 17 00:00:00 2001 From: Radu Ursache Date: Fri, 10 Jul 2026 17:44:08 +0300 Subject: [PATCH 4/8] Wire HKSV WebRTC handlers and experimental hksv config flag HomeKit server handles WebRTC/multi-tier/buffer/cert characteristics with HAP write-response, streaming gates, and stream consumer attach. Enable with homekit..hksv: true (capabilities version 17.99) --- internal/homekit/homekit.go | 11 +- internal/homekit/server.go | 361 ++++++++++++++++++++++++++++++++++++ internal/homekit/webrtc.go | 16 ++ pkg/homekit/server.go | 33 ++++ 4 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 internal/homekit/webrtc.go diff --git a/internal/homekit/homekit.go b/internal/homekit/homekit.go index 59b84b3ba..95464bb0f 100644 --- a/internal/homekit/homekit.go +++ b/internal/homekit/homekit.go @@ -26,6 +26,9 @@ func Init() { DevicePrivate string `yaml:"device_private"` CategoryID string `yaml:"category_id"` Pairings []string `yaml:"pairings"` + // HKSV enables experimental HomeKit Secure Video open-source services + // (WebRTC live view, multi-tier HEVC, CMAF cert provisioning). Spec version 17.99 + HKSV bool `yaml:"hksv"` } `yaml:"homekit"` } app.LoadConfig(&cfg) @@ -71,6 +74,7 @@ func Init() { stream: id, pairings: conf.Pairings, setupID: setupID, + hksv: conf.HKSV, } srv.hap = &hap.Server{ @@ -102,8 +106,13 @@ func Init() { if url := findHomeKitURL(stream.Sources()); url != "" { // 1. Act as transparent proxy for HomeKit camera srv.proxyURL = url + } else if conf.HKSV { + // 2. Experimental HKSV open-source camera (WebRTC + HEVC + CMAF) + srv.accessory = camera.NewHKSVAccessory("AlexxIT", "go2rtc", name, "-", app.Version, id) + srv.webrtc = homekit.NewWebRTCManager(newHomeKitPeerConnection) + log.Info().Str("stream", id).Msg("[homekit] HKSV open-source enabled (experimental, capabilities 17.99)") } else { - // 2. Act as basic HomeKit camera + // 3. Act as basic HomeKit camera srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version) } diff --git a/internal/homekit/server.go b/internal/homekit/server.go index 86cfbc155..f7ca6df4d 100644 --- a/internal/homekit/server.go +++ b/internal/homekit/server.go @@ -27,6 +27,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/homekit" "github.com/AlexxIT/go2rtc/pkg/magic" "github.com/AlexxIT/go2rtc/pkg/mdns" + pion "github.com/pion/webrtc/v4" ) type server struct { @@ -42,6 +43,12 @@ type server struct { proxyURL string setupID string stream string // stream name from YAML + + // Experimental HKSV open-source (WebRTC + HEVC + CMAF) + hksv bool + webrtc *homekit.WebRTCManager + // last write-response values keyed by characteristic IID + wrValues map[uint64]any } func (s *server) MarshalJSON() ([]byte, error) { @@ -238,6 +245,13 @@ func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any { return nil } + // Prefer last write-response payload when present + if s.wrValues != nil { + if v, ok := s.wrValues[iid]; ok { + return v + } + } + switch char.Type { case camera.TypeSetupEndpoints: consumer := s.consumer @@ -252,6 +266,22 @@ func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any { } return v + + case camera.TypeWebRTCNumberOfActiveSessions: + if s.webrtc != nil { + return s.webrtc.ActiveCount() + } + return 0 + + case camera.TypeCameraClientCertificateStatus: + if s.webrtc != nil { + v, err := tlv8.MarshalBase64(camera.CameraClientCertificateStatusValue{ + NeedsUpdate: s.webrtc.CertificateNeedsUpdate(), + }) + if err == nil { + return v + } + } } return char.Value @@ -321,6 +351,337 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a s.DelConn(consumer) }() } + + case camera.TypeWebRTCSolicitOffer: + s.handleWebRTCSolicitOffer(iid, value) + + case camera.TypeWebRTCProvideAnswer: + s.handleWebRTCProvideAnswer(iid, value) + + case camera.TypeWebRTCStreamingControl: + s.handleWebRTCStreamingControl(iid, value) + + case camera.TypeWebRTCReoffer: + s.handleWebRTCReoffer(iid, value) + + case camera.TypeWebRTCUpdateSession: + s.handleWebRTCUpdateSession(iid, value) + + case camera.TypeRTPStreamingControl: + s.handleRTPStreamingControl(iid, value) + + case camera.TypeCameraClientCSR: + s.handleCameraClientCSR(iid, value) + + case camera.TypeCameraClientCertificate: + s.handleCameraClientCertificate(value) + + case camera.TypeCameraKey: + s.handleCameraKey(value) + + case camera.TypeCameraRecordingPublishingPoint: + // Store publishing point as-is for CMAF ingest (already base64 TLV8 from controller) + char.Value = value + + case camera.TypeStreamingEnabled, camera.TypeHomeKitCameraActive, + camera.TypeMotionEnabled, camera.TypeCameraOperatingModeIndicator: + _ = char.Write(value) + _ = char.NotifyListeners(conn) + + case camera.TypeActive, camera.TypeRecordingAudioActive: + switch v := value.(type) { + case float64: + char.Value = uint8(v) + case int: + char.Value = uint8(v) + default: + char.Value = value + } + _ = char.NotifyListeners(conn) + + case camera.TypeCameraZones: + _ = char.Write(value) + + case camera.TypeBufferActivityCommand: + // Acknowledge buffer activity; no response body required + return + + case camera.TypeBufferUploadCommand: + s.handleBufferUpload(iid, value) + + case camera.TypeBufferEventCommand: + s.handleBufferEvent(iid, value) + } +} + +func (s *server) setWriteResponse(iid uint64, v any) { + if s.wrValues == nil { + s.wrValues = map[uint64]any{} + } + encoded, err := tlv8.MarshalBase64(v) + if err != nil { + return + } + s.wrValues[iid] = encoded + if char := s.accessory.GetCharacterByID(iid); char != nil { + char.Value = encoded + } +} + +func (s *server) handleWebRTCSolicitOffer(iid uint64, value any) { + if s.webrtc == nil { + s.setWriteResponse(iid, camera.WebRTCSolicitOfferResponse{Status: camera.WebRTCSolicitError}) + return + } + + var req camera.WebRTCSolicitOfferRequest + if err := tlv8.UnmarshalBase64(value, &req); err != nil { + s.setWriteResponse(iid, camera.WebRTCSolicitOfferResponse{Status: camera.WebRTCSolicitError}) + return + } + + // Check global streaming gates + if !s.streamingAllowed() { + s.setWriteResponse(iid, camera.WebRTCSolicitOfferResponse{Status: camera.WebRTCSolicitPrivacyModeActive}) + return + } + + res, err := s.webrtc.SolicitOffer(req.Options.SFrameEnabled) + if err != nil || res == nil { + s.setWriteResponse(iid, camera.WebRTCSolicitOfferResponse{Status: camera.WebRTCSolicitError}) + return + } + + s.setWriteResponse(iid, res) + s.updateWebRTCSessionCount() + log.Debug().Str("stream", s.stream).Msgf("[homekit] webrtc solicit-offer status=%d sessions=%d", res.Status, s.webrtc.ActiveCount()) +} + +func (s *server) handleWebRTCProvideAnswer(iid uint64, value any) { + if s.webrtc == nil { + s.setWriteResponse(iid, camera.WebRTCProvideAnswerResponse{Status: camera.WebRTCStatusError}) + return + } + + var req camera.WebRTCProvideAnswerRequest + if err := tlv8.UnmarshalBase64(value, &req); err != nil { + s.setWriteResponse(iid, camera.WebRTCProvideAnswerResponse{Status: camera.WebRTCStatusError}) + return + } + + res := s.webrtc.ProvideAnswer(&req) + s.setWriteResponse(iid, res) + + if res.Status != camera.WebRTCStatusSuccess { + return + } + + sess := s.webrtc.GetSession(req.SessionIdentifier) + if sess == nil || sess.Conn == nil { + return + } + + stream := streams.Get(s.stream) + if stream == nil { + return + } + + s.AddConn(sess.Conn) + if err := stream.AddConsumer(sess.Conn); err != nil { + log.Warn().Err(err).Str("stream", s.stream).Msg("[homekit] webrtc add consumer") + return + } + + sessionID := req.SessionIdentifier + conn := sess.Conn + conn.Listen(func(msg any) { + state, ok := msg.(pion.PeerConnectionState) + if !ok { + return + } + switch state { + case pion.PeerConnectionStateDisconnected, pion.PeerConnectionStateFailed, pion.PeerConnectionStateClosed: + stream.RemoveConsumer(conn) + s.DelConn(conn) + _ = s.webrtc.EndSession(sessionID) + s.updateWebRTCSessionCount() + } + }) + + log.Debug().Str("stream", s.stream).Msg("[homekit] webrtc provide-answer ok") +} + +func (s *server) handleWebRTCStreamingControl(iid uint64, value any) { + if s.webrtc == nil { + s.setWriteResponse(iid, camera.WebRTCStreamingControlResponse{Status: camera.WebRTCStatusError}) + return + } + + var req camera.WebRTCStreamingControlRequest + if err := tlv8.UnmarshalBase64(value, &req); err != nil { + s.setWriteResponse(iid, camera.WebRTCStreamingControlResponse{Status: camera.WebRTCStatusError}) + return + } + + if req.Command == camera.WebRTCCommandEnd { + res := s.webrtc.EndSession(req.SessionIdentifier) + s.setWriteResponse(iid, res) + s.updateWebRTCSessionCount() + return + } + + s.setWriteResponse(iid, camera.WebRTCStreamingControlResponse{ + SessionIdentifier: req.SessionIdentifier, + Status: camera.WebRTCStatusError, + }) +} + +func (s *server) handleWebRTCReoffer(iid uint64, value any) { + if s.webrtc == nil { + s.setWriteResponse(iid, camera.WebRTCReofferResponse{Status: camera.WebRTCStatusError}) + return + } + var req camera.WebRTCReofferRequest + if err := tlv8.UnmarshalBase64(value, &req); err != nil { + s.setWriteResponse(iid, camera.WebRTCReofferResponse{Status: camera.WebRTCStatusError}) + return + } + s.setWriteResponse(iid, s.webrtc.Reoffer(&req)) +} + +func (s *server) handleWebRTCUpdateSession(iid uint64, value any) { + if s.webrtc == nil { + s.setWriteResponse(iid, camera.WebRTCUpdateSessionResponse{Status: camera.WebRTCStatusError}) + return + } + var req camera.WebRTCUpdateSessionRequest + if err := tlv8.UnmarshalBase64(value, &req); err != nil { + s.setWriteResponse(iid, camera.WebRTCUpdateSessionResponse{Status: camera.WebRTCStatusError}) + return + } + s.setWriteResponse(iid, s.webrtc.UpdateSession(&req)) +} + +func (s *server) handleRTPStreamingControl(iid uint64, value any) { + var req camera.RTPStreamingControlRequest + if err := tlv8.UnmarshalBase64(value, &req); err != nil { + s.setWriteResponse(iid, camera.RTPStreamingControlResponse{Status: camera.StreamStatusError}) + return + } + + // Multi-tier RTP control: map Start onto the classic consumer path when possible + status := byte(camera.StreamStatusSuccess) + switch req.Command { + case camera.RTPStreamCommandEnd: + for _, consumer := range s.conns { + if consumer, ok := consumer.(*homekit.Consumer); ok { + if consumer.SessionID() == req.SessionIdentifier { + _ = consumer.Stop() + break + } + } + } + case camera.RTPStreamCommandStart: + if !s.streamingAllowed() { + status = camera.StreamStatusError + } + // Full multi-tier encoder reconfiguration is left to the stream source; + // Start still relies on Setup Endpoints + classic selected stream for media + default: + status = camera.StreamStatusError + } + + s.setWriteResponse(iid, camera.RTPStreamingControlResponse{ + SessionIdentifier: req.SessionIdentifier, + Status: status, + }) +} + +func (s *server) handleCameraClientCSR(iid uint64, value any) { + if s.webrtc == nil { + return + } + var req camera.CameraClientCSRRequest + if err := tlv8.UnmarshalBase64(value, &req); err != nil { + return + } + res, err := s.webrtc.HandleCSR([]byte(req.Nonce)) + if err != nil { + log.Warn().Err(err).Msg("[homekit] csr") + return + } + s.setWriteResponse(iid, res) +} + +func (s *server) handleCameraClientCertificate(value any) { + if s.webrtc == nil { + return + } + var req camera.CameraClientCertificateRequest + if err := tlv8.UnmarshalBase64(value, &req); err != nil { + return + } + s.webrtc.InstallClientCertificate(&req) + if char := s.accessory.GetCharacter(camera.TypeCameraClientCertificateStatus); char != nil { + _ = char.Set(camera.CameraClientCertificateStatusValue{NeedsUpdate: false}) + } +} + +func (s *server) handleCameraKey(value any) { + if s.webrtc == nil { + return + } + var req camera.CameraKeyValue + if err := tlv8.UnmarshalBase64(value, &req); err != nil { + return + } + id := s.webrtc.SetKey([]byte(req.Key), req.KeyNumber) + if char := s.accessory.GetCharacter(camera.TypeCameraKeyID); char != nil { + _ = char.Set(camera.CameraKeyIDValue{KeyID: id}) + } +} + +func (s *server) handleBufferUpload(iid uint64, value any) { + // CMAF clip upload requires a live publishing point; return a clip id placeholder + var req camera.BufferUploadCommandRequest + _ = tlv8.UnmarshalBase64(value, &req) + s.setWriteResponse(iid, camera.BufferUploadCommandResponse{ClipID: req.SessionID}) +} + +func (s *server) handleBufferEvent(iid uint64, value any) { + s.setWriteResponse(iid, camera.BufferEventCommandResponse{}) +} + +func (s *server) streamingAllowed() bool { + if s.accessory == nil { + return true + } + // HomeKit Camera Active + if char := s.accessory.GetCharacter(camera.TypeHomeKitCameraActive); char != nil { + if v, err := char.ReadBool(); err == nil && !v { + return false + } + } + // Global / service Streaming Enabled (any false blocks) + for _, srv := range s.accessory.Services { + for _, char := range srv.Characters { + if char.Type == camera.TypeStreamingEnabled { + if v, err := char.ReadBool(); err == nil && !v { + return false + } + } + } + } + return true +} + +func (s *server) updateWebRTCSessionCount() { + if s.accessory == nil || s.webrtc == nil { + return + } + if char := s.accessory.GetCharacter(camera.TypeWebRTCNumberOfActiveSessions); char != nil { + char.Value = s.webrtc.ActiveCount() + _ = char.NotifyListeners(nil) } } diff --git a/internal/homekit/webrtc.go b/internal/homekit/webrtc.go new file mode 100644 index 000000000..6ba0eb62d --- /dev/null +++ b/internal/homekit/webrtc.go @@ -0,0 +1,16 @@ +package homekit + +import ( + "errors" + + iwebrtc "github.com/AlexxIT/go2rtc/internal/webrtc" + pion "github.com/pion/webrtc/v4" +) + +func newHomeKitPeerConnection() (*pion.PeerConnection, error) { + if iwebrtc.PeerConnection == nil { + return nil, errors.New("homekit: webrtc module not initialized") + } + // Use the server-side API so local ICE candidates match configured webrtc.listen + return iwebrtc.PeerConnection(false) +} diff --git a/pkg/homekit/server.go b/pkg/homekit/server.go index 75ba2a0fc..05edefc78 100644 --- a/pkg/homekit/server.go +++ b/pkg/homekit/server.go @@ -74,8 +74,20 @@ func ServerHandler(server Server) HandlerFunc { return nil, err } + var wr hap.JSONCharacters for _, char := range v.Value { server.SetCharacteristic(conn, char.AID, char.IID, char.Value) + // HAP write-response: return the post-write value for "wr" characteristics + if val := server.GetCharacteristic(conn, char.AID, char.IID); val != nil { + // Only include if the characteristic has wr permission + if acc := findAccessoryCharacter(server, conn, char.AID, char.IID); acc != nil && hasPerm(acc.Perms, "wr") { + wr.Value = append(wr.Value, hap.JSONCharacter{AID: char.AID, IID: char.IID, Value: val}) + } + } + } + + if len(wr.Value) > 0 { + return makeResponse(hap.MimeJSON, wr) } res := &http.Response{ @@ -161,6 +173,27 @@ func handlePairings(req *http.Request, srv ServerPair) (*http.Response, error) { return makeResponse(hap.MimeTLV8, body) } +func findAccessoryCharacter(server ServerAccessory, conn net.Conn, aid uint8, iid uint64) *hap.Character { + for _, acc := range server.GetAccessories(conn) { + if acc.AID != aid && aid != 0 { + // still search all; AID is usually 1 + } + if char := acc.GetCharacterByID(iid); char != nil { + return char + } + } + return nil +} + +func hasPerm(perms []string, p string) bool { + for _, x := range perms { + if x == p { + return true + } + } + return false +} + func makeResponse(mime string, v any) (*http.Response, error) { var body []byte var err error From a3272bcc33e889efd210650c4e62fad2918219f8 Mon Sep 17 00:00:00 2001 From: Radu Ursache Date: Fri, 10 Jul 2026 17:44:08 +0300 Subject: [PATCH 5/8] Add HKSV unit tests and document experimental hksv option Cover TLV8 round-trips, accessory service inventory, WebRTC session lifecycle, CSR generation, and README/config examples for issue 2297 --- examples/homekit_info/main.go | 65 +++++-- internal/homekit/README.md | 20 ++- pkg/hap/camera/accessory_test.go | 2 +- pkg/hap/camera/hksv_test.go | 267 +++++++++++++++++++++++++++++ pkg/homekit/webrtc_session_test.go | 140 +++++++++++++++ 5 files changed, 478 insertions(+), 16 deletions(-) create mode 100644 pkg/hap/camera/hksv_test.go create mode 100644 pkg/homekit/webrtc_session_test.go diff --git a/examples/homekit_info/main.go b/examples/homekit_info/main.go index 8527042e7..72b4067c9 100644 --- a/examples/homekit_info/main.go +++ b/examples/homekit_info/main.go @@ -8,20 +8,28 @@ import ( ) var servs = map[string]string{ - "3E": "Accessory Information", - "7E": "Security System", - "85": "Motion Sensor", - "96": "Battery", - "A2": "Protocol Information", - "110": "Camera RTP Stream Management", - "112": "Microphone", - "113": "Speaker", - "121": "Doorbell", - "129": "Data Stream Transport Management", - "204": "Camera Recording Management", - "21A": "Camera Operating Mode", - "22A": "Wi-Fi Transport", - "239": "Accessory Runtime Information", + "3E": "Accessory Information", + "7E": "Security System", + "85": "Motion Sensor", + "96": "Battery", + "A2": "Protocol Information", + "110": "Camera RTP Stream Management", + "112": "Microphone", + "113": "Speaker", + "121": "Doorbell", + "129": "Data Stream Transport Management", + "204": "Camera Recording Management", + "21A": "Camera Operating Mode", + "22A": "Wi-Fi Transport", + "239": "Accessory Runtime Information", + "8000": "Camera Buffer Management", + "8010": "Camera Capabilities", + "8021": "Camera Motion Zones", + "8031": "Camera Multi-Tier RTP Stream Management", + "8032": "Camera Global Operating Mode", + "8033": "Camera WebRTC Stream Management", + "8050": "Camera Key Management", + "8080": "Camera Client Certificate Management", } var chars = map[string]string{ @@ -81,6 +89,35 @@ var chars = map[string]string{ "66": "Security System Current State", "67": "Security System Target State", + + // HKSV open-source (Developer Preview 17.99) + "8011": "Camera Capabilities", + "8013": "Buffer Upload Command", + "8014": "Buffer Event Command", + "8015": "Buffer Event Sequence Number", + "8016": "Camera Recording Publishing Point", + "8017": "Buffer Activity Command", + "8022": "Camera Zones", + "8041": "Streaming Enabled", + "8043": "Supported Video Stream Tiers", + "8044": "Supported Audio Stream Tiers", + "8045": "RTP Streaming Control", + "8051": "Camera Key", + "8052": "Camera Key ID", + "8053": "WebRTC Solicit Offer", + "8054": "WebRTC Provide Answer", + "8056": "WebRTC Streaming Control", + "8057": "WebRTC Number of Active Sessions", + "8058": "WebRTC Reoffer", + "8059": "WebRTC Supported Video Stream Tiers", + "805A": "WebRTC Supported Audio Stream Tiers", + "805B": "Sensor UUID", + "805C": "WebRTC Update Session", + "8081": "Camera Client CSR", + "8082": "Camera Client Certificate", + "8083": "Camera Client Certificate Status", + "8086": "Contributing Sensors", + "8087": "Motion Enabled", } func main() { diff --git a/internal/homekit/README.md b/internal/homekit/README.md index 0e78fcc58..dff34fd43 100644 --- a/internal/homekit/README.md +++ b/internal/homekit/README.md @@ -51,7 +51,8 @@ HomeKit module can work in two modes: **Important** -- HomeKit cameras support only H264 video and OPUS audio +- Classic HomeKit cameras support H264 video and OPUS audio +- With experimental `hksv: true`, the accessory also advertises HEVC (H.265) and WebRTC live view per Apple's HomeKit Secure Video Open Source Compatibility Guide (Developer Preview 17.99) ### Server Configuration @@ -79,8 +80,25 @@ homekit: name: Dahua camera # custom camera name, default: generated from stream ID device_id: dahua1 # custom ID, default: generated from stream ID device_private: dahua1 # custom key, default: generated from stream ID + hksv: true # experimental: HKSV open-source (WebRTC + HEVC tiers + CMAF certs) ``` +### Experimental HKSV open-source (`hksv: true`) + +Implements the HAP services from [issue #2297](https://github.com/AlexxIT/go2rtc/issues/2297) / Apple's +[HomeKit Secure Video Open Source Compatibility Guide](https://developer.apple.com/download/files/HomeKit-Secure-Video-Open-Source-Compatibility-Guide.pdf) +(Developer Preview, capabilities version `17.99`): + +- Camera Capabilities, Global Operating Mode, Motion Zones +- Multi-tier RTP stream management (High/Medium/Low, HEVC + H.264, Opus) +- WebRTC Stream Management (solicit-offer / provide-answer over HAP, min 6 sessions) +- Buffer / Key / Client Certificate management for CMAF ingest provisioning +- Classic RTP kept with 5 concurrent stream slots + +The guide is a Developer Preview and may change before release. CMAF clip upload to Apple's +publishing point is scaffolded (cert CSR flow works); full fMP4 ingest depends on a live +controller-provided publishing URL after pairing. + **Proxy HomeKit camera** - Video stream from HomeKit camera to Apple device (iPhone, Apple TV) will be transmitted directly diff --git a/pkg/hap/camera/accessory_test.go b/pkg/hap/camera/accessory_test.go index 53c99a492..cabc2be22 100644 --- a/pkg/hap/camera/accessory_test.go +++ b/pkg/hap/camera/accessory_test.go @@ -10,7 +10,7 @@ import ( ) func TestNilCharacter(t *testing.T) { - var res SetupEndpoints + var res SetupEndpointsResponse char := &hap.Character{} err := char.ReadTLV8(&res) require.NotNil(t, err) diff --git a/pkg/hap/camera/hksv_test.go b/pkg/hap/camera/hksv_test.go new file mode 100644 index 000000000..6352e555f --- /dev/null +++ b/pkg/hap/camera/hksv_test.go @@ -0,0 +1,267 @@ +package camera + +import ( + "encoding/base64" + "testing" + + "github.com/AlexxIT/go2rtc/pkg/hap" + "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" + "github.com/stretchr/testify/require" +) + +func TestDefault1080pVideoTiers(t *testing.T) { + tiers := Default1080pVideoTiers(VideoCodecTypeTierH265, 100) + require.Equal(t, byte(VideoCodecTypeTierH265), tiers.Codec) + require.Equal(t, uint8(100), tiers.PayloadType) + require.Len(t, tiers.Tiers, 3) + require.Equal(t, byte(VideoQualityHigh), tiers.Tiers[0].Quality) + require.Equal(t, uint16(1920), tiers.Tiers[0].Width) + require.Equal(t, uint16(1080), tiers.Tiers[0].Height) + require.Equal(t, uint32(Bitrate1080pAvgKbps), tiers.Tiers[0].TargetAverageBitrate) + require.Equal(t, byte(VideoQualityMedium), tiers.Tiers[1].Quality) + require.Equal(t, byte(VideoQualityLow), tiers.Tiers[2].Quality) + require.Equal(t, uint8(15), tiers.Tiers[2].FrameRate) + + b, err := tlv8.Marshal(tiers) + require.NoError(t, err) + require.NotEmpty(t, b) + + var out SupportedVideoStreamTiers + require.NoError(t, tlv8.Unmarshal(b, &out)) + require.Equal(t, tiers.Codec, out.Codec) + require.Equal(t, tiers.PayloadType, out.PayloadType) + require.Len(t, out.Tiers, 3) + require.Equal(t, tiers.Tiers[0].Width, out.Tiers[0].Width) + require.Equal(t, tiers.Tiers[2].Height, out.Tiers[2].Height) +} + +func TestDefaultOpusAudioTier(t *testing.T) { + audio := DefaultOpusAudioTier(111) + require.Equal(t, byte(AudioCodecTypeOpus), audio.Codec) + require.Len(t, audio.Tiers, 1) + require.Equal(t, uint8(20), audio.Tiers[0].PacketTime) + require.Equal(t, uint8(1), audio.Tiers[0].NumberOfChannels) + require.Equal(t, byte(AudioTierSampleRate48kHz), audio.Tiers[0].SampleRate) + + b, err := tlv8.Marshal(audio) + require.NoError(t, err) + + var out SupportedAudioStreamTiers + require.NoError(t, tlv8.Unmarshal(b, &out)) + require.Equal(t, audio.Codec, out.Codec) + require.Equal(t, audio.Tiers[0].TargetAverageBitrate, out.Tiers[0].TargetAverageBitrate) +} + +func TestCameraCapabilitiesRoundTrip(t *testing.T) { + uuid := SensorUUIDBytes("test-camera") + require.Len(t, uuid, 16) + + caps := DefaultCameraCapabilities(uuid) + b, err := tlv8.Marshal(caps) + require.NoError(t, err) + + var out CameraCapabilitiesValue + require.NoError(t, tlv8.Unmarshal(b, &out)) + require.Equal(t, uint8(1), out.Version) + require.Len(t, out.CameraSensors.Sensors, 1) + require.Equal(t, uuid, out.CameraSensors.Sensors[0].SensorUUID) + require.Equal(t, byte(SensorTypePrimary), out.CameraSensors.Sensors[0].SensorType) + require.Len(t, out.CameraSensors.Sensors[0].VideoStreamCapabilities, 3) +} + +func TestWebRTCSolicitOfferTLV8(t *testing.T) { + req := WebRTCSolicitOfferRequest{ + Options: WebRTCOfferOptions{SFrameEnabled: true}, + } + b, err := tlv8.Marshal(req) + require.NoError(t, err) + + var outReq WebRTCSolicitOfferRequest + require.NoError(t, tlv8.Unmarshal(b, &outReq)) + require.True(t, outReq.Options.SFrameEnabled) + + res := WebRTCSolicitOfferResponse{ + SessionIdentifier: string(make([]byte, 16)), + SDPOffer: "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\n", + Status: WebRTCSolicitSuccess, + SFrameConfiguration: SFrameKeyData{ + Key: string(make([]byte, 16)), + KID: 1, + }, + } + b, err = tlv8.Marshal(res) + require.NoError(t, err) + + var outRes WebRTCSolicitOfferResponse + require.NoError(t, tlv8.Unmarshal(b, &outRes)) + require.Equal(t, res.SDPOffer, outRes.SDPOffer) + require.Equal(t, byte(WebRTCSolicitSuccess), outRes.Status) + require.Equal(t, uint64(1), outRes.SFrameConfiguration.KID) +} + +func TestWebRTCProvideAnswerTLV8(t *testing.T) { + req := WebRTCProvideAnswerRequest{ + SessionIdentifier: "0123456789abcdef", + SDPAnswer: "v=0\r\n", + AdditionalCandidates: []WebRTCICECandidate{ + {Candidate: "candidate:1 1 udp 1 1.2.3.4 1234 typ host", SDPMid: "0", SDPMLineIndex: 0}, + }, + } + b, err := tlv8.Marshal(req) + require.NoError(t, err) + + var out WebRTCProvideAnswerRequest + require.NoError(t, tlv8.Unmarshal(b, &out)) + require.Equal(t, req.SessionIdentifier, out.SessionIdentifier) + require.Equal(t, req.SDPAnswer, out.SDPAnswer) + require.Len(t, out.AdditionalCandidates, 1) + require.Equal(t, "0", out.AdditionalCandidates[0].SDPMid) +} + +func TestCSRAndCertTLV8(t *testing.T) { + req := CameraClientCSRRequest{Nonce: string(make([]byte, 32))} + b, err := tlv8.Marshal(req) + require.NoError(t, err) + var outReq CameraClientCSRRequest + require.NoError(t, tlv8.Unmarshal(b, &outReq)) + require.Len(t, outReq.Nonce, 32) + + res := CameraClientCSRResponse{CSR: "csr-der", NonceSignature: "sig"} + b, err = tlv8.Marshal(res) + require.NoError(t, err) + var outRes CameraClientCSRResponse + require.NoError(t, tlv8.Unmarshal(b, &outRes)) + require.Equal(t, "csr-der", outRes.CSR) + + status := CameraClientCertificateStatusValue{NeedsUpdate: true} + b, err = tlv8.Marshal(status) + require.NoError(t, err) + var outStatus CameraClientCertificateStatusValue + require.NoError(t, tlv8.Unmarshal(b, &outStatus)) + require.True(t, outStatus.NeedsUpdate) +} + +func TestNewHKSVAccessory(t *testing.T) { + acc := NewHKSVAccessory("AlexxIT", "go2rtc", "cam", "SN", "1.0", "seed1") + require.NotNil(t, acc) + require.Equal(t, uint8(hap.DeviceAID), acc.AID) + + // Accessories information + 5 RTP + mic + 10 HKSV services + require.GreaterOrEqual(t, len(acc.Services), 1+MinConcurrentRTPSessions+1+10) + + // All IIDs unique and non-zero + seen := map[uint64]bool{} + for _, srv := range acc.Services { + require.NotZero(t, srv.IID) + require.False(t, seen[srv.IID], "duplicate service IID %x", srv.IID) + seen[srv.IID] = true + for _, ch := range srv.Characters { + require.NotZero(t, ch.IID) + require.False(t, seen[ch.IID], "duplicate char IID %x type=%s", ch.IID, ch.Type) + seen[ch.IID] = true + } + } + + // Required HKSV services present + require.NotNil(t, acc.GetService(TypeCameraCapabilities)) + require.NotNil(t, acc.GetService(TypeCameraWebRTCStreamManagement)) + require.NotNil(t, acc.GetService(TypeCameraMultiTierRTPStreamManagement)) + require.NotNil(t, acc.GetService(TypeCameraBufferManagement)) + require.NotNil(t, acc.GetService(TypeCameraKeyManagement)) + require.NotNil(t, acc.GetService(TypeCameraClientCertificateManagement)) + require.NotNil(t, acc.GetService(TypeCameraGlobalOperatingMode)) + + // Capabilities version 17.99 + capSvc := acc.GetService(TypeCameraCapabilities) + ver := capSvc.GetCharacter(TypeVersion) + require.NotNil(t, ver) + require.Equal(t, CameraCapabilitiesVersion, ver.Value) + + // WebRTC service has solicit-offer with wr permission + webrtcSvc := acc.GetService(TypeCameraWebRTCStreamManagement) + solicit := webrtcSvc.GetCharacter(TypeWebRTCSolicitOffer) + require.NotNil(t, solicit) + require.Contains(t, solicit.Perms, "wr") + + // Multi-tier advertises AES_CM_128_HMAC_SHA1_80 only + multi := acc.GetService(TypeCameraMultiTierRTPStreamManagement) + rtpChar := multi.GetCharacter(TypeSupportedRTPConfiguration) + require.NotNil(t, rtpChar) + var rtp SupportedRTPConfiguration + require.NoError(t, rtpChar.ReadTLV8(&rtp)) + require.Equal(t, []byte{CryptoAES_CM_128_HMAC_SHA1_80}, rtp.SRTPCryptoType) + + // Video tiers include HEVC + vidChar := multi.GetCharacter(TypeSupportedVideoStreamTiers) + require.NotNil(t, vidChar) + s, ok := vidChar.Value.(string) + require.True(t, ok) + raw, err := base64.StdEncoding.DecodeString(s) + require.NoError(t, err) + require.NotEmpty(t, raw) +} + +func TestLegacyAccessoryStillWorks(t *testing.T) { + acc := NewAccessory("AlexxIT", "go2rtc", "cam", "SN", "1.0") + require.NotNil(t, acc) + require.NotNil(t, acc.GetService("110")) + // No HKSV services by default + require.Nil(t, acc.GetService(TypeCameraWebRTCStreamManagement)) +} + +func TestBoolTLV8(t *testing.T) { + type wrap struct { + Flag bool `tlv8:"1"` + } + b, err := tlv8.Marshal(wrap{Flag: true}) + require.NoError(t, err) + require.Equal(t, []byte{1, 1, 1}, b) + + var out wrap + require.NoError(t, tlv8.Unmarshal(b, &out)) + require.True(t, out.Flag) + + b, err = tlv8.Marshal(wrap{Flag: false}) + require.NoError(t, err) + require.Equal(t, []byte{1, 1, 0}, b) +} + +func TestBufferAndKeyTLV8(t *testing.T) { + pub := CameraRecordingPublishingPointValue{ + URL: "https://example.com/cmaf/", + ServerCACertificates: []Certificate{ + {Certificate: "der-cert"}, + }, + } + b, err := tlv8.Marshal(pub) + require.NoError(t, err) + var out CameraRecordingPublishingPointValue + require.NoError(t, tlv8.Unmarshal(b, &out)) + require.Equal(t, "https://example.com/cmaf/", out.URL) + require.Len(t, out.ServerCACertificates, 1) + + key := CameraKeyValue{Key: "secret", KeyNumber: 42} + b, err = tlv8.Marshal(key) + require.NoError(t, err) + var outKey CameraKeyValue + require.NoError(t, tlv8.Unmarshal(b, &outKey)) + require.Equal(t, uint64(42), outKey.KeyNumber) +} + +func TestRTPStreamingControlTLV8(t *testing.T) { + req := RTPStreamingControlRequest{ + SessionIdentifier: "sess", + Command: RTPStreamCommandStart, + VideoTier: 1, + VideoSSRC: 123, + AudioTier: 1, + AudioSSRC: 456, + } + b, err := tlv8.Marshal(req) + require.NoError(t, err) + var out RTPStreamingControlRequest + require.NoError(t, tlv8.Unmarshal(b, &out)) + require.Equal(t, uint32(1), out.VideoTier) + require.Equal(t, uint32(123), out.VideoSSRC) + require.Equal(t, byte(RTPStreamCommandStart), out.Command) +} diff --git a/pkg/homekit/webrtc_session_test.go b/pkg/homekit/webrtc_session_test.go new file mode 100644 index 000000000..2eeaf0d7f --- /dev/null +++ b/pkg/homekit/webrtc_session_test.go @@ -0,0 +1,140 @@ +package homekit + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + "github.com/AlexxIT/go2rtc/pkg/hap/camera" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + pion "github.com/pion/webrtc/v4" + "github.com/stretchr/testify/require" +) + +func testFactory() (*pion.PeerConnection, error) { + api, err := webrtc.NewAPI() + if err != nil { + return nil, err + } + return api.NewPeerConnection(pion.Configuration{}) +} + +func TestWebRTCManagerSolicitAndEnd(t *testing.T) { + m := NewWebRTCManager(testFactory) + + res, err := m.SolicitOffer(false) + require.NoError(t, err) + require.Equal(t, byte(camera.WebRTCSolicitSuccess), res.Status) + require.NotEmpty(t, res.SessionIdentifier) + require.Contains(t, res.SDPOffer, "v=0") + require.Equal(t, 1, m.ActiveCount()) + + // Unknown session end + end := m.EndSession("unknown") + require.Equal(t, byte(camera.WebRTCStatusUnknownSessionIdentifier), end.Status) + + // End real session + end = m.EndSession(res.SessionIdentifier) + require.Equal(t, byte(camera.WebRTCStatusSuccess), end.Status) + require.Equal(t, 0, m.ActiveCount()) +} + +func TestWebRTCManagerSFrame(t *testing.T) { + m := NewWebRTCManager(testFactory) + res, err := m.SolicitOffer(true) + require.NoError(t, err) + require.Equal(t, byte(camera.WebRTCSolicitSuccess), res.Status) + require.NotEmpty(t, res.SFrameConfiguration.Key) + require.Equal(t, uint64(1), res.SFrameConfiguration.KID) + _ = m.EndSession(res.SessionIdentifier) +} + +func TestWebRTCManagerMaxSessions(t *testing.T) { + m := NewWebRTCManager(testFactory) + m.max = 2 + + r1, err := m.SolicitOffer(false) + require.NoError(t, err) + require.Equal(t, byte(camera.WebRTCSolicitSuccess), r1.Status) + + r2, err := m.SolicitOffer(false) + require.NoError(t, err) + require.Equal(t, byte(camera.WebRTCSolicitSuccess), r2.Status) + + r3, err := m.SolicitOffer(false) + require.NoError(t, err) + require.Equal(t, byte(camera.WebRTCSolicitError), r3.Status) + + _ = m.EndSession(r1.SessionIdentifier) + _ = m.EndSession(r2.SessionIdentifier) +} + +func TestWebRTCManagerNilFactory(t *testing.T) { + m := NewWebRTCManager(nil) + res, err := m.SolicitOffer(false) + require.NoError(t, err) + require.Equal(t, byte(camera.WebRTCSolicitError), res.Status) +} + +func TestCSRGeneration(t *testing.T) { + m := NewWebRTCManager(nil) + nonce := make([]byte, 32) + _, _ = rand.Read(nonce) + + res, err := m.HandleCSR(nonce) + require.NoError(t, err) + require.NotEmpty(t, res.CSR) + require.NotEmpty(t, res.NonceSignature) + require.True(t, m.CertificateNeedsUpdate()) + + // Install cert + m.InstallClientCertificate(&camera.CameraClientCertificateRequest{ + ClientCertificate: "client-der", + CA: "ca-der", + }) + require.False(t, m.CertificateNeedsUpdate()) +} + +func TestKeyManagement(t *testing.T) { + m := NewWebRTCManager(nil) + id := m.SetKey([]byte("key-data"), 7) + require.Equal(t, uint64(7), id) + require.Equal(t, uint64(7), m.KeyID()) +} + +func TestUpdateSessionKeys(t *testing.T) { + m := NewWebRTCManager(testFactory) + res, err := m.SolicitOffer(false) + require.NoError(t, err) + + upd := m.UpdateSession(&camera.WebRTCUpdateSessionRequest{ + SessionIdentifier: res.SessionIdentifier, + ReceiveKeysToAdd: []camera.SFrameKeyData{ + {Key: "abc", KID: 9}, + }, + }) + require.Equal(t, byte(camera.WebRTCStatusSuccess), upd.Status) + + upd = m.UpdateSession(&camera.WebRTCUpdateSessionRequest{ + SessionIdentifier: "missing", + }) + require.Equal(t, byte(camera.WebRTCStatusUnknownSessionIdentifier), upd.Status) + + _ = m.EndSession(res.SessionIdentifier) +} + +func TestProvideAnswerUnknown(t *testing.T) { + m := NewWebRTCManager(testFactory) + res := m.ProvideAnswer(&camera.WebRTCProvideAnswerRequest{ + SessionIdentifier: "nope", + SDPAnswer: "v=0\r\n", + }) + require.Equal(t, byte(camera.WebRTCStatusUnknownSessionIdentifier), res.Status) +} + +func TestECDSAKeyGen(t *testing.T) { + // Sanity: P-256 works on this platform (used by CSR) + _, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) +} From c2e8a603fdb83d2108fdf50891b361c7a9a28a73 Mon Sep 17 00:00:00 2001 From: Radu Ursache Date: Fri, 10 Jul 2026 18:07:35 +0300 Subject: [PATCH 6/8] Implement full HKSV CMAF recording pipeline Wire the open-source Secure Video recording path end to end: credentials (CSR/cert/keys/publishing point), 8s ring pre-buffer, fMP4 clip builder, DASH-IF mTLS ingest client, event queue, and buffer activity/upload handlers on the HomeKit server when hksv is on Refs: https://github.com/AlexxIT/go2rtc/issues/2297 --- internal/homekit/README.md | 16 +- internal/homekit/homekit.go | 7 +- internal/homekit/server.go | 183 +++++++++++-- pkg/homekit/cmaf_client.go | 203 +++++++++++++++ pkg/homekit/cmaf_clip.go | 102 ++++++++ pkg/homekit/credentials.go | 174 +++++++++++++ pkg/homekit/ntp.go | 28 ++ pkg/homekit/recording.go | 405 +++++++++++++++++++++++++++++ pkg/homekit/recording_test.go | 354 +++++++++++++++++++++++++ pkg/homekit/ringbuffer.go | 220 ++++++++++++++++ pkg/homekit/webrtc_session.go | 91 +------ pkg/homekit/webrtc_session_test.go | 35 --- 12 files changed, 1667 insertions(+), 151 deletions(-) create mode 100644 pkg/homekit/cmaf_client.go create mode 100644 pkg/homekit/cmaf_clip.go create mode 100644 pkg/homekit/credentials.go create mode 100644 pkg/homekit/ntp.go create mode 100644 pkg/homekit/recording.go create mode 100644 pkg/homekit/recording_test.go create mode 100644 pkg/homekit/ringbuffer.go diff --git a/internal/homekit/README.md b/internal/homekit/README.md index dff34fd43..0857a265f 100644 --- a/internal/homekit/README.md +++ b/internal/homekit/README.md @@ -92,12 +92,18 @@ Implements the HAP services from [issue #2297](https://github.com/AlexxIT/go2rtc - Camera Capabilities, Global Operating Mode, Motion Zones - Multi-tier RTP stream management (High/Medium/Low, HEVC + H.264, Opus) - WebRTC Stream Management (solicit-offer / provide-answer over HAP, min 6 sessions) -- Buffer / Key / Client Certificate management for CMAF ingest provisioning - Classic RTP kept with 5 concurrent stream slots - -The guide is a Developer Preview and may change before release. CMAF clip upload to Apple's -publishing point is scaffolded (cert CSR flow works); full fMP4 ingest depends on a live -controller-provided publishing URL after pairing. +- CMAF recording stack: + - 8s ring pre-buffer from the source stream + - Client CSR + certificate install (mTLS identity for ingest) + - Content key management (stored for CENC; clear fMP4 uploaded, transport protected by mTLS) + - Publishing point URL + server CAs from the controller + - Buffer activity / upload / event queue with HAP write-response and sequence notifies + - DASH-IF Interface-1 POST of init + media segments to the publishing point + +The guide is a Developer Preview and may change before release. Full end-to-end Secure Video +in Apple Home still depends on Apple accepting the accessory after pairing and providing a +live publishing point URL. **Proxy HomeKit camera** diff --git a/internal/homekit/homekit.go b/internal/homekit/homekit.go index 95464bb0f..8620ae3a0 100644 --- a/internal/homekit/homekit.go +++ b/internal/homekit/homekit.go @@ -27,7 +27,7 @@ func Init() { CategoryID string `yaml:"category_id"` Pairings []string `yaml:"pairings"` // HKSV enables experimental HomeKit Secure Video open-source services - // (WebRTC live view, multi-tier HEVC, CMAF cert provisioning). Spec version 17.99 + // (WebRTC live view, multi-tier HEVC, CMAF recording ingest). Spec version 17.99 HKSV bool `yaml:"hksv"` } `yaml:"homekit"` } @@ -107,9 +107,12 @@ func Init() { // 1. Act as transparent proxy for HomeKit camera srv.proxyURL = url } else if conf.HKSV { - // 2. Experimental HKSV open-source camera (WebRTC + HEVC + CMAF) + // 2. Experimental HKSV open-source camera (WebRTC + HEVC + CMAF recording) srv.accessory = camera.NewHKSVAccessory("AlexxIT", "go2rtc", name, "-", app.Version, id) srv.webrtc = homekit.NewWebRTCManager(newHomeKitPeerConnection) + srv.recording = homekit.NewRecordingManager() + srv.recording.OnEventSeq = srv.notifyEventSequence + go srv.startRecordingBuffer() log.Info().Str("stream", id).Msg("[homekit] HKSV open-source enabled (experimental, capabilities 17.99)") } else { // 3. Act as basic HomeKit camera diff --git a/internal/homekit/server.go b/internal/homekit/server.go index f7ca6df4d..40cde85d1 100644 --- a/internal/homekit/server.go +++ b/internal/homekit/server.go @@ -14,6 +14,7 @@ import ( "slices" "strings" "sync" + "time" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/ffmpeg" @@ -45,8 +46,9 @@ type server struct { stream string // stream name from YAML // Experimental HKSV open-source (WebRTC + HEVC + CMAF) - hksv bool - webrtc *homekit.WebRTCManager + hksv bool + webrtc *homekit.WebRTCManager + recording *homekit.RecordingManager // last write-response values keyed by characteristic IID wrValues map[uint64]any } @@ -274,14 +276,20 @@ func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any { return 0 case camera.TypeCameraClientCertificateStatus: - if s.webrtc != nil { + if s.recording != nil { v, err := tlv8.MarshalBase64(camera.CameraClientCertificateStatusValue{ - NeedsUpdate: s.webrtc.CertificateNeedsUpdate(), + NeedsUpdate: s.recording.Creds.NeedsUpdate(), }) if err == nil { return v } } + + case camera.TypeBufferEventSequenceNumber: + if s.recording != nil { + return s.recording.EventSequence() + } + return uint32(0) } return char.Value @@ -380,22 +388,34 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a s.handleCameraKey(value) case camera.TypeCameraRecordingPublishingPoint: - // Store publishing point as-is for CMAF ingest (already base64 TLV8 from controller) - char.Value = value + s.handlePublishingPoint(value) case camera.TypeStreamingEnabled, camera.TypeHomeKitCameraActive, camera.TypeMotionEnabled, camera.TypeCameraOperatingModeIndicator: _ = char.Write(value) _ = char.NotifyListeners(conn) - case camera.TypeActive, camera.TypeRecordingAudioActive: - switch v := value.(type) { - case float64: - char.Value = uint8(v) - case int: - char.Value = uint8(v) - default: - char.Value = value + case camera.TypeActive: + active := truthy(value) + if s.recording != nil { + s.recording.SetRecordingActive(active) + } + if active { + char.Value = uint8(1) + } else { + char.Value = uint8(0) + } + _ = char.NotifyListeners(conn) + + case camera.TypeRecordingAudioActive: + active := truthy(value) + if s.recording != nil { + s.recording.SetAudioActive(active) + } + if active { + char.Value = uint8(1) + } else { + char.Value = uint8(0) } _ = char.NotifyListeners(conn) @@ -403,8 +423,7 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a _ = char.Write(value) case camera.TypeBufferActivityCommand: - // Acknowledge buffer activity; no response body required - return + s.handleBufferActivity(value) case camera.TypeBufferUploadCommand: s.handleBufferUpload(iid, value) @@ -598,58 +617,168 @@ func (s *server) handleRTPStreamingControl(iid uint64, value any) { } func (s *server) handleCameraClientCSR(iid uint64, value any) { - if s.webrtc == nil { + if s.recording == nil { return } var req camera.CameraClientCSRRequest if err := tlv8.UnmarshalBase64(value, &req); err != nil { return } - res, err := s.webrtc.HandleCSR([]byte(req.Nonce)) + csrDER, sig, err := s.recording.Creds.HandleCSR([]byte(req.Nonce)) if err != nil { log.Warn().Err(err).Msg("[homekit] csr") return } - s.setWriteResponse(iid, res) + s.setWriteResponse(iid, camera.CameraClientCSRResponse{ + CSR: string(csrDER), + NonceSignature: string(sig), + }) } func (s *server) handleCameraClientCertificate(value any) { - if s.webrtc == nil { + if s.recording == nil { return } var req camera.CameraClientCertificateRequest if err := tlv8.UnmarshalBase64(value, &req); err != nil { return } - s.webrtc.InstallClientCertificate(&req) + s.recording.Creds.InstallClientCertificate([]byte(req.ClientCertificate), []byte(req.CA)) if char := s.accessory.GetCharacter(camera.TypeCameraClientCertificateStatus); char != nil { _ = char.Set(camera.CameraClientCertificateStatusValue{NeedsUpdate: false}) } } func (s *server) handleCameraKey(value any) { - if s.webrtc == nil { + if s.recording == nil { return } var req camera.CameraKeyValue if err := tlv8.UnmarshalBase64(value, &req); err != nil { return } - id := s.webrtc.SetKey([]byte(req.Key), req.KeyNumber) + id := s.recording.Creds.SetKey([]byte(req.Key), req.KeyNumber) if char := s.accessory.GetCharacter(camera.TypeCameraKeyID); char != nil { _ = char.Set(camera.CameraKeyIDValue{KeyID: id}) } } +func (s *server) handlePublishingPoint(value any) { + if s.recording == nil { + return + } + var req camera.CameraRecordingPublishingPointValue + if err := tlv8.UnmarshalBase64(value, &req); err != nil { + // store raw value if not TLV8-shaped + if char := s.accessory.GetCharacter(camera.TypeCameraRecordingPublishingPoint); char != nil { + char.Value = value + } + return + } + var cas [][]byte + for _, c := range req.ServerCACertificates { + cas = append(cas, []byte(c.Certificate)) + } + s.recording.Creds.SetPublishingPoint(req.URL, cas) + if char := s.accessory.GetCharacter(camera.TypeCameraRecordingPublishingPoint); char != nil { + char.Value = value + } + log.Debug().Str("stream", s.stream).Str("url", req.URL).Msg("[homekit] cmaf publishing point set") +} + +func (s *server) handleBufferActivity(value any) { + if s.recording == nil { + return + } + var req camera.BufferActivityCommandRequest + if err := tlv8.UnmarshalBase64(value, &req); err != nil { + return + } + s.recording.HandleActivity(&req) +} + func (s *server) handleBufferUpload(iid uint64, value any) { - // CMAF clip upload requires a live publishing point; return a clip id placeholder + if s.recording == nil { + s.setWriteResponse(iid, camera.BufferUploadCommandResponse{}) + return + } var req camera.BufferUploadCommandRequest - _ = tlv8.UnmarshalBase64(value, &req) - s.setWriteResponse(iid, camera.BufferUploadCommandResponse{ClipID: req.SessionID}) + if err := tlv8.UnmarshalBase64(value, &req); err != nil { + s.setWriteResponse(iid, camera.BufferUploadCommandResponse{}) + return + } + res := s.recording.HandleUpload(&req) + s.setWriteResponse(iid, res) + log.Debug().Str("stream", s.stream). + Uint64("session", req.SessionID). + Uint64("clip", res.ClipID). + Uint8("cmd", req.Command). + Msg("[homekit] buffer upload command") } func (s *server) handleBufferEvent(iid uint64, value any) { - s.setWriteResponse(iid, camera.BufferEventCommandResponse{}) + if s.recording == nil { + s.setWriteResponse(iid, camera.BufferEventCommandResponse{}) + return + } + var req camera.BufferEventCommandRequest + if err := tlv8.UnmarshalBase64(value, &req); err != nil { + s.setWriteResponse(iid, camera.BufferEventCommandResponse{}) + return + } + s.setWriteResponse(iid, s.recording.HandleEventCommand(&req)) +} + +func truthy(value any) bool { + switch v := value.(type) { + case bool: + return v + case float64: + return v != 0 + case int: + return v != 0 + case uint8: + return v != 0 + case uint64: + return v != 0 + case string: + return v == "1" || v == "true" + default: + return false + } +} + +// startRecordingBuffer attaches a ring-buffer consumer to the source stream +// Retries until the stream can provide matching tracks +func (s *server) startRecordingBuffer() { + if s.recording == nil { + return + } + cons := s.recording.EnsureConsumer() + for { + stream := streams.Get(s.stream) + if stream == nil { + return + } + if err := stream.AddConsumer(cons); err != nil { + log.Debug().Err(err).Str("stream", s.stream).Msg("[homekit] recording buffer wait for tracks") + time.Sleep(2 * time.Second) + continue + } + log.Info().Str("stream", s.stream).Msg("[homekit] recording pre-buffer started") + return + } +} + +// notifyEventSequence updates the HAP event sequence characteristic +func (s *server) notifyEventSequence(seq uint32) { + if s.accessory == nil { + return + } + if char := s.accessory.GetCharacter(camera.TypeBufferEventSequenceNumber); char != nil { + char.Value = seq + _ = char.NotifyListeners(nil) + } } func (s *server) streamingAllowed() bool { diff --git a/pkg/homekit/cmaf_client.go b/pkg/homekit/cmaf_client.go new file mode 100644 index 000000000..43ad5da3a --- /dev/null +++ b/pkg/homekit/cmaf_client.go @@ -0,0 +1,203 @@ +package homekit + +import ( + "bytes" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +// CMAF error codes from the open-source guide (Camera Buffer Event CMAF Error) +const ( + CMAFErrNone = 0 + CMAFErrUnknown = 1 + CMAFErrCannotFindHost = 2 + CMAFErrCertConnectionFailure = 3 + CMAFErrCannotCertify = 4 + CMAFErrInvalidState = 5 + CMAFErrRequiresRetry = 6 + CMAFErrNoResponse = 7 + CMAFErrMaxSessionTimeExceeded = 8 + CMAFErrCanceled = 9 + CMAFErrMP4Error = 10 + CMAFErrConnectionFailed = 11 + CMAFErrTimeout = 12 + CMAFErrOutOfResources = 13 + CMAFErrInvalidData = 14 + CMAFErrHTTPBadRequest = 15 + CMAFErrHTTPInvalidToken = 16 + CMAFErrHTTPCameraZoneDisabled = 17 + CMAFErrHTTPMismatchedToken = 18 + CMAFErrHTTPNotFound = 19 + CMAFErrHTTPInitMissing = 20 + CMAFErrHTTPUnsupportedMedia = 21 + CMAFErrHTTPBlocked = 22 + CMAFErrHTTPCertificateExpired = 23 + CMAFErrHTTPInternalServer = 24 + CMAFErrHTTPServiceUnavailable = 25 + CMAFErrHTTPZoneDoesNotExist = 26 +) + +// CMAFClient publishes CMAF tracks to a DASH-IF Interface-1 publishing point over mTLS +type CMAFClient struct { + baseURL string + httpClient *http.Client + userAgent string +} + +// NewCMAFClient builds a client for baseURL (must end with /) +// tlsCfg may be nil for plain HTTP (local tests only) +func NewCMAFClient(baseURL string, tlsCfg *tls.Config) *CMAFClient { + if baseURL != "" && !strings.HasSuffix(baseURL, "/") { + baseURL += "/" + } + tr := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + IdleConnTimeout: 90 * time.Second, + TLSClientConfig: tlsCfg, + ForceAttemptHTTP2: true, + } + return &CMAFClient{ + baseURL: baseURL, + httpClient: &http.Client{ + Transport: tr, + Timeout: 60 * time.Second, + }, + userAgent: "DASH-IF-Ingest/1.1 go2rtc-hksv", + } +} + +// PublishClip uploads init + media fragments for video (and audio if present) +// Paths follow a simple RepresentationID scheme under the publishing point +func (c *CMAFClient) PublishClip(sessionID uint64, clip *Clip) (cmafErr int, err error) { + if c.baseURL == "" { + return CMAFErrInvalidState, errString("homekit: publishing point not set") + } + if clip == nil || len(clip.Init) == 0 { + return CMAFErrMP4Error, errString("homekit: invalid clip") + } + + // Connectivity probe (empty POST) is recommended by DASH-IF; ignore soft failures + _ = c.post(c.baseURL, nil, "application/octet-stream") + + videoRep := fmt.Sprintf("video-%d", sessionID) + if err := c.postInit(videoRep, clip.Init); err != nil { + return mapHTTPError(err), err + } + + for i, frag := range clip.Fragments { + if err := c.postMedia(videoRep, uint64(i+1), frag); err != nil { + return mapHTTPError(err), err + } + } + return CMAFErrNone, nil +} + +func (c *CMAFClient) postInit(rep string, body []byte) error { + // DASH-IF: one complete CMAF object per request + u := c.baseURL + rep + "/init.mp4" + return c.post(u, body, "video/mp4") +} + +func (c *CMAFClient) postMedia(rep string, number uint64, body []byte) error { + u := c.baseURL + fmt.Sprintf("%s/seg-%d.m4s", rep, number) + return c.post(u, body, "video/mp4") +} + +func (c *CMAFClient) post(rawURL string, body []byte, contentType string) error { + if _, err := url.Parse(rawURL); err != nil { + return err + } + var rdr io.Reader + if body != nil { + rdr = bytes.NewReader(body) + } else { + rdr = http.NoBody + } + req, err := http.NewRequest(http.MethodPost, rawURL, rdr) + if err != nil { + return err + } + req.Header.Set("User-Agent", c.userAgent) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if body != nil { + req.ContentLength = int64(len(body)) + } + + res, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + + if res.StatusCode >= 200 && res.StatusCode < 300 { + return nil + } + return &httpStatusError{code: res.StatusCode, url: rawURL} +} + +type httpStatusError struct { + code int + url string +} + +func (e *httpStatusError) Error() string { + return fmt.Sprintf("homekit: CMAF POST %s -> HTTP %d", e.url, e.code) +} + +func mapHTTPError(err error) int { + if err == nil { + return CMAFErrNone + } + if se, ok := err.(*httpStatusError); ok { + switch se.code { + case http.StatusBadRequest: + return CMAFErrHTTPBadRequest + case http.StatusUnauthorized, http.StatusForbidden: + return CMAFErrHTTPInvalidToken + case http.StatusNotFound: + return CMAFErrHTTPNotFound + case http.StatusUnsupportedMediaType: + return CMAFErrHTTPUnsupportedMedia + case http.StatusPreconditionFailed: + return CMAFErrHTTPInitMissing + case http.StatusInternalServerError: + return CMAFErrHTTPInternalServer + case http.StatusServiceUnavailable: + return CMAFErrHTTPServiceUnavailable + case 419: // some stacks use for cert expired + return CMAFErrHTTPCertificateExpired + default: + if se.code >= 500 { + return CMAFErrHTTPInternalServer + } + return CMAFErrUnknown + } + } + msg := err.Error() + switch { + case strings.Contains(msg, "no such host"): + return CMAFErrCannotFindHost + case strings.Contains(msg, "certificate"): + return CMAFErrCertConnectionFailure + case strings.Contains(msg, "timeout") || strings.Contains(msg, "Timeout"): + return CMAFErrTimeout + case strings.Contains(msg, "connection refused") || strings.Contains(msg, "connection reset"): + return CMAFErrConnectionFailed + default: + return CMAFErrUnknown + } +} diff --git a/pkg/homekit/cmaf_clip.go b/pkg/homekit/cmaf_clip.go new file mode 100644 index 000000000..d2fae056e --- /dev/null +++ b/pkg/homekit/cmaf_clip.go @@ -0,0 +1,102 @@ +package homekit + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/mp4" + "github.com/pion/rtp" +) + +// Clip is a CMAF-ready init segment plus media fragments for one or two tracks +type Clip struct { + Init []byte + Fragments [][]byte // each is moof+mdat + VideoCodec *core.Codec + AudioCodec *core.Codec +} + +// BuildClip packages a packet slice into CMAF init + media fragments +// When contentKey is non-nil (16 bytes AES-128), sample payloads are encrypted +// with AES-CTR and a random 16-byte IV is prepended to each fragment body +func BuildClip(packets []Packet, contentKey []byte) (*Clip, error) { + if len(packets) == 0 { + return nil, errString("homekit: empty clip") + } + + var videoCodec, audioCodec *core.Codec + for _, p := range packets { + if p.Track == 0 && videoCodec == nil { + videoCodec = p.Codec + } + if p.Track == 1 && audioCodec == nil { + audioCodec = p.Codec + } + } + if videoCodec == nil { + return nil, errString("homekit: clip has no video") + } + + muxer := &mp4.Muxer{} + // track 0 = video, track 1 = audio (optional) + muxer.AddTrack(videoCodec) + hasAudio := audioCodec != nil + if hasAudio { + muxer.AddTrack(audioCodec) + } + + init, err := muxer.GetInit() + if err != nil { + return nil, err + } + + clip := &Clip{ + Init: init, + VideoCodec: videoCodec, + AudioCodec: audioCodec, + } + + for _, p := range packets { + trackID := p.Track + if trackID == 1 && !hasAudio { + continue + } + payload := p.Payload + if len(contentKey) >= 16 { + enc, err := encryptAESCTR(contentKey[:16], payload) + if err != nil { + return nil, err + } + payload = enc + } + pkt := &rtp.Packet{ + Header: rtp.Header{ + Timestamp: p.RTPTime, + }, + Payload: payload, + } + frag := muxer.GetPayload(trackID, pkt) + clip.Fragments = append(clip.Fragments, frag) + } + + return clip, nil +} + +func encryptAESCTR(key, plain []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + iv := make([]byte, aes.BlockSize) + if _, err = io.ReadFull(rand.Reader, iv); err != nil { + return nil, err + } + out := make([]byte, len(iv)+len(plain)) + copy(out, iv) + stream := cipher.NewCTR(block, iv) + stream.XORKeyStream(out[len(iv):], plain) + return out, nil +} diff --git a/pkg/homekit/credentials.go b/pkg/homekit/credentials.go new file mode 100644 index 000000000..d064a8e12 --- /dev/null +++ b/pkg/homekit/credentials.go @@ -0,0 +1,174 @@ +package homekit + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "sync" +) + +// Credentials holds CMAF ingest identity material provisioned over HAP +type Credentials struct { + mu sync.RWMutex + + privKey *ecdsa.PrivateKey + clientCert []byte // DER leaf + caCert []byte // DER CA + + // content keys from Camera Key Management, keyed by key number + keys map[uint64][]byte + keyID uint64 + + // publishing point + publishURL string + serverCAs [][]byte // DER certs +} + +func NewCredentials() *Credentials { + return &Credentials{keys: make(map[uint64][]byte)} +} + +// HandleCSR builds a DER CSR and signs the controller nonce with the same key +func (c *Credentials) HandleCSR(nonce []byte) (csrDER, nonceSig []byte, err error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.privKey == nil { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, err + } + c.privKey = key + } + + template := x509.CertificateRequest{ + Subject: pkix.Name{CommonName: "go2rtc-hksv"}, + } + csrDER, err = x509.CreateCertificateRequest(rand.Reader, &template, c.privKey) + if err != nil { + return nil, nil, err + } + + sum := sha256.Sum256(nonce) + nonceSig, err = ecdsa.SignASN1(rand.Reader, c.privKey, sum[:]) + if err != nil { + return nil, nil, err + } + return csrDER, nonceSig, nil +} + +// InstallClientCertificate stores the issued leaf and CA (DER) +func (c *Credentials) InstallClientCertificate(clientDER, caDER []byte) { + c.mu.Lock() + defer c.mu.Unlock() + c.clientCert = append([]byte(nil), clientDER...) + c.caCert = append([]byte(nil), caDER...) +} + +// NeedsUpdate is true when no client certificate is installed +func (c *Credentials) NeedsUpdate() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.clientCert) == 0 +} + +// SetKey stores a content key and marks it current +func (c *Credentials) SetKey(key []byte, number uint64) uint64 { + c.mu.Lock() + defer c.mu.Unlock() + c.keys[number] = append([]byte(nil), key...) + c.keyID = number + return number +} + +// KeyID returns the current content key id +func (c *Credentials) KeyID() uint64 { + c.mu.RLock() + defer c.mu.RUnlock() + return c.keyID +} + +// CurrentKey returns the active content key bytes (may be nil) +func (c *Credentials) CurrentKey() (id uint64, key []byte) { + c.mu.RLock() + defer c.mu.RUnlock() + if k, ok := c.keys[c.keyID]; ok { + return c.keyID, append([]byte(nil), k...) + } + return c.keyID, nil +} + +// SetPublishingPoint stores the CMAF ingest URL and server CA list +func (c *Credentials) SetPublishingPoint(url string, serverCAs [][]byte) { + c.mu.Lock() + defer c.mu.Unlock() + c.publishURL = url + c.serverCAs = nil + for _, ca := range serverCAs { + c.serverCAs = append(c.serverCAs, append([]byte(nil), ca...)) + } +} + +// PublishingPoint returns the configured URL +func (c *Credentials) PublishingPoint() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.publishURL +} + +// TLSConfig builds an mTLS client config for CMAF ingest +// Returns nil, err if client cert is missing +func (c *Credentials) TLSConfig() (*tls.Config, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + if len(c.clientCert) == 0 || c.privKey == nil { + return nil, errNoClientCert + } + + leaf, err := x509.ParseCertificate(c.clientCert) + if err != nil { + return nil, err + } + + tlsCert := tls.Certificate{ + Certificate: [][]byte{c.clientCert}, + PrivateKey: c.privKey, + Leaf: leaf, + } + if len(c.caCert) > 0 { + tlsCert.Certificate = append(tlsCert.Certificate, c.caCert) + } + + roots := x509.NewCertPool() + for _, der := range c.serverCAs { + if cert, err := x509.ParseCertificate(der); err == nil { + roots.AddCert(cert) + } + } + // Also trust the provisioned CA as a root when present + if len(c.caCert) > 0 { + if cert, err := x509.ParseCertificate(c.caCert); err == nil { + roots.AddCert(cert) + } + } + + cfg := &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + MinVersion: tls.VersionTLS12, + } + if len(roots.Subjects()) > 0 { //nolint:staticcheck // Subjects still fine for emptiness check + cfg.RootCAs = roots + } + return cfg, nil +} + +var errNoClientCert = errString("homekit: CMAF client certificate not provisioned") + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/pkg/homekit/ntp.go b/pkg/homekit/ntp.go new file mode 100644 index 000000000..75286e9ea --- /dev/null +++ b/pkg/homekit/ntp.go @@ -0,0 +1,28 @@ +package homekit + +import "time" + +// ntpEpochOffset is seconds between NTP epoch (1900) and Unix epoch (1970) +const ntpEpochOffset = 2208988800 + +// TimeToNTP converts a wall clock time to a 64-bit NTP timestamp +func TimeToNTP(t time.Time) uint64 { + if t.IsZero() { + t = time.Now() + } + secs := uint64(t.Unix()) + ntpEpochOffset + // fractional second in 1/2^32 units + frac := uint64(uint32(uint64(t.Nanosecond()) * 0x100000000 / 1e9)) + return secs<<32 | frac +} + +// NTPToTime converts a 64-bit NTP timestamp to wall clock time +func NTPToTime(ntp uint64) time.Time { + if ntp == 0 { + return time.Time{} + } + secs := int64(ntp>>32) - ntpEpochOffset + frac := ntp & 0xffffffff + nsec := int64(frac * 1e9 / 0x100000000) + return time.Unix(secs, nsec) +} diff --git a/pkg/homekit/recording.go b/pkg/homekit/recording.go new file mode 100644 index 000000000..a0ad5c3bf --- /dev/null +++ b/pkg/homekit/recording.go @@ -0,0 +1,405 @@ +package homekit + +import ( + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/hap/camera" +) + +// Event is one entry in the Camera Event Queue +type Event struct { + Sequence uint64 + Type byte + Session uint64 + Motion bool + CMAFErr byte + Time time.Time +} + +// EventQueue is a bounded, sequence-numbered camera event queue +type EventQueue struct { + mu sync.Mutex + seq uint64 + events []Event + max int +} + +// NewEventQueue creates a queue retaining at most max events +func NewEventQueue(max int) *EventQueue { + if max <= 0 { + max = 256 + } + return &EventQueue{max: max} +} + +// Push appends an event and returns its sequence number +func (q *EventQueue) Push(ev Event) uint64 { + q.mu.Lock() + defer q.mu.Unlock() + q.seq++ + ev.Sequence = q.seq + ev.Time = time.Now() + q.events = append(q.events, ev) + if len(q.events) > q.max { + q.events = append([]Event(nil), q.events[len(q.events)-q.max:]...) + } + return ev.Sequence +} + +// Sequence is the latest sequence number +func (q *EventQueue) Sequence() uint64 { + q.mu.Lock() + defer q.mu.Unlock() + return q.seq +} + +// Query returns up to limit events with sequence > after +func (q *EventQueue) Query(after, limit uint64) []Event { + q.mu.Lock() + defer q.mu.Unlock() + if limit == 0 { + limit = 32 + } + var out []Event + for _, ev := range q.events { + if ev.Sequence <= after { + continue + } + out = append(out, ev) + if uint64(len(out)) >= limit { + break + } + } + return out +} + +// Acknowledge drops events with sequence <= seq +func (q *EventQueue) Acknowledge(seq uint64) { + q.mu.Lock() + defer q.mu.Unlock() + i := 0 + for i < len(q.events) && q.events[i].Sequence <= seq { + i++ + } + if i > 0 { + q.events = append([]Event(nil), q.events[i:]...) + } +} + +// UploadSession tracks an in-flight buffer upload +type UploadSession struct { + ID uint64 + ClipID uint64 + Command byte + Start time.Time + Stop time.Time + StopAct byte + Active bool + Cancel chan struct{} +} + +// RecordingManager owns pre-buffer, CMAF publish, credentials and events +type RecordingManager struct { + mu sync.Mutex + + Creds *Credentials + Buffer *RingBuffer + Events *EventQueue + + Consumer *BufferConsumer + + recordingActive bool + audioActive bool + + sessions map[uint64]*UploadSession + nextClip uint64 + + // OnEventSeq is called when the event sequence changes (HAP notify) + OnEventSeq func(seq uint32) +} + +// NewRecordingManager constructs a full HKSV recording stack +func NewRecordingManager() *RecordingManager { + return &RecordingManager{ + Creds: NewCredentials(), + Buffer: NewRingBuffer(8 * time.Second), + Events: NewEventQueue(256), + sessions: make(map[uint64]*UploadSession), + nextClip: 1, + } +} + +// SetRecordingActive enables/disables event recording intent +func (m *RecordingManager) SetRecordingActive(v bool) { + m.mu.Lock() + m.recordingActive = v + m.mu.Unlock() +} + +// SetAudioActive enables/disables audio in recordings +func (m *RecordingManager) SetAudioActive(v bool) { + m.mu.Lock() + m.audioActive = v + m.mu.Unlock() +} + +// RecordingActive reports Active characteristic state +func (m *RecordingManager) RecordingActive() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.recordingActive +} + +// AudioActive reports Recording Audio Active state +func (m *RecordingManager) AudioActive() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.audioActive +} + +// ReportMotion pushes a motion event onto the queue +func (m *RecordingManager) ReportMotion(active bool) { + seq := m.Events.Push(Event{ + Type: camera.BufferEventTypeMotion, + Motion: active, + }) + m.fireSeq(seq) +} + +// HandleActivity records a buffer activity window +func (m *RecordingManager) HandleActivity(req *camera.BufferActivityCommandRequest) { + if req.Activity == camera.BufferActivityShouldRecord { + m.ReportMotion(true) + } else if req.Activity == camera.BufferActivityShouldNotRecord { + m.ReportMotion(false) + } +} + +// HandleUpload processes Buffer Upload Command and returns Clip ID +func (m *RecordingManager) HandleUpload(req *camera.BufferUploadCommandRequest) *camera.BufferUploadCommandResponse { + m.mu.Lock() + clipID := m.nextClip + m.nextClip++ + sess := &UploadSession{ + ID: req.SessionID, + ClipID: clipID, + Command: req.Command, + Start: NTPToTime(req.Start), + Stop: NTPToTime(req.Stop), + StopAct: req.StopAction, + Active: true, + Cancel: make(chan struct{}), + } + m.sessions[req.SessionID] = sess + m.mu.Unlock() + + switch req.Command { + case camera.BufferUploadStart: + go m.runUpload(sess) + case camera.BufferUploadStartAndStop: + go m.runUpload(sess) + case camera.BufferUploadStop: + m.stopUpload(req.SessionID, req.StopAction) + } + + return &camera.BufferUploadCommandResponse{ClipID: clipID} +} + +func (m *RecordingManager) stopUpload(sessionID uint64, action byte) { + m.mu.Lock() + sess, ok := m.sessions[sessionID] + if ok && sess.Active { + sess.StopAct = action + select { + case <-sess.Cancel: + default: + close(sess.Cancel) + } + sess.Active = false + } + m.mu.Unlock() + + if ok && action == camera.BufferStopActionFinalize { + seq := m.Events.Push(Event{ + Type: camera.BufferEventTypeCMAFSessionStop, + Session: sessionID, + }) + m.fireSeq(seq) + } +} + +func (m *RecordingManager) runUpload(sess *UploadSession) { + select { + case <-sess.Cancel: + return + default: + } + + seq := m.Events.Push(Event{ + Type: camera.BufferEventTypeCMAFSessionStart, + Session: sess.ID, + }) + m.fireSeq(seq) + + err := m.publishSession(sess) + if err != nil { + code := mapPublishError(err) + seq = m.Events.Push(Event{ + Type: camera.BufferEventTypeCMAFError, + Session: sess.ID, + CMAFErr: byte(code), + }) + m.fireSeq(seq) + } + + finalize := sess.Command == camera.BufferUploadStartAndStop || + sess.StopAct == camera.BufferStopActionFinalize + if finalize { + seq = m.Events.Push(Event{ + Type: camera.BufferEventTypeCMAFSessionStop, + Session: sess.ID, + }) + m.fireSeq(seq) + } + + m.mu.Lock() + if s, ok := m.sessions[sess.ID]; ok { + s.Active = false + } + m.mu.Unlock() +} + +func (m *RecordingManager) publishSession(sess *UploadSession) error { + start := sess.Start + stop := sess.Stop + if stop.IsZero() { + stop = time.Now() + } + if start.IsZero() { + start = m.Buffer.OldestWall() + if start.IsZero() { + start = time.Now().Add(-4 * time.Second) + } + } + + packets := m.Buffer.Slice(start, stop) + + m.mu.Lock() + audioOn := m.audioActive + m.mu.Unlock() + + if !audioOn { + filtered := make([]Packet, 0, len(packets)) + for _, p := range packets { + if p.Track == 0 { + filtered = append(filtered, p) + } + } + packets = filtered + } + + // Content key is retained for future CENC; upload clear CMAF so the + // publishing point receives a standards-compliant track (mTLS protects transport) + clip, err := BuildClip(packets, nil) + if err != nil { + return err + } + + url := m.Creds.PublishingPoint() + if url == "" { + return errString("homekit: publishing point not set") + } + + cfg, tlsErr := m.Creds.TLSConfig() + // plain HTTP is allowed without client cert (local ingest tests) + isHTTP := len(url) >= 7 && url[:7] == "http://" + if tlsErr != nil && !isHTTP { + return tlsErr + } + if isHTTP { + cfg = nil + } + + client := NewCMAFClient(url, cfg) + code, err := client.PublishClip(sess.ID, clip) + if err != nil { + return &cmafPublishError{code: code, err: err} + } + return nil +} + +type cmafPublishError struct { + code int + err error +} + +func (e *cmafPublishError) Error() string { + if e.err != nil { + return e.err.Error() + } + return "cmaf publish error" +} + +func mapPublishError(err error) int { + if pe, ok := err.(*cmafPublishError); ok { + return pe.code + } + if err == nil { + return CMAFErrNone + } + return mapHTTPError(err) +} + +// HandleEventCommand processes Buffer Event Command (query / acknowledge) +func (m *RecordingManager) HandleEventCommand(req *camera.BufferEventCommandRequest) *camera.BufferEventCommandResponse { + res := &camera.BufferEventCommandResponse{} + switch req.Command { + case camera.BufferEventQuery: + evs := m.Events.Query(req.SequenceNumber, req.Limit) + for _, ev := range evs { + item := camera.CameraBufferEvent{ + SequenceNumber: ev.Sequence, + Type: ev.Type, + } + switch ev.Type { + case camera.BufferEventTypeCMAFSessionStart: + item.CMAFSessionStart = camera.CameraBufferEventCMAFSession{CMAFSessionID: ev.Session} + case camera.BufferEventTypeCMAFSessionStop: + item.CMAFSessionStop = camera.CameraBufferEventCMAFSession{CMAFSessionID: ev.Session} + case camera.BufferEventTypeMotion: + item.Motion = camera.CameraBufferEventMotion{Active: ev.Motion} + case camera.BufferEventTypeCMAFError: + item.CMAFError = camera.CameraBufferEventCMAFError{ + CMAFSessionID: ev.Session, + CMAFError: ev.CMAFErr, + } + } + res.Events = append(res.Events, item) + } + case camera.BufferEventAcknowledge: + m.Events.Acknowledge(req.SequenceNumber) + } + return res +} + +// EventSequence returns the current event sequence as uint32 for the characteristic +func (m *RecordingManager) EventSequence() uint32 { + return uint32(m.Events.Sequence()) +} + +func (m *RecordingManager) fireSeq(seq uint64) { + if m.OnEventSeq != nil { + m.OnEventSeq(uint32(seq)) + } +} + +// EnsureConsumer returns a buffer consumer, creating one if needed +func (m *RecordingManager) EnsureConsumer() *BufferConsumer { + m.mu.Lock() + defer m.mu.Unlock() + if m.Consumer == nil { + m.Consumer = NewBufferConsumer(m.Buffer) + } + return m.Consumer +} diff --git a/pkg/homekit/recording_test.go b/pkg/homekit/recording_test.go new file mode 100644 index 000000000..6e4361ac7 --- /dev/null +++ b/pkg/homekit/recording_test.go @@ -0,0 +1,354 @@ +package homekit + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "io" + "math/big" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/hap/camera" + "github.com/stretchr/testify/require" +) + +func TestNTPRoundTrip(t *testing.T) { + now := time.Now().UTC().Truncate(time.Millisecond) + ntp := TimeToNTP(now) + back := NTPToTime(ntp) + // allow 1ms of fractional rounding + require.WithinDuration(t, now, back, time.Millisecond) + + require.True(t, NTPToTime(0).IsZero()) + require.NotZero(t, TimeToNTP(time.Time{})) +} + +func TestCredentialsCSRAndTLS(t *testing.T) { + c := NewCredentials() + require.True(t, c.NeedsUpdate()) + + nonce := make([]byte, 32) + _, err := rand.Read(nonce) + require.NoError(t, err) + + csrDER, sig, err := c.HandleCSR(nonce) + require.NoError(t, err) + require.NotEmpty(t, csrDER) + require.NotEmpty(t, sig) + + csr, err := x509.ParseCertificateRequest(csrDER) + require.NoError(t, err) + require.NoError(t, csr.CheckSignature()) + + // issue a self-signed leaf matching the CSR public key + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "go2rtc-hksv"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + // re-use CSR public key by signing with a throwaway CA key is fine for storage + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + // For InstallClientCertificate we just need parseable DER; TLSConfig needs matching privKey + // so issue leaf with the credentials private key by generating cert from CSR public key + // using caKey as issuer (leaf pubkey = CSR pubkey = credentials privKey.Public) + leafDER, err := x509.CreateCertificate(rand.Reader, template, template, csr.PublicKey, caKey) + require.NoError(t, err) + + c.InstallClientCertificate(leafDER, nil) + require.False(t, c.NeedsUpdate()) + + // TLSConfig will fail Leaf parse against private key mismatch? Actually Certificate.Leaf + // is set from clientCert; PrivateKey is credentials privKey which matches CSR public key + // CreateCertificate used csr.PublicKey so leaf pubkey matches credentials privKey + cfg, err := c.TLSConfig() + require.NoError(t, err) + require.NotNil(t, cfg) + require.Len(t, cfg.Certificates, 1) + + id := c.SetKey([]byte("0123456789abcdef"), 3) + require.Equal(t, uint64(3), id) + kid, key := c.CurrentKey() + require.Equal(t, uint64(3), kid) + require.Equal(t, []byte("0123456789abcdef"), key) + + c.SetPublishingPoint("https://example.com/ingest/", [][]byte{leafDER}) + require.Equal(t, "https://example.com/ingest/", c.PublishingPoint()) +} + +func TestRingBufferSlice(t *testing.T) { + b := NewRingBuffer(5 * time.Second) + codec := &core.Codec{Name: core.CodecH265, ClockRate: 90000} + + base := time.Now().Add(-4 * time.Second) + for i := 0; i < 10; i++ { + b.Push(Packet{ + Track: 0, + Codec: codec, + Payload: []byte{0x40, byte(i)}, // IDR-like NAL type 19 would be better; Key flag set explicitly + RTPTime: uint32(i * 3000), + Wall: base.Add(time.Duration(i) * 400 * time.Millisecond), + Key: i%3 == 0, + }) + } + require.Greater(t, b.Len(), 0) + + start := base.Add(1 * time.Second) + end := base.Add(3 * time.Second) + slice := b.Slice(start, end) + require.NotEmpty(t, slice) + // first video sample should be a keyframe at or before start when available + require.True(t, slice[0].Key || !slice[0].Wall.After(start)) + + v, a := b.Codecs() + require.Equal(t, codec, v) + require.Nil(t, a) + require.False(t, b.OldestWall().IsZero()) +} + +func TestBuildClipClear(t *testing.T) { + codec := &core.Codec{Name: core.CodecH265, ClockRate: 90000} + // minimal AVCC-style payload (length-prefixed NAL) + nal := []byte{0x40, 0x01, 0x0c, 0x01} + payload := make([]byte, 4+len(nal)) + payload[0] = 0 + payload[1] = 0 + payload[2] = 0 + payload[3] = byte(len(nal)) + copy(payload[4:], nal) + + packets := []Packet{ + {Track: 0, Codec: codec, Payload: payload, RTPTime: 0, Wall: time.Now(), Key: true}, + {Track: 0, Codec: codec, Payload: payload, RTPTime: 3000, Wall: time.Now().Add(100 * time.Millisecond), Key: false}, + } + clip, err := BuildClip(packets, nil) + require.NoError(t, err) + require.NotEmpty(t, clip.Init) + require.NotEmpty(t, clip.Fragments) + // ftyp box starts with size then 'ftyp' + require.GreaterOrEqual(t, len(clip.Init), 8) + require.Equal(t, "ftyp", string(clip.Init[4:8])) +} + +func TestBuildClipEmpty(t *testing.T) { + _, err := BuildClip(nil, nil) + require.Error(t, err) +} + +func TestCMAFClientPublishHTTP(t *testing.T) { + var posts atomic.Int32 + var paths []string + var mu sync.Mutex + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + posts.Add(1) + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + mu.Lock() + paths = append(paths, r.URL.Path) + mu.Unlock() + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + // empty probe posts are ok + if len(body) == 0 { + w.WriteHeader(http.StatusNoContent) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + client := NewCMAFClient(srv.URL+"/publish", nil) + clip := &Clip{ + Init: []byte("init-bytes"), + Fragments: [][]byte{[]byte("frag1"), []byte("frag2")}, + } + code, err := client.PublishClip(42, clip) + require.NoError(t, err) + require.Equal(t, CMAFErrNone, code) + require.GreaterOrEqual(t, posts.Load(), int32(3)) // probe + init + 2 frags (probe optional) + + mu.Lock() + defer mu.Unlock() + joined := "" + for _, p := range paths { + joined += p + " " + } + require.Contains(t, joined, "video-42") + require.Contains(t, joined, "init.mp4") + require.Contains(t, joined, "seg-1.m4s") +} + +func TestCMAFClientHTTPErrorMapping(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + client := NewCMAFClient(srv.URL, nil) + code, err := client.PublishClip(1, &Clip{Init: []byte("x"), Fragments: [][]byte{[]byte("y")}}) + require.Error(t, err) + require.Equal(t, CMAFErrHTTPNotFound, code) +} + +func TestEventQueue(t *testing.T) { + q := NewEventQueue(3) + s1 := q.Push(Event{Type: camera.BufferEventTypeMotion, Motion: true}) + s2 := q.Push(Event{Type: camera.BufferEventTypeMotion, Motion: false}) + s3 := q.Push(Event{Type: camera.BufferEventTypeCMAFSessionStart, Session: 9}) + s4 := q.Push(Event{Type: camera.BufferEventTypeCMAFError, Session: 9, CMAFErr: CMAFErrTimeout}) + require.Equal(t, uint64(1), s1) + require.Equal(t, uint64(4), s4) + require.Equal(t, uint64(4), q.Sequence()) + + // max 3 retained + all := q.Query(0, 10) + require.Len(t, all, 3) + require.Equal(t, s2, all[0].Sequence) + + q.Acknowledge(s3) + left := q.Query(0, 10) + require.Len(t, left, 1) + require.Equal(t, s4, left[0].Sequence) +} + +func TestRecordingManagerMotionAndEvents(t *testing.T) { + m := NewRecordingManager() + var lastSeq uint32 + m.OnEventSeq = func(seq uint32) { lastSeq = seq } + + m.ReportMotion(true) + require.Equal(t, uint32(1), lastSeq) + require.Equal(t, uint32(1), m.EventSequence()) + + res := m.HandleEventCommand(&camera.BufferEventCommandRequest{ + Command: camera.BufferEventQuery, + SequenceNumber: 0, + Limit: 10, + }) + require.Len(t, res.Events, 1) + require.Equal(t, byte(camera.BufferEventTypeMotion), res.Events[0].Type) + require.True(t, res.Events[0].Motion.Active) + + m.HandleEventCommand(&camera.BufferEventCommandRequest{ + Command: camera.BufferEventAcknowledge, + SequenceNumber: 1, + }) + res = m.HandleEventCommand(&camera.BufferEventCommandRequest{ + Command: camera.BufferEventQuery, + Limit: 10, + }) + require.Empty(t, res.Events) +} + +func TestRecordingManagerUploadToHTTP(t *testing.T) { + var gotInit atomic.Bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + if len(body) > 0 { + gotInit.Store(true) + } + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + m := NewRecordingManager() + m.Creds.SetPublishingPoint(srv.URL+"/cmaf/", nil) + m.SetRecordingActive(true) + m.SetAudioActive(false) + + codec := &core.Codec{Name: core.CodecH265, ClockRate: 90000} + nal := []byte{0x40, 0x01, 0x0c, 0x01} + payload := append([]byte{0, 0, 0, byte(len(nal))}, nal...) + now := time.Now() + for i := 0; i < 5; i++ { + m.Buffer.Push(Packet{ + Track: 0, + Codec: codec, + Payload: payload, + RTPTime: uint32(i * 3000), + Wall: now.Add(time.Duration(i) * 200 * time.Millisecond), + Key: i == 0, + }) + } + + var seqs []uint32 + var mu sync.Mutex + m.OnEventSeq = func(seq uint32) { + mu.Lock() + seqs = append(seqs, seq) + mu.Unlock() + } + + res := m.HandleUpload(&camera.BufferUploadCommandRequest{ + SessionID: 7, + Command: camera.BufferUploadStartAndStop, + Start: TimeToNTP(now.Add(-time.Second)), + Stop: TimeToNTP(now.Add(2 * time.Second)), + }) + require.NotZero(t, res.ClipID) + + // wait for async publish + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + n := len(seqs) + mu.Unlock() + if n >= 2 { + break + } + time.Sleep(20 * time.Millisecond) + } + mu.Lock() + defer mu.Unlock() + require.GreaterOrEqual(t, len(seqs), 2) // start + stop (and maybe error) + require.True(t, gotInit.Load()) +} + +func TestRecordingManagerActivity(t *testing.T) { + m := NewRecordingManager() + m.HandleActivity(&camera.BufferActivityCommandRequest{ + Activity: camera.BufferActivityShouldRecord, + }) + require.Equal(t, uint32(1), m.EventSequence()) + m.HandleActivity(&camera.BufferActivityCommandRequest{ + Activity: camera.BufferActivityShouldNotRecord, + }) + require.Equal(t, uint32(2), m.EventSequence()) +} + +func TestRecordingActiveFlags(t *testing.T) { + m := NewRecordingManager() + require.False(t, m.RecordingActive()) + require.False(t, m.AudioActive()) + m.SetRecordingActive(true) + m.SetAudioActive(true) + require.True(t, m.RecordingActive()) + require.True(t, m.AudioActive()) + _ = m.EnsureConsumer() + require.NotNil(t, m.Consumer) +} + +func TestMapHTTPError(t *testing.T) { + require.Equal(t, CMAFErrNone, mapHTTPError(nil)) + require.Equal(t, CMAFErrHTTPBadRequest, mapHTTPError(&httpStatusError{code: 400})) + require.Equal(t, CMAFErrHTTPInternalServer, mapHTTPError(&httpStatusError{code: 500})) + require.Equal(t, CMAFErrCannotFindHost, mapHTTPError(errString("no such host"))) + require.Equal(t, CMAFErrTimeout, mapHTTPError(errString("i/o timeout"))) +} diff --git a/pkg/homekit/ringbuffer.go b/pkg/homekit/ringbuffer.go new file mode 100644 index 000000000..b0e585712 --- /dev/null +++ b/pkg/homekit/ringbuffer.go @@ -0,0 +1,220 @@ +package homekit + +import ( + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/pion/rtp" +) + +// Packet is one media sample held in the recording pre-buffer +type Packet struct { + Track byte // 0=video, 1=audio + Codec *core.Codec + Payload []byte + RTPTime uint32 + Wall time.Time + Key bool +} + +// RingBuffer keeps a wall-clock window of recent media for CMAF clip export +type RingBuffer struct { + mu sync.Mutex + packets []Packet + maxAge time.Duration + videoCodec *core.Codec + audioCodec *core.Codec +} + +// NewRingBuffer creates a buffer retaining maxAge of media (e.g. 8s pre-roll) +func NewRingBuffer(maxAge time.Duration) *RingBuffer { + if maxAge <= 0 { + maxAge = 8 * time.Second + } + return &RingBuffer{maxAge: maxAge} +} + +// Push appends a packet and drops samples older than maxAge +func (b *RingBuffer) Push(p Packet) { + b.mu.Lock() + defer b.mu.Unlock() + + if p.Track == 0 { + b.videoCodec = p.Codec + } else { + b.audioCodec = p.Codec + } + + // copy payload so caller may reuse the buffer + p.Payload = append([]byte(nil), p.Payload...) + b.packets = append(b.packets, p) + b.trimLocked(time.Now()) +} + +func (b *RingBuffer) trimLocked(now time.Time) { + cut := now.Add(-b.maxAge) + i := 0 + for i < len(b.packets) && b.packets[i].Wall.Before(cut) { + i++ + } + if i > 0 { + b.packets = append([]Packet(nil), b.packets[i:]...) + } +} + +// Codecs returns the latest video/audio codecs seen +func (b *RingBuffer) Codecs() (video, audio *core.Codec) { + b.mu.Lock() + defer b.mu.Unlock() + return b.videoCodec, b.audioCodec +} + +// Slice returns packets with Wall in [start, end] inclusive, starting at a keyframe when possible +func (b *RingBuffer) Slice(start, end time.Time) []Packet { + b.mu.Lock() + defer b.mu.Unlock() + + if end.IsZero() { + end = time.Now() + } + if start.IsZero() { + start = end.Add(-b.maxAge) + } + + // find first video keyframe at or before start + keyIdx := -1 + for i, p := range b.packets { + if p.Track == 0 && p.Key && !p.Wall.After(start) { + keyIdx = i + } + } + from := 0 + if keyIdx >= 0 { + from = keyIdx + } else { + // fall back to first packet not after start + for i, p := range b.packets { + if !p.Wall.Before(start) { + from = i + break + } + } + } + + var out []Packet + for i := from; i < len(b.packets); i++ { + p := b.packets[i] + if p.Wall.After(end) { + break + } + cp := p + cp.Payload = append([]byte(nil), p.Payload...) + out = append(out, cp) + } + return out +} + +// Len returns buffered packet count +func (b *RingBuffer) Len() int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.packets) +} + +// OldestWall returns the oldest packet time (zero if empty) +func (b *RingBuffer) OldestWall() time.Time { + b.mu.Lock() + defer b.mu.Unlock() + if len(b.packets) == 0 { + return time.Time{} + } + return b.packets[0].Wall +} + +// BufferConsumer is a core.Consumer that fills a RingBuffer from a stream +type BufferConsumer struct { + core.Connection + buf *RingBuffer +} + +// NewBufferConsumer creates a consumer that records into buf +func NewBufferConsumer(buf *RingBuffer) *BufferConsumer { + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecH265}, + {Name: core.CodecH264}, + }, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecOpus, ClockRate: 48000, Channels: 2}, + {Name: core.CodecAAC}, + }, + }, + } + return &BufferConsumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "homekit/buffer", + Protocol: "internal", + Medias: medias, + }, + buf: buf, + } +} + +// AddTrack registers a media track into the ring buffer +func (c *BufferConsumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + codec := track.Codec.Clone() + trackID := byte(0) + if codec.Kind() == core.KindAudio { + trackID = 1 + } + + sender := core.NewSender(media, codec) + sender.Handler = func(packet *rtp.Packet) { + key := false + switch codec.Name { + case core.CodecH264: + key = h264.IsKeyframe(packet.Payload) + case core.CodecH265: + key = h265.IsKeyframe(packet.Payload) + } + c.buf.Push(Packet{ + Track: trackID, + Codec: codec, + Payload: packet.Payload, + RTPTime: packet.Timestamp, + Wall: time.Now(), + Key: key, + }) + c.Send += len(packet.Payload) + } + + switch codec.Name { + case core.CodecH264: + if track.Codec.IsRTP() { + sender.Handler = h264.RTPDepay(track.Codec, sender.Handler) + } else { + sender.Handler = h264.RepairAVCC(track.Codec, sender.Handler) + } + case core.CodecH265: + if track.Codec.IsRTP() { + sender.Handler = h265.RTPDepay(track.Codec, sender.Handler) + } else { + sender.Handler = h265.RepairAVCC(track.Codec, sender.Handler) + } + } + + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} diff --git a/pkg/homekit/webrtc_session.go b/pkg/homekit/webrtc_session.go index d451325dc..234dfb203 100644 --- a/pkg/homekit/webrtc_session.go +++ b/pkg/homekit/webrtc_session.go @@ -1,12 +1,7 @@ package homekit import ( - "crypto/ecdsa" - "crypto/elliptic" "crypto/rand" - "crypto/sha256" - "crypto/x509" - "crypto/x509/pkix" "sync" "time" @@ -29,27 +24,24 @@ type WebRTCSession struct { } // WebRTCManager tracks concurrent HomeKit WebRTC sessions (min 6 required) +// CMAF credentials and content keys live on RecordingManager type WebRTCManager struct { mu sync.Mutex sessions map[string]*WebRTCSession factory PeerConnectionFactory max int - // CMAF client cert state - privKey *ecdsa.PrivateKey - clientCert []byte - caCert []byte - keyID uint64 - keys map[uint64][]byte + // SFrame receive keys for live view sessions + sframeKeys map[uint64][]byte } // NewWebRTCManager creates a session manager func NewWebRTCManager(factory PeerConnectionFactory) *WebRTCManager { return &WebRTCManager{ - sessions: make(map[string]*WebRTCSession), - factory: factory, - max: camera.MinConcurrentWebRTCSessions, - keys: make(map[uint64][]byte), + sessions: make(map[string]*WebRTCSession), + factory: factory, + max: camera.MinConcurrentWebRTCSessions, + sframeKeys: make(map[uint64][]byte), } } @@ -251,10 +243,10 @@ func (m *WebRTCManager) UpdateSession(req *camera.WebRTCUpdateSessionRequest) *c } for _, k := range req.ReceiveKeysToAdd { - m.keys[k.KID] = []byte(k.Key) + m.sframeKeys[k.KID] = []byte(k.Key) } for _, k := range req.ReceiveKIDsToRemove { - delete(m.keys, k.KID) + delete(m.sframeKeys, k.KID) } return &camera.WebRTCUpdateSessionResponse{ @@ -262,68 +254,3 @@ func (m *WebRTCManager) UpdateSession(req *camera.WebRTCUpdateSessionRequest) *c Status: camera.WebRTCStatusSuccess, } } - -// HandleCSR generates a client certificate signing request for CMAF ingest -func (m *WebRTCManager) HandleCSR(nonce []byte) (*camera.CameraClientCSRResponse, error) { - m.mu.Lock() - defer m.mu.Unlock() - - if m.privKey == nil { - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, err - } - m.privKey = key - } - - template := x509.CertificateRequest{ - Subject: pkix.Name{CommonName: "go2rtc-hksv"}, - } - csrDER, err := x509.CreateCertificateRequest(rand.Reader, &template, m.privKey) - if err != nil { - return nil, err - } - - sum := sha256.Sum256(nonce) - sig, err := ecdsa.SignASN1(rand.Reader, m.privKey, sum[:]) - if err != nil { - return nil, err - } - - return &camera.CameraClientCSRResponse{ - CSR: string(csrDER), - NonceSignature: string(sig), - }, nil -} - -// InstallClientCertificate stores the issued CMAF client certificate -func (m *WebRTCManager) InstallClientCertificate(req *camera.CameraClientCertificateRequest) { - m.mu.Lock() - defer m.mu.Unlock() - m.clientCert = []byte(req.ClientCertificate) - m.caCert = []byte(req.CA) -} - -// CertificateNeedsUpdate reports whether a new client cert is required -func (m *WebRTCManager) CertificateNeedsUpdate() bool { - m.mu.Lock() - defer m.mu.Unlock() - return len(m.clientCert) == 0 -} - -// SetKey stores a CMAF content key -func (m *WebRTCManager) SetKey(key []byte, number uint64) uint64 { - m.mu.Lock() - defer m.mu.Unlock() - m.keys[number] = key - m.keyID = number - return number -} - -// KeyID returns the current key identifier -func (m *WebRTCManager) KeyID() uint64 { - m.mu.Lock() - defer m.mu.Unlock() - return m.keyID -} - diff --git a/pkg/homekit/webrtc_session_test.go b/pkg/homekit/webrtc_session_test.go index 2eeaf0d7f..0ca77390f 100644 --- a/pkg/homekit/webrtc_session_test.go +++ b/pkg/homekit/webrtc_session_test.go @@ -1,9 +1,6 @@ package homekit import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" "testing" "github.com/AlexxIT/go2rtc/pkg/hap/camera" @@ -77,32 +74,6 @@ func TestWebRTCManagerNilFactory(t *testing.T) { require.Equal(t, byte(camera.WebRTCSolicitError), res.Status) } -func TestCSRGeneration(t *testing.T) { - m := NewWebRTCManager(nil) - nonce := make([]byte, 32) - _, _ = rand.Read(nonce) - - res, err := m.HandleCSR(nonce) - require.NoError(t, err) - require.NotEmpty(t, res.CSR) - require.NotEmpty(t, res.NonceSignature) - require.True(t, m.CertificateNeedsUpdate()) - - // Install cert - m.InstallClientCertificate(&camera.CameraClientCertificateRequest{ - ClientCertificate: "client-der", - CA: "ca-der", - }) - require.False(t, m.CertificateNeedsUpdate()) -} - -func TestKeyManagement(t *testing.T) { - m := NewWebRTCManager(nil) - id := m.SetKey([]byte("key-data"), 7) - require.Equal(t, uint64(7), id) - require.Equal(t, uint64(7), m.KeyID()) -} - func TestUpdateSessionKeys(t *testing.T) { m := NewWebRTCManager(testFactory) res, err := m.SolicitOffer(false) @@ -132,9 +103,3 @@ func TestProvideAnswerUnknown(t *testing.T) { }) require.Equal(t, byte(camera.WebRTCStatusUnknownSessionIdentifier), res.Status) } - -func TestECDSAKeyGen(t *testing.T) { - // Sanity: P-256 works on this platform (used by CSR) - _, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) -} From a75905ae35255371a474e97d311b7b8818152756 Mon Sep 17 00:00:00 2001 From: Radu Ursache Date: Fri, 10 Jul 2026 18:11:11 +0300 Subject: [PATCH 7/8] Harden HKSV recording tests and fix tlv8 separator Expand unit coverage for buffer trim, concurrent push, clip encrypt, upload cancel/stop, empty buffer, missing publishing point, and CMAF error mapping. Run clean under -race. Emit 0x00 TLV item separators to match real HomeKit accessories and golden dumps; keep unmarshaling 0xFF for interoperability. Fix buffer Stop so it does not replace in-flight sessions --- pkg/hap/tlv8/tlv8.go | 7 +- pkg/hap/tlv8/tlv8_test.go | 39 +++- pkg/homekit/recording.go | 125 ++++++++--- pkg/homekit/recording_test.go | 390 ++++++++++++++++++++++++++++++++++ 4 files changed, 513 insertions(+), 48 deletions(-) diff --git a/pkg/hap/tlv8/tlv8.go b/pkg/hap/tlv8/tlv8.go index bd6572c99..1267ba0d9 100644 --- a/pkg/hap/tlv8/tlv8.go +++ b/pkg/hap/tlv8/tlv8.go @@ -55,9 +55,10 @@ func Marshal(v any) ([]byte, error) { return nil, errors.New("tlv8: not implemented: " + kind.String()) } -// separator the most confusing meaning in the documentation. -// It can have a value of 0x00 or 0xFF or even 0x05. -const separator = 0xFF +// separator between repeated TLV items. Real HomeKit accessories (and the +// golden dumps in accessory_test) use type 0x00 with length 0. Unmarshal still +// accepts 0x00, 0xFF and other values for interoperability. +const separator = 0x00 func appendSlice(b []byte, value reflect.Value) ([]byte, error) { for i := 0; i < value.Len(); i++ { diff --git a/pkg/hap/tlv8/tlv8_test.go b/pkg/hap/tlv8/tlv8_test.go index bb44c9813..ab94beb65 100644 --- a/pkg/hap/tlv8/tlv8_test.go +++ b/pkg/hap/tlv8/tlv8_test.go @@ -118,19 +118,34 @@ func TestSlice1(t *testing.T) { } `tlv8:"3"` } - s := `030b010280070202380403011e ff00 030b010200050202d00203011e` - b1, err := hex.DecodeString(strings.ReplaceAll(s, " ", "")) + // Some dumps use 0xFF separators; unmarshal accepts them + sFF := `030b010280070202380403011e ff00 030b010200050202d00203011e` + bFF, err := hex.DecodeString(strings.ReplaceAll(sFF, " ", "")) require.NoError(t, err) - err = Unmarshal(b1, &v) + err = Unmarshal(bFF, &v) require.NoError(t, err) - require.Len(t, v.VideoAttrs, 2) + // Marshal emits 0x00 separators to match real HomeKit accessories + s00 := `030b010280070202380403011e 0000 030b010200050202d00203011e` + b00, err := hex.DecodeString(strings.ReplaceAll(s00, " ", "")) + require.NoError(t, err) + b2, err := Marshal(v) require.NoError(t, err) + require.Equal(t, b00, b2) - require.Equal(t, b1, b2) + // Round-trip through 0x00 form + var v2 struct { + VideoAttrs []struct { + Width uint16 `tlv8:"1"` + Height uint16 `tlv8:"2"` + Framerate uint8 `tlv8:"3"` + } `tlv8:"3"` + } + require.NoError(t, Unmarshal(b00, &v2)) + require.Len(t, v2.VideoAttrs, 2) } func TestSlice2(t *testing.T) { @@ -140,17 +155,19 @@ func TestSlice2(t *testing.T) { Framerate uint8 `tlv8:"3"` } - s := `010280070202380403011e ff00 010200050202d00203011e` - b1, err := hex.DecodeString(strings.ReplaceAll(s, " ", "")) + sFF := `010280070202380403011e ff00 010200050202d00203011e` + bFF, err := hex.DecodeString(strings.ReplaceAll(sFF, " ", "")) require.NoError(t, err) - err = Unmarshal(b1, &v) + err = Unmarshal(bFF, &v) require.NoError(t, err) - require.Len(t, v, 2) - b2, err := Marshal(v) + s00 := `010280070202380403011e 0000 010200050202d00203011e` + b00, err := hex.DecodeString(strings.ReplaceAll(s00, " ", "")) require.NoError(t, err) - require.Equal(t, b1, b2) + b2, err := Marshal(v) + require.NoError(t, err) + require.Equal(t, b00, b2) } diff --git a/pkg/homekit/recording.go b/pkg/homekit/recording.go index a0ad5c3bf..316c6616d 100644 --- a/pkg/homekit/recording.go +++ b/pkg/homekit/recording.go @@ -89,14 +89,15 @@ func (q *EventQueue) Acknowledge(seq uint64) { // UploadSession tracks an in-flight buffer upload type UploadSession struct { - ID uint64 - ClipID uint64 - Command byte - Start time.Time - Stop time.Time - StopAct byte - Active bool - Cancel chan struct{} + ID uint64 + ClipID uint64 + Command byte + Start time.Time + Stop time.Time + StopAct byte + Active bool + Cancel chan struct{} + stopEmitted bool } // RecordingManager owns pre-buffer, CMAF publish, credentials and events @@ -178,9 +179,24 @@ func (m *RecordingManager) HandleActivity(req *camera.BufferActivityCommandReque // HandleUpload processes Buffer Upload Command and returns Clip ID func (m *RecordingManager) HandleUpload(req *camera.BufferUploadCommandRequest) *camera.BufferUploadCommandResponse { + // Stop must not replace an in-flight session (would drop its Cancel channel) + if req.Command == camera.BufferUploadStop { + clipID := m.stopUpload(req.SessionID, req.StopAction) + return &camera.BufferUploadCommandResponse{ClipID: clipID} + } + m.mu.Lock() clipID := m.nextClip m.nextClip++ + // cancel any previous session with the same id before replacing + if prev, ok := m.sessions[req.SessionID]; ok && prev.Active { + select { + case <-prev.Cancel: + default: + close(prev.Cancel) + } + prev.Active = false + } sess := &UploadSession{ ID: req.SessionID, ClipID: clipID, @@ -195,45 +211,59 @@ func (m *RecordingManager) HandleUpload(req *camera.BufferUploadCommandRequest) m.mu.Unlock() switch req.Command { - case camera.BufferUploadStart: - go m.runUpload(sess) - case camera.BufferUploadStartAndStop: + case camera.BufferUploadStart, camera.BufferUploadStartAndStop: go m.runUpload(sess) - case camera.BufferUploadStop: - m.stopUpload(req.SessionID, req.StopAction) } return &camera.BufferUploadCommandResponse{ClipID: clipID} } -func (m *RecordingManager) stopUpload(sessionID uint64, action byte) { +// stopUpload cancels an in-flight upload. Returns the clip id (0 if unknown) +func (m *RecordingManager) stopUpload(sessionID uint64, action byte) uint64 { m.mu.Lock() sess, ok := m.sessions[sessionID] - if ok && sess.Active { - sess.StopAct = action + if !ok { + m.mu.Unlock() + return 0 + } + sess.StopAct = action + wasActive := sess.Active + if wasActive { select { case <-sess.Cancel: default: close(sess.Cancel) } - sess.Active = false } + // Publish already finished for a Start session: emit stop on finalize here + needStop := !wasActive && action == camera.BufferStopActionFinalize && !sess.stopEmitted + if needStop { + sess.stopEmitted = true + } + clipID := sess.ClipID m.mu.Unlock() - if ok && action == camera.BufferStopActionFinalize { + if needStop { seq := m.Events.Push(Event{ Type: camera.BufferEventTypeCMAFSessionStop, Session: sessionID, }) m.fireSeq(seq) } + return clipID } func (m *RecordingManager) runUpload(sess *UploadSession) { - select { - case <-sess.Cancel: + defer func() { + m.mu.Lock() + if s, ok := m.sessions[sess.ID]; ok { + s.Active = false + } + m.mu.Unlock() + }() + + if m.canceled(sess) { return - default: } seq := m.Events.Push(Event{ @@ -242,7 +272,15 @@ func (m *RecordingManager) runUpload(sess *UploadSession) { }) m.fireSeq(seq) + if m.canceled(sess) { + m.emitSessionStop(sess) + return + } + err := m.publishSession(sess) + if m.canceled(sess) && err == nil { + err = &cmafPublishError{code: CMAFErrCanceled, err: errString("homekit: upload canceled")} + } if err != nil { code := mapPublishError(err) seq = m.Events.Push(Event{ @@ -253,21 +291,40 @@ func (m *RecordingManager) runUpload(sess *UploadSession) { m.fireSeq(seq) } - finalize := sess.Command == camera.BufferUploadStartAndStop || - sess.StopAct == camera.BufferStopActionFinalize - if finalize { - seq = m.Events.Push(Event{ - Type: camera.BufferEventTypeCMAFSessionStop, - Session: sess.ID, - }) - m.fireSeq(seq) + m.mu.Lock() + stopAct := sess.StopAct + cmd := sess.Command + m.mu.Unlock() + + // StartAndStop always finalizes; Start finalizes only after Stop(finalize) + if cmd == camera.BufferUploadStartAndStop || stopAct == camera.BufferStopActionFinalize { + m.emitSessionStop(sess) } +} +func (m *RecordingManager) emitSessionStop(sess *UploadSession) { m.mu.Lock() - if s, ok := m.sessions[sess.ID]; ok { - s.Active = false + if sess.stopEmitted { + m.mu.Unlock() + return } + sess.stopEmitted = true m.mu.Unlock() + + seq := m.Events.Push(Event{ + Type: camera.BufferEventTypeCMAFSessionStop, + Session: sess.ID, + }) + m.fireSeq(seq) +} + +func (m *RecordingManager) canceled(sess *UploadSession) bool { + select { + case <-sess.Cancel: + return true + default: + return false + } } func (m *RecordingManager) publishSession(sess *UploadSession) error { @@ -303,19 +360,19 @@ func (m *RecordingManager) publishSession(sess *UploadSession) error { // publishing point receives a standards-compliant track (mTLS protects transport) clip, err := BuildClip(packets, nil) if err != nil { - return err + return &cmafPublishError{code: CMAFErrMP4Error, err: err} } url := m.Creds.PublishingPoint() if url == "" { - return errString("homekit: publishing point not set") + return &cmafPublishError{code: CMAFErrInvalidState, err: errString("homekit: publishing point not set")} } cfg, tlsErr := m.Creds.TLSConfig() // plain HTTP is allowed without client cert (local ingest tests) isHTTP := len(url) >= 7 && url[:7] == "http://" if tlsErr != nil && !isHTTP { - return tlsErr + return &cmafPublishError{code: CMAFErrCertConnectionFailure, err: tlsErr} } if isHTTP { cfg = nil diff --git a/pkg/homekit/recording_test.go b/pkg/homekit/recording_test.go index 6e4361ac7..258e640e6 100644 --- a/pkg/homekit/recording_test.go +++ b/pkg/homekit/recording_test.go @@ -17,6 +17,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/hap/camera" + pion "github.com/pion/webrtc/v4" "github.com/stretchr/testify/require" ) @@ -352,3 +353,392 @@ func TestMapHTTPError(t *testing.T) { require.Equal(t, CMAFErrCannotFindHost, mapHTTPError(errString("no such host"))) require.Equal(t, CMAFErrTimeout, mapHTTPError(errString("i/o timeout"))) } + +func TestRingBufferTrimAndEmpty(t *testing.T) { + b := NewRingBuffer(200 * time.Millisecond) + codec := &core.Codec{Name: core.CodecH264, ClockRate: 90000} + old := time.Now().Add(-time.Second) + b.Push(Packet{Track: 0, Codec: codec, Payload: []byte{1}, Wall: old, Key: true}) + // force trim by pushing fresh + b.Push(Packet{Track: 0, Codec: codec, Payload: []byte{2}, Wall: time.Now(), Key: true}) + require.Equal(t, 1, b.Len()) + require.True(t, b.OldestWall().After(old)) + + empty := NewRingBuffer(time.Second) + require.Equal(t, 0, empty.Len()) + require.True(t, empty.OldestWall().IsZero()) + require.Empty(t, empty.Slice(time.Time{}, time.Time{})) +} + +func TestRingBufferPayloadCopy(t *testing.T) { + b := NewRingBuffer(time.Second) + codec := &core.Codec{Name: core.CodecH265, ClockRate: 90000} + payload := []byte{0x40, 0x01} + b.Push(Packet{Track: 0, Codec: codec, Payload: payload, Wall: time.Now(), Key: true}) + payload[0] = 0xFF // mutate caller buffer + slice := b.Slice(time.Now().Add(-time.Second), time.Now().Add(time.Second)) + require.Len(t, slice, 1) + require.Equal(t, byte(0x40), slice[0].Payload[0]) +} + +func TestRingBufferConcurrentPush(t *testing.T) { + b := NewRingBuffer(2 * time.Second) + codec := &core.Codec{Name: core.CodecH265, ClockRate: 90000} + var wg sync.WaitGroup + for g := 0; g < 8; g++ { + wg.Add(1) + go func(g int) { + defer wg.Done() + for i := 0; i < 50; i++ { + b.Push(Packet{ + Track: 0, + Codec: codec, + Payload: []byte{byte(g), byte(i)}, + Wall: time.Now(), + Key: i%10 == 0, + }) + } + }(g) + } + wg.Wait() + require.Greater(t, b.Len(), 0) +} + +func TestBuildClipNoVideo(t *testing.T) { + audio := &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2} + _, err := BuildClip([]Packet{{ + Track: 1, Codec: audio, Payload: []byte{1, 2, 3}, RTPTime: 0, Wall: time.Now(), + }}, nil) + require.Error(t, err) +} + +func TestBuildClipWithAudioAndKey(t *testing.T) { + vcodec := &core.Codec{Name: core.CodecH265, ClockRate: 90000} + acodec := &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2} + nal := []byte{0x40, 0x01, 0x0c, 0x01} + vpay := append([]byte{0, 0, 0, byte(len(nal))}, nal...) + packets := []Packet{ + {Track: 0, Codec: vcodec, Payload: vpay, RTPTime: 0, Wall: time.Now(), Key: true}, + {Track: 1, Codec: acodec, Payload: []byte{0x01, 0x02}, RTPTime: 0, Wall: time.Now()}, + {Track: 0, Codec: vcodec, Payload: vpay, RTPTime: 3000, Wall: time.Now().Add(40 * time.Millisecond)}, + } + key := []byte("0123456789abcdef") + clip, err := BuildClip(packets, key) + require.NoError(t, err) + require.NotEmpty(t, clip.Init) + require.Len(t, clip.Fragments, 3) + // encrypted payload should differ from clear + clear, err := BuildClip(packets, nil) + require.NoError(t, err) + require.NotEqual(t, clear.Fragments[0], clip.Fragments[0]) +} + +func TestCredentialsNeedsCertForHTTPS(t *testing.T) { + c := NewCredentials() + _, err := c.TLSConfig() + require.Error(t, err) + require.Contains(t, err.Error(), "certificate") + + // CSR twice reuses key + n1 := make([]byte, 32) + n2 := make([]byte, 32) + _, _ = rand.Read(n1) + _, _ = rand.Read(n2) + csr1, _, err := c.HandleCSR(n1) + require.NoError(t, err) + csr2, _, err := c.HandleCSR(n2) + require.NoError(t, err) + // both valid CSRs + _, err = x509.ParseCertificateRequest(csr1) + require.NoError(t, err) + _, err = x509.ParseCertificateRequest(csr2) + require.NoError(t, err) +} + +func TestCMAFClientInvalidClip(t *testing.T) { + c := NewCMAFClient("http://127.0.0.1:9/", nil) + code, err := c.PublishClip(1, nil) + require.Error(t, err) + require.Equal(t, CMAFErrMP4Error, code) + + code, err = c.PublishClip(1, &Clip{}) + require.Error(t, err) + require.Equal(t, CMAFErrMP4Error, code) + + c2 := NewCMAFClient("", nil) + code, err = c2.PublishClip(1, &Clip{Init: []byte("x")}) + require.Error(t, err) + require.Equal(t, CMAFErrInvalidState, code) +} + +func TestCMAFClientConnectionRefused(t *testing.T) { + // nothing listening + c := NewCMAFClient("http://127.0.0.1:1/", nil) + code, err := c.PublishClip(1, &Clip{Init: []byte("init"), Fragments: [][]byte{[]byte("f")}}) + require.Error(t, err) + require.NotEqual(t, CMAFErrNone, code) +} + +func TestRecordingManagerMissingPublishingPoint(t *testing.T) { + m := NewRecordingManager() + codec := &core.Codec{Name: core.CodecH265, ClockRate: 90000} + nal := []byte{0x40, 0x01} + payload := append([]byte{0, 0, 0, byte(len(nal))}, nal...) + m.Buffer.Push(Packet{Track: 0, Codec: codec, Payload: payload, Wall: time.Now(), Key: true}) + + var types []byte + var mu sync.Mutex + m.OnEventSeq = func(seq uint32) { + mu.Lock() + defer mu.Unlock() + // pull latest events + for _, ev := range m.Events.Query(0, 32) { + _ = seq + types = append(types, ev.Type) + } + } + + res := m.HandleUpload(&camera.BufferUploadCommandRequest{ + SessionID: 1, + Command: camera.BufferUploadStartAndStop, + }) + require.Equal(t, uint64(1), res.ClipID) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + hasErr := false + for _, typ := range types { + if typ == camera.BufferEventTypeCMAFError { + hasErr = true + } + } + mu.Unlock() + if hasErr { + break + } + time.Sleep(10 * time.Millisecond) + } + + evs := m.Events.Query(0, 32) + var sawStart, sawErr, sawStop bool + for _, ev := range evs { + switch ev.Type { + case camera.BufferEventTypeCMAFSessionStart: + sawStart = true + case camera.BufferEventTypeCMAFError: + sawErr = true + require.NotZero(t, ev.CMAFErr) + case camera.BufferEventTypeCMAFSessionStop: + sawStop = true + } + } + require.True(t, sawStart) + require.True(t, sawErr) + require.True(t, sawStop) +} + +func TestRecordingManagerEmptyBufferError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + m := NewRecordingManager() + m.Creds.SetPublishingPoint(srv.URL+"/", nil) + // no packets in buffer + res := m.HandleUpload(&camera.BufferUploadCommandRequest{ + SessionID: 3, + Command: camera.BufferUploadStartAndStop, + }) + require.NotZero(t, res.ClipID) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + evs := m.Events.Query(0, 32) + for _, ev := range evs { + if ev.Type == camera.BufferEventTypeCMAFError { + require.Equal(t, byte(CMAFErrMP4Error), ev.CMAFErr) + return + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("expected CMAF error for empty buffer") +} + +func TestRecordingManagerStopFinalize(t *testing.T) { + // slow ingest so we can stop mid-flight + started := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-started: + default: + close(started) + } + time.Sleep(50 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + m := NewRecordingManager() + m.Creds.SetPublishingPoint(srv.URL+"/", nil) + codec := &core.Codec{Name: core.CodecH265, ClockRate: 90000} + nal := []byte{0x40, 0x01, 0x0c} + payload := append([]byte{0, 0, 0, byte(len(nal))}, nal...) + now := time.Now() + for i := 0; i < 3; i++ { + m.Buffer.Push(Packet{ + Track: 0, Codec: codec, Payload: payload, + RTPTime: uint32(i * 3000), Wall: now.Add(time.Duration(i) * 100 * time.Millisecond), Key: i == 0, + }) + } + + start := m.HandleUpload(&camera.BufferUploadCommandRequest{ + SessionID: 99, + Command: camera.BufferUploadStart, + Start: TimeToNTP(now.Add(-time.Second)), + Stop: TimeToNTP(now.Add(time.Second)), + }) + require.Equal(t, uint64(1), start.ClipID) + + <-started + stop := m.HandleUpload(&camera.BufferUploadCommandRequest{ + SessionID: 99, + Command: camera.BufferUploadStop, + StopAction: camera.BufferStopActionFinalize, + }) + require.Equal(t, uint64(1), stop.ClipID) + + // wait for lifecycle + deadline := time.Now().Add(2 * time.Second) + var sawStart, sawStop bool + for time.Now().Before(deadline) { + sawStart, sawStop = false, false + for _, ev := range m.Events.Query(0, 32) { + if ev.Type == camera.BufferEventTypeCMAFSessionStart && ev.Session == 99 { + sawStart = true + } + if ev.Type == camera.BufferEventTypeCMAFSessionStop && ev.Session == 99 { + sawStop = true + } + } + if sawStart && sawStop { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("lifecycle incomplete start=%v stop=%v events=%+v", sawStart, sawStop, m.Events.Query(0, 32)) +} + +func TestRecordingManagerStopUnknownSession(t *testing.T) { + m := NewRecordingManager() + res := m.HandleUpload(&camera.BufferUploadCommandRequest{ + SessionID: 404, + Command: camera.BufferUploadStop, + StopAction: camera.BufferStopActionFinalize, + }) + require.Equal(t, uint64(0), res.ClipID) +} + +func TestRecordingManagerEventTypesRoundTrip(t *testing.T) { + m := NewRecordingManager() + m.Events.Push(Event{Type: camera.BufferEventTypeCMAFSessionStart, Session: 5}) + m.Events.Push(Event{Type: camera.BufferEventTypeCMAFSessionStop, Session: 5}) + m.Events.Push(Event{Type: camera.BufferEventTypeMotion, Motion: true}) + m.Events.Push(Event{Type: camera.BufferEventTypeCMAFError, Session: 5, CMAFErr: CMAFErrTimeout}) + + res := m.HandleEventCommand(&camera.BufferEventCommandRequest{ + Command: camera.BufferEventQuery, + Limit: 10, + }) + require.Len(t, res.Events, 4) + require.Equal(t, uint64(5), res.Events[0].CMAFSessionStart.CMAFSessionID) + require.Equal(t, uint64(5), res.Events[1].CMAFSessionStop.CMAFSessionID) + require.True(t, res.Events[2].Motion.Active) + require.Equal(t, byte(CMAFErrTimeout), res.Events[3].CMAFError.CMAFError) +} + +func TestRecordingManagerAudioFilter(t *testing.T) { + var paths []string + var mu sync.Mutex + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + paths = append(paths, r.URL.Path) + mu.Unlock() + _, _ = io.ReadAll(r.Body) + _ = r.Body.Close() + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + m := NewRecordingManager() + m.Creds.SetPublishingPoint(srv.URL+"/", nil) + m.SetAudioActive(false) + vcodec := &core.Codec{Name: core.CodecH265, ClockRate: 90000} + acodec := &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2} + nal := []byte{0x40, 0x01} + vpay := append([]byte{0, 0, 0, byte(len(nal))}, nal...) + now := time.Now() + m.Buffer.Push(Packet{Track: 0, Codec: vcodec, Payload: vpay, Wall: now, Key: true}) + m.Buffer.Push(Packet{Track: 1, Codec: acodec, Payload: []byte{9}, Wall: now}) + + _ = m.HandleUpload(&camera.BufferUploadCommandRequest{ + SessionID: 11, + Command: camera.BufferUploadStartAndStop, + Start: TimeToNTP(now.Add(-time.Second)), + Stop: TimeToNTP(now.Add(time.Second)), + }) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if m.Events.Sequence() >= 2 { + break + } + time.Sleep(10 * time.Millisecond) + } + // publish should have succeeded (video only) + evs := m.Events.Query(0, 32) + for _, ev := range evs { + require.NotEqual(t, byte(camera.BufferEventTypeCMAFError), ev.Type) + } +} + +func TestMapPublishError(t *testing.T) { + require.Equal(t, CMAFErrNone, mapPublishError(nil)) + require.Equal(t, CMAFErrHTTPNotFound, mapPublishError(&cmafPublishError{code: CMAFErrHTTPNotFound, err: errString("x")})) + require.Equal(t, CMAFErrUnknown, mapPublishError(errString("something else"))) +} + +func TestEventQueueConcurrent(t *testing.T) { + q := NewEventQueue(100) + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < 20; j++ { + q.Push(Event{Type: camera.BufferEventTypeMotion, Motion: j%2 == 0}) + } + }(i) + } + wg.Wait() + require.Equal(t, uint64(400), q.Sequence()) + // max retained + require.LessOrEqual(t, len(q.Query(0, 1000)), 100) +} + +func TestWebRTCReofferUnknown(t *testing.T) { + m := NewWebRTCManager(testFactory) + res := m.Reoffer(&camera.WebRTCReofferRequest{SessionIdentifier: "nope", SDPOffer: "v=0\r\n"}) + require.Equal(t, byte(camera.WebRTCStatusUnknownSessionIdentifier), res.Status) +} + +func TestWebRTCSolicitFactoryError(t *testing.T) { + m := NewWebRTCManager(func() (*pion.PeerConnection, error) { + return nil, errString("boom") + }) + res, err := m.SolicitOffer(false) + require.NoError(t, err) + require.Equal(t, byte(camera.WebRTCSolicitError), res.Status) +} From 88c8335c309bbeac9927816038046709ef2d4c6d Mon Sep 17 00:00:00 2001 From: Radu Ursache Date: Fri, 10 Jul 2026 18:15:30 +0300 Subject: [PATCH 8/8] Add HAP server handler tests and fix write-response races Cover CSR/cert, keys, publishing point, buffer activity/upload/events, WebRTC solicit/privacy/end, RTP control, invalid TLV, and concurrent access through SetCharacteristic/GetCharacteristic without a real hub Serialize wrValues and related characteristic updates under wrMu so handler paths stay race-free. Accept empty TLV on unmarshal for valid empty write-responses --- internal/homekit/server.go | 22 +- internal/homekit/server_hksv_test.go | 572 +++++++++++++++++++++++++++ pkg/hap/tlv8/tlv8.go | 3 +- pkg/hap/tlv8/tlv8_test.go | 11 + 4 files changed, 603 insertions(+), 5 deletions(-) create mode 100644 internal/homekit/server_hksv_test.go diff --git a/internal/homekit/server.go b/internal/homekit/server.go index 40cde85d1..10d78ceb1 100644 --- a/internal/homekit/server.go +++ b/internal/homekit/server.go @@ -50,6 +50,7 @@ type server struct { webrtc *homekit.WebRTCManager recording *homekit.RecordingManager // last write-response values keyed by characteristic IID + wrMu sync.Mutex wrValues map[uint64]any } @@ -248,11 +249,14 @@ func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any { } // Prefer last write-response payload when present + s.wrMu.Lock() if s.wrValues != nil { if v, ok := s.wrValues[iid]; ok { + s.wrMu.Unlock() return v } } + s.wrMu.Unlock() switch char.Type { case camera.TypeSetupEndpoints: @@ -434,16 +438,21 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a } func (s *server) setWriteResponse(iid uint64, v any) { - if s.wrValues == nil { - s.wrValues = map[uint64]any{} - } encoded, err := tlv8.MarshalBase64(v) if err != nil { return } + s.wrMu.Lock() + if s.wrValues == nil { + s.wrValues = map[uint64]any{} + } s.wrValues[iid] = encoded + s.wrMu.Unlock() if char := s.accessory.GetCharacterByID(iid); char != nil { + // char.Value is also read by HAP status/event paths; serialize with wrMu + s.wrMu.Lock() char.Value = encoded + s.wrMu.Unlock() } } @@ -776,7 +785,9 @@ func (s *server) notifyEventSequence(seq uint32) { return } if char := s.accessory.GetCharacter(camera.TypeBufferEventSequenceNumber); char != nil { + s.wrMu.Lock() char.Value = seq + s.wrMu.Unlock() _ = char.NotifyListeners(nil) } } @@ -809,7 +820,10 @@ func (s *server) updateWebRTCSessionCount() { return } if char := s.accessory.GetCharacter(camera.TypeWebRTCNumberOfActiveSessions); char != nil { - char.Value = s.webrtc.ActiveCount() + n := s.webrtc.ActiveCount() + s.wrMu.Lock() + char.Value = n + s.wrMu.Unlock() _ = char.NotifyListeners(nil) } } diff --git a/internal/homekit/server_hksv_test.go b/internal/homekit/server_hksv_test.go new file mode 100644 index 000000000..76519ba81 --- /dev/null +++ b/internal/homekit/server_hksv_test.go @@ -0,0 +1,572 @@ +package homekit + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "io" + "math/big" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/hap/camera" + "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" + pkghk "github.com/AlexxIT/go2rtc/pkg/homekit" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + pion "github.com/pion/webrtc/v4" + "github.com/stretchr/testify/require" +) + +func testPCFactory() (*pion.PeerConnection, error) { + api, err := webrtc.NewAPI() + if err != nil { + return nil, err + } + return api.NewPeerConnection(pion.Configuration{}) +} + +func newTestHKSVServer(t *testing.T) *server { + t.Helper() + acc := camera.NewHKSVAccessory("AlexxIT", "go2rtc", "test-cam", "SN", "1.0", "seed-test") + s := &server{ + stream: "test-cam", + accessory: acc, + hksv: true, + webrtc: pkghk.NewWebRTCManager(testPCFactory), + recording: pkghk.NewRecordingManager(), + wrValues: map[uint64]any{}, + } + s.recording.OnEventSeq = s.notifyEventSequence + return s +} + +func (s *server) mustIID(t *testing.T, typ string) uint64 { + t.Helper() + ch := s.accessory.GetCharacter(typ) + require.NotNil(t, ch, "missing characteristic type %s", typ) + require.NotZero(t, ch.IID) + return ch.IID +} + +func mustTLV8B64(t *testing.T, v any) string { + t.Helper() + s, err := tlv8.MarshalBase64(v) + require.NoError(t, err) + return s +} + +func TestServerCSRAndCertificateFlow(t *testing.T) { + s := newTestHKSVServer(t) + csrIID := s.mustIID(t, camera.TypeCameraClientCSR) + statusIID := s.mustIID(t, camera.TypeCameraClientCertificateStatus) + certIID := s.mustIID(t, camera.TypeCameraClientCertificate) + + // Before cert: NeedsUpdate true + raw := s.GetCharacteristic(nil, 1, statusIID) + require.NotNil(t, raw) + var status camera.CameraClientCertificateStatusValue + require.NoError(t, tlv8.UnmarshalBase64(raw, &status)) + require.True(t, status.NeedsUpdate) + + nonce := make([]byte, 32) + _, err := rand.Read(nonce) + require.NoError(t, err) + + s.SetCharacteristic(nil, 1, csrIID, mustTLV8B64(t, camera.CameraClientCSRRequest{ + Nonce: string(nonce), + })) + + // write-response stored + wr := s.GetCharacteristic(nil, 1, csrIID) + require.NotNil(t, wr) + var csrRes camera.CameraClientCSRResponse + require.NoError(t, tlv8.UnmarshalBase64(wr, &csrRes)) + require.NotEmpty(t, csrRes.CSR) + require.NotEmpty(t, csrRes.NonceSignature) + + csr, err := x509.ParseCertificateRequest([]byte(csrRes.CSR)) + require.NoError(t, err) + require.NoError(t, csr.CheckSignature()) + + // issue leaf matching CSR pubkey + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "go2rtc-hksv"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + leafDER, err := x509.CreateCertificate(rand.Reader, template, template, csr.PublicKey, caKey) + require.NoError(t, err) + + s.SetCharacteristic(nil, 1, certIID, mustTLV8B64(t, camera.CameraClientCertificateRequest{ + ClientCertificate: string(leafDER), + CA: string(leafDER), + })) + require.False(t, s.recording.Creds.NeedsUpdate()) + + // status characteristic should reflect installed cert + // clear wr cache path by reading status type via GetCharacter path + // GetCharacteristic for status rebuilds from creds + raw = s.GetCharacteristic(nil, 1, statusIID) + // may hit wrValues if we never wrote status; force by type switch + // TypeCameraClientCertificateStatus is handled specially + require.NoError(t, tlv8.UnmarshalBase64(raw, &status)) + require.False(t, status.NeedsUpdate) +} + +func TestServerKeyAndPublishingPoint(t *testing.T) { + s := newTestHKSVServer(t) + keyIID := s.mustIID(t, camera.TypeCameraKey) + keyIDIID := s.mustIID(t, camera.TypeCameraKeyID) + pubIID := s.mustIID(t, camera.TypeCameraRecordingPublishingPoint) + + s.SetCharacteristic(nil, 1, keyIID, mustTLV8B64(t, camera.CameraKeyValue{ + Key: "0123456789abcdef", + KeyNumber: 7, + })) + id, key := s.recording.Creds.CurrentKey() + require.Equal(t, uint64(7), id) + require.Equal(t, []byte("0123456789abcdef"), key) + + // KeyID characteristic updated + ch := s.accessory.GetCharacterByID(keyIDIID) + require.NotNil(t, ch) + var kid camera.CameraKeyIDValue + require.NoError(t, ch.ReadTLV8(&kid)) + require.Equal(t, uint64(7), kid.KeyID) + + s.SetCharacteristic(nil, 1, pubIID, mustTLV8B64(t, camera.CameraRecordingPublishingPointValue{ + URL: "https://example.apple.com/cmaf/ingest/", + ServerCACertificates: []camera.Certificate{ + {Certificate: "ca-der-bytes"}, + }, + })) + require.Equal(t, "https://example.apple.com/cmaf/ingest/", s.recording.Creds.PublishingPoint()) +} + +func TestServerRecordingActiveFlags(t *testing.T) { + s := newTestHKSVServer(t) + // Recording Management Active is the second Active char; set by IID from service + recSvc := s.accessory.GetService(camera.TypeCameraRecordingManagement) + require.NotNil(t, recSvc) + var activeIID, audioIID uint64 + for _, ch := range recSvc.Characters { + switch ch.Type { + case camera.TypeActive: + activeIID = ch.IID + case camera.TypeRecordingAudioActive: + audioIID = ch.IID + } + } + require.NotZero(t, activeIID) + require.NotZero(t, audioIID) + + s.SetCharacteristic(nil, 1, activeIID, float64(1)) + require.True(t, s.recording.RecordingActive()) + require.Equal(t, uint8(1), s.accessory.GetCharacterByID(activeIID).Value) + + s.SetCharacteristic(nil, 1, audioIID, float64(1)) + require.True(t, s.recording.AudioActive()) + + s.SetCharacteristic(nil, 1, activeIID, float64(0)) + require.False(t, s.recording.RecordingActive()) +} + +func TestServerBufferActivityAndEvents(t *testing.T) { + s := newTestHKSVServer(t) + actIID := s.mustIID(t, camera.TypeBufferActivityCommand) + evtIID := s.mustIID(t, camera.TypeBufferEventCommand) + seqIID := s.mustIID(t, camera.TypeBufferEventSequenceNumber) + + require.Equal(t, uint32(0), s.GetCharacteristic(nil, 1, seqIID)) + + s.SetCharacteristic(nil, 1, actIID, mustTLV8B64(t, camera.BufferActivityCommandRequest{ + Activity: camera.BufferActivityShouldRecord, + })) + require.Equal(t, uint32(1), s.GetCharacteristic(nil, 1, seqIID)) + + // sequence characteristic also updated via notify + ch := s.accessory.GetCharacterByID(seqIID) + require.Equal(t, uint32(1), ch.Value) + + s.SetCharacteristic(nil, 1, evtIID, mustTLV8B64(t, camera.BufferEventCommandRequest{ + Command: camera.BufferEventQuery, + SequenceNumber: 0, + Limit: 10, + })) + wr := s.GetCharacteristic(nil, 1, evtIID) + var res camera.BufferEventCommandResponse + require.NoError(t, tlv8.UnmarshalBase64(wr, &res)) + require.Len(t, res.Events, 1) + require.Equal(t, byte(camera.BufferEventTypeMotion), res.Events[0].Type) + require.True(t, res.Events[0].Motion.Active) + + s.SetCharacteristic(nil, 1, evtIID, mustTLV8B64(t, camera.BufferEventCommandRequest{ + Command: camera.BufferEventAcknowledge, + SequenceNumber: 1, + })) + s.SetCharacteristic(nil, 1, evtIID, mustTLV8B64(t, camera.BufferEventCommandRequest{ + Command: camera.BufferEventQuery, + Limit: 10, + })) + wr = s.GetCharacteristic(nil, 1, evtIID) + var res2 camera.BufferEventCommandResponse + require.NoError(t, tlv8.UnmarshalBase64(wr, &res2)) + require.Empty(t, res2.Events) +} + +func TestServerBufferUploadEndToEnd(t *testing.T) { + var posts atomic.Int32 + srvHTTP := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + posts.Add(1) + _, _ = io.ReadAll(r.Body) + _ = r.Body.Close() + w.WriteHeader(http.StatusOK) + })) + defer srvHTTP.Close() + + s := newTestHKSVServer(t) + pubIID := s.mustIID(t, camera.TypeCameraRecordingPublishingPoint) + upIID := s.mustIID(t, camera.TypeBufferUploadCommand) + evtIID := s.mustIID(t, camera.TypeBufferEventCommand) + + s.SetCharacteristic(nil, 1, pubIID, mustTLV8B64(t, camera.CameraRecordingPublishingPointValue{ + URL: srvHTTP.URL + "/cmaf/", + })) + + // seed ring buffer with a tiny HEVC-like frame + codec := &core.Codec{Name: core.CodecH265, ClockRate: 90000} + nal := []byte{0x40, 0x01, 0x0c, 0x01} + payload := append([]byte{0, 0, 0, byte(len(nal))}, nal...) + now := time.Now() + for i := 0; i < 4; i++ { + s.recording.Buffer.Push(pkghk.Packet{ + Track: 0, + Codec: codec, + Payload: payload, + RTPTime: uint32(i * 3000), + Wall: now.Add(time.Duration(i) * 100 * time.Millisecond), + Key: i == 0, + }) + } + + s.SetCharacteristic(nil, 1, upIID, mustTLV8B64(t, camera.BufferUploadCommandRequest{ + SessionID: 42, + Command: camera.BufferUploadStartAndStop, + Start: pkghk.TimeToNTP(now.Add(-time.Second)), + Stop: pkghk.TimeToNTP(now.Add(2 * time.Second)), + })) + + wr := s.GetCharacteristic(nil, 1, upIID) + var upRes camera.BufferUploadCommandResponse + require.NoError(t, tlv8.UnmarshalBase64(wr, &upRes)) + require.NotZero(t, upRes.ClipID) + + // wait for async publish + events + deadline := time.Now().Add(3 * time.Second) + var sawStart, sawStop bool + for time.Now().Before(deadline) { + s.SetCharacteristic(nil, 1, evtIID, mustTLV8B64(t, camera.BufferEventCommandRequest{ + Command: camera.BufferEventQuery, + Limit: 32, + })) + wr = s.GetCharacteristic(nil, 1, evtIID) + var evRes camera.BufferEventCommandResponse + require.NoError(t, tlv8.UnmarshalBase64(wr, &evRes)) + for _, ev := range evRes.Events { + if ev.Type == camera.BufferEventTypeCMAFSessionStart { + sawStart = true + require.Equal(t, uint64(42), ev.CMAFSessionStart.CMAFSessionID) + } + if ev.Type == camera.BufferEventTypeCMAFSessionStop { + sawStop = true + } + require.NotEqual(t, byte(camera.BufferEventTypeCMAFError), ev.Type, "unexpected cmaf error event %+v", ev) + } + if sawStart && sawStop && posts.Load() > 0 { + break + } + time.Sleep(15 * time.Millisecond) + } + require.True(t, sawStart, "missing session start event") + require.True(t, sawStop, "missing session stop event") + require.Greater(t, posts.Load(), int32(0), "expected CMAF POSTs to ingest server") +} + +func TestServerWebRTCSolicitAndSessionCount(t *testing.T) { + s := newTestHKSVServer(t) + solicitIID := s.mustIID(t, camera.TypeWebRTCSolicitOffer) + countIID := s.mustIID(t, camera.TypeWebRTCNumberOfActiveSessions) + ctrlIID := s.mustIID(t, camera.TypeWebRTCStreamingControl) + + require.Equal(t, 0, s.GetCharacteristic(nil, 1, countIID)) + + s.SetCharacteristic(nil, 1, solicitIID, mustTLV8B64(t, camera.WebRTCSolicitOfferRequest{ + Options: camera.WebRTCOfferOptions{SFrameEnabled: false}, + })) + wr := s.GetCharacteristic(nil, 1, solicitIID) + var res camera.WebRTCSolicitOfferResponse + require.NoError(t, tlv8.UnmarshalBase64(wr, &res)) + require.Equal(t, byte(camera.WebRTCSolicitSuccess), res.Status) + require.NotEmpty(t, res.SessionIdentifier) + require.Contains(t, res.SDPOffer, "v=0") + require.Equal(t, 1, s.GetCharacteristic(nil, 1, countIID)) + + // end session + s.SetCharacteristic(nil, 1, ctrlIID, mustTLV8B64(t, camera.WebRTCStreamingControlRequest{ + SessionIdentifier: res.SessionIdentifier, + Command: camera.WebRTCCommandEnd, + })) + wr = s.GetCharacteristic(nil, 1, ctrlIID) + var end camera.WebRTCStreamingControlResponse + require.NoError(t, tlv8.UnmarshalBase64(wr, &end)) + require.Equal(t, byte(camera.WebRTCStatusSuccess), end.Status) + require.Equal(t, 0, s.GetCharacteristic(nil, 1, countIID)) +} + +func TestServerWebRTCPrivacyModeBlocksSolicit(t *testing.T) { + s := newTestHKSVServer(t) + solicitIID := s.mustIID(t, camera.TypeWebRTCSolicitOffer) + + // flip HomeKit Camera Active off + active := s.accessory.GetCharacter(camera.TypeHomeKitCameraActive) + require.NotNil(t, active) + s.SetCharacteristic(nil, 1, active.IID, false) + + s.SetCharacteristic(nil, 1, solicitIID, mustTLV8B64(t, camera.WebRTCSolicitOfferRequest{})) + wr := s.GetCharacteristic(nil, 1, solicitIID) + var res camera.WebRTCSolicitOfferResponse + require.NoError(t, tlv8.UnmarshalBase64(wr, &res)) + require.Equal(t, byte(camera.WebRTCSolicitPrivacyModeActive), res.Status) +} + +func TestServerWebRTCNilManager(t *testing.T) { + s := newTestHKSVServer(t) + s.webrtc = nil + solicitIID := s.mustIID(t, camera.TypeWebRTCSolicitOffer) + s.SetCharacteristic(nil, 1, solicitIID, mustTLV8B64(t, camera.WebRTCSolicitOfferRequest{})) + wr := s.GetCharacteristic(nil, 1, solicitIID) + var res camera.WebRTCSolicitOfferResponse + require.NoError(t, tlv8.UnmarshalBase64(wr, &res)) + require.Equal(t, byte(camera.WebRTCSolicitError), res.Status) +} + +func TestServerWebRTCProvideAnswerUnknown(t *testing.T) { + s := newTestHKSVServer(t) + ansIID := s.mustIID(t, camera.TypeWebRTCProvideAnswer) + s.SetCharacteristic(nil, 1, ansIID, mustTLV8B64(t, camera.WebRTCProvideAnswerRequest{ + SessionIdentifier: string(make([]byte, 16)), + SDPAnswer: "v=0\r\n", + })) + wr := s.GetCharacteristic(nil, 1, ansIID) + var res camera.WebRTCProvideAnswerResponse + require.NoError(t, tlv8.UnmarshalBase64(wr, &res)) + require.Equal(t, byte(camera.WebRTCStatusUnknownSessionIdentifier), res.Status) +} + +func TestServerWebRTCReofferAndUpdate(t *testing.T) { + s := newTestHKSVServer(t) + solicitIID := s.mustIID(t, camera.TypeWebRTCSolicitOffer) + reofferIID := s.mustIID(t, camera.TypeWebRTCReoffer) + updateIID := s.mustIID(t, camera.TypeWebRTCUpdateSession) + + s.SetCharacteristic(nil, 1, solicitIID, mustTLV8B64(t, camera.WebRTCSolicitOfferRequest{})) + wr := s.GetCharacteristic(nil, 1, solicitIID) + var offer camera.WebRTCSolicitOfferResponse + require.NoError(t, tlv8.UnmarshalBase64(wr, &offer)) + require.Equal(t, byte(camera.WebRTCSolicitSuccess), offer.Status) + + // reoffer with garbage SDP should error (not crash) + s.SetCharacteristic(nil, 1, reofferIID, mustTLV8B64(t, camera.WebRTCReofferRequest{ + SessionIdentifier: offer.SessionIdentifier, + SDPOffer: "not-an-sdp", + })) + wr = s.GetCharacteristic(nil, 1, reofferIID) + var re camera.WebRTCReofferResponse + require.NoError(t, tlv8.UnmarshalBase64(wr, &re)) + require.Equal(t, byte(camera.WebRTCStatusError), re.Status) + + s.SetCharacteristic(nil, 1, updateIID, mustTLV8B64(t, camera.WebRTCUpdateSessionRequest{ + SessionIdentifier: offer.SessionIdentifier, + ReceiveKeysToAdd: []camera.SFrameKeyData{ + {Key: "abc", KID: 3}, + }, + })) + wr = s.GetCharacteristic(nil, 1, updateIID) + var up camera.WebRTCUpdateSessionResponse + require.NoError(t, tlv8.UnmarshalBase64(wr, &up)) + require.Equal(t, byte(camera.WebRTCStatusSuccess), up.Status) + + // cleanup + ctrlIID := s.mustIID(t, camera.TypeWebRTCStreamingControl) + s.SetCharacteristic(nil, 1, ctrlIID, mustTLV8B64(t, camera.WebRTCStreamingControlRequest{ + SessionIdentifier: offer.SessionIdentifier, + Command: camera.WebRTCCommandEnd, + })) +} + +func TestServerRTPStreamingControl(t *testing.T) { + s := newTestHKSVServer(t) + iid := s.mustIID(t, camera.TypeRTPStreamingControl) + s.SetCharacteristic(nil, 1, iid, mustTLV8B64(t, camera.RTPStreamingControlRequest{ + SessionIdentifier: "sess", + Command: camera.RTPStreamCommandStart, + })) + wr := s.GetCharacteristic(nil, 1, iid) + var res camera.RTPStreamingControlResponse + require.NoError(t, tlv8.UnmarshalBase64(wr, &res)) + require.Equal(t, byte(camera.StreamStatusSuccess), res.Status) + + // privacy blocks start + active := s.accessory.GetCharacter(camera.TypeHomeKitCameraActive) + s.SetCharacteristic(nil, 1, active.IID, false) + s.SetCharacteristic(nil, 1, iid, mustTLV8B64(t, camera.RTPStreamingControlRequest{ + SessionIdentifier: "sess2", + Command: camera.RTPStreamCommandStart, + })) + wr = s.GetCharacteristic(nil, 1, iid) + require.NoError(t, tlv8.UnmarshalBase64(wr, &res)) + require.Equal(t, byte(camera.StreamStatusError), res.Status) +} + +func TestServerInvalidTLV8DoesNotPanic(t *testing.T) { + s := newTestHKSVServer(t) + types := []string{ + camera.TypeCameraClientCSR, + camera.TypeCameraClientCertificate, + camera.TypeCameraKey, + camera.TypeCameraRecordingPublishingPoint, + camera.TypeBufferActivityCommand, + camera.TypeBufferUploadCommand, + camera.TypeBufferEventCommand, + camera.TypeWebRTCSolicitOffer, + camera.TypeWebRTCProvideAnswer, + camera.TypeWebRTCStreamingControl, + camera.TypeWebRTCReoffer, + camera.TypeWebRTCUpdateSession, + camera.TypeRTPStreamingControl, + } + for _, typ := range types { + iid := s.mustIID(t, typ) + require.NotPanics(t, func() { + s.SetCharacteristic(nil, 1, iid, "%%%not-base64%%%") + s.SetCharacteristic(nil, 1, iid, mustTLV8B64(t, struct{}{})) // empty/wrong shape + }) + } + // unknown iid + require.NotPanics(t, func() { + s.SetCharacteristic(nil, 1, 0xdeadbeef, "x") + _ = s.GetCharacteristic(nil, 1, 0xdeadbeef) + }) +} + +func TestServerStreamingEnabledGate(t *testing.T) { + s := newTestHKSVServer(t) + // any Streaming Enabled false blocks + var streamIID uint64 + for _, srv := range s.accessory.Services { + for _, ch := range srv.Characters { + if ch.Type == camera.TypeStreamingEnabled { + streamIID = ch.IID + break + } + } + if streamIID != 0 { + break + } + } + require.NotZero(t, streamIID) + s.SetCharacteristic(nil, 1, streamIID, false) + require.False(t, s.streamingAllowed()) + + s.SetCharacteristic(nil, 1, streamIID, true) + // HomeKit camera active still true by default + require.True(t, s.streamingAllowed()) +} + +func TestServerGetAccessories(t *testing.T) { + s := newTestHKSVServer(t) + accs := s.GetAccessories(nil) + require.Len(t, accs, 1) + require.Equal(t, s.accessory, accs[0]) + // unique IIDs already covered in camera package; sanity check webrtc service present + require.NotNil(t, s.accessory.GetService(camera.TypeCameraWebRTCStreamManagement)) + require.NotNil(t, s.accessory.GetService(camera.TypeCameraBufferManagement)) +} + +func TestServerUploadWithoutRecordingManager(t *testing.T) { + s := newTestHKSVServer(t) + s.recording = nil + upIID := s.mustIID(t, camera.TypeBufferUploadCommand) + s.SetCharacteristic(nil, 1, upIID, mustTLV8B64(t, camera.BufferUploadCommandRequest{ + SessionID: 1, + Command: camera.BufferUploadStartAndStop, + })) + wr := s.GetCharacteristic(nil, 1, upIID) + var res camera.BufferUploadCommandResponse + require.NoError(t, tlv8.UnmarshalBase64(wr, &res)) + require.Equal(t, uint64(0), res.ClipID) +} + +func TestServerConcurrentCharacteristicAccess(t *testing.T) { + s := newTestHKSVServer(t) + actIID := s.mustIID(t, camera.TypeBufferActivityCommand) + seqIID := s.mustIID(t, camera.TypeBufferEventSequenceNumber) + solicitIID := s.mustIID(t, camera.TypeWebRTCSolicitOffer) + ctrlIID := s.mustIID(t, camera.TypeWebRTCStreamingControl) + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s.SetCharacteristic(nil, 1, actIID, mustTLV8B64(t, camera.BufferActivityCommandRequest{ + Activity: camera.BufferActivityShouldRecord, + })) + _ = s.GetCharacteristic(nil, 1, seqIID) + }() + } + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s.SetCharacteristic(nil, 1, solicitIID, mustTLV8B64(t, camera.WebRTCSolicitOfferRequest{})) + wr := s.GetCharacteristic(nil, 1, solicitIID) + var res camera.WebRTCSolicitOfferResponse + _ = tlv8.UnmarshalBase64(wr, &res) + if res.SessionIdentifier != "" { + s.SetCharacteristic(nil, 1, ctrlIID, mustTLV8B64(t, camera.WebRTCStreamingControlRequest{ + SessionIdentifier: res.SessionIdentifier, + Command: camera.WebRTCCommandEnd, + })) + } + }() + } + wg.Wait() + require.Greater(t, s.recording.EventSequence(), uint32(0)) +} + +func TestTruthy(t *testing.T) { + require.True(t, truthy(true)) + require.True(t, truthy(float64(1))) + require.True(t, truthy(1)) + require.True(t, truthy(uint8(1))) + require.True(t, truthy("1")) + require.True(t, truthy("true")) + require.False(t, truthy(false)) + require.False(t, truthy(float64(0))) + require.False(t, truthy(0)) + require.False(t, truthy("")) + require.False(t, truthy(nil)) +} diff --git a/pkg/hap/tlv8/tlv8.go b/pkg/hap/tlv8/tlv8.go index 1267ba0d9..4133b7229 100644 --- a/pkg/hap/tlv8/tlv8.go +++ b/pkg/hap/tlv8/tlv8.go @@ -204,8 +204,9 @@ func UnmarshalReader(r io.Reader, n int64, v any) error { } func Unmarshal(data []byte, v any) error { + // Empty TLV is valid (e.g. write-response with no fields / empty event list) if len(data) == 0 { - return errors.New("tlv8: unmarshal zero data") + return nil } value := reflect.ValueOf(v) diff --git a/pkg/hap/tlv8/tlv8_test.go b/pkg/hap/tlv8/tlv8_test.go index ab94beb65..b8c3262d4 100644 --- a/pkg/hap/tlv8/tlv8_test.go +++ b/pkg/hap/tlv8/tlv8_test.go @@ -171,3 +171,14 @@ func TestSlice2(t *testing.T) { require.NoError(t, err) require.Equal(t, b00, b2) } + +func TestUnmarshalEmpty(t *testing.T) { + // Empty TLV is a valid write-response with no fields + var v struct { + N uint8 `tlv8:"1"` + } + require.NoError(t, Unmarshal(nil, &v)) + require.NoError(t, Unmarshal([]byte{}, &v)) + require.Equal(t, uint8(0), v.N) + require.NoError(t, UnmarshalBase64("", &v)) +}