feat(hksv): add example CLI application for RTSP to HomeKit Secure Video streaming

This commit is contained in:
Sergey Krashevich
2026-03-06 20:39:47 +03:00
parent c567831c91
commit c5b7ba7162
+210
View File
@@ -0,0 +1,210 @@
// Example CLI application that exports an RTSP camera stream as a HomeKit
// Secure Video (HKSV) camera using the pkg/hksv library.
//
// Author: Sergei "svk" Krashevich <svk@svk.su>
//
// Usage:
//
// go run ./pkg/hksv/example -url rtsp://camera:554/stream
// go run ./pkg/hksv/example -url rtsp://admin:pass@192.168.1.100:554/h264
//
// Then open the Home app on your iPhone/iPad, tap "+" → "Add Accessory",
// and scan the QR code or enter the PIN manually (default: 270-41-991).
package main
import (
"encoding/json"
"flag"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/hap"
"github.com/AlexxIT/go2rtc/pkg/hksv"
"github.com/AlexxIT/go2rtc/pkg/mdns"
"github.com/AlexxIT/go2rtc/pkg/rtsp"
"github.com/rs/zerolog"
)
func main() {
streamURL := flag.String("url", "", "RTSP stream URL (required)")
pin := flag.String("pin", "27041991", "HomeKit pairing PIN")
port := flag.Int("port", 0, "HAP HTTP port (0 = auto)")
motion := flag.String("motion", "detect", "Motion mode: detect, continuous, api")
threshold := flag.Float64("threshold", 2.0, "Motion detection threshold (lower = more sensitive)")
pairFile := flag.String("pairings", "pairings.json", "Pairings persistence file")
flag.Parse()
if *streamURL == "" {
fmt.Fprintln(os.Stderr, "Usage: hksv-camera -url rtsp://camera/stream")
flag.PrintDefaults()
os.Exit(1)
}
log := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}).With().Timestamp().Logger()
// 1. Connect to RTSP source
client := rtsp.NewClient(*streamURL)
if err := client.Dial(); err != nil {
log.Fatal().Err(err).Msg("RTSP dial failed")
}
if err := client.Describe(); err != nil {
log.Fatal().Err(err).Msg("RTSP describe failed")
}
log.Info().Str("url", *streamURL).Int("tracks", len(client.Medias)).Msg("RTSP connected")
// Pre-setup all recvonly tracks so consumers can share receivers
for _, media := range client.Medias {
if media.Direction == core.DirectionRecvonly && len(media.Codecs) > 0 {
if _, err := client.GetTrack(media, media.Codecs[0]); err != nil {
log.Warn().Err(err).Str("media", media.String()).Msg("track setup failed")
} else {
log.Info().Str("media", media.String()).Msg("track ready")
}
}
}
// 2. Listen for HAP connections
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
if err != nil {
log.Fatal().Err(err).Msg("listen failed")
}
actualPort := uint16(ln.Addr().(*net.TCPAddr).Port)
// 3. Load saved pairings
store := &filePairingStore{path: *pairFile}
pairings := store.Load()
// 4. Create HKSV server
srv, err := hksv.NewServer(hksv.Config{
StreamName: "camera",
Pin: *pin,
HKSV: true,
MotionMode: *motion,
MotionThreshold: *threshold,
Streams: &streamProvider{client: client, log: log},
Store: store,
Pairings: pairings,
Logger: log,
Port: actualPort,
UserAgent: "hksv-example",
Version: "1.0.0",
})
if err != nil {
log.Fatal().Err(err).Msg("server create failed")
}
// 5. Start mDNS advertisement
go func() {
if err := mdns.Serve(mdns.ServiceHAP, []*mdns.ServiceEntry{srv.MDNSEntry()}); err != nil {
log.Error().Err(err).Msg("mDNS failed")
}
}()
// 6. Start RTSP streaming (after everything is set up)
go func() {
if err := client.Start(); err != nil {
log.Error().Err(err).Msg("RTSP stream ended")
}
}()
// 7. Start HTTP server for HAP protocol
mux := http.NewServeMux()
mux.HandleFunc(hap.PathPairSetup, srv.Handle)
mux.HandleFunc(hap.PathPairVerify, srv.Handle)
go func() {
if err := http.Serve(ln, mux); err != nil {
log.Fatal().Err(err).Msg("HTTP server failed")
}
}()
// Print server info
info, _ := json.MarshalIndent(srv, "", " ")
fmt.Fprintf(os.Stderr, "\nHomeKit camera ready on port %d\n%s\n\n", actualPort, info)
// Wait for shutdown signal
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Info().Msg("shutting down")
_ = client.Stop()
}
// streamProvider connects HKSV consumers to the RTSP producer.
// It implements hksv.StreamProvider.
type streamProvider struct {
client *rtsp.Conn
log zerolog.Logger
mu sync.Mutex
}
func (p *streamProvider) AddConsumer(_ string, cons core.Consumer) error {
p.mu.Lock()
defer p.mu.Unlock()
var matched int
for _, consMedia := range cons.GetMedias() {
if consMedia.Direction != core.DirectionSendonly {
continue
}
for _, prodMedia := range p.client.Medias {
prodCodec, consCodec := prodMedia.MatchMedia(consMedia)
if prodCodec == nil {
continue
}
track, err := p.client.GetTrack(prodMedia, prodCodec)
if err != nil {
p.log.Warn().Err(err).Str("codec", prodCodec.Name).Msg("get track failed")
continue
}
if err := cons.AddTrack(consMedia, consCodec, track); err != nil {
p.log.Warn().Err(err).Str("codec", consCodec.Name).Msg("add track failed")
continue
}
matched++
break
}
}
if matched == 0 {
return fmt.Errorf("no matching codecs between RTSP stream and consumer")
}
return nil
}
func (p *streamProvider) RemoveConsumer(_ string, _ core.Consumer) {}
// filePairingStore persists HomeKit pairings to a JSON file.
type filePairingStore struct {
path string
}
func (s *filePairingStore) Load() []string {
data, err := os.ReadFile(s.path)
if err != nil {
return nil
}
var pairings []string
_ = json.Unmarshal(data, &pairings)
return pairings
}
func (s *filePairingStore) SavePairings(_ string, pairings []string) error {
data, err := json.Marshal(pairings)
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0644)
}