Browse Source

Initial import

master
parent
commit
1700eafff2
19 changed files with 33104 additions and 0 deletions
  1. +94
    -0
      camera.go
  2. +98
    -0
      config.go
  3. +51
    -0
      device.go
  4. +15
    -0
      go.mod
  5. +128
    -0
      go.sum
  6. +45
    -0
      logger.go
  7. +33
    -0
      main.go
  8. +115
    -0
      manager.go
  9. +86
    -0
      renderer.go
  10. +186
    -0
      server.go
  11. +10598
    -0
      static/vendor/jquery/jquery-3.4.1.js
  12. +2
    -0
      static/vendor/jquery/jquery-3.4.1.min.js
  13. +21
    -0
      static/vendor/materialize/LICENSE
  14. +91
    -0
      static/vendor/materialize/README.md
  15. +9067
    -0
      static/vendor/materialize/css/materialize.css
  16. +13
    -0
      static/vendor/materialize/css/materialize.min.css
  17. +12374
    -0
      static/vendor/materialize/js/materialize.js
  18. +6
    -0
      static/vendor/materialize/js/materialize.min.js
  19. +81
    -0
      template/index.html

+ 94
- 0
camera.go View File

@ -0,0 +1,94 @@
package main
import (
"bytes"
"fmt"
"sync"
"maze.io/x/esphome"
)
// Camera stream.
type Camera struct {
esphome.Camera
sync.RWMutex
jpeg []byte
frame []byte
client map[chan []byte]struct{}
}
// NewCamera starts streaming from the ESPHome camera.
func NewCamera(camera esphome.Camera) (*Camera, error) {
c := &Camera{
Camera: camera,
client: make(map[chan []byte]struct{}),
}
stream, err := c.Stream()
if err != nil {
return nil, err
}
go c.stream(stream)
return c, nil
}
const boundaryWord = "MJPEGBOUNDARY"
func (c *Camera) stream(stream <-chan *bytes.Buffer) {
for frame := range stream {
const headerFormat = "\r\n" +
"--" + boundaryWord + "\r\n" +
"Content-Type: image/jpeg\r\n" +
"Content-Length: %d\r\n" +
"X-Timestamp: 0.000000\r\n" +
"\r\n"
var (
jpeg = frame.Bytes()
size = len(jpeg)
header = fmt.Sprintf(headerFormat, size)
)
c.Lock()
if len(c.jpeg) < size {
c.jpeg = make([]byte, size)
}
copy(c.jpeg, jpeg)
if len(c.frame) < size+len(header) {
c.frame = make([]byte, (size+len(header))*2)
}
copy(c.frame, header)
copy(c.frame[len(header):], jpeg)
for client := range c.client {
select {
case client <- c.frame:
default:
}
}
c.Unlock()
}
}
// Add a client stream.
func (c *Camera) Add(client chan []byte) {
c.Lock()
c.client[client] = struct{}{}
c.Unlock()
}
// Remove a client stream.
func (c *Camera) Remove(client chan []byte) {
c.Lock()
delete(c.client, client)
c.Unlock()
}
// JPEG returns the last received raw JPEG.
func (c *Camera) JPEG() []byte {
c.RLock()
jpeg := make([]byte, len(c.jpeg))
copy(jpeg, c.jpeg)
c.RUnlock()
return jpeg
}

+ 98
- 0
config.go View File

@ -0,0 +1,98 @@
package main
import (
"strings"
"time"
"github.com/BurntSushi/toml"
"maze.io/x/esphome"
)
type Config struct {
// Listen is the server listen address.
Listen string `toml:"listen"`
// Static files path.
Static string `toml:"static"`
// Template files path.
Template string `toml:"template"`
// Scan configuration.
Scan ScanConfig `toml:"scan"`
// Secret is a map of device name to native API secret.
Secret map[string]string `toml:"secret"`
// DefaultSecret is the default native API secret.
DefaultSecret string `toml:"default_secret"`
}
type ScanConfig struct {
Domain string
Service string
Interval time.Duration
Timeout time.Duration
}
func DefaultConfig() *Config {
return &Config{
Listen: DefaultServerListen,
Static: DefaultServerStatic,
Template: DefaultServerTemplate,
Scan: ScanConfig{
Domain: esphome.DefaultMDNSDomain,
Service: esphome.DefaultMDNSService,
Interval: 15 * time.Second,
Timeout: 10 * time.Second,
},
Secret: make(map[string]string),
}
}
func (c *Config) Load(name string) error {
if _, err := toml.DecodeFile(name, c); err != nil {
return err
}
/*
c.Listen = pick(c.Listen, DefaultServerListen)
c.Static = pick(c.Static, DefaultServerStatic)
c.Template = pick(c.Template, DefaultServerTemplate)
c.Scan.Domain = pick(c.Scan.Domain, esphome.DefaultMDNSDomain)
c.Scan.Service = pick(c.Scan.Service, esphome.DefaultMDNSService)
c.Scan.Timeout = pickDuration(c.Scan.Timeout, 10*time.Second)
c.Scan.Interval = pickDuration(c.Scan.Interval, 15*time.Second)
*/
if c.Scan.Timeout >= c.Scan.Interval {
c.Scan.Timeout = c.Scan.Interval - time.Second
}
return nil
}
// GetSecret returns the native API secret for a device.
func (c *Config) GetSecret(hostname string) string {
return pick(c.Secret[strings.ToLower(hostname)], c.DefaultSecret)
}
func pick(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
func pickDuration(values ...time.Duration) time.Duration {
for _, v := range values {
if v != 0 {
return v
}
}
return 0
}

+ 51
- 0
device.go View File

@ -0,0 +1,51 @@
package main
import (
"time"
"maze.io/x/esphome"
)
const (
DefaultConfigPath = "/etc/esphome"
clientTimeout = 5 * time.Second
)
type Device struct {
Name string
Addr string
Version string
Info esphome.DeviceInfo
Camera *Camera
Last time.Time
client *esphome.Client
secret string
connecting uint32
}
func NewDevice(name, addr string) *Device {
return &Device{
Name: name,
Addr: addr,
}
}
func (d *Device) Connect(secret string, timeout time.Duration) error {
var err error
if d.client, err = esphome.DialTimeout(d.Addr, timeout); err != nil {
return err
}
d.client.Timeout = timeout
if err = d.client.Login(secret); err != nil {
return err
}
if d.Info, err = d.client.DeviceInfo(); err != nil {
return err
}
return nil
}
// IsAvailable checks if the device has an active connection.
func (d *Device) IsAvailable() bool {
return d.client != nil
}

+ 15
- 0
go.mod View File

@ -0,0 +1,15 @@
module git.maze.io/maze/esphome-streamer
go 1.13
require (
github.com/BurntSushi/toml v0.3.1
github.com/flosch/pongo2 v0.0.0-20190707114632-bbf5a6c351f4
github.com/juju/errors v0.0.0-20190930114154-d42613fe1ab9
github.com/labstack/echo/v4 v4.1.14
github.com/mattn/go-isatty v0.0.12 // indirect
github.com/neko-neko/echo-logrus/v2 v2.0.1
github.com/sirupsen/logrus v1.4.2
gopkg.in/yaml.v3 v3.0.0-20200121175148-a6ecf24a6d71
maze.io/x/esphome v0.0.0-20200127205229-fd981d947543
)

+ 128
- 0
go.sum View File

@ -0,0 +1,128 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/flosch/pongo2 v0.0.0-20190707114632-bbf5a6c351f4 h1:GY1+t5Dr9OKADM64SYnQjw/w99HMYvQ0A8/JoUkxVmc=
github.com/flosch/pongo2 v0.0.0-20190707114632-bbf5a6c351f4/go.mod h1:T9YF2M40nIgbVgp3rreNmTged+9HrbNTIQf1PsaIiTA=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/juju/errors v0.0.0-20181118221551-089d3ea4e4d5 h1:rhqTjzJlm7EbkELJDKMTU7udov+Se0xZkWmugr6zGok=
github.com/juju/errors v0.0.0-20181118221551-089d3ea4e4d5/go.mod h1:W54LbzXuIE0boCoNJfwqpmkKJ1O4TCTZMetAt6jGk7Q=
github.com/juju/errors v0.0.0-20190930114154-d42613fe1ab9 h1:hJix6idebFclqlfZCHE7EUX7uqLCyb70nHNHH1XKGBg=
github.com/juju/errors v0.0.0-20190930114154-d42613fe1ab9/go.mod h1:W54LbzXuIE0boCoNJfwqpmkKJ1O4TCTZMetAt6jGk7Q=
github.com/juju/loggo v0.0.0-20180524022052-584905176618 h1:MK144iBQF9hTSwBW/9eJm034bVoG30IshVm688T2hi8=
github.com/juju/loggo v0.0.0-20180524022052-584905176618/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U=
github.com/juju/testing v0.0.0-20180920084828-472a3e8b2073 h1:WQM1NildKThwdP7qWrNAFGzp4ijNLw8RlgENkaI4MJs=
github.com/juju/testing v0.0.0-20180920084828-472a3e8b2073/go.mod h1:63prj8cnj0tU0S9OHjGJn+b1h0ZghCndfnbQolrYTwA=
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/labstack/echo/v4 v4.0.0/go.mod h1:tZv7nai5buKSg5h/8E6zz4LsD/Dqh9/91Mvs7Z5Zyno=
github.com/labstack/echo/v4 v4.1.14 h1:h8XP66UfB3tUm+L3QPw7tmwAu3pJaA/nyfHPCcz46ic=
github.com/labstack/echo/v4 v4.1.14/go.mod h1:Q5KZ1vD3V5FEzjM79hjwVrC3ABr7F5IdM23bXQMRDGg=
github.com/labstack/gommon v0.2.8/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4=
github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
github.com/miekg/dns v1.1.27 h1:aEH/kqUzUxGJ/UHcEKdJY+ugH6WEzsEBBSPa8zuy1aM=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/neko-neko/echo-logrus v1.1.0 h1:WHSHKtxdzlP7+xBC8DNoz9ER1oJBSp17C3/BAWmdanI=
github.com/neko-neko/echo-logrus/v2 v2.0.1 h1:BX2U6uv2N3UiUY75y+SntQak5S1AJIel9j+5Y6h4Nb4=
github.com/neko-neko/echo-logrus/v2 v2.0.1/go.mod h1:GDYWo9CY4VXk/vn5ac5reoutYEkZEexlFI01MzHXVG0=
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/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4/go.mod h1:50wTf68f99/Zt14pr046Tgt3Lp2vLyFZKzbFXTOabXw=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/fasttemplate v1.1.0 h1:RZqt0yGBsps8NGvLSGW804QQqCUYYLsaOjTVHy1Ocw4=
github.com/valyala/fasttemplate v1.1.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190130090550-b01c7a725664/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
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-20191227163750-53104e6ec876 h1:sKJQZMuxjOAR/Uo2LBfU90onWEf1dF4C+0hPJCc9Mpc=
golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200117160349-530e935923ad h1:Jh8cai0fqIK+f6nG0UgPW5wFk8wmiMhM3AyciDBdtQg=
golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
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-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa h1:F+8P+gmewFQYRk6JoLQLwjBCTu3mcIURZfNkVweuRKA=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/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-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8 h1:JA8d3MPx/IToSyXZG/RhwYEtfrKO1Fxrqe8KrkiLXKM=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSfrPzImPoVxuomtbT2nk=
golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200121175148-a6ecf24a6d71 h1:Xe2gvTZUJpsvOWUnvmL/tmhVBZUmHSvLbMjRj6NUUKo=
gopkg.in/yaml.v3 v3.0.0-20200121175148-a6ecf24a6d71/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maze.io/x/esphome v0.0.0-20200125144458-ec2230eb77d3 h1:+CChlXkhVvn7imlAShj/bp/n9Be9WbAUTe/XQeKnxfM=
maze.io/x/esphome v0.0.0-20200125144458-ec2230eb77d3/go.mod h1:FLiAxWzUdcZASeTBuiAG+Jhd8OlIN6b4UxXT3mjCk0c=
maze.io/x/esphome v0.0.0-20200126201346-70b1769eceb2 h1:oOcOqmKCA5945UOT3PreJB1TFBq6PN++WeGnH6wntEk=
maze.io/x/esphome v0.0.0-20200126201346-70b1769eceb2/go.mod h1:R3Qn1v32brYI4q6W3NKu+4tNHWUp4vCtIWCN369A1qM=
maze.io/x/esphome v0.0.0-20200126212734-ce499d43afa2 h1:z5wR7B4RNpCTbpcNz3bHxm4cjjWUa82Fy5+yvEnPCis=
maze.io/x/esphome v0.0.0-20200126212734-ce499d43afa2/go.mod h1:R3Qn1v32brYI4q6W3NKu+4tNHWUp4vCtIWCN369A1qM=
maze.io/x/esphome v0.0.0-20200126225942-b2fee7b98232 h1:xOYZhFAhFxNguGBDflmvU27nifIOTa11YPyjVhiRtKM=
maze.io/x/esphome v0.0.0-20200126225942-b2fee7b98232/go.mod h1:R3Qn1v32brYI4q6W3NKu+4tNHWUp4vCtIWCN369A1qM=
maze.io/x/esphome v0.0.0-20200126230711-addd2aacb64c h1:Ir64GZkgO0PZOjAK6zW7m1AweyWnxUbvFHH9G/fN9R4=
maze.io/x/esphome v0.0.0-20200126230711-addd2aacb64c/go.mod h1:R3Qn1v32brYI4q6W3NKu+4tNHWUp4vCtIWCN369A1qM=
maze.io/x/esphome v0.0.0-20200127204337-ce569dfe224b h1:YiXKliRBgertHDi9LIe1cktAMu+6/whXI+S6GlQO1fs=
maze.io/x/esphome v0.0.0-20200127204337-ce569dfe224b/go.mod h1:R3Qn1v32brYI4q6W3NKu+4tNHWUp4vCtIWCN369A1qM=
maze.io/x/esphome v0.0.0-20200127205229-fd981d947543 h1:w2+xmMg1PZYXI/KQ1c3hyG/SclxwC/Qweys642X1YMQ=
maze.io/x/esphome v0.0.0-20200127205229-fd981d947543/go.mod h1:R3Qn1v32brYI4q6W3NKu+4tNHWUp4vCtIWCN369A1qM=

+ 45
- 0
logger.go View File

@ -0,0 +1,45 @@
package main
import (
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/sirupsen/logrus"
)
// Logger returns a middleware that logs HTTP requests.
func Logger() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
req := c.Request()
res := c.Response()
start := time.Now()
var err error
if err = next(c); err != nil {
c.Error(err)
}
stop := time.Now()
reqSize := req.Header.Get(echo.HeaderContentLength)
if reqSize == "" {
reqSize = "0"
}
log.WithFields(logrus.Fields{
"ip": c.RealIP(),
"host": req.Host,
"method": req.Method,
"uri": req.RequestURI,
"status": res.Status,
"request_size": reqSize,
"size": res.Size,
"duration": stop.Sub(start),
"referer": req.Referer(),
"ua": req.UserAgent(),
}).Info(http.StatusText(res.Status))
return err
}
}
}

+ 33
- 0
main.go View File

@ -0,0 +1,33 @@
package main
import (
"flag"
"github.com/sirupsen/logrus"
)
var log = logrus.New()
func main() {
var (
config = DefaultConfig()
configPathFlag = flag.String("config", "", "configuration path")
)
flag.StringVar(&config.Listen, "listen", config.Listen, "server listen address")
flag.StringVar(&config.Static, "static", config.Static, "static files path")
flag.StringVar(&config.Template, "template", config.Template, "template files path")
flag.Parse()
if *configPathFlag != "" {
if err := config.Load(*configPathFlag); err != nil {
log.WithField("config", *configPathFlag).Fatalln("error reading configuration")
}
}
s, err := NewServer(config)
if err != nil {
log.Fatalln(err)
}
log.Println("server: starting on http://" + config.Listen)
log.Fatalln(s.Start(config.Listen))
}

+ 115
- 0
manager.go View File

@ -0,0 +1,115 @@
package main
import (
"sync"
"time"
"github.com/juju/errors"
"github.com/sirupsen/logrus"
"maze.io/x/esphome"
)
type Manager struct {
sync.RWMutex
Device map[string]*Device
config *Config
}
func NewManager(config *Config) *Manager {
m := &Manager{
Device: make(map[string]*Device),
config: config,
}
go m.keepalive()
return m
}
func (m *Manager) keepalive() {
m.discoverDevices()
m.checkDevicesAlive()
ticker := time.NewTicker(m.config.Scan.Interval)
for {
<-ticker.C
m.discoverDevices()
m.checkDevicesAlive()
}
}
func (m *Manager) discoverDevices() {
log.Debugln("scanning for devices")
devices := make(chan *esphome.Device, 32)
defer close(devices)
go m.addDiscoveredDevices(devices)
if err := esphome.DiscoverService(devices, m.config.Scan.Service, m.config.Scan.Domain, m.config.Scan.Timeout); err != nil {
log.Printf("manager: error discovering: %v", err)
}
}
func (m *Manager) addDiscoveredDevices(devices <-chan *esphome.Device) {
for discovered := range devices {
log := log.WithFields(logrus.Fields{
"name": discovered.Name,
"addr": discovered.Addr(),
})
log.Debug("mDNS response from device")
m.RLock()
device, ok := m.Device[discovered.Name]
m.RUnlock()
if !ok {
log.Info("discovered new device")
device = NewDevice(discovered.Name, discovered.Addr())
device.Version = discovered.Version
m.Lock()
m.Device[device.Name] = device
m.Unlock()
}
device.Last = time.Now()
if device.IsAvailable() {
continue
}
if err := device.Connect(m.config.GetSecret(discovered.Name), m.config.Scan.Timeout); err != nil {
if err == esphome.ErrPassword {
err = errors.Wrap(err, errors.New("update device password in config"))
}
log.WithError(err).Warn("error connecting")
} else {
log.Info("connected")
}
}
}
func (m *Manager) checkDevicesAlive() {
m.RLock()
for _, device := range m.Device {
// We're not polling devices that have no camera, they are mainly
// listed for brevity.
if device.IsAvailable() && device.Camera != nil {
m.checkDeviceAlive(device)
}
}
m.RUnlock()
}
func (m *Manager) checkDeviceAlive(device *Device) {
var (
idle = time.Since(device.client.LastMessage())
log = log.WithFields(logrus.Fields{
"name": device.Name,
"idle": idle,
})
)
log.Debug("check alive")
if idle > m.config.Scan.Interval {
log.Warn("device is unresponsive, scheduling reconnect")
_ = device.client.Close()
device.client = nil
}
}

+ 86
- 0
renderer.go View File

@ -0,0 +1,86 @@
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"github.com/flosch/pongo2"
"github.com/labstack/echo/v4"
)
// Renderer manages a pongo2 TemplateSet
type Renderer struct {
Path string
TemplateSet *pongo2.TemplateSet
Debug bool
}
// NewRenderer creates a new instance of Renderer
func NewRenderer(root string) (*Renderer, error) {
// check if root exists
fInfo, err := os.Lstat(root)
if err != nil {
return nil, err
}
if fInfo.IsDir() == false {
return nil, fmt.Errorf("%s is not a directory", root)
}
rdr := Renderer{}
loader, err := pongo2.NewLocalFileSystemLoader(root)
if err != nil {
return nil, err
}
rdr.TemplateSet = pongo2.NewSet("TemplateSet-"+filepath.Base(root), loader)
return &rdr, nil
}
// Render implements echo.Render interface
func (r *Renderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
// get template, compile it anf store it in cache
//tpl, err := r.TemplateSet.FromCache(name)
tpl, err := r.TemplateSet.FromFile(name)
if err != nil {
return err
}
// convert supplied data to pongo2.Context
val, err := toPongoCtx(data)
if err != nil {
return err
}
// generate render the template
err = tpl.ExecuteWriter(val, w)
return err
}
// toPongoCtx converts a pongo2.Context, struct, map[string] to
// pongo2.Context
func toPongoCtx(data interface{}) (pongo2.Context, error) {
if c, ok := data.(pongo2.Context); ok {
return c, nil
}
var (
ctx = pongo2.Context{}
val = reflect.ValueOf(data)
)
if val.Kind().String() == "struct" {
for i := 0; i < val.NumField(); i++ {
ctx[val.Type().Field(i).Name] = val.Field(i).Interface()
}
} else if strings.HasPrefix(val.Type().String(), "map[string]") {
for _, k := range val.MapKeys() {
ctx[k.String()] = val.MapIndex(k).Interface()
}
} else {
return nil, fmt.Errorf("cant convert %T to pongo2.Context", data)
}
return ctx, nil
}

+ 186
- 0
server.go View File

@ -0,0 +1,186 @@
package main
import (
"net/http"
"sort"
"strings"
"sync"
"time"
"maze.io/x/esphome"
"github.com/flosch/pongo2"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
// Server defaults.
var (
DefaultServerListen = "localhost:6080"
DefaultServerStatic = "static"
DefaultServerTemplate = "template"
)
type Server struct {
*echo.Echo
m *Manager
mutex sync.RWMutex
camera map[string]*Camera
config *Config
}
func NewServer(config *Config) (*Server, error) {
var (
s = &Server{
Echo: echo.New(),
m: NewManager(config),
camera: make(map[string]*Camera),
config: config,
}
err error
)
if s.Renderer, err = NewRenderer(config.Template); err != nil {
return nil, err
}
s.HideBanner = true
// HTTP middlewares
s.Use(middleware.AddTrailingSlash())
s.Use(Logger())
//s.Use(middleware.Logger())
s.Use(middleware.Recover())
s.Use(middleware.Static(config.Static))
// HTTP routes
s.GET("/", s.index)
s.GET("/device/:name", s.deviceIndex)
s.GET("/device/:name/camera/current.jpeg", s.deviceCameraImage)
s.GET("/device/:name/camera/stream.jpeg", s.deviceCameraStream)
go s.streamCameras()
return s, nil
}
func (s *Server) streamCameras() {
s.startCameras()
ticker := time.NewTicker(s.config.Scan.Interval)
for {
<-ticker.C
s.startCameras()
}
}
func (s *Server) startCameras() {
for name, device := range s.m.Device {
log := log.WithField("device", name)
s.mutex.RLock()
_, streaming := s.camera[name]
s.mutex.RUnlock()
if streaming {
continue
}
if device.IsAvailable() {
camera, err := device.client.Camera()
if err != nil {
if err != esphome.ErrEntity {
log.WithError(err).Warn("failed to start camera")
}
continue
}
log.Info("starting camera stream")
cameraStream, err := NewCamera(camera)
if err != nil {
log.WithError(err).Warn("failed to start camera stream")
continue
}
s.mutex.Lock()
s.camera[name] = cameraStream
device.Camera = cameraStream
s.mutex.Unlock()
}
}
}
func (s *Server) index(c echo.Context) error {
var devices = make([]*Device, 0, len(s.m.Device))
for _, device := range s.m.Device {
devices = append(devices, device)
}
sort.Slice(devices, func(i, j int) bool {
var a, b = devices[i], devices[j]
if a.Camera != nil && b.Camera == nil {
return true
}
if b.Camera != nil && a.Camera == nil {
return false
}
return strings.Compare(a.Name, b.Name) < 0
})
return c.Render(http.StatusOK, "index.html", pongo2.Context{
"devices": devices,
})
}
func (s *Server) deviceIndex(c echo.Context) error {
device, ok := s.m.Device[c.Param("name")]
if !ok {
return echo.NewHTTPError(http.StatusNotFound, "Device not found")
}
return c.Render(http.StatusOK, "device.html", pongo2.Context{
"device": device,
})
}
func (s *Server) deviceCameraImage(c echo.Context) error {
var (
name = c.Param("name")
response = c.Response()
header = response.Header()
camera, ok = s.camera[name]
)
if !ok {
log.WithField("camera", name).Debug("not found")
return echo.NewHTTPError(http.StatusNotFound, "Camera not found")
}
header.Set("Content-Type", "image/jpeg")
_, err := response.Write(camera.JPEG())
return err
}
func (s *Server) deviceCameraStream(c echo.Context) error {
var (
name = c.Param("name")
addr = c.Request().RemoteAddr
camera, ok = s.camera[name]
)
if !ok {
log.WithField("camera", name).Debug("not found")
return echo.NewHTTPError(http.StatusNotFound, "Camera not found")
}
stream := make(chan []byte)
camera.Add(stream)
defer camera.Remove(stream)
log.Printf("streamer: camera %s has new client %s", name, addr)
w := c.Response().Writer
w.Header().Add("Content-Type", "multipart/x-mixed-replace;boundary="+boundaryWord)
for frame := range stream {
if _, err := w.Write(frame); err != nil {
return nil
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
return nil
}

+ 10598
- 0
static/vendor/jquery/jquery-3.4.1.js
File diff suppressed because it is too large
View File


+ 2
- 0
static/vendor/jquery/jquery-3.4.1.min.js
File diff suppressed because it is too large
View File


+ 21
- 0
static/vendor/materialize/LICENSE View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014-2018 Materialize
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

+ 91
- 0
static/vendor/materialize/README.md View File

@ -0,0 +1,91 @@
<p align="center">
<a href="http://materializecss.com/">
<img src="http://materializecss.com/res/materialize.svg" width="150">
</a>
</p>
<h3 align="center">MaterializeCSS</h3>
<p align="center">
Materialize, a CSS Framework based on material design.
<br>
<a href="http://materializecss.com/"><strong>-- Browse the docs --</strong></a>
<br>
<br>
<a href="https://travis-ci.org/Dogfalo/materialize">
<img src="https://travis-ci.org/Dogfalo/materialize.svg?branch=master" alt="Travis CI badge">
</a>
<a href="https://badge.fury.io/js/materialize-css">
<img src="https://badge.fury.io/js/materialize-css.svg" alt="npm version badge">
</a>
<a href="https://cdnjs.com/libraries/materialize">
<img src="https://img.shields.io/cdnjs/v/materialize.svg" alt="CDNJS version badge">
</a>
<a href="https://david-dm.org/Dogfalo/materialize">
<img src="https://david-dm.org/Dogfalo/materialize/status.svg" alt="dependencies Status badge">
</a>
<a href="https://david-dm.org/Dogfalo/materialize#info=devDependencies">
<img src="https://david-dm.org/Dogfalo/materialize/dev-status.svg" alt="devDependency Status badge">
</a>
<a href="https://gitter.im/Dogfalo/materialize">
<img src="https://badges.gitter.im/Join%20Chat.svg" alt="Gitter badge">
</a>
</p>
## Table of Contents
- [Quickstart](#quickstart)
- [Documentation](#documentation)
- [Supported Browsers](#supported-browsers)
- [Changelog](#changelog)
- [Testing](#testing)
- [Contributing](#contributing)
- [Copyright and license](#copyright-and-license)
## Quickstart:
Read the [getting started guide](http://materializecss.com/getting-started.html) for more information on how to use materialize.
- [Download the latest release](https://github.com/Dogfalo/materialize/releases/latest) of materialize directly from GitHub. ([Beta](https://github.com/Dogfalo/materialize/releases/))
- Clone the repo: `git clone https://github.com/Dogfalo/materialize.git` (Beta: `git clone -b v1-dev https://github.com/Dogfalo/materialize.git`)
- Include the files via [cdnjs](https://cdnjs.com/libraries/materialize). More [here](http://materializecss.com/getting-started.html). ([Beta](https://cdnjs.com/libraries/materialize/1.0.0-beta))
- Install with [npm](https://www.npmjs.com): `npm install materialize-css` (Beta: `npm install materialize-css@next`)
- Install with [Bower](https://bower.io): `bower install materialize` ([DEPRECATED](https://bower.io/blog/2017/how-to-migrate-away-from-bower/))
- Install with [Atmosphere](https://atmospherejs.com): `meteor add materialize:materialize` (Beta: `meteor add materialize:materialize@=1.0.0-beta`)
## Documentation
The documentation can be found at <http://materializecss.com>. To run the documentation locally on your machine, you need [Node.js](https://nodejs.org/en/) installed on your computer.
### Running documentation locally
Run these commands to set up the documentation:
```bash
git clone https://github.com/Dogfalo/materialize
cd materialize
npm install
```
Then run `grunt monitor` to compile the documentation. When it finishes, open a new browser window and navigate to `localhost:8000`. We use [BrowserSync](https://www.browsersync.io/) to display the documentation.
### Documentation for previous releases
Previous releases and their documentation are available for [download](https://github.com/Dogfalo/materialize/releases).
## Supported Browsers:
Materialize is compatible with:
- Chrome 35+
- Firefox 31+
- Safari 9+
- Opera
- Edge
- IE 11+
## Changelog
For changelogs, check out [the Releases section of materialize](https://github.com/Dogfalo/materialize/releases) or the [CHANGELOG.md](CHANGELOG.md).
## Testing
We use Jasmine as our testing framework and we're trying to write a robust test suite for our components. If you want to help, [here's a starting guide on how to write tests in Jasmine](CONTRIBUTING.md#jasmine-testing-guide).
## Contributing
Check out the [CONTRIBUTING document](CONTRIBUTING.md) in the root of the repository to learn how you can contribute. You can also browse the [help-wanted](https://github.com/Dogfalo/materialize/labels/help-wanted) tag in our issue tracker to find things to do.
## Copyright and license
Code Copyright 2018 Materialize. Code released under the MIT license.

+ 9067
- 0
static/vendor/materialize/css/materialize.css
File diff suppressed because it is too large
View File


+ 13
- 0
static/vendor/materialize/css/materialize.min.css
File diff suppressed because it is too large
View File


+ 12374
- 0
static/vendor/materialize/js/materialize.js
File diff suppressed because it is too large
View File


+ 6
- 0
static/vendor/materialize/js/materialize.min.js
File diff suppressed because it is too large
View File


+ 81
- 0
template/index.html View File

@ -0,0 +1,81 @@
<!DOCTYPE html>
<html>
<head>
<!--Import Google Icon Font-->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<!--Import materialize.css-->
<link type="text/css" rel="stylesheet" href="/vendor/materialize/css/materialize.min.css" media="screen,projection"/>
<!--Let browser know website is optimized for mobile-->
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
</head>
<body>
<div class="navbar-fixed">
<nav class="blue darken-2">
<div class="nav-wrapper container">
<a href="/" class="brand-logo">Streamer</a>
<ul id="nav-mobile" class="right hide-on-med-and-down">
<li><a href="https://git.maze.io/maze/esphome-streamer">Git</a><i class="material-icons">receipt</i></li>
</ul>
</div>
</nav>
</div>
<div class="container">
<h2>Discovered ESPHome cameras</h2>
<table class="highlight">
<thead>
<tr>
<th>Device</th>
<th>Model</th>
<th>Version</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
{% if device.Camera %}
<tr>
<td><a class="waves-effect waves-light btn modal-trigger" href="#camera-{{ device.Name }}">{{ device.Name }}</a></td>
<td>{{ device.Info.Model }}</td>
<td>{{ device.Info.EsphomeVersion }}</td>
</tr>
{% endif %}
{% endfor %}
{% for device in devices %}
{% if not device.Camera %}
<tr>
<td><span style="text-decoration: line-through">{{ device.Name }}</span></td>
<td>{{ device.Info.Model }}</td>
<td>{{ device.Info.EsphomeVersion }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% for device in devices %}
{% if device.Camera %}
<div id="camera-{{ device.Name }}" class="modal modal-fixed-footer">
<div class="modal-content">
<h4>Camera ({{ device.Name }})</h4>
<img src="/device/{{ device.Name }}/camera/stream.jpeg" style="max-width: 100%">
</div>
<div class="modal-footer">
<a href="#!" class="modal-close waves-effect waves-green btn-flat">Close</a>
</div>
</div>
{% endif %}
{% endfor %}
</div>
<script type="text/javascript" src="/vendor/jquery/jquery-3.4.1.min.js"></script>
<script type="text/javascript" src="/vendor/materialize/js/materialize.min.js"></script>
<script type="text/javascript">
$(document).ready(function(){
$('.modal').modal();
});
</script>
</body>
</html>

Loading…
Cancel
Save