From ab27a042c18034effaddf1286bb86f604b449f54 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Wed, 4 Mar 2026 13:50:50 +0300 Subject: [PATCH] feat(homekit): add HKSV support with motion detection and doorbell functionality - Introduced HKSV configuration options in homekit.go, allowing for motion detection and doorbell features. - Implemented API endpoints for triggering motion detection and doorbell events. - Enhanced server.go to handle HKSV sessions and manage motion detection states. - Created new accessory types for HKSV and doorbell in accessory.go. - Added support for audio recording configurations in ch207.go. - Defined new services for motion detection and doorbell in services_hksv.go. - Implemented opack encoding/decoding for HDS protocol in opack.go and protocol.go. - Updated OpenAPI documentation to reflect new endpoints and features. - Extended schema.json to include HKSV configuration options. --- internal/homekit/README.md | 66 +++++ internal/homekit/hksv.go | 289 ++++++++++++++++++++++ internal/homekit/homekit.go | 45 +++- internal/homekit/server.go | 120 ++++++++- pkg/hap/camera/accessory.go | 60 +++++ pkg/hap/camera/ch207.go | 13 + pkg/hap/camera/services_hksv.go | 194 +++++++++++++++ pkg/hap/hds/opack.go | 416 ++++++++++++++++++++++++++++++++ pkg/hap/hds/protocol.go | 266 ++++++++++++++++++++ pkg/homekit/server.go | 21 +- website/api/openapi.yaml | 52 ++++ www/schema.json | 14 ++ 12 files changed, 1551 insertions(+), 5 deletions(-) create mode 100644 internal/homekit/hksv.go create mode 100644 pkg/hap/camera/services_hksv.go create mode 100644 pkg/hap/hds/opack.go create mode 100644 pkg/hap/hds/protocol.go diff --git a/internal/homekit/README.md b/internal/homekit/README.md index 0e78fcc5..638bfa9f 100644 --- a/internal/homekit/README.md +++ b/internal/homekit/README.md @@ -81,6 +81,72 @@ homekit: device_private: dahua1 # custom key, default: generated from stream ID ``` +### HKSV (HomeKit Secure Video) + +go2rtc can expose any camera as a HomeKit Secure Video (HKSV) camera. This allows Apple Home to record video clips to iCloud when motion is detected. + +**Requirements:** +- Apple Home Hub (Apple TV, HomePod or iPad) on the same network +- iCloud storage plan with HomeKit Secure Video support +- Camera source with H264 video (AAC audio recommended) + +**Minimal HKSV config** + +```yaml +streams: + outdoor: rtsp://admin:password@192.168.1.123/stream1 + +homekit: + outdoor: + hksv: true # enable HomeKit Secure Video + motion: continuous # always report motion, Home Hub decides what to record +``` + +**Full HKSV config** + +```yaml +streams: + outdoor: + - rtsp://admin:password@192.168.1.123/stream1 + - ffmpeg:outdoor#video=h264#hardware # transcode to H264 if needed + - ffmpeg:outdoor#audio=aac # AAC-LC audio for HKSV recording + +homekit: + outdoor: + pin: 12345678 + name: Outdoor Camera + hksv: true + motion: api # motion triggered via API +``` + +**HKSV Doorbell config** + +```yaml +homekit: + front_door: + category_id: doorbell + hksv: true + motion: api +``` + +**Motion modes:** + +- `continuous` — MotionDetected is always true; Home Hub continuously receives video and decides what to save. Simplest setup, recommended for most cameras. +- `api` — motion is triggered externally via HTTP API. Use this with Frigate, ONVIF events, or any other motion detection system. + +**Motion API:** + +```bash +# Trigger motion start +curl -X POST "http://localhost:1984/api/homekit/motion?id=outdoor" + +# Clear motion +curl -X DELETE "http://localhost:1984/api/homekit/motion?id=outdoor" + +# Trigger doorbell ring +curl -X POST "http://localhost:1984/api/homekit/doorbell?id=front_door" +``` + **Proxy HomeKit camera** - Video stream from HomeKit camera to Apple device (iPhone, Apple TV) will be transmitted directly diff --git a/internal/homekit/hksv.go b/internal/homekit/hksv.go new file mode 100644 index 00000000..81380970 --- /dev/null +++ b/internal/homekit/hksv.go @@ -0,0 +1,289 @@ +package homekit + +import ( + "io" + "net" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/hap" + "github.com/AlexxIT/go2rtc/pkg/hap/hds" + "github.com/AlexxIT/go2rtc/pkg/mp4" + "github.com/pion/rtp" +) + +// hksvSession manages the HDS DataStream connection for HKSV recording +type hksvSession struct { + server *server + hapConn *hap.Conn + hdsConn *hds.Conn + session *hds.Session + + mu sync.Mutex + consumer *hksvConsumer +} + +func newHKSVSession(srv *server, hapConn *hap.Conn, hdsConn *hds.Conn) *hksvSession { + session := hds.NewSession(hdsConn) + hs := &hksvSession{ + server: srv, + hapConn: hapConn, + hdsConn: hdsConn, + session: session, + } + session.OnDataSendOpen = hs.handleOpen + session.OnDataSendClose = hs.handleClose + return hs +} + +func (hs *hksvSession) Run() error { + return hs.session.Run() +} + +func (hs *hksvSession) Close() { + hs.mu.Lock() + defer hs.mu.Unlock() + if hs.consumer != nil { + hs.stopRecording() + } + _ = hs.session.Close() +} + +func (hs *hksvSession) handleOpen(streamID int) error { + hs.mu.Lock() + defer hs.mu.Unlock() + + log.Debug().Str("stream", hs.server.stream).Int("streamID", streamID).Msg("[homekit] HKSV dataSend open") + + if hs.consumer != nil { + hs.stopRecording() + } + + consumer := newHKSVConsumer(hs.session, streamID) + hs.consumer = consumer + + stream := streams.Get(hs.server.stream) + if err := stream.AddConsumer(consumer); err != nil { + log.Error().Err(err).Str("stream", hs.server.stream).Msg("[homekit] HKSV add consumer failed") + hs.consumer = nil + return nil // don't kill the session + } + + hs.server.AddConn(consumer) + + // wait for tracks to be added, then send init + go func() { + if err := consumer.waitAndSendInit(); err != nil { + log.Error().Err(err).Str("stream", hs.server.stream).Msg("[homekit] HKSV send init failed") + } + }() + + return nil +} + +func (hs *hksvSession) handleClose(streamID int) error { + hs.mu.Lock() + defer hs.mu.Unlock() + + log.Debug().Str("stream", hs.server.stream).Int("streamID", streamID).Msg("[homekit] HKSV dataSend close") + + if hs.consumer != nil { + hs.stopRecording() + } + return nil +} + +func (hs *hksvSession) stopRecording() { + consumer := hs.consumer + hs.consumer = nil + + stream := streams.Get(hs.server.stream) + stream.RemoveConsumer(consumer) + _ = consumer.Stop() + hs.server.DelConn(consumer) +} + +// hksvConsumer implements core.Consumer, generates fMP4 and sends over HDS +type hksvConsumer struct { + core.Connection + session *hds.Session + muxer *mp4.Muxer + streamID int + seqNum int + mu sync.Mutex + start bool + done chan struct{} +} + +func newHKSVConsumer(session *hds.Session, streamID int) *hksvConsumer { + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecH264}, + }, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecAAC}, + }, + }, + } + return &hksvConsumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "hksv", + Protocol: "hds", + Medias: medias, + }, + session: session, + muxer: &mp4.Muxer{}, + streamID: streamID, + done: make(chan struct{}), + } +} + +func (c *hksvConsumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + trackID := byte(len(c.Senders)) + + codec := track.Codec.Clone() + handler := core.NewSender(media, codec) + + switch track.Codec.Name { + case core.CodecH264: + handler.Handler = func(packet *rtp.Packet) { + if !c.start { + if !h264.IsKeyframe(packet.Payload) { + return + } + c.start = true + } + + c.mu.Lock() + b := c.muxer.GetPayload(trackID, packet) + if err := c.session.SendMediaFragment(c.streamID, b, c.seqNum); err == nil { + c.Send += len(b) + c.seqNum++ + } + c.mu.Unlock() + } + + if track.Codec.IsRTP() { + handler.Handler = h264.RTPDepay(track.Codec, handler.Handler) + } else { + handler.Handler = h264.RepairAVCC(track.Codec, handler.Handler) + } + + case core.CodecAAC: + handler.Handler = func(packet *rtp.Packet) { + if !c.start { + return + } + + c.mu.Lock() + b := c.muxer.GetPayload(trackID, packet) + if err := c.session.SendMediaFragment(c.streamID, b, c.seqNum); err == nil { + c.Send += len(b) + c.seqNum++ + } + c.mu.Unlock() + } + + if track.Codec.IsRTP() { + handler.Handler = aac.RTPDepay(handler.Handler) + } + + default: + return nil // skip unsupported codecs + } + + c.muxer.AddTrack(codec) + handler.HandleRTP(track) + c.Senders = append(c.Senders, handler) + + return nil +} + +func (c *hksvConsumer) waitAndSendInit() error { + // wait for at least one track to be added + for i := 0; i < 50; i++ { + if len(c.Senders) > 0 { + break + } + time.Sleep(100 * time.Millisecond) + } + + init, err := c.muxer.GetInit() + if err != nil { + return err + } + return c.session.SendMediaInit(c.streamID, init) +} + +func (c *hksvConsumer) WriteTo(io.Writer) (int64, error) { + <-c.done + return 0, nil +} + +func (c *hksvConsumer) Stop() error { + select { + case <-c.done: + default: + close(c.done) + } + return c.Connection.Stop() +} + +// acceptHDS opens a TCP listener for the HDS DataStream connection from the Home Hub +func (s *server) acceptHDS(hapConn *hap.Conn, ln net.Listener, salt string) { + defer ln.Close() + + if tcpLn, ok := ln.(*net.TCPListener); ok { + _ = tcpLn.SetDeadline(time.Now().Add(30 * time.Second)) + } + + rawConn, err := ln.Accept() + if err != nil { + log.Error().Err(err).Str("stream", s.stream).Msg("[homekit] HKSV accept failed") + return + } + defer rawConn.Close() + + // Create HDS encrypted connection (controller=false, we are accessory) + hdsConn, err := hds.NewConn(rawConn, hapConn.SharedKey, salt, false) + if err != nil { + log.Error().Err(err).Str("stream", s.stream).Msg("[homekit] HKSV hds conn failed") + return + } + + s.AddConn(hdsConn) + defer s.DelConn(hdsConn) + + session := newHKSVSession(s, hapConn, hdsConn) + + s.mu.Lock() + s.hksvSession = session + s.mu.Unlock() + + defer func() { + s.mu.Lock() + if s.hksvSession == session { + s.hksvSession = nil + } + s.mu.Unlock() + session.Close() + }() + + log.Debug().Str("stream", s.stream).Msg("[homekit] HKSV session started") + + if err := session.Run(); err != nil { + log.Debug().Err(err).Str("stream", s.stream).Msg("[homekit] HKSV session ended") + } +} diff --git a/internal/homekit/homekit.go b/internal/homekit/homekit.go index 59b84b3b..275d78be 100644 --- a/internal/homekit/homekit.go +++ b/internal/homekit/homekit.go @@ -26,6 +26,8 @@ func Init() { DevicePrivate string `yaml:"device_private"` CategoryID string `yaml:"category_id"` Pairings []string `yaml:"pairings"` + HKSV bool `yaml:"hksv"` + Motion string `yaml:"motion"` } `yaml:"homekit"` } app.LoadConfig(&cfg) @@ -36,6 +38,8 @@ func Init() { api.HandleFunc("api/homekit", apiHomekit) api.HandleFunc("api/homekit/accessories", apiHomekitAccessories) + api.HandleFunc("api/homekit/motion", apiMotion) + api.HandleFunc("api/homekit/doorbell", apiDoorbell) api.HandleFunc("api/discovery/homekit", apiDiscovery) if cfg.Mod == nil { @@ -102,8 +106,16 @@ func Init() { if url := findHomeKitURL(stream.Sources()); url != "" { // 1. Act as transparent proxy for HomeKit camera srv.proxyURL = url + } else if conf.HKSV { + // 2. Act as HKSV camera + srv.motionMode = conf.Motion + if conf.CategoryID == "doorbell" { + srv.accessory = camera.NewHKSVDoorbellAccessory("AlexxIT", "go2rtc", name, "-", app.Version) + } else { + srv.accessory = camera.NewHKSVAccessory("AlexxIT", "go2rtc", name, "-", app.Version) + } } else { - // 2. Act as basic HomeKit camera + // 3. Act as basic HomeKit camera srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version) } @@ -189,6 +201,37 @@ func findHomeKitURL(sources []string) string { return "" } +func apiMotion(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + srv := servers[id] + if srv == nil { + http.Error(w, "server not found: "+id, http.StatusNotFound) + return + } + switch r.Method { + case "POST": + srv.SetMotionDetected(true) + case "DELETE": + srv.SetMotionDetected(false) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func apiDoorbell(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + id := r.URL.Query().Get("id") + srv := servers[id] + if srv == nil { + http.Error(w, "server not found: "+id, http.StatusNotFound) + return + } + srv.TriggerDoorbell() +} + func parseBitrate(s string) int { n := len(s) if n == 0 { diff --git a/internal/homekit/server.go b/internal/homekit/server.go index 86cfbc15..29987395 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" @@ -42,6 +43,10 @@ type server struct { proxyURL string setupID string stream string // stream name from YAML + + // HKSV fields + motionMode string // "api", "continuous" + hksvSession *hksvSession } func (s *server) MarshalJSON() ([]byte, error) { @@ -120,9 +125,15 @@ func (s *server) Handle(w http.ResponseWriter, r *http.Request) { handler = homekit.ProxyHandler(s, client.Conn) } + log.Debug().Str("stream", s.stream).Msgf("[homekit] handler started for %s", conn.RemoteAddr()) + // If your iPhone goes to sleep, it will be an EOF error. - if err = handler(controller); err != nil && !errors.Is(err, io.EOF) { - log.Error().Err(err).Caller().Send() + if err = handler(controller); err != nil { + if errors.Is(err, io.EOF) { + log.Debug().Str("stream", s.stream).Msgf("[homekit] %s: connection closed (EOF)", conn.RemoteAddr()) + } else { + log.Error().Err(err).Str("stream", s.stream).Caller().Send() + } return } } @@ -226,6 +237,12 @@ func (s *server) PatchConfig() { } func (s *server) GetAccessories(_ net.Conn) []*hap.Accessory { + log.Trace().Str("stream", s.stream).Msg("[homekit] GET /accessories") + if log.Trace().Enabled() { + if b, err := json.Marshal(s.accessory); err == nil { + log.Trace().Str("stream", s.stream).RawJSON("accessory", b).Msg("[homekit] accessory JSON") + } + } return []*hap.Accessory{s.accessory} } @@ -321,6 +338,65 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a s.DelConn(consumer) }() } + + case camera.TypeSetupDataStreamTransport: + var req camera.SetupDataStreamTransportRequest + if err := tlv8.UnmarshalBase64(value, &req); err != nil { + log.Error().Err(err).Str("stream", s.stream).Msg("[homekit] HKSV parse ch131 failed") + return + } + + log.Debug().Str("stream", s.stream).Uint8("cmd", req.SessionCommandType). + Uint8("transport", req.TransportType).Msg("[homekit] HKSV DataStream setup") + + if req.SessionCommandType != 0 { + // 0 = start, 1 = close + log.Debug().Str("stream", s.stream).Msg("[homekit] HKSV DataStream close request") + if s.hksvSession != nil { + s.hksvSession.Close() + } + return + } + + accessoryKeySalt := core.RandString(32, 0) + combinedSalt := req.ControllerKeySalt + accessoryKeySalt + + ln, err := net.ListenTCP("tcp", nil) + if err != nil { + log.Error().Err(err).Str("stream", s.stream).Msg("[homekit] HKSV listen failed") + return + } + port := ln.Addr().(*net.TCPAddr).Port + + resp := camera.SetupDataStreamTransportResponse{ + Status: 0, + AccessoryKeySalt: accessoryKeySalt, + } + resp.TransportTypeSessionParameters.TCPListeningPort = uint16(port) + + v, err := tlv8.MarshalBase64(resp) + if err != nil { + ln.Close() + return + } + char.Value = v + + log.Debug().Str("stream", s.stream).Int("port", port).Msg("[homekit] HKSV listening for HDS") + + hapConn := conn.(*hap.Conn) + go s.acceptHDS(hapConn, ln, combinedSalt) + + case camera.TypeSelectedCameraRecordingConfiguration: + log.Debug().Str("stream", s.stream).Msg("[homekit] HKSV selected recording config") + char.Value = value + + if s.motionMode == "continuous" { + go s.startContinuousMotion() + } + + default: + // Store value for all other writable characteristics + char.Value = value } } @@ -351,6 +427,46 @@ func (s *server) GetImage(conn net.Conn, width, height int) []byte { return b } +func (s *server) SetMotionDetected(detected bool) { + if s.accessory == nil { + return + } + char := s.accessory.GetCharacter("22") // MotionDetected + if char == nil { + return + } + char.Value = detected + _ = char.NotifyListeners(nil) + log.Debug().Str("stream", s.stream).Bool("motion", detected).Msg("[homekit] motion") +} + +func (s *server) TriggerDoorbell() { + if s.accessory == nil { + return + } + char := s.accessory.GetCharacter("73") // ProgrammableSwitchEvent + if char == nil { + return + } + char.Value = 0 // SINGLE_PRESS + _ = char.NotifyListeners(nil) + log.Debug().Str("stream", s.stream).Msg("[homekit] doorbell") +} + +func (s *server) startContinuousMotion() { + s.SetMotionDetected(true) + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for range ticker.C { + if s.accessory == nil { + return + } + s.SetMotionDetected(true) + } +} + func calcName(name, seed string) string { if name != "" { return name diff --git a/pkg/hap/camera/accessory.go b/pkg/hap/camera/accessory.go index 37724497..35f4a0de 100644 --- a/pkg/hap/camera/accessory.go +++ b/pkg/hap/camera/accessory.go @@ -19,6 +19,66 @@ func NewAccessory(manuf, model, name, serial, firmware string) *hap.Accessory { 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(), + motionSensor, + operatingMode, + recordingMgmt, + dataStreamMgmt, + }, + } + acc.InitIID() + + // CameraOperatingMode links to RTPStreamManagement and RecordingManagement + operatingMode.Linked = []int{int(rtpStream.IID), int(recordingMgmt.IID)} + // CameraEventRecordingManagement links to DataStreamManagement and MotionSensor + recordingMgmt.Linked = []int{int(dataStreamMgmt.IID), int(motionSensor.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(), + motionSensor, + operatingMode, + recordingMgmt, + dataStreamMgmt, + doorbell, + }, + } + acc.InitIID() + + // CameraOperatingMode links to RTPStreamManagement and RecordingManagement + operatingMode.Linked = []int{int(rtpStream.IID), int(recordingMgmt.IID)} + // CameraEventRecordingManagement links to DataStreamManagement, MotionSensor, and Doorbell + recordingMgmt.Linked = []int{int(dataStreamMgmt.IID), int(motionSensor.IID), int(doorbell.IID)} + + return acc +} + func ServiceMicrophone() *hap.Service { return &hap.Service{ Type: "112", // 'Microphone' diff --git a/pkg/hap/camera/ch207.go b/pkg/hap/camera/ch207.go index 5d389923..1a7ffac0 100644 --- a/pkg/hap/camera/ch207.go +++ b/pkg/hap/camera/ch207.go @@ -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"` } diff --git a/pkg/hap/camera/services_hksv.go b/pkg/hap/camera/services_hksv.go new file mode 100644 index 00000000..d05c44c4 --- /dev/null +++ b/pkg/hap/camera/services_hksv.go @@ -0,0 +1,194 @@ +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}, + }, + }, + }, + }, + }) + + 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: "", + 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, + }, + }, + } +} diff --git a/pkg/hap/hds/opack.go b/pkg/hap/hds/opack.go new file mode 100644 index 00000000..2ace225b --- /dev/null +++ b/pkg/hap/hds/opack.go @@ -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 +} diff --git a/pkg/hap/hds/protocol.go b/pkg/hap/hds/protocol.go new file mode 100644 index 00000000..42520332 --- /dev/null +++ b/pkg/hap/hds/protocol.go @@ -0,0 +1,266 @@ +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) +} + +// SendMediaInit sends the fMP4 initialization segment (ftyp+moov) +func (s *Session) SendMediaInit(streamID int, initData []byte) error { + return s.WriteEvent(ProtoDataSend, TopicData, map[string]any{ + "streamId": streamID, + "packets": 1, + "type": "mediaInitialization", + "data": initData, + }) +} + +// SendMediaFragment sends an fMP4 fragment (moof+mdat) +func (s *Session) SendMediaFragment(streamID int, fragment []byte, sequence int) error { + return s.WriteEvent(ProtoDataSend, TopicData, map[string]any{ + "streamId": streamID, + "packets": 1, + "type": "mediaFragment", + "data": fragment, + "dataSequenceNumber": sequence, + "isLastDataChunk": true, + "dataChunkSequenceNumber": 0, + }) +} + +// 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 +} diff --git a/pkg/homekit/server.go b/pkg/homekit/server.go index 75ba2a0f..cf8aaeb0 100644 --- a/pkg/homekit/server.go +++ b/pkg/homekit/server.go @@ -68,14 +68,31 @@ func ServerHandler(server Server) HandlerFunc { AID uint8 `json:"aid"` IID uint64 `json:"iid"` Value any `json:"value"` + Event any `json:"ev"` } `json:"characteristics"` } if err := json.NewDecoder(req.Body).Decode(&v); err != nil { return nil, err } - for _, char := range v.Value { - server.SetCharacteristic(conn, char.AID, char.IID, char.Value) + for _, c := range v.Value { + if c.Value != nil { + server.SetCharacteristic(conn, c.AID, c.IID, c.Value) + } + if c.Event != nil { + // subscribe/unsubscribe to events + accs := server.GetAccessories(conn) + for _, acc := range accs { + if char := acc.GetCharacterByID(c.IID); char != nil { + if ev, ok := c.Event.(bool); ok && ev { + char.AddListener(conn) + } else { + char.RemoveListener(conn) + } + break + } + } + } } res := &http.Response{ diff --git a/website/api/openapi.yaml b/website/api/openapi.yaml index b6110572..c116728d 100644 --- a/website/api/openapi.yaml +++ b/website/api/openapi.yaml @@ -1059,6 +1059,58 @@ paths: "404": description: Stream not found + /api/homekit/motion: + post: + summary: Trigger motion detection for HKSV camera + description: Sets MotionDetected characteristic to true, which triggers the Home Hub to start recording. + tags: [ HomeKit ] + parameters: + - name: id + in: query + description: Stream name / server ID + required: true + schema: { type: string } + example: outdoor + responses: + "200": + description: Motion triggered + "404": + description: Server not found + delete: + summary: Clear motion detection for HKSV camera + description: Sets MotionDetected characteristic to false. + tags: [ HomeKit ] + parameters: + - name: id + in: query + description: Stream name / server ID + required: true + schema: { type: string } + example: outdoor + responses: + "200": + description: Motion cleared + "404": + description: Server not found + + /api/homekit/doorbell: + post: + summary: Trigger doorbell ring event + description: Sends ProgrammableSwitchEvent to Home Hub, triggering a doorbell notification on Apple devices. + tags: [ HomeKit ] + parameters: + - name: id + in: query + description: Stream name / server ID (must have category_id=doorbell) + required: true + schema: { type: string } + example: front_door + responses: + "200": + description: Doorbell event sent + "404": + description: Server not found + /api/homekit/accessories: get: summary: Get HomeKit accessories JSON for a stream diff --git a/www/schema.json b/www/schema.json index 27fee57d..4eaa96ff 100644 --- a/www/schema.json +++ b/www/schema.json @@ -331,6 +331,20 @@ "items": { "type": "string" } + }, + "hksv": { + "description": "Enable HomeKit Secure Video recording support", + "type": "boolean", + "default": false + }, + "motion": { + "description": "Motion detection mode for HKSV: `api` (triggered via HTTP API) or `continuous` (always report motion)", + "type": "string", + "enum": [ + "api", + "continuous" + ], + "default": "api" } } }