Merge branch 'hksv' into beta

# Conflicts:
#	pkg/hap/hds/hds_test.go
This commit is contained in:
Sergey Krashevich
2026-03-10 23:53:40 +03:00
35 changed files with 8399 additions and 481 deletions
+73
View File
@@ -13,12 +13,85 @@ func NewAccessory(manuf, model, name, serial, firmware string) *hap.Accessory {
ServiceCameraRTPStreamManagement(),
//hap.ServiceHAPProtocolInformation(),
ServiceMicrophone(),
ServiceSpeaker(),
},
}
acc.InitIID()
return acc
}
func NewHKSVAccessory(manuf, model, name, serial, firmware string) *hap.Accessory {
rtpStream := ServiceCameraRTPStreamManagement()
motionSensor := ServiceMotionSensor()
operatingMode := ServiceCameraOperatingMode()
recordingMgmt := ServiceCameraEventRecordingManagement()
dataStreamMgmt := ServiceDataStreamManagement()
acc := &hap.Accessory{
AID: hap.DeviceAID,
Services: []*hap.Service{
hap.ServiceAccessoryInformation(manuf, model, name, serial, firmware),
rtpStream,
ServiceMicrophone(),
ServiceSpeaker(),
motionSensor,
operatingMode,
recordingMgmt,
dataStreamMgmt,
},
}
acc.InitIID()
// HAP-NodeJS: only RecordingManagement links to DataStreamManagement
recordingMgmt.Linked = []int{int(dataStreamMgmt.IID)}
return acc
}
func NewHKSVDoorbellAccessory(manuf, model, name, serial, firmware string) *hap.Accessory {
rtpStream := ServiceCameraRTPStreamManagement()
motionSensor := ServiceMotionSensor()
operatingMode := ServiceCameraOperatingMode()
recordingMgmt := ServiceCameraEventRecordingManagement()
dataStreamMgmt := ServiceDataStreamManagement()
doorbell := ServiceDoorbell()
acc := &hap.Accessory{
AID: hap.DeviceAID,
Services: []*hap.Service{
hap.ServiceAccessoryInformation(manuf, model, name, serial, firmware),
rtpStream,
ServiceMicrophone(),
ServiceSpeaker(),
motionSensor,
operatingMode,
recordingMgmt,
dataStreamMgmt,
doorbell,
},
}
acc.InitIID()
// HAP-NodeJS: only RecordingManagement links to DataStreamManagement
recordingMgmt.Linked = []int{int(dataStreamMgmt.IID)}
return acc
}
func ServiceSpeaker() *hap.Service {
return &hap.Service{
Type: "113", // 'Speaker'
Characters: []*hap.Character{
{
Type: "11A",
Format: hap.FormatBool,
Value: 0,
Perms: hap.EVPRPW,
},
},
}
}
func ServiceMicrophone() *hap.Service {
return &hap.Service{
Type: "112", // 'Microphone'
+13
View File
@@ -2,6 +2,19 @@ package camera
const TypeSupportedAudioRecordingConfiguration = "207"
//goland:noinspection ALL
const (
AudioRecordingCodecTypeAACELD = 2
AudioRecordingCodecTypeAACLC = 3
AudioRecordingSampleRate8Khz = 0
AudioRecordingSampleRate16Khz = 1
AudioRecordingSampleRate24Khz = 2
AudioRecordingSampleRate32Khz = 3
AudioRecordingSampleRate44Khz = 4
AudioRecordingSampleRate48Khz = 5
)
type SupportedAudioRecordingConfiguration struct {
CodecConfigs []AudioRecordingCodecConfiguration `tlv8:"1"`
}
+237
View File
@@ -0,0 +1,237 @@
package camera
import (
"github.com/AlexxIT/go2rtc/pkg/hap"
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
)
func ServiceMotionSensor() *hap.Service {
return &hap.Service{
Type: "85",
Characters: []*hap.Character{
{
Type: "22",
Format: hap.FormatBool,
Value: false,
Perms: hap.EVPR,
},
{
Type: "75",
Format: hap.FormatBool,
Value: true,
Perms: hap.EVPR,
},
},
}
}
func ServiceCameraOperatingMode() *hap.Service {
return &hap.Service{
Type: "21A",
Characters: []*hap.Character{
{
Type: "21B",
Format: hap.FormatBool,
Value: true,
Perms: hap.EVPRPW,
},
{
Type: "223",
Format: hap.FormatBool,
Value: true,
Perms: hap.EVPRPW,
},
{
Type: "225",
Format: hap.FormatBool,
Value: true,
Perms: hap.EVPRPW,
},
},
}
}
func ServiceCameraEventRecordingManagement() *hap.Service {
val205, _ := tlv8.MarshalBase64(SupportedCameraRecordingConfiguration{
PrebufferLength: 4000,
EventTriggerOptions: 0x01, // motion
MediaContainerConfigurations: MediaContainerConfigurations{
MediaContainerType: 0, // fragmented MP4
MediaContainerParameters: MediaContainerParameters{
FragmentLength: 4000,
},
},
})
val206, _ := tlv8.MarshalBase64(SupportedVideoRecordingConfiguration{
CodecConfigs: []VideoRecordingCodecConfiguration{
{
CodecType: VideoCodecTypeH264,
CodecParams: VideoRecordingCodecParameters{
ProfileID: VideoCodecProfileHigh,
Level: VideoCodecLevel40,
Bitrate: 2000,
IFrameInterval: 4000,
},
CodecAttrs: VideoCodecAttributes{Width: 1920, Height: 1080, Framerate: 30},
},
{
CodecType: VideoCodecTypeH264,
CodecParams: VideoRecordingCodecParameters{
ProfileID: VideoCodecProfileMain,
Level: VideoCodecLevel31,
Bitrate: 1000,
IFrameInterval: 4000,
},
CodecAttrs: VideoCodecAttributes{Width: 1280, Height: 720, Framerate: 30},
},
},
})
val207, _ := tlv8.MarshalBase64(SupportedAudioRecordingConfiguration{
CodecConfigs: []AudioRecordingCodecConfiguration{
{
CodecType: AudioRecordingCodecTypeAACLC,
CodecParams: []AudioRecordingCodecParameters{
{
Channels: 1,
BitrateMode: []byte{AudioCodecBitrateVariable},
SampleRate: []byte{AudioRecordingSampleRate24Khz, AudioRecordingSampleRate32Khz, AudioRecordingSampleRate48Khz},
MaxAudioBitrate: []uint32{64},
},
},
},
},
})
// Default selected recording configuration (Home Hub expects this to persist)
val209, _ := tlv8.MarshalBase64(SelectedCameraRecordingConfiguration{
GeneralConfig: SupportedCameraRecordingConfiguration{
PrebufferLength: 4000,
EventTriggerOptions: 0x01, // motion
MediaContainerConfigurations: MediaContainerConfigurations{
MediaContainerType: 0,
MediaContainerParameters: MediaContainerParameters{
FragmentLength: 4000,
},
},
},
VideoConfig: SupportedVideoRecordingConfiguration{
CodecConfigs: []VideoRecordingCodecConfiguration{
{
CodecType: VideoCodecTypeH264,
CodecParams: VideoRecordingCodecParameters{
ProfileID: VideoCodecProfileHigh,
Level: VideoCodecLevel40,
Bitrate: 2000,
IFrameInterval: 4000,
},
CodecAttrs: VideoCodecAttributes{Width: 1920, Height: 1080, Framerate: 30},
},
},
},
AudioConfig: SupportedAudioRecordingConfiguration{
CodecConfigs: []AudioRecordingCodecConfiguration{
{
CodecType: AudioRecordingCodecTypeAACLC,
CodecParams: []AudioRecordingCodecParameters{
{
Channels: 1,
BitrateMode: []byte{AudioCodecBitrateVariable},
SampleRate: []byte{AudioRecordingSampleRate24Khz},
MaxAudioBitrate: []uint32{64},
},
},
},
},
},
})
return &hap.Service{
Type: "204",
Characters: []*hap.Character{
{
Type: "B0",
Format: hap.FormatUInt8,
Value: 0,
Perms: hap.EVPRPW,
},
{
Type: TypeSupportedCameraRecordingConfiguration,
Format: hap.FormatTLV8,
Value: val205,
Perms: hap.EVPR,
},
{
Type: TypeSupportedVideoRecordingConfiguration,
Format: hap.FormatTLV8,
Value: val206,
Perms: hap.EVPR,
},
{
Type: TypeSupportedAudioRecordingConfiguration,
Format: hap.FormatTLV8,
Value: val207,
Perms: hap.EVPR,
},
{
Type: TypeSelectedCameraRecordingConfiguration,
Format: hap.FormatTLV8,
Value: val209,
Perms: hap.EVPRPW,
},
{
Type: "226",
Format: hap.FormatUInt8,
Value: 0,
Perms: hap.EVPRPW,
},
},
}
}
func ServiceDataStreamManagement() *hap.Service {
val130, _ := tlv8.MarshalBase64(SupportedDataStreamTransportConfiguration{
Configs: []TransferTransportConfiguration{
{TransportType: 0}, // TCP
},
})
return &hap.Service{
Type: "129",
Characters: []*hap.Character{
{
Type: TypeSupportedDataStreamTransportConfiguration,
Format: hap.FormatTLV8,
Value: val130,
Perms: hap.PR,
},
{
Type: TypeSetupDataStreamTransport,
Format: hap.FormatTLV8,
Value: "",
Perms: []string{"pr", "pw", "wr"},
},
{
Type: "37",
Format: hap.FormatString,
Value: "1.0",
Perms: hap.PR,
},
},
}
}
func ServiceDoorbell() *hap.Service {
return &hap.Service{
Type: "121",
Characters: []*hap.Character{
{
Type: "73",
Format: hap.FormatUInt8,
Value: nil,
Perms: hap.EVPR,
},
},
}
}
+4
View File
@@ -48,6 +48,10 @@ func (c *Character) RemoveListener(w io.Writer) {
}
}
func (c *Character) ListenerCount() int {
return len(c.listeners)
}
func (c *Character) NotifyListeners(ignore io.Writer) error {
if c.listeners == nil {
return nil
+12 -12
View File
@@ -1,8 +1,7 @@
package hds
import (
"bufio"
"bytes"
"net"
"testing"
"github.com/AlexxIT/go2rtc/pkg/core"
@@ -13,22 +12,23 @@ func TestEncryption(t *testing.T) {
key := []byte(core.RandString(16, 0))
salt := core.RandString(32, 0)
c, err := NewConn(nil, key, salt, true)
c1, c2 := net.Pipe()
t.Cleanup(func() { c1.Close(); c2.Close() })
writer, err := NewConn(c1, key, salt, true)
require.NoError(t, err)
buf := bytes.NewBuffer(nil)
c.wr = bufio.NewWriter(buf)
n, err := c.Write([]byte("test"))
reader, err := NewConn(c2, key, salt, false)
require.NoError(t, err)
require.Equal(t, 4, n)
c, err = NewConn(nil, key, salt, false)
c.rd = bufio.NewReader(buf)
require.NoError(t, err)
go func() {
n, err := writer.Write([]byte("test"))
require.NoError(t, err)
require.Equal(t, 4, n)
}()
b := make([]byte, 32)
n, err = c.Read(b)
n, err := reader.Read(b)
require.NoError(t, err)
require.Equal(t, "test", string(b[:n]))
+416
View File
@@ -0,0 +1,416 @@
package hds
import (
"encoding/binary"
"errors"
"math"
)
// opack tags
const (
opackTrue = 0x01
opackFalse = 0x02
opackTerminator = 0x03
opackNull = 0x04
opackIntNeg1 = 0x07
opackSmallInt0 = 0x08 // 0x08-0x2F = integers 0-39
opackSmallInt39 = 0x2F
opackInt8 = 0x30
opackInt16 = 0x31
opackInt32 = 0x32
opackInt64 = 0x33
opackFloat32 = 0x35
opackFloat64 = 0x36
opackStr0 = 0x40 // 0x40-0x60 = inline string, length 0-32
opackStr32 = 0x60
opackStrLen1 = 0x61
opackStrLen2 = 0x62
opackStrLen4 = 0x63
opackStrLen8 = 0x64
opackData0 = 0x70 // 0x70-0x90 = inline data, length 0-32
opackData32 = 0x90
opackDataLen1 = 0x91
opackDataLen2 = 0x92
opackDataLen4 = 0x93
opackDataLen8 = 0x94
opackArr0 = 0xD0 // 0xD0-0xDE = counted array, 0-14 elements
opackArr14 = 0xDE
opackArrTerm = 0xDF // terminated array
opackDict0 = 0xE0 // 0xE0-0xEE = counted dict, 0-14 pairs
opackDict14 = 0xEE
opackDictTerm = 0xEF // terminated dict
)
func OpackMarshal(v any) []byte {
var buf []byte
return opackEncode(buf, v)
}
func OpackUnmarshal(data []byte) (any, error) {
v, _, err := opackDecode(data)
return v, err
}
func opackEncode(buf []byte, v any) []byte {
switch v := v.(type) {
case nil:
return append(buf, opackNull)
case bool:
if v {
return append(buf, opackTrue)
}
return append(buf, opackFalse)
case int:
return opackEncodeInt(buf, int64(v))
case int8:
return opackEncodeInt(buf, int64(v))
case int16:
return opackEncodeInt(buf, int64(v))
case int32:
return opackEncodeInt(buf, int64(v))
case int64:
return opackEncodeInt(buf, v)
case uint:
return opackEncodeInt(buf, int64(v))
case uint8:
return opackEncodeInt(buf, int64(v))
case uint16:
return opackEncodeInt(buf, int64(v))
case uint32:
return opackEncodeInt(buf, int64(v))
case uint64:
return opackEncodeInt(buf, int64(v))
case float32:
buf = append(buf, opackFloat32)
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, math.Float32bits(v))
return append(buf, b...)
case float64:
buf = append(buf, opackFloat64)
b := make([]byte, 8)
binary.LittleEndian.PutUint64(b, math.Float64bits(v))
return append(buf, b...)
case string:
return opackEncodeString(buf, v)
case []byte:
return opackEncodeData(buf, v)
case []any:
return opackEncodeArray(buf, v)
case map[string]any:
return opackEncodeDict(buf, v)
default:
return append(buf, opackNull)
}
}
func opackEncodeInt(buf []byte, v int64) []byte {
if v == -1 {
return append(buf, opackIntNeg1)
}
if v >= 0 && v <= 39 {
return append(buf, byte(opackSmallInt0+v))
}
if v >= -128 && v <= 127 {
return append(buf, opackInt8, byte(v))
}
if v >= -32768 && v <= 32767 {
buf = append(buf, opackInt16)
b := make([]byte, 2)
binary.LittleEndian.PutUint16(b, uint16(v))
return append(buf, b...)
}
if v >= -2147483648 && v <= 2147483647 {
buf = append(buf, opackInt32)
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, uint32(v))
return append(buf, b...)
}
buf = append(buf, opackInt64)
b := make([]byte, 8)
binary.LittleEndian.PutUint64(b, uint64(v))
return append(buf, b...)
}
func opackEncodeString(buf []byte, s string) []byte {
n := len(s)
if n <= 32 {
buf = append(buf, byte(opackStr0+n))
} else if n <= 0xFF {
buf = append(buf, opackStrLen1, byte(n))
} else if n <= 0xFFFF {
buf = append(buf, opackStrLen2)
b := make([]byte, 2)
binary.LittleEndian.PutUint16(b, uint16(n))
buf = append(buf, b...)
} else {
buf = append(buf, opackStrLen4)
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, uint32(n))
buf = append(buf, b...)
}
return append(buf, s...)
}
func opackEncodeData(buf []byte, data []byte) []byte {
n := len(data)
if n <= 32 {
buf = append(buf, byte(opackData0+n))
} else if n <= 0xFF {
buf = append(buf, opackDataLen1, byte(n))
} else if n <= 0xFFFF {
buf = append(buf, opackDataLen2)
b := make([]byte, 2)
binary.LittleEndian.PutUint16(b, uint16(n))
buf = append(buf, b...)
} else {
buf = append(buf, opackDataLen4)
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, uint32(n))
buf = append(buf, b...)
}
return append(buf, data...)
}
func opackEncodeArray(buf []byte, arr []any) []byte {
n := len(arr)
if n <= 14 {
buf = append(buf, byte(opackArr0+n))
} else {
buf = append(buf, opackArrTerm)
}
for _, v := range arr {
buf = opackEncode(buf, v)
}
if n > 14 {
buf = append(buf, opackTerminator)
}
return buf
}
func opackEncodeDict(buf []byte, dict map[string]any) []byte {
n := len(dict)
if n <= 14 {
buf = append(buf, byte(opackDict0+n))
} else {
buf = append(buf, opackDictTerm)
}
for k, v := range dict {
buf = opackEncodeString(buf, k)
buf = opackEncode(buf, v)
}
if n > 14 {
buf = append(buf, opackTerminator)
}
return buf
}
var errOpackTruncated = errors.New("opack: truncated data")
var errOpackInvalidTag = errors.New("opack: invalid tag")
func opackDecode(data []byte) (any, int, error) {
if len(data) == 0 {
return nil, 0, errOpackTruncated
}
tag := data[0]
off := 1
switch {
case tag == opackNull:
return nil, off, nil
case tag == opackTrue:
return true, off, nil
case tag == opackFalse:
return false, off, nil
case tag == opackTerminator:
return nil, off, nil
case tag == opackIntNeg1:
return int64(-1), off, nil
case tag >= opackSmallInt0 && tag <= opackSmallInt39:
return int64(tag - opackSmallInt0), off, nil
case tag == opackInt8:
if len(data) < 2 {
return nil, 0, errOpackTruncated
}
return int64(int8(data[1])), 2, nil
case tag == opackInt16:
if len(data) < 3 {
return nil, 0, errOpackTruncated
}
v := int16(binary.LittleEndian.Uint16(data[1:3]))
return int64(v), 3, nil
case tag == opackInt32:
if len(data) < 5 {
return nil, 0, errOpackTruncated
}
v := int32(binary.LittleEndian.Uint32(data[1:5]))
return int64(v), 5, nil
case tag == opackInt64:
if len(data) < 9 {
return nil, 0, errOpackTruncated
}
v := int64(binary.LittleEndian.Uint64(data[1:9]))
return int64(v), 9, nil
case tag == opackFloat32:
if len(data) < 5 {
return nil, 0, errOpackTruncated
}
v := math.Float32frombits(binary.LittleEndian.Uint32(data[1:5]))
return float64(v), 5, nil
case tag == opackFloat64:
if len(data) < 9 {
return nil, 0, errOpackTruncated
}
v := math.Float64frombits(binary.LittleEndian.Uint64(data[1:9]))
return v, 9, nil
// Inline string (0-32 bytes)
case tag >= opackStr0 && tag <= opackStr32:
n := int(tag - opackStr0)
if len(data) < off+n {
return nil, 0, errOpackTruncated
}
return string(data[off : off+n]), off + n, nil
// String with length prefix
case tag >= opackStrLen1 && tag <= opackStrLen4:
n, sz := opackReadLen(data[off:], tag-opackStrLen1+1)
if sz == 0 {
return nil, 0, errOpackTruncated
}
off += sz
if len(data) < off+n {
return nil, 0, errOpackTruncated
}
return string(data[off : off+n]), off + n, nil
// Inline data (0-32 bytes)
case tag >= opackData0 && tag <= opackData32:
n := int(tag - opackData0)
if len(data) < off+n {
return nil, 0, errOpackTruncated
}
b := make([]byte, n)
copy(b, data[off:off+n])
return b, off + n, nil
// Data with length prefix
case tag >= opackDataLen1 && tag <= opackDataLen4:
n, sz := opackReadLen(data[off:], tag-opackDataLen1+1)
if sz == 0 {
return nil, 0, errOpackTruncated
}
off += sz
if len(data) < off+n {
return nil, 0, errOpackTruncated
}
b := make([]byte, n)
copy(b, data[off:off+n])
return b, off + n, nil
// Counted array (0-14)
case tag >= opackArr0 && tag <= opackArr14:
count := int(tag - opackArr0)
return opackDecodeArray(data[off:], count, false)
// Terminated array
case tag == opackArrTerm:
return opackDecodeArray(data[off:], 0, true)
// Counted dict (0-14)
case tag >= opackDict0 && tag <= opackDict14:
count := int(tag - opackDict0)
return opackDecodeDict(data[off:], count, false)
// Terminated dict
case tag == opackDictTerm:
return opackDecodeDict(data[off:], 0, true)
}
return nil, 0, errOpackInvalidTag
}
// opackReadLen reads a length from data using the given byte count (1=1byte, 2=2bytes, 3=4bytes, 4=8bytes)
func opackReadLen(data []byte, lenBytes byte) (int, int) {
switch lenBytes {
case 1:
if len(data) < 1 {
return 0, 0
}
return int(data[0]), 1
case 2:
if len(data) < 2 {
return 0, 0
}
return int(binary.LittleEndian.Uint16(data[:2])), 2
case 3: // 4-byte length (tag offset 3 = 4 bytes)
if len(data) < 4 {
return 0, 0
}
return int(binary.LittleEndian.Uint32(data[:4])), 4
case 4: // 8-byte length
if len(data) < 8 {
return 0, 0
}
return int(binary.LittleEndian.Uint64(data[:8])), 8
}
return 0, 0
}
func opackDecodeArray(data []byte, count int, terminated bool) ([]any, int, error) {
var arr []any
off := 0
for i := 0; terminated || i < count; i++ {
if off >= len(data) {
return nil, 0, errOpackTruncated
}
if terminated && data[off] == opackTerminator {
off++
break
}
v, n, err := opackDecode(data[off:])
if err != nil {
return nil, 0, err
}
arr = append(arr, v)
off += n
}
return arr, off + 1, nil // +1 for outer tag
}
func opackDecodeDict(data []byte, count int, terminated bool) (map[string]any, int, error) {
dict := make(map[string]any)
off := 0
for i := 0; terminated || i < count; i++ {
if off >= len(data) {
return nil, 0, errOpackTruncated
}
if terminated && data[off] == opackTerminator {
off++
break
}
// key
k, n, err := opackDecode(data[off:])
if err != nil {
return nil, 0, err
}
off += n
key, ok := k.(string)
if !ok {
return nil, 0, errors.New("opack: dict key is not string")
}
// value
if off >= len(data) {
return nil, 0, errOpackTruncated
}
v, n2, err := opackDecode(data[off:])
if err != nil {
return nil, 0, err
}
off += n2
dict[key] = v
}
return dict, off + 1, nil // +1 for outer tag
}
+298
View File
@@ -0,0 +1,298 @@
package hds
import (
"errors"
"sync"
)
// HDS message types
const (
ProtoDataSend = "dataSend"
ProtoControl = "control"
TopicOpen = "open"
TopicData = "data"
TopicClose = "close"
TopicAck = "ack"
TopicHello = "hello"
StatusSuccess = 0
)
// Message represents an HDS application-level message
type Message struct {
Protocol string
Topic string
ID int64
IsEvent bool
Status int64
Body map[string]any
}
// Session wraps an HDS encrypted connection with application-level protocol handling.
// HDS messages format: [1 byte header_length][opack header dict][opack message dict]
type Session struct {
conn *Conn
mu sync.Mutex
id int64
OnDataSendOpen func(streamID int) error
OnDataSendClose func(streamID int) error
}
func NewSession(conn *Conn) *Session {
return &Session{conn: conn}
}
// ReadMessage reads and decodes an HDS application message
func (s *Session) ReadMessage() (*Message, error) {
buf := make([]byte, 64*1024)
n, err := s.conn.Read(buf)
if err != nil {
return nil, err
}
data := buf[:n]
if len(data) < 2 {
return nil, errors.New("hds: message too short")
}
headerLen := int(data[0])
if len(data) < 1+headerLen {
return nil, errors.New("hds: header truncated")
}
headerData := data[1 : 1+headerLen]
bodyData := data[1+headerLen:]
headerVal, err := OpackUnmarshal(headerData)
if err != nil {
return nil, err
}
header, ok := headerVal.(map[string]any)
if !ok {
return nil, errors.New("hds: header is not dict")
}
msg := &Message{
Protocol: opackString(header["protocol"]),
}
if topic, ok := header["event"]; ok {
msg.IsEvent = true
msg.Topic = opackString(topic)
} else if topic, ok := header["request"]; ok {
msg.Topic = opackString(topic)
msg.ID = opackInt(header["id"])
} else if topic, ok := header["response"]; ok {
msg.Topic = opackString(topic)
msg.ID = opackInt(header["id"])
msg.Status = opackInt(header["status"])
}
if len(bodyData) > 0 {
bodyVal, err := OpackUnmarshal(bodyData)
if err != nil {
return nil, err
}
if m, ok := bodyVal.(map[string]any); ok {
msg.Body = m
}
}
return msg, nil
}
// WriteMessage sends an HDS application message
func (s *Session) WriteMessage(header, body map[string]any) error {
headerBytes := OpackMarshal(header)
bodyBytes := OpackMarshal(body)
msg := make([]byte, 0, 1+len(headerBytes)+len(bodyBytes))
msg = append(msg, byte(len(headerBytes)))
msg = append(msg, headerBytes...)
msg = append(msg, bodyBytes...)
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.conn.Write(msg)
return err
}
// WriteResponse sends a response to a request
func (s *Session) WriteResponse(protocol, topic string, id int64, status int, body map[string]any) error {
header := map[string]any{
"protocol": protocol,
"response": topic,
"id": id,
"status": status,
}
if body == nil {
body = map[string]any{}
}
return s.WriteMessage(header, body)
}
// WriteEvent sends an unsolicited event
func (s *Session) WriteEvent(protocol, topic string, body map[string]any) error {
header := map[string]any{
"protocol": protocol,
"event": topic,
}
if body == nil {
body = map[string]any{}
}
return s.WriteMessage(header, body)
}
// WriteRequest sends a request
func (s *Session) WriteRequest(protocol, topic string, body map[string]any) (int64, error) {
s.mu.Lock()
s.id++
id := s.id
s.mu.Unlock()
header := map[string]any{
"protocol": protocol,
"request": topic,
"id": id,
}
if body == nil {
body = map[string]any{}
}
return id, s.WriteMessage(header, body)
}
// maxChunkSize is the maximum data chunk size for HDS media transfer (256 KiB)
const maxChunkSize = 0x40000
// SendMediaInit sends the fMP4 initialization segment (ftyp+moov)
func (s *Session) SendMediaInit(streamID int, initData []byte) error {
return s.sendMediaData(streamID, "mediaInitialization", initData, 1)
}
// SendMediaFragment sends an fMP4 fragment (moof+mdat), splitting into chunks if needed
func (s *Session) SendMediaFragment(streamID int, fragment []byte, sequence int) error {
return s.sendMediaData(streamID, "mediaFragment", fragment, sequence)
}
// sendMediaData sends media data with proper HAP-NodeJS compatible packet structure.
// Large data is split into chunks of maxChunkSize bytes.
func (s *Session) sendMediaData(streamID int, dataType string, data []byte, sequence int) error {
totalSize := len(data)
chunkSeq := 1
for offset := 0; offset < totalSize; offset += maxChunkSize {
end := offset + maxChunkSize
if end > totalSize {
end = totalSize
}
chunk := data[offset:end]
isLast := end >= totalSize
metadata := map[string]any{
"dataType": dataType,
"dataSequenceNumber": sequence,
"dataChunkSequenceNumber": chunkSeq,
"isLastDataChunk": isLast,
}
if chunkSeq == 1 {
metadata["dataTotalSize"] = totalSize
}
body := map[string]any{
"streamId": streamID,
"packets": []any{
map[string]any{
"data": chunk,
"metadata": metadata,
},
},
}
if err := s.WriteEvent(ProtoDataSend, TopicData, body); err != nil {
return err
}
chunkSeq++
}
return nil
}
// Run processes incoming HDS messages in a loop
func (s *Session) Run() error {
// Handle control/hello handshake
msg, err := s.ReadMessage()
if err != nil {
return err
}
if msg.Protocol == ProtoControl && msg.Topic == TopicHello {
if err := s.WriteResponse(ProtoControl, TopicHello, msg.ID, StatusSuccess, nil); err != nil {
return err
}
}
// Main message loop
for {
msg, err := s.ReadMessage()
if err != nil {
return err
}
if msg.Protocol != ProtoDataSend {
continue
}
switch msg.Topic {
case TopicOpen:
streamID := int(opackInt(msg.Body["streamId"]))
// Acknowledge the open request
if err := s.WriteResponse(ProtoDataSend, TopicOpen, msg.ID, StatusSuccess, nil); err != nil {
return err
}
if s.OnDataSendOpen != nil {
if err := s.OnDataSendOpen(streamID); err != nil {
return err
}
}
case TopicClose:
streamID := int(opackInt(msg.Body["streamId"]))
// Acknowledge the close request
if err := s.WriteResponse(ProtoDataSend, TopicClose, msg.ID, StatusSuccess, nil); err != nil {
return err
}
if s.OnDataSendClose != nil {
if err := s.OnDataSendClose(streamID); err != nil {
return err
}
}
case TopicAck:
// Acknowledgement from controller, nothing to do
}
}
}
func (s *Session) Close() error {
return s.conn.Close()
}
func opackString(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
func opackInt(v any) int64 {
switch v := v.(type) {
case int64:
return v
case int:
return int64(v)
case float64:
return int64(v)
}
return 0
}
+486
View File
@@ -0,0 +1,486 @@
package hds
import (
"bytes"
"net"
"testing"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/stretchr/testify/require"
)
// newSessionPair creates a connected accessory/controller session pair for testing.
func newSessionPair(t *testing.T) (accessory *Session, controller *Session) {
t.Helper()
key := []byte(core.RandString(16, 0))
salt := core.RandString(32, 0)
c1, c2 := net.Pipe()
t.Cleanup(func() { c1.Close(); c2.Close() })
accConn, err := NewConn(c1, key, salt, false) // accessory
require.NoError(t, err)
ctrlConn, err := NewConn(c2, key, salt, true) // controller
require.NoError(t, err)
return NewSession(accConn), NewSession(ctrlConn)
}
// readLargeMsg reads a message using a large buffer (for messages with 256KB+ chunks).
// Session.ReadMessage uses 64KB which is too small for media chunks in tests.
func readLargeMsg(t *testing.T, s *Session) *Message {
t.Helper()
buf := make([]byte, 512*1024) // 512KB
n, err := s.conn.Read(buf)
require.NoError(t, err)
data := buf[:n]
require.GreaterOrEqual(t, len(data), 2)
headerLen := int(data[0])
require.GreaterOrEqual(t, len(data), 1+headerLen)
headerVal, err := OpackUnmarshal(data[1 : 1+headerLen])
require.NoError(t, err)
header := headerVal.(map[string]any)
msg := &Message{Protocol: opackString(header["protocol"])}
if topic, ok := header["event"]; ok {
msg.IsEvent = true
msg.Topic = opackString(topic)
} else if topic, ok := header["response"]; ok {
msg.Topic = opackString(topic)
msg.ID = opackInt(header["id"])
msg.Status = opackInt(header["status"])
} else if topic, ok := header["request"]; ok {
msg.Topic = opackString(topic)
msg.ID = opackInt(header["id"])
}
bodyData := data[1+headerLen:]
if len(bodyData) > 0 {
bodyVal, err := OpackUnmarshal(bodyData)
require.NoError(t, err)
if m, ok := bodyVal.(map[string]any); ok {
msg.Body = m
}
}
return msg
}
// extractPacket extracts data and metadata from a dataSend.data message body.
func extractPacket(t *testing.T, body map[string]any) (data []byte, metadata map[string]any) {
t.Helper()
packets, ok := body["packets"].([]any)
require.True(t, ok, "packets must be array")
require.Len(t, packets, 1)
pkt, ok := packets[0].(map[string]any)
require.True(t, ok, "packet element must be dict")
data, ok = pkt["data"].([]byte)
require.True(t, ok, "data must be []byte")
metadata, ok = pkt["metadata"].(map[string]any)
require.True(t, ok, "metadata must be dict")
return
}
// --- SendMediaInit tests ---
func TestSendMediaInit_Structure(t *testing.T) {
acc, ctrl := newSessionPair(t)
initData := bytes.Repeat([]byte{0xAB}, 100)
go func() {
require.NoError(t, acc.SendMediaInit(1, initData))
}()
msg, err := ctrl.ReadMessage()
require.NoError(t, err)
require.Equal(t, ProtoDataSend, msg.Protocol)
require.Equal(t, TopicData, msg.Topic)
require.True(t, msg.IsEvent)
require.Equal(t, int64(1), opackInt(msg.Body["streamId"]))
data, meta := extractPacket(t, msg.Body)
require.Equal(t, initData, data)
require.Equal(t, "mediaInitialization", opackString(meta["dataType"]))
require.Equal(t, int64(1), opackInt(meta["dataSequenceNumber"]))
require.Equal(t, int64(1), opackInt(meta["dataChunkSequenceNumber"]))
require.Equal(t, true, meta["isLastDataChunk"])
require.Equal(t, int64(len(initData)), opackInt(meta["dataTotalSize"]))
}
func TestSendMediaInit_AlwaysSeqOne(t *testing.T) {
acc, ctrl := newSessionPair(t)
go func() {
require.NoError(t, acc.SendMediaInit(42, []byte{1, 2, 3}))
}()
msg, err := ctrl.ReadMessage()
require.NoError(t, err)
_, meta := extractPacket(t, msg.Body)
require.Equal(t, int64(1), opackInt(meta["dataSequenceNumber"]))
require.Equal(t, int64(42), opackInt(msg.Body["streamId"]))
}
// --- SendMediaFragment single chunk tests ---
func TestSendMediaFragment_SingleChunk(t *testing.T) {
acc, ctrl := newSessionPair(t)
fragment := bytes.Repeat([]byte{0xCD}, 1000) // well under 256KB
go func() {
require.NoError(t, acc.SendMediaFragment(5, fragment, 3))
}()
msg, err := ctrl.ReadMessage()
require.NoError(t, err)
data, meta := extractPacket(t, msg.Body)
require.Equal(t, fragment, data)
require.Equal(t, "mediaFragment", opackString(meta["dataType"]))
require.Equal(t, int64(3), opackInt(meta["dataSequenceNumber"]))
require.Equal(t, int64(1), opackInt(meta["dataChunkSequenceNumber"]))
require.Equal(t, true, meta["isLastDataChunk"])
require.Equal(t, int64(1000), opackInt(meta["dataTotalSize"]))
}
// --- SendMediaFragment multi-chunk tests (using readLargeMsg) ---
func TestSendMediaFragment_MultipleChunks(t *testing.T) {
acc, ctrl := newSessionPair(t)
totalSize := maxChunkSize*2 + 100 // 2 full chunks + partial
fragment := make([]byte, totalSize)
for i := range fragment {
fragment[i] = byte(i % 251) // use prime to verify no data corruption
}
go func() {
require.NoError(t, acc.SendMediaFragment(1, fragment, 7))
}()
var assembled []byte
// Chunk 1: full 256KB
msg1 := readLargeMsg(t, ctrl)
data1, meta1 := extractPacket(t, msg1.Body)
require.Len(t, data1, maxChunkSize)
require.Equal(t, int64(1), opackInt(meta1["dataChunkSequenceNumber"]))
require.Equal(t, false, meta1["isLastDataChunk"])
require.Equal(t, int64(totalSize), opackInt(meta1["dataTotalSize"]))
require.Equal(t, int64(7), opackInt(meta1["dataSequenceNumber"]))
assembled = append(assembled, data1...)
// Chunk 2: full 256KB
msg2 := readLargeMsg(t, ctrl)
data2, meta2 := extractPacket(t, msg2.Body)
require.Len(t, data2, maxChunkSize)
require.Equal(t, int64(2), opackInt(meta2["dataChunkSequenceNumber"]))
require.Equal(t, false, meta2["isLastDataChunk"])
// dataTotalSize only in first chunk
_, hasTotalSize := meta2["dataTotalSize"]
require.False(t, hasTotalSize, "dataTotalSize should only be in first chunk")
assembled = append(assembled, data2...)
// Chunk 3: remaining 100 bytes
msg3 := readLargeMsg(t, ctrl)
data3, meta3 := extractPacket(t, msg3.Body)
require.Len(t, data3, 100)
require.Equal(t, int64(3), opackInt(meta3["dataChunkSequenceNumber"]))
require.Equal(t, true, meta3["isLastDataChunk"])
assembled = append(assembled, data3...)
require.Equal(t, fragment, assembled, "reassembled data must match original")
}
func TestSendMediaFragment_ExactChunkBoundary(t *testing.T) {
acc, ctrl := newSessionPair(t)
fragment := bytes.Repeat([]byte{0xAA}, maxChunkSize) // exactly 256KB
go func() {
require.NoError(t, acc.SendMediaFragment(1, fragment, 2))
}()
msg := readLargeMsg(t, ctrl)
data, meta := extractPacket(t, msg.Body)
require.Len(t, data, maxChunkSize)
require.Equal(t, int64(1), opackInt(meta["dataChunkSequenceNumber"]))
require.Equal(t, true, meta["isLastDataChunk"]) // single chunk
}
func TestSendMediaFragment_TwoExactChunks(t *testing.T) {
acc, ctrl := newSessionPair(t)
fragment := bytes.Repeat([]byte{0xBB}, maxChunkSize*2) // exactly 2 chunks
go func() {
require.NoError(t, acc.SendMediaFragment(1, fragment, 4))
}()
msg1 := readLargeMsg(t, ctrl)
_, meta1 := extractPacket(t, msg1.Body)
require.Equal(t, false, meta1["isLastDataChunk"])
require.Equal(t, int64(1), opackInt(meta1["dataChunkSequenceNumber"]))
msg2 := readLargeMsg(t, ctrl)
_, meta2 := extractPacket(t, msg2.Body)
require.Equal(t, true, meta2["isLastDataChunk"])
require.Equal(t, int64(2), opackInt(meta2["dataChunkSequenceNumber"]))
}
func TestSendMediaFragment_SequencePreserved(t *testing.T) {
acc, ctrl := newSessionPair(t)
// All chunks of a multi-chunk fragment share the same dataSequenceNumber
totalSize := maxChunkSize + 50
fragment := bytes.Repeat([]byte{0x11}, totalSize)
go func() {
require.NoError(t, acc.SendMediaFragment(1, fragment, 42))
}()
msg1 := readLargeMsg(t, ctrl)
_, meta1 := extractPacket(t, msg1.Body)
require.Equal(t, int64(42), opackInt(meta1["dataSequenceNumber"]))
msg2, err := ctrl.ReadMessage() // second chunk is small (50 bytes)
require.NoError(t, err)
_, meta2 := extractPacket(t, msg2.Body)
require.Equal(t, int64(42), opackInt(meta2["dataSequenceNumber"]))
}
// --- WriteEvent / WriteResponse / WriteRequest round-trip tests ---
func TestWriteEvent_ReadMessage(t *testing.T) {
acc, ctrl := newSessionPair(t)
go func() {
require.NoError(t, acc.WriteEvent("testProto", "testTopic", map[string]any{
"key": "value",
}))
}()
msg, err := ctrl.ReadMessage()
require.NoError(t, err)
require.Equal(t, "testProto", msg.Protocol)
require.Equal(t, "testTopic", msg.Topic)
require.True(t, msg.IsEvent)
require.Equal(t, "value", msg.Body["key"])
}
func TestWriteResponse_ReadMessage(t *testing.T) {
acc, ctrl := newSessionPair(t)
go func() {
require.NoError(t, acc.WriteResponse("proto", "topic", 5, 0, map[string]any{"ok": true}))
}()
msg, err := ctrl.ReadMessage()
require.NoError(t, err)
require.Equal(t, "proto", msg.Protocol)
require.Equal(t, "topic", msg.Topic)
require.Equal(t, int64(5), msg.ID)
require.Equal(t, int64(0), msg.Status)
require.False(t, msg.IsEvent)
require.Equal(t, true, msg.Body["ok"])
}
func TestWriteRequest_ReadMessage(t *testing.T) {
acc, ctrl := newSessionPair(t)
go func() {
id, err := acc.WriteRequest("proto", "topic", map[string]any{"x": int64(10)})
require.NoError(t, err)
require.Equal(t, int64(1), id) // first request
}()
msg, err := ctrl.ReadMessage()
require.NoError(t, err)
require.Equal(t, "proto", msg.Protocol)
require.Equal(t, "topic", msg.Topic)
require.Equal(t, int64(1), msg.ID)
require.False(t, msg.IsEvent)
}
func TestWriteRequest_IncrementingIDs(t *testing.T) {
acc, ctrl := newSessionPair(t)
go func() {
id1, _ := acc.WriteRequest("p", "t", nil)
id2, _ := acc.WriteRequest("p", "t", nil)
id3, _ := acc.WriteRequest("p", "t", nil)
require.Equal(t, int64(1), id1)
require.Equal(t, int64(2), id2)
require.Equal(t, int64(3), id3)
}()
for expected := int64(1); expected <= 3; expected++ {
msg, err := ctrl.ReadMessage()
require.NoError(t, err)
require.Equal(t, expected, msg.ID)
}
}
func TestWriteEvent_NilBody(t *testing.T) {
acc, ctrl := newSessionPair(t)
go func() {
require.NoError(t, acc.WriteEvent("p", "t", nil))
}()
msg, err := ctrl.ReadMessage()
require.NoError(t, err)
require.NotNil(t, msg.Body) // nil is replaced with empty map
}
func TestWriteResponse_NilBody(t *testing.T) {
acc, ctrl := newSessionPair(t)
go func() {
require.NoError(t, acc.WriteResponse("p", "t", 1, 0, nil))
}()
msg, err := ctrl.ReadMessage()
require.NoError(t, err)
require.NotNil(t, msg.Body)
}
// --- Helper tests ---
func TestOpackHelpers(t *testing.T) {
require.Equal(t, "", opackString(nil))
require.Equal(t, "", opackString(42))
require.Equal(t, "hello", opackString("hello"))
require.Equal(t, int64(0), opackInt(nil))
require.Equal(t, int64(0), opackInt("not a number"))
require.Equal(t, int64(42), opackInt(int64(42)))
require.Equal(t, int64(7), opackInt(int(7)))
require.Equal(t, int64(3), opackInt(float64(3.9)))
}
// --- Benchmarks ---
func BenchmarkSendMediaFragment_Small(b *testing.B) {
key := []byte(core.RandString(16, 0))
salt := core.RandString(32, 0)
c1, c2 := net.Pipe()
defer c1.Close()
defer c2.Close()
accConn, _ := NewConn(c1, key, salt, false)
ctrlConn, _ := NewConn(c2, key, salt, true)
acc := NewSession(accConn)
fragment := bytes.Repeat([]byte{0xAA}, 2000) // 2KB typical P-frame fragment
go func() {
buf := make([]byte, 64*1024)
for {
if _, err := ctrlConn.Read(buf); err != nil {
return
}
}
}()
b.SetBytes(int64(len(fragment)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = acc.SendMediaFragment(1, fragment, i)
}
}
func BenchmarkSendMediaFragment_Large(b *testing.B) {
key := []byte(core.RandString(16, 0))
salt := core.RandString(32, 0)
c1, c2 := net.Pipe()
defer c1.Close()
defer c2.Close()
accConn, _ := NewConn(c1, key, salt, false)
ctrlConn, _ := NewConn(c2, key, salt, true)
acc := NewSession(accConn)
fragment := bytes.Repeat([]byte{0xBB}, 5*1024*1024) // 5MB typical GOP
go func() {
buf := make([]byte, 512*1024)
for {
if _, err := ctrlConn.Read(buf); err != nil {
return
}
}
}()
b.SetBytes(int64(len(fragment)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = acc.SendMediaFragment(1, fragment, i)
}
}
func BenchmarkOpackMarshal_MediaBody(b *testing.B) {
data := bytes.Repeat([]byte{0xCC}, maxChunkSize)
body := map[string]any{
"streamId": 1,
"packets": []any{
map[string]any{
"data": data,
"metadata": map[string]any{
"dataType": "mediaFragment",
"dataSequenceNumber": 42,
"dataChunkSequenceNumber": 1,
"isLastDataChunk": true,
"dataTotalSize": len(data),
},
},
},
}
b.SetBytes(int64(len(data)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
OpackMarshal(body)
}
}
func BenchmarkWriteMessage(b *testing.B) {
key := []byte(core.RandString(16, 0))
salt := core.RandString(32, 0)
c1, c2 := net.Pipe()
defer c1.Close()
defer c2.Close()
accConn, _ := NewConn(c1, key, salt, false)
ctrlConn, _ := NewConn(c2, key, salt, true)
acc := NewSession(accConn)
go func() {
buf := make([]byte, 64*1024)
for {
if _, err := ctrlConn.Read(buf); err != nil {
return
}
}
}()
header := map[string]any{"protocol": "dataSend", "event": "data"}
body := map[string]any{"streamId": 1, "test": true}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = acc.WriteMessage(header, body)
}
}