From cdfa63a5e79731d1535d0c42e7e3f0f063415789 Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Thu, 6 Oct 2022 23:22:08 +0100 Subject: [PATCH] Initial commit --- .gitignore | 4 ++++ LICENSE | 14 ++++++++++++++ README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ blueiris/lib.go | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/hkbi/main.go | 580 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ config-example.toml | 7 +++++++ go.mod | 22 ++++++++++++++++++++++ go.sum | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 926 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 blueiris/lib.go create mode 100644 cmd/hkbi/main.go create mode 100644 config-example.toml create mode 100644 go.mod create mode 100644 go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6beb092 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea/ +config.toml +data/ +main diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8c3bdb1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + +Copyright (C) 2004 Sam Hocevar + +Everyone is permitted to copy and distribute verbatim or modified +copies of this license document, and changing it is allowed as long +as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1997b8 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# `hkbi` + +**Easy native HomeKit-BlueIris integration** + +`hkbi` allows you to natively integrate BlueIris into your home without +the effort of dealing with WebRTC, or attempting to configure generic +solutions to deal with BlueIris. With `hkbi` you simply need to pass a +config file containing your BlueIris credentials and set up a trigger +for motion alerts via HomeKit. + +### Usage + +```bash +$ hkbi ./config.toml +``` + +To pair, enter the pin `11111112`. + +### Config + +```toml +listen-address = "0.0.0.0:53238" +data-dir = "/var/lib/hkbi/" + +[blueiris] +instance = "http://127.0.0.1:81" +username = "abcdef" +password = "123456" +``` + +### BlueIris Trigger Setup + +Go to your camera's settings, select `Trigger` and enable `Motion Sensor`. Now go to the +`Alerts` tab, and create an `On alert...` HTTP request pointing to +`http://127.0.0.1:3333/trigger?state=on&cam=&CAM`. Do the same for `On reset...` but with +`state=off`. `&CAM` is a magic value in BlueIris referring to your camera's ID. + +### Alternatives + +There's a major open-source community around HomeKit, and security systems +in home automation - with various ways of integrating BlueIris into them. + +- [hkcam](https://github.com/brutella/hkcam) +- [Scrypted](https://github.com/koush/scrypted) +- [ha-blueiris](https://github.com/elad-bar/ha-blueiris) diff --git a/blueiris/lib.go b/blueiris/lib.go new file mode 100644 index 0000000..5384a6e --- /dev/null +++ b/blueiris/lib.go @@ -0,0 +1,186 @@ +package blueiris + +import ( + "bytes" + "crypto/md5" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sync" +) + +type BlueirisConfig struct { + Instance string + Username string + Password string +} + +type Blueiris struct { + mutex *sync.RWMutex + sessionToken string + BaseUrl *url.URL + username string + password string +} + +func NewBlueiris(config BlueirisConfig) (*Blueiris, error) { + base, err := url.Parse(config.Instance) + if err != nil { + return nil, err + } + + bi := &Blueiris{ + mutex: &sync.RWMutex{}, + sessionToken: "", + BaseUrl: base, + username: config.Username, + password: config.Password, + } + + err = bi.login() + if err != nil { + return nil, err + } + + return bi, nil +} + +func (b *Blueiris) getSessionToken() string { + cmd := struct { + Cmd string `json:"cmd"` + }{ + Cmd: "login", + } + + var result struct { + Session string `json:"session"` + } + + _ = b.sendRequest(cmd, &result) + + return result.Session +} + +func (b *Blueiris) login() error { + b.mutex.Lock() + defer b.mutex.Unlock() + + b.sessionToken = b.getSessionToken() + + tokenHash := md5.Sum([]byte(fmt.Sprintf("%s:%s:%s", b.username, b.sessionToken, b.password))) + token := hex.EncodeToString(tokenHash[:]) + + cmd := struct { + Cmd string `json:"cmd"` + Session string `json:"session"` + Response string `json:"response"` + }{ + Cmd: "login", + Session: b.sessionToken, + Response: token, + } + + var result struct { + Result string `json:"result"` + } + + err := b.sendRequest(cmd, &result) + if err != nil { + return err + } else if result.Result != "success" { + return errors.New("unsuccessful login") + } + + return nil +} + +type Camera struct { + Id string `json:"optionValue"` + Name string `json:"optionDisplay"` + IsOnline bool `json:"isOnline"` + HasAudio bool `json:"audio"` + IsGroup bool `json:"group"` + IsSystem bool `json:"is_system"` + Type int `json:"type"` +} + +func (b *Blueiris) ListCameras() ([]Camera, error) { + b.mutex.RLock() + defer b.mutex.RUnlock() + + request := struct { + Cmd string `json:"cmd"` + Session string `json:"session"` + }{ + Cmd: "camlist", + Session: b.sessionToken, + } + + var response struct { + Data []Camera `json:"data"` + } + + err := b.sendRequest(request, &response) + if err != nil { + return nil, err + } + + return response.Data, nil +} + +func (b *Blueiris) triggerCamera(camera string) error { + b.mutex.RLock() + defer b.mutex.RUnlock() + + request := struct { + Cmd string `json:"cmd"` + Camera string `json:"camera"` + Session string `json:"session"` + }{ + Cmd: "trigger", + Camera: camera, + Session: b.sessionToken, + } + + response := struct{}{} + + err := b.sendRequest(request, &response) + if err != nil { + return err + } + + return nil +} + +func (b *Blueiris) FetchSnapshot(camera string) (*http.Request, error) { + uri := b.BaseUrl.JoinPath("image", camera) + uri.User = url.UserPassword(b.username, b.password) + return http.NewRequest("GET", uri.String(), nil) +} + +// TODO: try to renew session if result == 'fail' +func (b *Blueiris) sendRequest(request interface{}, res any) error { + buf, err := json.Marshal(request) + if err != nil { + return err + } + + reader := bytes.NewReader(buf) + + uri := b.BaseUrl.JoinPath("json") + response, err := http.Post(uri.String(), "application/json", reader) + if err != nil { + return err + } + + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return err + } + + return json.Unmarshal(responseBytes, res) +} diff --git a/cmd/hkbi/main.go b/cmd/hkbi/main.go new file mode 100644 index 0000000..abc2559 --- /dev/null +++ b/cmd/hkbi/main.go @@ -0,0 +1,580 @@ +package main + +import ( + "context" + "encoding/gob" + "encoding/hex" + "encoding/json" + "fmt" + "github.com/BurntSushi/toml" + "github.com/brutella/hap" + "github.com/brutella/hap/accessory" + "github.com/brutella/hap/characteristic" + "github.com/brutella/hap/log" + "github.com/brutella/hap/rtp" + "github.com/brutella/hap/service" + "github.com/brutella/hap/tlv8" + "github.com/w4/hkbi/blueiris" + "io" + "math/rand" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "sync" + "syscall" +) + +type Config struct { + ListenAddress string `toml:"listen-address"` + DataDir string `toml:"data-dir"` + Blueiris blueiris.BlueirisConfig +} + +type GlobalState struct { + ssrcVideo int32 + ssrcAudio int32 + blueiris *blueiris.Blueiris +} + +func main() { + log.Info.Enable() + log.Debug.Enable() + + if len(os.Args) != 2 || os.Args[1] == "" { + log.Info.Fatalf("usage: %s /path/to/config.toml", os.Args[0]) + } + + var config = readConfig(os.Args[1]) + run(config) +} + +func readConfig(path string) Config { + var cfg Config + _, err := toml.DecodeFile(path, &cfg) + if err != nil { + log.Info.Panic(err) + } + + return cfg +} + +func run(config Config) { + bi, err := blueiris.NewBlueiris(config.Blueiris) + if err != nil { + log.Info.Fatalf("failed to login to bi: %s\n", err) + } + + globalState := &GlobalState{ + ssrcVideo: rand.Int31(), + ssrcAudio: rand.Int31(), + } + + // fetch cameras from BlueIris + biCameras, err := bi.ListCameras() + if err != nil { + log.Info.Fatalf("failed to load cameras from bi: %s\n", err) + } + + err = os.MkdirAll(config.DataDir, os.FileMode(0755)) + if err != nil { + log.Info.Fatalf("failed to create data directory: %s\n", err) + } + + knownCamerasPath := filepath.Join(config.DataDir, "knownCameras") + + // read our known camera list from disk for stable ids + var knownCameras = make(map[string]int) + file, err := os.Open(knownCamerasPath) + if err != nil { + // if not exists, we'll just ignore the error and default to the empty list + if !os.IsNotExist(err) { + log.Info.Fatalf("failed to read knownCameras: %s\n", err) + } + } else { + err = gob.NewDecoder(file).Decode(&knownCameras) + if err != nil { + log.Info.Fatalf("failed to decode knownCameras: %s\n", err) + } + } + _ = file.Close() + + hasDiscoveredNewCameras := false + + // create HomeKit cameras and motion sensors from the fetched BlueIris cameras + cameras := make([]*accessory.Camera, 0, len(biCameras)) + motionSensors := make(map[string]*service.MotionSensor) + for _, camera := range biCameras { + // create the HomeKit camera accessory + cam := accessory.NewCamera(accessory.Info{ + Name: camera.Name, + Manufacturer: "HKBI", + }) + + if id := knownCameras[camera.Name]; id != 0 { + log.Debug.Printf("reusing previously assigned id %d for camera %s", id, camera.Name) + cam.Id = uint64(id) + } else { + newId := 0 + for _, i := range knownCameras { + if i >= newId { + newId = i + 1 + } + } + + log.Info.Printf("newly discovered camera %s assigned id %d", camera.Name, newId) + knownCameras[camera.Name] = newId + cam.Id = uint64(newId) + + hasDiscoveredNewCameras = true + } + + // setup stream request handling on management channels 1 & 2 + startListeningForStreams(camera.Id, cam.StreamManagement1, globalState, &config, bi.BaseUrl) + startListeningForStreams(camera.Id, cam.StreamManagement2, globalState, &config, bi.BaseUrl) + + // create the HomeKit motion sensor accessory + motionSensor := service.NewMotionSensor() + cam.AddS(motionSensor.S) + + // add the cameras to our output array/map for adding to the server and dispatching + // events to + cameras = append(cameras, cam) + motionSensors[camera.Id] = motionSensor + } + + // write newly discovered cameras to disk + if hasDiscoveredNewCameras { + file, err = os.Create(knownCamerasPath) + if err != nil { + log.Info.Fatalf("failed to create knownCameras: %s\n", err) + } + + err = gob.NewEncoder(file).Encode(knownCameras) + if err != nil { + log.Info.Fatalf("failed to encode knownCameras: %s\n", err) + } + + _ = file.Close() + } + + // fetch all the created accessories for exposing to HomeKit + var accessories = make([]*accessory.A, 0, len(cameras)) + for _, camera := range cameras { + accessories = append(accessories, camera.A) + } + + // setup hap's state storage + fs := hap.NewFsStore(config.DataDir) + + // start building the homekit accessory protocol (hap) server + server, err := hap.NewServer(fs, accessories[0], accessories[1:]...) + if err != nil { + log.Info.Panic(err) + } + + // set our HAP config + server.Pin = "11111112" + server.Addr = config.ListenAddress + + // endpoint to trigger a camera's motion sensor for 10 seconds + server.ServeMux().HandleFunc("/trigger", func(res http.ResponseWriter, req *http.Request) { + query := req.URL.Query() + state := query.Get("state") + cam := query.Get("cam") + + if sensor := motionSensors[cam]; sensor != nil { + var motionDetected bool + if state == "off" { + motionDetected = false + } else { + motionDetected = true + } + + sensor.MotionDetected.SetValue(motionDetected) + + res.WriteHeader(http.StatusOK) + } else { + log.Info.Printf("Received trigger request for unknown camera: %s", cam) + res.WriteHeader(http.StatusBadRequest) + } + }) + + // endpoint to handle snapshot requests from HomeKit + server.ServeMux().HandleFunc("/resource", func(res http.ResponseWriter, req *http.Request) { + var request struct { + Type string `json:"resource-type"` + Aid int `json:"aid"` + } + + // ensure this is a valid resource request + if !server.IsAuthorized(req) { + _ = hap.JsonError(res, hap.JsonStatusInsufficientPrivileges) + return + } else if req.Method != http.MethodPost { + res.WriteHeader(http.StatusBadRequest) + return + } + + // read request body + body, err := io.ReadAll(req.Body) + if err != nil { + log.Info.Println(err) + res.WriteHeader(http.StatusInternalServerError) + return + } + + // parse request body + err = json.Unmarshal(body, &request) + if err != nil { + log.Info.Println(err) + res.WriteHeader(http.StatusBadRequest) + return + } + + var cameraName string + for name, i := range knownCameras { + if i == request.Aid { + cameraName = name + break + } + } + + if cameraName == "" { + log.Info.Printf("a snapshot was requested for camera %d but not camera with that id exists", request.Aid) + res.WriteHeader(http.StatusBadRequest) + return + } + + switch request.Type { + case "image": + // build request to fetch a snapshot of the camera from blueiris + req, err := bi.FetchSnapshot(cameraName) + if err != nil { + log.Info.Println(err) + res.WriteHeader(http.StatusInternalServerError) + return + } + + // send request to blueiris + imageResponse, err := http.DefaultClient.Do(req) + if err != nil { + log.Info.Println(err) + res.WriteHeader(http.StatusInternalServerError) + return + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(imageResponse.Body) + + // set response headers + res.Header().Set("Content-Type", "image/jpeg") + + // stream response from blueiris to HomeKit + wr := hap.NewChunkedWriter(res, 2048) + _, err = io.Copy(wr, imageResponse.Body) + if err != nil { + log.Info.Printf("Failed to copy bytes for snapshot to HomeKit: %s\n", err) + return + } + default: + log.Info.Printf("unsupported resource request \"%s\"\n", request.Type) + res.WriteHeader(http.StatusInternalServerError) + return + } + }) + + // set up a listener for sigint and sigterm signals to stop the server + c := make(chan os.Signal) + signal.Notify(c, os.Interrupt) + signal.Notify(c, syscall.SIGTERM) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + <-c + signal.Stop(c) + cancel() + }() + + // spawn the server + err = server.ListenAndServe(ctx) + if err != nil { + log.Info.Panic(err) + } +} + +// sets up a camera accessory for streaming +func startListeningForStreams(cameraName string, mgmt *service.CameraRTPStreamManagement, globalState *GlobalState, config *Config, blueirisBase *url.URL) { + // set up some basic parameters for HomeKit to know that the camera is available + setTlv8Payload(mgmt.StreamingStatus.Bytes, rtp.StreamingStatus{Status: rtp.StreamingStatusAvailable}) + setTlv8Payload(mgmt.SupportedRTPConfiguration.Bytes, rtp.NewConfiguration(rtp.CryptoSuite_AES_CM_128_HMAC_SHA1_80)) + setTlv8Payload(mgmt.SupportedVideoStreamConfiguration.Bytes, rtp.DefaultVideoStreamConfiguration()) + setTlv8Payload(mgmt.SupportedAudioStreamConfiguration.Bytes, rtp.DefaultAudioStreamConfiguration()) + + // shared state for all the spawned streams, with a mapping to the session id for us to + // figure out which stream is being referred to + var activeStreams = ActiveStreams{ + mutex: &sync.Mutex{}, + streams: map[string]*Stream{}, + } + + // handle the initial request sent to us from HomeKit to set up a new stream + mgmt.SetupEndpoints.OnValueUpdate(func(new, old []byte, r *http.Request) { + // HomeKit ends up sending us two requests, but the second one doesn't have a http request attached, + // so we can just ignore it + if r == nil { + return + } + + var req rtp.SetupEndpoints + + // unmarshal request from HomeKit + err := tlv8.Unmarshal(new, &req) + if err != nil { + log.Info.Printf("Could not unmarshal tlv8 data: %s\n", err) + return + } + + // encode the session id, so it's human-readable for logging + var uuid = hex.EncodeToString(req.SessionId) + + // build the response to send back to HomeKit + resp := rtp.SetupEndpointsResponse{ + SessionId: req.SessionId, + Status: rtp.SessionStatusSuccess, + AccessoryAddr: rtp.Addr{ + IPVersion: req.ControllerAddr.IPVersion, + IPAddr: strings.Split(r.Context().Value(http.LocalAddrContextKey).(net.Addr).String(), ":")[0], + VideoRtpPort: req.ControllerAddr.VideoRtpPort, + AudioRtpPort: req.ControllerAddr.AudioRtpPort, + }, + Video: req.Video, + Audio: req.Audio, + SsrcVideo: globalState.ssrcVideo, + SsrcAudio: globalState.ssrcAudio, + } + + // create and track the new stream + activeStreams.mutex.Lock() + activeStreams.streams[uuid] = &Stream{ + mutex: &sync.Mutex{}, + cmd: nil, + req: req, + resp: resp, + } + activeStreams.mutex.Unlock() + + // send the response to HomeKit + setTlv8Payload(mgmt.SetupEndpoints.Bytes, resp) + }) + + // handle streaming requests from HomeKit + mgmt.SelectedRTPStreamConfiguration.OnValueRemoteUpdate(func(buf []byte) { + var cfg rtp.StreamConfiguration + + // unmarshal request from HomeKit + err := tlv8.Unmarshal(buf, &cfg) + if err != nil { + log.Info.Fatalf("Could not unmarshal tlv8 data: %s\n", err) + } + + // encode the session id, so it's human-readable for logging + uuid := hex.EncodeToString(cfg.Command.Identifier) + + // match the command that HomeKit wants to perform for the stream uuid + switch cfg.Command.Type { + case rtp.SessionControlCommandTypeStart: + stream := activeStreams.streams[uuid] + if stream == nil { + return + } + + log.Info.Printf("%s: starting stream\n", uuid) + + // lock the stream, so we're not racing with another request to spawn an ffmpeg instance + // and update the state + stream.mutex.Lock() + defer stream.mutex.Unlock() + + // close any previous ffmpeg instances that were open for the given stream uuid + if stream.cmd != nil && stream.cmd.Process != nil { + log.Info.Printf("%s: requested to start stream, but stream was already running. shutting down previous\n", uuid) + + _ = stream.cmd.Process.Signal(syscall.SIGINT) + status, _ := stream.cmd.Process.Wait() + log.Info.Printf("%s: ffmpeg exited with %s\n", uuid, status.String()) + + stream.cmd = nil + } + + // build the endpoint that HomeKit wants us to stream to + endpoint := fmt.Sprintf( + "srtp://%s:%d?rtcpport=%d&pkt_size=%d", + stream.req.ControllerAddr.IPAddr, + stream.req.ControllerAddr.VideoRtpPort, + stream.req.ControllerAddr.VideoRtpPort, + 1378, + ) + + // build the blueiris rtsp source + source := blueirisBase.JoinPath(cameraName) + source.Scheme = "rtsp" + source.User = url.UserPassword(config.Blueiris.Username, config.Blueiris.Password) + + // build ffmpeg command for pulling RTSP stream from BlueIris and forwarding to the HomeKit + // controller's SRTP port using pass-through for low CPU, the BlueIris RTSP web server needs + // to be set to 2,000kb/s bitrate though otherwise iOS will silently fail + cmd := exec.Command( + "ffmpeg", + // input + "-an", + "-rtsp_transport", "tcp", + "-use_wallclock_as_timestamps", "1", + "-i", source.String(), + // no audio + "-an", + // no subs + "-sn", + // no data + "-dn", + // add extra keyframes, so we don't need to worry about the blueiris settings + "-bsf:v", "dump_extra", + // copy data directly from the blueiris stream + "-vcodec", "copy", + // requested payload type from client + "-payload_type", fmt.Sprintf("%d", cfg.Video.RTP.PayloadType), + // sync source + "-ssrc", fmt.Sprintf("%d", globalState.ssrcVideo), + // format rtp + "-f", "rtp", + // forward over srtp to the controller + "-srtp_out_suite", "AES_CM_128_HMAC_SHA1_80", + "-srtp_out_params", stream.req.Video.SrtpKey(), + endpoint, + ) + + // forward ffmpeg to console + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + log.Debug.Println(cmd) + + // spawn ffmpeg command + err := cmd.Start() + if err != nil { + log.Info.Printf("Failed to spawn ffmpeg: %s\n", err) + return + } + + // update our state to contain the spawned command, so we can control it later + stream.cmd = cmd + + // sanity check to ensure our status is still available so new clients can still request + // streams + setTlv8Payload(mgmt.StreamingStatus.Bytes, rtp.StreamingStatus{Status: rtp.StreamingStatusAvailable}) + case rtp.SessionControlCommandTypeEnd: + stream := activeStreams.streams[uuid] + if stream == nil { + return + } + + log.Info.Printf("%s: ending stream\n", uuid) + + // lock the stream, so we're not racing with another request on the process and update + // the state + stream.mutex.Lock() + defer stream.mutex.Unlock() + + // ensure the stream is still open + if stream.cmd == nil || stream.cmd.Process == nil { + log.Info.Printf("%s: attempted to end already ended stream\n", uuid) + return + } + + // send a sigint to ffmpeg and wait for it to finish + _ = stream.cmd.Process.Signal(syscall.SIGINT) + status, _ := stream.cmd.Process.Wait() + log.Info.Printf("%s: ffmpeg exited with %s\n", uuid, status.String()) + + // remove command from our state so HomeKit can't attempt to close it twice + stream.cmd = nil + + // sanity check to ensure our status is still available so new clients can still request + // streams + setTlv8Payload(mgmt.StreamingStatus.Bytes, rtp.StreamingStatus{Status: rtp.StreamingStatusAvailable}) + case rtp.SessionControlCommandTypeSuspend: + stream := activeStreams.streams[uuid] + if stream == nil { + return + } + + log.Info.Printf("%s: suspending stream\n", uuid) + + // lock the stream, so we're not racing with another request on the process + stream.mutex.Lock() + defer stream.mutex.Unlock() + + // ensure HomeKit isn't attempting to suspend a closed stream + if stream.cmd == nil || stream.cmd.Process == nil { + log.Info.Printf("%s: attempted to suspend inactive stream\n", uuid) + return + } + + // send a sigstop signal to ffmpeg + err := stream.cmd.Process.Signal(syscall.SIGSTOP) + if err != nil { + log.Info.Printf("%s: failed to suspend ffmpeg: %s\n", uuid, err) + } + case rtp.SessionControlCommandTypeResume: + stream := activeStreams.streams[uuid] + if stream == nil { + return + } + + log.Info.Printf("%s: resuming stream\n", uuid) + + // lock the stream, so we're not racing with another request on the process + stream.mutex.Lock() + defer stream.mutex.Unlock() + + // ensure HomeKit isn't attempting to resume a closed stream + if stream.cmd == nil || stream.cmd.Process == nil { + log.Info.Printf("%s: attempted to resume inactive stream\n", uuid) + return + } + + // send a sigcont signal to ffmpeg + err := stream.cmd.Process.Signal(syscall.SIGCONT) + if err != nil { + log.Info.Printf("%s: failed to resume ffmpeg: %s\n", uuid, err) + } + case rtp.SessionControlCommandTypeReconfigure: + log.Info.Printf("%s: ignoring reconfigure message\n", uuid) + default: + log.Debug.Printf("%s: Unknown command type %d\n", uuid, cfg.Command.Type) + } + }) +} + +func setTlv8Payload(c *characteristic.Bytes, v interface{}) { + if val, err := tlv8.Marshal(v); err == nil { + c.SetValue(val) + } else { + log.Info.Println(err) + } +} + +type Stream struct { + mutex *sync.Mutex + cmd *exec.Cmd + req rtp.SetupEndpoints + resp rtp.SetupEndpointsResponse +} + +type ActiveStreams struct { + mutex *sync.Mutex + streams map[string]*Stream +} diff --git a/config-example.toml b/config-example.toml new file mode 100644 index 0000000..53ae456 --- /dev/null +++ b/config-example.toml @@ -0,0 +1,7 @@ +listen-address = "0.0.0.0:53238" +data-dir = "./data" + +[blueiris] +instance = "http://127.0.0.1:81" +username = "abcdef" +password = "123456" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d6ff4c8 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module github.com/w4/hkbi + +go 1.19 + +require ( + github.com/BurntSushi/toml v1.2.0 + github.com/brutella/hap v0.0.18 +) + +require ( + github.com/brutella/dnssd v1.2.4 // indirect + github.com/go-chi/chi v1.5.4 // indirect + github.com/miekg/dns v1.1.50 // indirect + github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 // indirect + github.com/xiam/to v0.0.0-20200126224905-d60d31e03561 // indirect + golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b // indirect + golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect + golang.org/x/net v0.0.0-20221004154528-8021a29435af // indirect + golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec // indirect + golang.org/x/text v0.3.7 // indirect + golang.org/x/tools v0.1.12 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f66cea6 --- /dev/null +++ b/go.sum @@ -0,0 +1,68 @@ +github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0= +github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/brutella/dnssd v1.2.3/go.mod h1:JoW2sJUrmVIef25G6lrLj7HS6Xdwh6q8WUIvMkkBYXs= +github.com/brutella/dnssd v1.2.4 h1:hmSHQnUS5qujI8PX1QKHDhmwzZVvd63YINFfF9jcGfE= +github.com/brutella/dnssd v1.2.4/go.mod h1:JoW2sJUrmVIef25G6lrLj7HS6Xdwh6q8WUIvMkkBYXs= +github.com/brutella/hap v0.0.18 h1:w9mXQCWgwUYakphKFsAXL4qBobonebd3ZCH12nG+JAI= +github.com/brutella/hap v0.0.18/go.mod h1:c2vEL5pzjRWEx07sa32kTVjzI9bBVlstrwBwKe3DlJ0= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= +github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= +github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= +github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI= +github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE= +github.com/xiam/to v0.0.0-20200126224905-d60d31e03561 h1:SVoNK97S6JlaYlHcaC+79tg3JUlQABcc0dH2VQ4Y+9s= +github.com/xiam/to v0.0.0-20200126224905-d60d31e03561/go.mod h1:cqbG7phSzrbdg3aj+Kn63bpVruzwDZi58CpxlZkjwzw= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b h1:huxqepDufQpLLIRXiVkTvnxrzJlpwmIWAObmcCcUFr0= +golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20221004154528-8021a29435af h1:wv66FM3rLZGPdxpYL+ApnDe2HzHcTFta3z5nsc13wI4= +golang.org/x/net v0.0.0-20221004154528-8021a29435af/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI= +golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -- libgit2 1.7.2