🏡 index : ~doyle/hkbi.git

author Jordan Doyle <jordan@doyle.la> 2022-10-06 22:22:08.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2022-10-06 23:09:30.0 +00:00:00
commit
cdfa63a5e79731d1535d0c42e7e3f0f063415789 [patch]
tree
0232f6fee3fcbacae2bf6496ab19762cb6cc1311
download
cdfa63a5e79731d1535d0c42e7e3f0f063415789.tar.gz

Initial commit



Diff

 .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(+)

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 <sam@hocevar.net>

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=