diff --git a/pkg/hksv/example/main.go b/pkg/hksv/example/main.go new file mode 100644 index 00000000..a27471e5 --- /dev/null +++ b/pkg/hksv/example/main.go @@ -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 +// +// 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) +}