Initial commit
Diff
.gitignore | 4 ++++
LICENSE | 14 ++++++++++++++
README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
config-example.toml | 7 +++++++
go.mod | 22 ++++++++++++++++++++++
go.sum | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
blueiris/lib.go | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
cmd/hkbi/main.go | 580 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 926 insertions(+)
@@ -1,0 +1,4 @@
.idea/
config.toml
data/
main
@@ -1,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.
@@ -1,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)
@@ -1,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"
@@ -1,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
github.com/go-chi/chi v1.5.4
github.com/miekg/dns v1.1.50
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9
github.com/xiam/to v0.0.0-20200126224905-d60d31e03561
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4
golang.org/x/net v0.0.0-20221004154528-8021a29435af
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec
golang.org/x/text v0.3.7
golang.org/x/tools v0.1.12
)
@@ -1,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=
@@ -1,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)
}
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)
}
@@ -1,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(),
}
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")
var knownCameras = make(map[string]int)
file, err := os.Open(knownCamerasPath)
if err != nil {
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
cameras := make([]*accessory.Camera, 0, len(biCameras))
motionSensors := make(map[string]*service.MotionSensor)
for _, camera := range biCameras {
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
}
startListeningForStreams(camera.Id, cam.StreamManagement1, globalState, &config, bi.BaseUrl)
startListeningForStreams(camera.Id, cam.StreamManagement2, globalState, &config, bi.BaseUrl)
motionSensor := service.NewMotionSensor()
cam.AddS(motionSensor.S)
cameras = append(cameras, cam)
motionSensors[camera.Id] = motionSensor
}
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()
}
var accessories = make([]*accessory.A, 0, len(cameras))
for _, camera := range cameras {
accessories = append(accessories, camera.A)
}
fs := hap.NewFsStore(config.DataDir)
server, err := hap.NewServer(fs, accessories[0], accessories[1:]...)
if err != nil {
log.Info.Panic(err)
}
server.Pin = "11111112"
server.Addr = config.ListenAddress
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)
}
})
server.ServeMux().HandleFunc("/resource", func(res http.ResponseWriter, req *http.Request) {
var request struct {
Type string `json:"resource-type"`
Aid int `json:"aid"`
}
if !server.IsAuthorized(req) {
_ = hap.JsonError(res, hap.JsonStatusInsufficientPrivileges)
return
} else if req.Method != http.MethodPost {
res.WriteHeader(http.StatusBadRequest)
return
}
body, err := io.ReadAll(req.Body)
if err != nil {
log.Info.Println(err)
res.WriteHeader(http.StatusInternalServerError)
return
}
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":
req, err := bi.FetchSnapshot(cameraName)
if err != nil {
log.Info.Println(err)
res.WriteHeader(http.StatusInternalServerError)
return
}
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)
res.Header().Set("Content-Type", "image/jpeg")
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
}
})
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()
}()
err = server.ListenAndServe(ctx)
if err != nil {
log.Info.Panic(err)
}
}
func startListeningForStreams(cameraName string, mgmt *service.CameraRTPStreamManagement, globalState *GlobalState, config *Config, blueirisBase *url.URL) {
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())
var activeStreams = ActiveStreams{
mutex: &sync.Mutex{},
streams: map[string]*Stream{},
}
mgmt.SetupEndpoints.OnValueUpdate(func(new, old []byte, r *http.Request) {
if r == nil {
return
}
var req rtp.SetupEndpoints
err := tlv8.Unmarshal(new, &req)
if err != nil {
log.Info.Printf("Could not unmarshal tlv8 data: %s\n", err)
return
}
var uuid = hex.EncodeToString(req.SessionId)
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,
}
activeStreams.mutex.Lock()
activeStreams.streams[uuid] = &Stream{
mutex: &sync.Mutex{},
cmd: nil,
req: req,
resp: resp,
}
activeStreams.mutex.Unlock()
setTlv8Payload(mgmt.SetupEndpoints.Bytes, resp)
})
mgmt.SelectedRTPStreamConfiguration.OnValueRemoteUpdate(func(buf []byte) {
var cfg rtp.StreamConfiguration
err := tlv8.Unmarshal(buf, &cfg)
if err != nil {
log.Info.Fatalf("Could not unmarshal tlv8 data: %s\n", err)
}
uuid := hex.EncodeToString(cfg.Command.Identifier)
switch cfg.Command.Type {
case rtp.SessionControlCommandTypeStart:
stream := activeStreams.streams[uuid]
if stream == nil {
return
}
log.Info.Printf("%s: starting stream\n", uuid)
stream.mutex.Lock()
defer stream.mutex.Unlock()
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
}
endpoint := fmt.Sprintf(
"srtp://%s:%d?rtcpport=%d&pkt_size=%d",
stream.req.ControllerAddr.IPAddr,
stream.req.ControllerAddr.VideoRtpPort,
stream.req.ControllerAddr.VideoRtpPort,
1378,
)
source := blueirisBase.JoinPath(cameraName)
source.Scheme = "rtsp"
source.User = url.UserPassword(config.Blueiris.Username, config.Blueiris.Password)
cmd := exec.Command(
"ffmpeg",
"-an",
"-rtsp_transport", "tcp",
"-use_wallclock_as_timestamps", "1",
"-i", source.String(),
"-an",
"-sn",
"-dn",
"-bsf:v", "dump_extra",
"-vcodec", "copy",
"-payload_type", fmt.Sprintf("%d", cfg.Video.RTP.PayloadType),
"-ssrc", fmt.Sprintf("%d", globalState.ssrcVideo),
"-f", "rtp",
"-srtp_out_suite", "AES_CM_128_HMAC_SHA1_80",
"-srtp_out_params", stream.req.Video.SrtpKey(),
endpoint,
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
log.Debug.Println(cmd)
err := cmd.Start()
if err != nil {
log.Info.Printf("Failed to spawn ffmpeg: %s\n", err)
return
}
stream.cmd = cmd
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)
stream.mutex.Lock()
defer stream.mutex.Unlock()
if stream.cmd == nil || stream.cmd.Process == nil {
log.Info.Printf("%s: attempted to end already ended stream\n", uuid)
return
}
_ = 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
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)
stream.mutex.Lock()
defer stream.mutex.Unlock()
if stream.cmd == nil || stream.cmd.Process == nil {
log.Info.Printf("%s: attempted to suspend inactive stream\n", uuid)
return
}
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)
stream.mutex.Lock()
defer stream.mutex.Unlock()
if stream.cmd == nil || stream.cmd.Process == nil {
log.Info.Printf("%s: attempted to resume inactive stream\n", uuid)
return
}
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
}