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.
This commit is contained in:
@@ -81,6 +81,72 @@ homekit:
|
|||||||
device_private: dahua1 # custom key, default: generated from stream ID
|
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**
|
**Proxy HomeKit camera**
|
||||||
|
|
||||||
- Video stream from HomeKit camera to Apple device (iPhone, Apple TV) will be transmitted directly
|
- Video stream from HomeKit camera to Apple device (iPhone, Apple TV) will be transmitted directly
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ func Init() {
|
|||||||
DevicePrivate string `yaml:"device_private"`
|
DevicePrivate string `yaml:"device_private"`
|
||||||
CategoryID string `yaml:"category_id"`
|
CategoryID string `yaml:"category_id"`
|
||||||
Pairings []string `yaml:"pairings"`
|
Pairings []string `yaml:"pairings"`
|
||||||
|
HKSV bool `yaml:"hksv"`
|
||||||
|
Motion string `yaml:"motion"`
|
||||||
} `yaml:"homekit"`
|
} `yaml:"homekit"`
|
||||||
}
|
}
|
||||||
app.LoadConfig(&cfg)
|
app.LoadConfig(&cfg)
|
||||||
@@ -36,6 +38,8 @@ func Init() {
|
|||||||
|
|
||||||
api.HandleFunc("api/homekit", apiHomekit)
|
api.HandleFunc("api/homekit", apiHomekit)
|
||||||
api.HandleFunc("api/homekit/accessories", apiHomekitAccessories)
|
api.HandleFunc("api/homekit/accessories", apiHomekitAccessories)
|
||||||
|
api.HandleFunc("api/homekit/motion", apiMotion)
|
||||||
|
api.HandleFunc("api/homekit/doorbell", apiDoorbell)
|
||||||
api.HandleFunc("api/discovery/homekit", apiDiscovery)
|
api.HandleFunc("api/discovery/homekit", apiDiscovery)
|
||||||
|
|
||||||
if cfg.Mod == nil {
|
if cfg.Mod == nil {
|
||||||
@@ -102,8 +106,16 @@ func Init() {
|
|||||||
if url := findHomeKitURL(stream.Sources()); url != "" {
|
if url := findHomeKitURL(stream.Sources()); url != "" {
|
||||||
// 1. Act as transparent proxy for HomeKit camera
|
// 1. Act as transparent proxy for HomeKit camera
|
||||||
srv.proxyURL = url
|
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 {
|
} else {
|
||||||
// 2. Act as basic HomeKit camera
|
// 3. Act as basic HomeKit camera
|
||||||
srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version)
|
srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +201,37 @@ func findHomeKitURL(sources []string) string {
|
|||||||
return ""
|
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 {
|
func parseBitrate(s string) int {
|
||||||
n := len(s)
|
n := len(s)
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
|
|||||||
+118
-2
@@ -14,6 +14,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/app"
|
"github.com/AlexxIT/go2rtc/internal/app"
|
||||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
||||||
@@ -42,6 +43,10 @@ type server struct {
|
|||||||
proxyURL string
|
proxyURL string
|
||||||
setupID string
|
setupID string
|
||||||
stream string // stream name from YAML
|
stream string // stream name from YAML
|
||||||
|
|
||||||
|
// HKSV fields
|
||||||
|
motionMode string // "api", "continuous"
|
||||||
|
hksvSession *hksvSession
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) MarshalJSON() ([]byte, error) {
|
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)
|
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 your iPhone goes to sleep, it will be an EOF error.
|
||||||
if err = handler(controller); err != nil && !errors.Is(err, io.EOF) {
|
if err = handler(controller); err != nil {
|
||||||
log.Error().Err(err).Caller().Send()
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,6 +237,12 @@ func (s *server) PatchConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) GetAccessories(_ net.Conn) []*hap.Accessory {
|
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}
|
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)
|
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
|
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 {
|
func calcName(name, seed string) string {
|
||||||
if name != "" {
|
if name != "" {
|
||||||
return name
|
return name
|
||||||
|
|||||||
@@ -19,6 +19,66 @@ func NewAccessory(manuf, model, name, serial, firmware string) *hap.Accessory {
|
|||||||
return acc
|
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 {
|
func ServiceMicrophone() *hap.Service {
|
||||||
return &hap.Service{
|
return &hap.Service{
|
||||||
Type: "112", // 'Microphone'
|
Type: "112", // 'Microphone'
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ package camera
|
|||||||
|
|
||||||
const TypeSupportedAudioRecordingConfiguration = "207"
|
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 {
|
type SupportedAudioRecordingConfiguration struct {
|
||||||
CodecConfigs []AudioRecordingCodecConfiguration `tlv8:"1"`
|
CodecConfigs []AudioRecordingCodecConfiguration `tlv8:"1"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+19
-2
@@ -68,14 +68,31 @@ func ServerHandler(server Server) HandlerFunc {
|
|||||||
AID uint8 `json:"aid"`
|
AID uint8 `json:"aid"`
|
||||||
IID uint64 `json:"iid"`
|
IID uint64 `json:"iid"`
|
||||||
Value any `json:"value"`
|
Value any `json:"value"`
|
||||||
|
Event any `json:"ev"`
|
||||||
} `json:"characteristics"`
|
} `json:"characteristics"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(req.Body).Decode(&v); err != nil {
|
if err := json.NewDecoder(req.Body).Decode(&v); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, char := range v.Value {
|
for _, c := range v.Value {
|
||||||
server.SetCharacteristic(conn, char.AID, char.IID, char.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{
|
res := &http.Response{
|
||||||
|
|||||||
@@ -1059,6 +1059,58 @@ paths:
|
|||||||
"404":
|
"404":
|
||||||
description: Stream not found
|
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:
|
/api/homekit/accessories:
|
||||||
get:
|
get:
|
||||||
summary: Get HomeKit accessories JSON for a stream
|
summary: Get HomeKit accessories JSON for a stream
|
||||||
|
|||||||
@@ -331,6 +331,20 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user