Skip to content

Commit

Permalink
Add web ui to view recent snapshots and install updates #16
Browse files Browse the repository at this point in the history
  • Loading branch information
brutella committed Apr 21, 2022
1 parent bc083d1 commit 5457763
Show file tree
Hide file tree
Showing 45 changed files with 1,783 additions and 49 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
build
.DS_Store
db
db
cmd/hkcam/fs.go
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,13 @@ The camera stream can be viewed in a HomeKit app. For example my [Home+](https:/

<img alt="Camera Stream" src="_img/home-app-camera.jpeg?raw=true" width="400" />



## Features

- Live streaming via HomeKit
- Works with any HomeKit app (ex. [Home+](https://hochgatterer.me/home))
- [Multistream Support](#multistream)
- [Persistent Snapshots](#persistent-snapshots)
- [Built-in Web Interface](#web-interface)
- Runs on multiple platforms (Raspberry Pi OS, macOS)

## Get Started
Expand All @@ -28,14 +27,16 @@ The fastest way to get started is to
```sh
git clone https://github.com/brutella/hkcam && cd hkcam
```
2. build and run `cmd/hkcam/main.go` by running `go run cmd/hkcam/main.go` in Terminal
2. build and run `cmd/hkcam/main.go` by running `task hkcam` in Terminal
3. open any HomeKit app and add the camera to HomeKit (pin for initial setup is `001 02 003`)
3. view the web ui by entering the url http://localhost:8080

These steps require *git*, *go* and *ffmpeg* to be installed. On macOS you can install them via Homebrew.
These steps require *git*, *go*, *task* and *ffmpeg* to be installed. On macOS you can install them via Homebrew.

```sh
brew install git
brew install go
brew install go-task/tap/go-task
brew install ffmpeg
```

Expand Down Expand Up @@ -104,7 +105,7 @@ wget https://github.com/brutella/hkcam/releases/download/v0.1.0/hkcam-v0.1.0_lin
- Extract the archive with `tar -xzf hkcam-v0.1.0_linux_arm.tar.gz`
- Run `hkcam` by executing the following command
```
./hkcam -data_dir=/var/lib/hkcam/data -multi_stream=true -verbose
./hkcam -data_dir=/var/lib/hkcam/data -multi_stream=true -port=8080 -verbose
```

8. Add the camera to HomeKit
Expand Down Expand Up @@ -179,6 +180,13 @@ Taking snapshots in automations is also supported.

<img alt="Automation" src="_img/homeplus-automation.jpeg?raw=true" width="280" />

## Web Interface

When running `hkcam` at a specific port (by specifying `-port=...`), you can access the web interface at the url http://{ip-address}:{port} with a browser.
The web interface shows the recent snapshot and lets you install updates.

<img alt="Web Interface" src="_img/web-interface.png?raw=true" width="280" />

## Raspberry Pi Zero W

I do get kernel panics when running hkcam with a ELP 1080P USB camera.
Expand Down
25 changes: 17 additions & 8 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,37 @@ tasks:
lint:
cmds:
- golangci-lint run
build-fs:
cmds:
- esc -o cmd/hkcam/fs.go -ignore ".*\.go" html static
hkcam:
cmds:
- "go build -o {{ .BUILD_DIR }}/hkcam -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go"
- "{{ .BUILD_DIR }}/hkcam --verbose"
- task: build-fs
- "go build -o {{ .BUILD_DIR }}/hkcam -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go cmd/hkcam/fs.go"
- "{{ .BUILD_DIR }}/hkcam --verbose --port={{ .PORT }} --data_dir=cmd/hkcam/db"
vars:
LDFLAGS: "\"-X main.Version={{ .VERSION }} -X main.Date={{ .DATE }}\""
PORT: '{{ default "8080" .PORT }}'
sources:
- static/**/*
- cmd/hkcam/main.go
- ./*.go
- api/*.go
- app/*.go
- html/**/*
- Taskfile.yml
pack:
cmds:
- task: build-fs
# Raspberry Pi
- "GOOS=linux GOARCH=arm GOARM=6 go build -o {{ .BUILD_DIR }}/{{ .PACKAGE_RPI }}/{{ .BINARY }} -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go"
- "GOOS=linux GOARCH=arm GOARM=6 go build -o {{ .BUILD_DIR }}/{{ .PACKAGE_RPI }}/{{ .BINARY }} -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go cmd/hkcam/fs.go"
# Linux
- "GOOS=linux GOARCH=amd64 go build -o {{ .BUILD_DIR }}/{{ .PACKAGE_LINUX_64 }}/{{ .BINARY }} -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go"
- "GOOS=linux GOARCH=amd64 go build -o {{ .BUILD_DIR }}/{{ .PACKAGE_LINUX_64 }}/{{ .BINARY }} -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go cmd/hkcam/fs.go"
# Linux
- "GOOS=linux GOARCH=386 go build -o {{ .BUILD_DIR }}/{{ .PACKAGE_LINUX_32 }}/{{ .BINARY }} -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go"
- "GOOS=linux GOARCH=386 go build -o {{ .BUILD_DIR }}/{{ .PACKAGE_LINUX_32 }}/{{ .BINARY }} -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go cmd/hkcam/fs.go"
# Intel Mac
- "GOOS=darwin GOARCH=amd64 go build -o {{ .BUILD_DIR }}/{{ .PACKAGE_INTEL_MAC }}/{{ .BINARY }} -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go"
- "GOOS=darwin GOARCH=amd64 go build -o {{ .BUILD_DIR }}/{{ .PACKAGE_INTEL_MAC }}/{{ .BINARY }} -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go cmd/hkcam/fs.go"
# M1 Mac
- "GOOS=darwin GOARCH=arm64 go build -o {{ .BUILD_DIR }}/{{ .PACKAGE_M1_MAC }}/{{ .BINARY }} -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go"
- "GOOS=darwin GOARCH=arm64 go build -o {{ .BUILD_DIR }}/{{ .PACKAGE_M1_MAC }}/{{ .BINARY }} -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go cmd/hkcam/fs.go"
# pack
- "tar -cvzf {{ .PACKAGE_RPI }}.tar.gz -C {{ .BUILD_DIR }}/{{ .PACKAGE_RPI }} {{ .BINARY }}"
- "tar -cvzf {{ .PACKAGE_LINUX_64 }}.tar.gz -C {{ .BUILD_DIR }}/{{ .PACKAGE_LINUX_64 }} {{ .BINARY }}"
Expand Down
Binary file added _img/web-interface.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 34 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package api

import (
"github.com/brutella/hkcam/app"
"github.com/go-chi/chi"

"net/http"
)

const (
ErrorInvalidPayload = 1
ErrorInvalidRequest = 2
ErrorUnknown = 3
)

type Api struct {
App *app.App
}

func (a *Api) Router() http.Handler {
r := chi.NewRouter()
r.Get("/system/heartbeat", a.SystemHeartbeat)
r.Get("/system/info", a.SystemInfo)
r.Post("/system/restart", a.SystemRestart)
r.Get("/snapshots/recent", a.RecentSnapshot)
r.Get("/snapshots/new", a.NewSnapshot)

return r
}

// RestartApp restarts the app.
func (a *Api) RestartApp() {
a.App.Restart()
}
43 changes: 43 additions & 0 deletions api/apiutil/decode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package apiutil

import (
"github.com/gorilla/schema"

"net/http"
"strconv"
)

// ParseInt64 converts a string to an 8-byte integer
func ParseInt64(s string) (int64, error) {
return strconv.ParseInt(s, 10, 64)
}

var decoder = schema.NewDecoder()

func DecodeForm(w http.ResponseWriter, r *http.Request, v interface{}) error {
if err := r.ParseForm(); err != nil {
return err
}

return decoder.Decode(v, r.Form)
}

func DecodeURLQuery(w http.ResponseWriter, r *http.Request, v interface{}) error {
if err := r.ParseForm(); err != nil {
return err
}

return decoder.Decode(v, r.URL.Query())
}

func ToBool(s string) bool {
switch s {
case "on":
return true
case "off":
return false
default:
v, _ := strconv.ParseBool(s)
return v
}
}
41 changes: 41 additions & 0 deletions api/apiutil/json.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package apiutil

import (
"bytes"
"encoding/json"
"io"
"net/http"
)

func JSONEncode(v interface{}) (*bytes.Buffer, error) {
buf := &bytes.Buffer{}
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(true)
err := enc.Encode(v)

return buf, err
}

func JSONDecode(r io.Reader, v interface{}) error {
return json.NewDecoder(r).Decode(v)
}

func WriteJSON(w http.ResponseWriter, r *http.Request, v interface{}) error {
buf, err := JSONEncode(v)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return err
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, err = w.Write(buf.Bytes())
return err
}

func ReadJSON(rc io.Reader, v interface{}) error {
if err := JSONDecode(rc, v); err != nil {
return err
}

return nil
}
19 changes: 19 additions & 0 deletions api/error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package api

type ErrResponse struct {
Error Error `json:"error"`
}

type Error struct {
Message string `json:"message"`
Code int `json:"code"`
}

func NewErrResponse(err error, code int) *ErrResponse {
return &ErrResponse{
Error{
Message: err.Error(),
Code: code,
},
}
}
19 changes: 19 additions & 0 deletions api/json.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package api

import (
"github.com/brutella/hkcam/api/apiutil"
"net/http"
)

// WriteJSON responds to request r by encoding and sending v as json.
// If v is an instance of of an ErrResponse, the response status code is 400 (Bad Request).
func WriteJSON(w http.ResponseWriter, r *http.Request, v interface{}) error {
switch v.(type) {
case *ErrResponse, ErrResponse:
w.WriteHeader(http.StatusBadRequest)
default:
w.WriteHeader(http.StatusOK)
}

return apiutil.WriteJSON(w, r, v)
}
92 changes: 92 additions & 0 deletions api/snapshot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package api

import (
"github.com/brutella/hkcam/api/apiutil"

"bytes"
"fmt"
"image/jpeg"
"net/http"
"time"
)

// SnapshotRequest is a request restart the system.
type SnapshotRequest struct {
Width uint `schema:"width"`
Height uint `schema:"height"`
}

// SnapshotResponse is a response to a SnapshotRequest.
type SnapshotResponse struct {
Data *SnapshotResponseData `json:"data"`
}

// SnapshotResponseData is the response data of a SnapshotRequest.
type SnapshotResponseData struct {
Date *time.Time `json:"date,omitempty"`
Bytes []byte `json:"bytes"`
}

// RecentSnapshot responds with the recent snapshot.
func (a *Api) RecentSnapshot(w http.ResponseWriter, r *http.Request) {
req := SnapshotRequest{
Width: 1920,
Height: 1080,
}
var resp interface{}

if err := apiutil.DecodeURLQuery(w, r, &req); err != nil {
resp = NewErrResponse(fmt.Errorf("invalid payload"), ErrorInvalidPayload)
} else if req.Width == 0 || req.Height == 0 {
resp = NewErrResponse(fmt.Errorf("invalid payload"), ErrorInvalidPayload)
} else if snapshot := a.App.FFMPEG.RecentSnapshot(req.Width, req.Height); snapshot != nil {
buf := new(bytes.Buffer)
if err := jpeg.Encode(buf, snapshot.Image, nil); err != nil {
resp = NewErrResponse(fmt.Errorf("encode: %v", err), ErrorUnknown)
}
resp = SnapshotResponse{
Data: &SnapshotResponseData{
Bytes: buf.Bytes(),
Date: &snapshot.Date,
},
}
} else {
resp = SnapshotResponse{}
}

if err := WriteJSON(w, r, resp); err != nil {
fmt.Println("responding failed", err)
}
}

// NewSnapshot create a new snapshot.
func (a *Api) NewSnapshot(w http.ResponseWriter, r *http.Request) {
req := SnapshotRequest{
Width: 1920,
Height: 1080,
}
var resp interface{}

if err := apiutil.DecodeURLQuery(w, r, &req); err != nil {
resp = NewErrResponse(fmt.Errorf("invalid payload"), ErrorInvalidPayload)
} else if req.Width == 0 || req.Height == 0 {
resp = NewErrResponse(fmt.Errorf("invalid payload"), ErrorInvalidPayload)
} else if snapshot, err := a.App.FFMPEG.Snapshot(req.Width, req.Height); err != nil {
resp = NewErrResponse(fmt.Errorf("snapshot: %v", err), ErrorUnknown)
} else {
buf := new(bytes.Buffer)
if err := jpeg.Encode(buf, snapshot.Image, nil); err != nil {
resp = NewErrResponse(fmt.Errorf("encode: %v", err), ErrorUnknown)
} else {
resp = SnapshotResponse{
Data: &SnapshotResponseData{
Bytes: buf.Bytes(),
},
}
}
}

if err := WriteJSON(w, r, resp); err != nil {
fmt.Println("responding failed", err)
}
}
Loading

0 comments on commit 5457763

Please sign in to comment.