mirror of
https://github.com/shouptech/tempgopher.git
synced 2026-02-03 16:49:42 +00:00
Compare commits
35 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 209c3afc2a | |||
| a6196d5fa8 | |||
| 8aebae0ebe | |||
| 924473090e | |||
| 927f5b9043 | |||
| 1b946b9ce8 | |||
| 14fa9a78c4 | |||
| 961a8de916 | |||
| 7ad2619369 | |||
| 14b5cc4a8b | |||
| 281af2b255 | |||
| 32f1c7fc9d | |||
| 1ce594d540 | |||
| a167da2230 | |||
| 195d167664 | |||
| 2107a7a3af | |||
| 071d0db161 | |||
| e3ec4803de | |||
| f13c557b38 | |||
| f541654a86 | |||
| bdba7afbfd | |||
| 0bd6c9bf73 | |||
| 64ca5a3bfd | |||
| ffe11c1965 | |||
| c6c82c1308 | |||
| 595751d5d4 | |||
| 5fd5acc0cc | |||
| dd3e78eb28 | |||
| ac50b755d1 | |||
| 6790b94b80 | |||
| 89657c381b | |||
| c817f27eb2 | |||
|
|
cfa7fc7fe8 | ||
|
|
8aa73706e6 | ||
| fb4905a7e8 |
24 changed files with 1269 additions and 218 deletions
|
|
@ -20,9 +20,16 @@ test:
|
||||||
stage: test
|
stage: test
|
||||||
variables:
|
variables:
|
||||||
GIN_MODE: debug
|
GIN_MODE: debug
|
||||||
|
INFLUXDB_DB: db
|
||||||
|
INFLUXDB_ADDR: http://influxdb:8086
|
||||||
|
services:
|
||||||
|
- influxdb
|
||||||
script:
|
script:
|
||||||
- go get -v -t ./...
|
- go get -v -t ./...
|
||||||
- go test -v
|
- go test -v -coverprofile=$CI_PROJECT_DIR/coverage.out
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- coverage.out
|
||||||
|
|
||||||
build:
|
build:
|
||||||
stage: build
|
stage: build
|
||||||
|
|
|
||||||
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -1,5 +1,29 @@
|
||||||
# TempGopher Changelog
|
# TempGopher Changelog
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
Release 2018-11-01
|
||||||
|
|
||||||
|
* Enable/disable heating or cooling via UI
|
||||||
|
* Improved unit tests!
|
||||||
|
* Fixed bug where updating/clearing changes would cause multiple refreshes in a row [#17](https://gitlab.com/shouptech/tempgopher/issues/17)
|
||||||
|
|
||||||
|
## 0.3.1
|
||||||
|
|
||||||
|
Release: 2018-10-24
|
||||||
|
|
||||||
|
* Fixes bug where UI would not display two sensors correctly [#15](https://gitlab.com/shouptech/tempgopher/issues/15)
|
||||||
|
* Fixes a typo during the CLI configuration [#14](https://gitlab.com/shouptech/tempgopher/issues/14)
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
Release: 2018-10-20
|
||||||
|
|
||||||
|
* You can now supply a list of users for simple authentication
|
||||||
|
* Will write data to an Influx DB if configured
|
||||||
|
* Adds the ability to selectively disable heating or cooling
|
||||||
|
* Checks for the existence of a config file before generating a new one
|
||||||
|
|
||||||
## 0.2.0
|
## 0.2.0
|
||||||
|
|
||||||
Release: 2018-10-11
|
Release: 2018-10-11
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -39,6 +39,8 @@ You will be asked some questions during the initial configuration of TempGopher.
|
||||||
* `Heating GPIO:` - The pin your heating relay switch is hooked into.
|
* `Heating GPIO:` - The pin your heating relay switch is hooked into.
|
||||||
* `Invert heating switch` - If set to `true`, the heating will be ON when the switch is LOW. This should usually be `false`, so that is the default
|
* `Invert heating switch` - If set to `true`, the heating will be ON when the switch is LOW. This should usually be `false`, so that is the default
|
||||||
* `Enable verbose logging` - If set to `true`, TempGopher will display in the console every thermostat reading. This can be quite verbose, so the default is `false`.
|
* `Enable verbose logging` - If set to `true`, TempGopher will display in the console every thermostat reading. This can be quite verbose, so the default is `false`.
|
||||||
|
* `Write data to an Influx database?` - Whether or not to configure an Influx database
|
||||||
|
* `Enable user authentication?` - Whether or not to enable authentication
|
||||||
|
|
||||||
## Example configuration script
|
## Example configuration script
|
||||||
|
|
||||||
|
|
@ -73,4 +75,20 @@ Heating minutes: 0.5
|
||||||
Heating GPIO: 13
|
Heating GPIO: 13
|
||||||
Invert heating switch [false]:
|
Invert heating switch [false]:
|
||||||
Enable verbose logging [false]:
|
Enable verbose logging [false]:
|
||||||
|
Write data to an Influx database?
|
||||||
|
[Y/n]: y
|
||||||
|
Influx address [http://influx:8086]:
|
||||||
|
Influx Username []:
|
||||||
|
Influx Password []:
|
||||||
|
Influx UserAgent [InfluxDBClient]:
|
||||||
|
Influx timeout (in seconds) [30]:
|
||||||
|
Influx database []: tempgopher
|
||||||
|
Enable InsecureSkipVerify? [fasle]:
|
||||||
|
Username: mike
|
||||||
|
Password: ********
|
||||||
|
Add another user? [y/N]: y
|
||||||
|
Username: foo
|
||||||
|
Password: ***
|
||||||
|
Add another user? [y/N]: n
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
76
auth.go
Normal file
76
auth.go
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
// Copyright 2014 Manu Martinez-Almeida. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT style
|
||||||
|
// license that can be found at: https://github.com/gin-gonic/gin/blob/master/LICENSE
|
||||||
|
|
||||||
|
// Modified to remove the WWW-Authenticate header for uses in TempGopher
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type authPair struct {
|
||||||
|
value string
|
||||||
|
user string
|
||||||
|
}
|
||||||
|
|
||||||
|
type authPairs []authPair
|
||||||
|
|
||||||
|
func (a authPairs) searchCredential(authValue string) (string, bool) {
|
||||||
|
if authValue == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, pair := range a {
|
||||||
|
if pair.value == authValue {
|
||||||
|
return pair.user, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasicAuth returns a Basic HTTP Authorization middleware. It takes as arguments a map[string]string where
|
||||||
|
// the key is the user name and the value is the password. This does not set a www-authenticate header.
|
||||||
|
func BasicAuth(accounts gin.Accounts) gin.HandlerFunc {
|
||||||
|
pairs := processAccounts(accounts)
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Search user in the slice of allowed credentials
|
||||||
|
user, found := pairs.searchCredential(c.GetHeader("Authorization"))
|
||||||
|
if !found {
|
||||||
|
// Credentials doesn't match, we return 401 and abort handlers chain.
|
||||||
|
c.AbortWithStatus(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user credentials was found, set user's id to key AuthUserKey in this context, the user's id can be read later using
|
||||||
|
// c.MustGet(gin.AuthUserKey).
|
||||||
|
c.Set(gin.AuthUserKey, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processAccounts(accounts gin.Accounts) authPairs {
|
||||||
|
if len(accounts) == 0 {
|
||||||
|
log.Panic("Empty list of authorized credentials")
|
||||||
|
}
|
||||||
|
pairs := make(authPairs, 0, len(accounts))
|
||||||
|
for user, password := range accounts {
|
||||||
|
if user == "" {
|
||||||
|
log.Panic("User can not be empty")
|
||||||
|
}
|
||||||
|
value := authorizationHeader(user, password)
|
||||||
|
pairs = append(pairs, authPair{
|
||||||
|
value: value,
|
||||||
|
user: user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizationHeader(user, password string) string {
|
||||||
|
base := user + ":" + password
|
||||||
|
return "Basic " + base64.StdEncoding.EncodeToString([]byte(base))
|
||||||
|
}
|
||||||
141
auth_test.go
Normal file
141
auth_test.go
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
// Copyright 2014 Manu Martinez-Almeida. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT style
|
||||||
|
// license that can be found at: https://github.com/gin-gonic/gin/blob/master/LICENSE
|
||||||
|
// Original source: https://github.com/gin-gonic/gin/blob/master/auth_test.go
|
||||||
|
|
||||||
|
// Modified to remove the WWW-Authenticate header for uses in TempGopher
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBasicAuth(t *testing.T) {
|
||||||
|
pairs := processAccounts(gin.Accounts{
|
||||||
|
"admin": "password",
|
||||||
|
"foo": "bar",
|
||||||
|
"bar": "foo",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Len(t, pairs, 3)
|
||||||
|
assert.Contains(t, pairs, authPair{
|
||||||
|
user: "bar",
|
||||||
|
value: "Basic YmFyOmZvbw==",
|
||||||
|
})
|
||||||
|
assert.Contains(t, pairs, authPair{
|
||||||
|
user: "foo",
|
||||||
|
value: "Basic Zm9vOmJhcg==",
|
||||||
|
})
|
||||||
|
assert.Contains(t, pairs, authPair{
|
||||||
|
user: "admin",
|
||||||
|
value: "Basic YWRtaW46cGFzc3dvcmQ=",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicAuthFails(t *testing.T) {
|
||||||
|
assert.Panics(t, func() { processAccounts(nil) })
|
||||||
|
assert.Panics(t, func() {
|
||||||
|
processAccounts(gin.Accounts{
|
||||||
|
"": "password",
|
||||||
|
"foo": "bar",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicAuthSearchCredential(t *testing.T) {
|
||||||
|
pairs := processAccounts(gin.Accounts{
|
||||||
|
"admin": "password",
|
||||||
|
"foo": "bar",
|
||||||
|
"bar": "foo",
|
||||||
|
})
|
||||||
|
|
||||||
|
user, found := pairs.searchCredential(authorizationHeader("admin", "password"))
|
||||||
|
assert.Equal(t, "admin", user)
|
||||||
|
assert.True(t, found)
|
||||||
|
|
||||||
|
user, found = pairs.searchCredential(authorizationHeader("foo", "bar"))
|
||||||
|
assert.Equal(t, "foo", user)
|
||||||
|
assert.True(t, found)
|
||||||
|
|
||||||
|
user, found = pairs.searchCredential(authorizationHeader("bar", "foo"))
|
||||||
|
assert.Equal(t, "bar", user)
|
||||||
|
assert.True(t, found)
|
||||||
|
|
||||||
|
user, found = pairs.searchCredential(authorizationHeader("admins", "password"))
|
||||||
|
assert.Empty(t, user)
|
||||||
|
assert.False(t, found)
|
||||||
|
|
||||||
|
user, found = pairs.searchCredential(authorizationHeader("foo", "bar "))
|
||||||
|
assert.Empty(t, user)
|
||||||
|
assert.False(t, found)
|
||||||
|
|
||||||
|
user, found = pairs.searchCredential("")
|
||||||
|
assert.Empty(t, user)
|
||||||
|
assert.False(t, found)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicAuthAuthorizationHeader(t *testing.T) {
|
||||||
|
assert.Equal(t, "Basic YWRtaW46cGFzc3dvcmQ=", authorizationHeader("admin", "password"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicAuthSucceed(t *testing.T) {
|
||||||
|
accounts := gin.Accounts{"admin": "password"}
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(BasicAuth(accounts))
|
||||||
|
router.GET("/login", func(c *gin.Context) {
|
||||||
|
c.String(http.StatusOK, c.MustGet(gin.AuthUserKey).(string))
|
||||||
|
})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/login", nil)
|
||||||
|
req.Header.Set("Authorization", authorizationHeader("admin", "password"))
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "admin", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicAuth401(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
accounts := gin.Accounts{"foo": "bar"}
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(BasicAuth(accounts))
|
||||||
|
router.GET("/login", func(c *gin.Context) {
|
||||||
|
called = true
|
||||||
|
c.String(http.StatusOK, c.MustGet(gin.AuthUserKey).(string))
|
||||||
|
})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/login", nil)
|
||||||
|
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:password")))
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.False(t, called)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicAuth401WithCustomRealm(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
accounts := gin.Accounts{"foo": "bar"}
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(BasicAuth(accounts))
|
||||||
|
router.GET("/login", func(c *gin.Context) {
|
||||||
|
called = true
|
||||||
|
c.String(http.StatusOK, c.MustGet(gin.AuthUserKey).(string))
|
||||||
|
})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/login", nil)
|
||||||
|
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:password")))
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.False(t, called)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||||
|
}
|
||||||
89
cli.go
89
cli.go
|
|
@ -3,10 +3,12 @@ package main
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/howeyc/gopass"
|
||||||
"github.com/yryz/ds18b20"
|
"github.com/yryz/ds18b20"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -39,8 +41,8 @@ func ParsePort(addr string) (uint16, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromptForConfiguration walks a user through configuration
|
// PromptForConfiguration walks a user through configuration
|
||||||
func PromptForConfiguration() Config {
|
func PromptForConfiguration(in io.Reader) Config {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(in)
|
||||||
|
|
||||||
var config Config
|
var config Config
|
||||||
|
|
||||||
|
|
@ -99,6 +101,14 @@ func PromptForConfiguration() Config {
|
||||||
panic("Alias cannot be blank")
|
panic("Alias cannot be blank")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Print("Disable cooling? [false]: ")
|
||||||
|
s.CoolDisable, err = strconv.ParseBool(ReadInput(reader, "false"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.CoolDisable {
|
||||||
|
|
||||||
fmt.Print("High temperature: ")
|
fmt.Print("High temperature: ")
|
||||||
s.HighTemp, err = strconv.ParseFloat(ReadInput(reader, ""), 64)
|
s.HighTemp, err = strconv.ParseFloat(ReadInput(reader, ""), 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -123,6 +133,15 @@ func PromptForConfiguration() Config {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Disable heating? [false]: ")
|
||||||
|
s.HeatDisable, err = strconv.ParseBool(ReadInput(reader, "false"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.HeatDisable {
|
||||||
|
|
||||||
fmt.Print("Low temperature: ")
|
fmt.Print("Low temperature: ")
|
||||||
s.LowTemp, err = strconv.ParseFloat(ReadInput(reader, ""), 64)
|
s.LowTemp, err = strconv.ParseFloat(ReadInput(reader, ""), 64)
|
||||||
|
|
@ -137,7 +156,7 @@ func PromptForConfiguration() Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Print("Heating GPIO: ")
|
fmt.Print("Heating GPIO: ")
|
||||||
resp, err = strconv.ParseInt(ReadInput(reader, ""), 10, 32)
|
resp, err := strconv.ParseInt(ReadInput(reader, ""), 10, 32)
|
||||||
s.HeatGPIO = int32(resp)
|
s.HeatGPIO = int32(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|
@ -148,6 +167,7 @@ func PromptForConfiguration() Config {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Print("Enable verbose logging [false]: ")
|
fmt.Print("Enable verbose logging [false]: ")
|
||||||
s.Verbose, err = strconv.ParseBool(ReadInput(reader, "false"))
|
s.Verbose, err = strconv.ParseBool(ReadInput(reader, "false"))
|
||||||
|
|
@ -158,12 +178,73 @@ func PromptForConfiguration() Config {
|
||||||
config.Sensors = append(config.Sensors, s)
|
config.Sensors = append(config.Sensors, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Println("Write data to an Influx database?")
|
||||||
|
fmt.Print("[Y/n]: ")
|
||||||
|
choice := ReadInput(reader, "y")
|
||||||
|
if strings.ToLower(choice)[0] == 'y' {
|
||||||
|
fmt.Print("Influx address [http://influx:8086]: ")
|
||||||
|
config.Influx.Addr = ReadInput(reader, "http://influx:8086")
|
||||||
|
|
||||||
|
fmt.Print("Influx Username []: ")
|
||||||
|
config.Influx.Username = ReadInput(reader, "")
|
||||||
|
|
||||||
|
fmt.Print("Influx Password []: ")
|
||||||
|
config.Influx.Password = ReadInput(reader, "")
|
||||||
|
|
||||||
|
fmt.Print("Influx UserAgent [InfluxDBClient]: ")
|
||||||
|
config.Influx.UserAgent = ReadInput(reader, "InfluxDBClient")
|
||||||
|
|
||||||
|
fmt.Print("Influx timeout (in seconds) [30]: ")
|
||||||
|
config.Influx.Timeout, err = strconv.ParseFloat(ReadInput(reader, "30"), 64)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Influx database []: ")
|
||||||
|
config.Influx.Database = ReadInput(reader, "")
|
||||||
|
|
||||||
|
fmt.Print("Enable InsecureSkipVerify? [false]: ")
|
||||||
|
config.Influx.InsecureSkipVerify, err = strconv.ParseBool(ReadInput(reader, "false"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Enable user authentication?")
|
||||||
|
fmt.Print("[Y/n]: ")
|
||||||
|
choice = ReadInput(reader, "y")
|
||||||
|
if strings.ToLower(choice)[0] == 'y' {
|
||||||
|
another := true
|
||||||
|
for another {
|
||||||
|
fmt.Print("Username: ")
|
||||||
|
username := ReadInput(reader, "")
|
||||||
|
fmt.Print("Password: ")
|
||||||
|
password, err := gopass.GetPasswdMasked()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
config.Users = append(config.Users, User{username, string(password)})
|
||||||
|
|
||||||
|
fmt.Print("Add another user? [y/N]: ")
|
||||||
|
choice = ReadInput(reader, "n")
|
||||||
|
if strings.ToLower(choice)[0] == 'n' {
|
||||||
|
another = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigCLI prompts the user for configuration and writes to a config file
|
// ConfigCLI prompts the user for configuration and writes to a config file
|
||||||
func ConfigCLI(path string) {
|
func ConfigCLI(path string) {
|
||||||
config := PromptForConfiguration()
|
// Check if path exists
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
fmt.Printf("File exists, or some other error trying to open file %s\n", path)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := PromptForConfiguration(os.Stdin)
|
||||||
|
|
||||||
SaveConfig(path, config)
|
SaveConfig(path, config)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
39
cli_test.go
Normal file
39
cli_test.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_ReadInput(t *testing.T) {
|
||||||
|
// Test basic input
|
||||||
|
buf := bytes.NewBufferString("foobar\n")
|
||||||
|
reader := bufio.NewReader(buf)
|
||||||
|
resp := ReadInput(reader, "")
|
||||||
|
assert.Equal(t, "foobar", resp)
|
||||||
|
|
||||||
|
// Test default input
|
||||||
|
buf = bytes.NewBufferString("\n")
|
||||||
|
reader = bufio.NewReader(buf)
|
||||||
|
resp = ReadInput(reader, "default")
|
||||||
|
assert.Equal(t, "default", resp)
|
||||||
|
|
||||||
|
// Test that a panic occurred
|
||||||
|
buf = bytes.NewBufferString("")
|
||||||
|
reader = bufio.NewReader(buf)
|
||||||
|
assert.Panics(t, func() { ReadInput(reader, "") })
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_ParsePort(t *testing.T) {
|
||||||
|
// Test that 600 is parsed succesfully
|
||||||
|
port, err := ParsePort(":600")
|
||||||
|
assert.Equal(t, uint16(600), port)
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
|
||||||
|
// Test failure
|
||||||
|
port, err = ParsePort("")
|
||||||
|
assert.NotEqual(t, nil, err)
|
||||||
|
}
|
||||||
37
config.go
37
config.go
|
|
@ -3,34 +3,55 @@ package main
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/jinzhu/copier"
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Influx defines an Influx database configuration
|
||||||
|
type Influx struct {
|
||||||
|
Addr string `json:"string" yaml:"addr"`
|
||||||
|
Username string `json:"username" yaml:"username"`
|
||||||
|
Password string `json:"-" yaml:"password"`
|
||||||
|
UserAgent string `json:"useragent" yaml:"useragent"`
|
||||||
|
Timeout float64 `json:"timeout" yaml:"timeout"`
|
||||||
|
InsecureSkipVerify bool `json:"insecureskipverify" yaml:"insecureskipverify"`
|
||||||
|
Database string `json:"database" yaml:"database"`
|
||||||
|
}
|
||||||
|
|
||||||
// Sensor defines configuration for a temperature sensor.
|
// Sensor defines configuration for a temperature sensor.
|
||||||
type Sensor struct {
|
type Sensor struct {
|
||||||
ID string `json:"id" yaml:"id"`
|
ID string `json:"id" yaml:"id"`
|
||||||
Alias string `json:"alias" yaml:"alias"`
|
Alias string `json:"alias" yaml:"alias"`
|
||||||
HighTemp float64 `json:"hightemp" yaml:"hightemp"`
|
HighTemp float64 `json:"hightemp" yaml:"hightemp"`
|
||||||
LowTemp float64 `json:"lowtemp" yaml:"lowtemp"`
|
LowTemp float64 `json:"lowtemp" yaml:"lowtemp"`
|
||||||
|
HeatDisable bool `json:"heatdisable" yaml:"heatdisable"`
|
||||||
HeatGPIO int32 `json:"heatgpio" yaml:"heatgpio"`
|
HeatGPIO int32 `json:"heatgpio" yaml:"heatgpio"`
|
||||||
HeatInvert bool `json:"heatinvert" yaml:"heatinvert"`
|
HeatInvert bool `json:"heatinvert" yaml:"heatinvert"`
|
||||||
HeatMinutes float64 `json:"heatminutes" yaml:"heatminutes"`
|
HeatMinutes float64 `json:"heatminutes" yaml:"heatminutes"`
|
||||||
|
CoolDisable bool `json:"cooldisable" yaml:"cooldisable"`
|
||||||
CoolGPIO int32 `json:"coolgpio" yaml:"coolgpio"`
|
CoolGPIO int32 `json:"coolgpio" yaml:"coolgpio"`
|
||||||
CoolInvert bool `json:"coolinvert" yaml:"coolinvert"`
|
CoolInvert bool `json:"coolinvert" yaml:"coolinvert"`
|
||||||
CoolMinutes float64 `json:"coolminutes" yaml:"coolminutes"`
|
CoolMinutes float64 `json:"coolminutes" yaml:"coolminutes"`
|
||||||
Verbose bool `json:"verbose" yaml:"verbose"`
|
Verbose bool `json:"verbose" yaml:"verbose"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User defines a user's configuration
|
||||||
|
type User struct {
|
||||||
|
Name string `json:"name" yaml:"name"`
|
||||||
|
Password string `json:"password" yaml:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
// Config contains the applications configuration
|
// Config contains the applications configuration
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Sensors []Sensor `yaml:"sensors"`
|
Sensors []Sensor `yaml:"sensors"`
|
||||||
|
Users []User `yaml:"users"`
|
||||||
BaseURL string `yaml:"baseurl"`
|
BaseURL string `yaml:"baseurl"`
|
||||||
ListenAddr string `yaml:"listenaddr"`
|
ListenAddr string `yaml:"listenaddr"`
|
||||||
DisplayFahrenheit bool `yaml:"displayfahrenheit"`
|
DisplayFahrenheit bool `yaml:"displayfahrenheit"`
|
||||||
|
Influx Influx `yaml:"influx"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var configFilePath string
|
var configFilePath string
|
||||||
|
|
@ -44,17 +65,7 @@ func UpdateSensorConfig(s Sensor) error {
|
||||||
|
|
||||||
for i := range config.Sensors {
|
for i := range config.Sensors {
|
||||||
if config.Sensors[i].ID == s.ID {
|
if config.Sensors[i].ID == s.ID {
|
||||||
config.Sensors[i].Alias = s.Alias
|
copier.Copy(&config.Sensors[i], &s)
|
||||||
config.Sensors[i].HighTemp = s.HighTemp
|
|
||||||
config.Sensors[i].LowTemp = s.LowTemp
|
|
||||||
config.Sensors[i].HeatGPIO = s.HeatGPIO
|
|
||||||
config.Sensors[i].HeatInvert = s.HeatInvert
|
|
||||||
config.Sensors[i].HeatMinutes = s.HeatMinutes
|
|
||||||
config.Sensors[i].CoolGPIO = s.CoolGPIO
|
|
||||||
config.Sensors[i].CoolInvert = s.CoolInvert
|
|
||||||
config.Sensors[i].CoolMinutes = s.CoolMinutes
|
|
||||||
config.Sensors[i].Verbose = s.Verbose
|
|
||||||
log.Println(config.Sensors[i])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,8 +73,6 @@ func UpdateSensorConfig(s Sensor) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println(config.Sensors[0])
|
|
||||||
|
|
||||||
if err = SignalReload(); err != nil {
|
if err = SignalReload(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
113
config_test.go
113
config_test.go
|
|
@ -1,13 +1,103 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func Test_UpdateSensorConfig(t *testing.T) {
|
||||||
|
testConfig := Config{
|
||||||
|
Sensors: []Sensor{
|
||||||
|
Sensor{
|
||||||
|
Alias: "foo",
|
||||||
|
CoolDisable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Users: []User{},
|
||||||
|
ListenAddr: ":8080",
|
||||||
|
}
|
||||||
|
newSensor := Sensor{Alias: "bar", CoolDisable: false}
|
||||||
|
|
||||||
|
// Create a temp file
|
||||||
|
tmpfile, err := ioutil.TempFile("", "tempgopher")
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
|
||||||
|
defer os.Remove(tmpfile.Name()) // Remove the tempfile when done
|
||||||
|
|
||||||
|
configFilePath = tmpfile.Name()
|
||||||
|
|
||||||
|
// Save to tempfile
|
||||||
|
err = SaveConfig(tmpfile.Name(), testConfig)
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
|
||||||
|
// Create a channel to capture SIGHUP
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, syscall.SIGHUP)
|
||||||
|
|
||||||
|
// Update the stored config
|
||||||
|
UpdateSensorConfig(newSensor)
|
||||||
|
|
||||||
|
// Load the config
|
||||||
|
config, err := LoadConfig(tmpfile.Name())
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
assert.Equal(t, "bar", config.Sensors[0].Alias)
|
||||||
|
|
||||||
|
// Validate SIGHUP
|
||||||
|
ret := <-sig
|
||||||
|
assert.Equal(t, syscall.SIGHUP, ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_SignalReload(t *testing.T) {
|
||||||
|
// Create a channel to capture SIGHUP
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, syscall.SIGHUP)
|
||||||
|
|
||||||
|
// Generate SIGHUP
|
||||||
|
err := SignalReload()
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
|
||||||
|
// Validate SIGHUP
|
||||||
|
ret := <-sig
|
||||||
|
assert.Equal(t, syscall.SIGHUP, ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_SaveConfig(t *testing.T) {
|
||||||
|
// Save zero-valued config
|
||||||
|
testConfig := Config{
|
||||||
|
Sensors: []Sensor{},
|
||||||
|
Users: []User{},
|
||||||
|
ListenAddr: ":8080",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test writing to a path that doesn't exist
|
||||||
|
err := SaveConfig("/this/does/not/exist", testConfig)
|
||||||
|
assert.NotEqual(t, nil, err)
|
||||||
|
|
||||||
|
// Create a temp file
|
||||||
|
tmpfile, err := ioutil.TempFile("", "tempgopher")
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
|
||||||
|
defer os.Remove(tmpfile.Name()) // Remove the tempfile when done
|
||||||
|
|
||||||
|
// Save to tempfile
|
||||||
|
err = SaveConfig(tmpfile.Name(), testConfig)
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
|
||||||
|
// Load the config
|
||||||
|
config, err := LoadConfig(tmpfile.Name())
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
assert.Equal(t, testConfig, *config)
|
||||||
|
}
|
||||||
|
|
||||||
func Test_LoadConfig(t *testing.T) {
|
func Test_LoadConfig(t *testing.T) {
|
||||||
testSensor := Sensor{
|
testConfig := Config{
|
||||||
|
Sensors: []Sensor{
|
||||||
|
Sensor{
|
||||||
ID: "28-000008083108",
|
ID: "28-000008083108",
|
||||||
Alias: "fermenter",
|
Alias: "fermenter",
|
||||||
HighTemp: 8,
|
HighTemp: 8,
|
||||||
|
|
@ -19,21 +109,32 @@ func Test_LoadConfig(t *testing.T) {
|
||||||
CoolInvert: false,
|
CoolInvert: false,
|
||||||
CoolMinutes: 10,
|
CoolMinutes: 10,
|
||||||
Verbose: true,
|
Verbose: true,
|
||||||
}
|
},
|
||||||
|
},
|
||||||
testConfig := Config{
|
Users: []User{
|
||||||
Sensors: []Sensor{testSensor},
|
User{
|
||||||
|
Name: "foo",
|
||||||
|
Password: "bar",
|
||||||
|
},
|
||||||
|
},
|
||||||
BaseURL: "https://foo.bar",
|
BaseURL: "https://foo.bar",
|
||||||
ListenAddr: "127.0.0.1:8080",
|
ListenAddr: ":8080",
|
||||||
DisplayFahrenheit: true,
|
DisplayFahrenheit: true,
|
||||||
|
Influx: Influx{Addr: "http://foo:8086"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test loading of config
|
||||||
loadedConfig, err := LoadConfig("tests/test_config.yml")
|
loadedConfig, err := LoadConfig("tests/test_config.yml")
|
||||||
assert.Equal(t, nil, err)
|
assert.Equal(t, nil, err)
|
||||||
assert.Equal(t, &testConfig, loadedConfig)
|
assert.Equal(t, &testConfig, loadedConfig)
|
||||||
|
|
||||||
|
// Test for failures with duplicate IDs and Aliases
|
||||||
_, err = LoadConfig("tests/duplicate_id.yml")
|
_, err = LoadConfig("tests/duplicate_id.yml")
|
||||||
assert.NotEqual(t, nil, err)
|
assert.NotEqual(t, nil, err)
|
||||||
_, err = LoadConfig("tests/duplicate_alias.yml")
|
_, err = LoadConfig("tests/duplicate_alias.yml")
|
||||||
assert.NotEqual(t, nil, err)
|
assert.NotEqual(t, nil, err)
|
||||||
|
|
||||||
|
// Test for non-existence
|
||||||
|
_, err = LoadConfig("DNE")
|
||||||
|
assert.NotEqual(t, nil, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
html/css/custom.css
Normal file
7
html/css/custom.css
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
label {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
@ -7,12 +7,9 @@
|
||||||
<link href="//fonts.googleapis.com/css?family=Raleway:400,300,600" rel="stylesheet" type="text/css">
|
<link href="//fonts.googleapis.com/css?family=Raleway:400,300,600" rel="stylesheet" type="text/css">
|
||||||
<link rel="stylesheet" href="css/normalize.css">
|
<link rel="stylesheet" href="css/normalize.css">
|
||||||
<link rel="stylesheet" href="css/skeleton.css">
|
<link rel="stylesheet" href="css/skeleton.css">
|
||||||
|
<link rel="stylesheet" href="css/custom.css">
|
||||||
<link rel="icon" type="image/png" href="img/favicon.png">
|
<link rel="icon" type="image/png" href="img/favicon.png">
|
||||||
<script src="js/jquery.min.js"></script>
|
<script src="js/jquery.min.js"></script>
|
||||||
<!--
|
|
||||||
TODO: Fix this. I don't like service /jsconfig.js out of the root.
|
|
||||||
This is a work-around due to the way HttpRouter handles wildcard routes.
|
|
||||||
-->
|
|
||||||
<script src="/jsconfig.js"></script>
|
<script src="/jsconfig.js"></script>
|
||||||
<script src="js/thermostat.js"></script>
|
<script src="js/thermostat.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -27,6 +24,9 @@
|
||||||
<div class="row" style="margin-top: 10rem">
|
<div class="row" style="margin-top: 10rem">
|
||||||
<h6 id="version"></h6>
|
<h6 id="version"></h6>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="one columns offset-by-eleven" id="logoutDiv"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
10
html/js/login.js
Normal file
10
html/js/login.js
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// Retrieve username and password from fields, store a token, and redirect to main app
|
||||||
|
function processLogin() {
|
||||||
|
var username = $("#loginName").val();
|
||||||
|
var password = $("#loginPassword").val();
|
||||||
|
window.localStorage.setItem("authtoken", btoa(username + ":" + password));
|
||||||
|
window.location.replace(jsconfig.baseurl + "/app/");
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the login page is displayed, we need to remvoe any existing auth tokens
|
||||||
|
window.localStorage.removeItem("authtoken");
|
||||||
|
|
@ -1,51 +1,105 @@
|
||||||
|
// Set the auth header if necessary
|
||||||
|
function authHeaders(xhr) {
|
||||||
|
if (window.localStorage.getItem("authtoken") !== null) {
|
||||||
|
var authToken = window.localStorage.getItem("authtoken");
|
||||||
|
xhr.setRequestHeader("Authorization", "Basic " + authToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect if not authorized
|
||||||
|
function redirectIfNotAuthorized() {
|
||||||
|
$.ajax({
|
||||||
|
url: jsconfig.baseurl + "/api/version",
|
||||||
|
beforeSend: authHeaders,
|
||||||
|
statusCode: {
|
||||||
|
401: function() {
|
||||||
|
window.location.replace(jsconfig.baseurl + "/app/login.html");
|
||||||
|
},
|
||||||
|
403: function() {
|
||||||
|
window.location.replace(jsconfig.baseurl + "/app/login.html");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call the redirect function
|
||||||
|
redirectIfNotAuthorized();
|
||||||
|
|
||||||
|
// Display version at bottom of page
|
||||||
|
function renderVersion() {
|
||||||
|
$.ajax({
|
||||||
|
url: jsconfig.baseurl + "/api/version",
|
||||||
|
beforeSend: authHeaders
|
||||||
|
}).then(function(data) {
|
||||||
|
var versionText = "TempGopher © 2018 Mike Shoup | Version: " + data.version;
|
||||||
|
$("#version").text(versionText);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
$(document).ready(renderVersion);
|
||||||
|
|
||||||
|
function displayLogoutButton() {
|
||||||
|
if (window.localStorage.getItem("authtoken") !== null) {
|
||||||
|
// Display a logout button
|
||||||
|
var logoutButton = $("<button>")
|
||||||
|
.text('Logout')
|
||||||
|
.click(function() {
|
||||||
|
window.localStorage.removeItem("authtoken");
|
||||||
|
window.location.replace(jsconfig.baseurl + "/app/login.html");
|
||||||
|
});
|
||||||
|
$("#logoutDiv").append(logoutButton);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(displayLogoutButton);
|
||||||
|
|
||||||
function celsiusToFahrenheit(degree) {
|
function celsiusToFahrenheit(degree) {
|
||||||
return degree * 1.8 + 32;
|
return degree * 1.8 + 32;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fahrenheitToCelsius(degree) {
|
function fahrenheitToCelsius(degree) {
|
||||||
return (degree - 32) * 5 / 9;
|
return (degree - 32) * 5 / 9;
|
||||||
}
|
};
|
||||||
|
|
||||||
function renderThermostats() {
|
function appendData(data) {
|
||||||
$.ajax({
|
|
||||||
url: jsconfig.baseurl + "/api/status/"
|
|
||||||
}).then(function(data) {
|
|
||||||
$("#thermostats").empty();
|
|
||||||
for (var key in data) {
|
|
||||||
// Title of thermostat
|
// Title of thermostat
|
||||||
var titleh = $("<h4></h4>").text(data[key].alias);
|
var titleh = $("<h4></h4>").text(data.alias);
|
||||||
var titlediv = $("<div></div>").addClass("row").append(titleh);
|
var titlediv = $("<div></div>").addClass("row").append(titleh);
|
||||||
|
|
||||||
// Thermostat status
|
// Thermostat status
|
||||||
var rowdiv = $("<div></div>");
|
var rowdiv = $("<div></div>");
|
||||||
rowdiv.addClass("row");
|
rowdiv.addClass("row");
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
// Display temperature
|
// Display temperature
|
||||||
if (jsconfig.fahrenheit) {
|
if (jsconfig.fahrenheit) {
|
||||||
var temp = celsiusToFahrenheit(parseFloat(data[key].temp)).toFixed(1) + "°F";
|
var temp = celsiusToFahrenheit(parseFloat(data.temp)).toFixed(1) + "°F";
|
||||||
} else {
|
} else {
|
||||||
var temp = parseFloat(data[key].temp).toFixed(1) + "°C";
|
var temp = parseFloat(data.temp).toFixed(1) + "°C";
|
||||||
}
|
}
|
||||||
var temph = $("<h2></h2>").text(temp);
|
var temph = $("<h2></h2>").text(temp);
|
||||||
var tempdiv = $("<div></div>").addClass("two columns").append(temph);
|
var tempdiv = $("<div></div>").addClass("two columns").append(temph);
|
||||||
rowdiv.append(tempdiv);
|
rowdiv.append(tempdiv);
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
// Display status
|
// Display status
|
||||||
if (data[key].cooling) {
|
if (data.cooling) {
|
||||||
var statustext = "Cooling"
|
var statustext = "Cooling"
|
||||||
} else if (data[key].heating) {
|
} else if (data.heating) {
|
||||||
var statustext = "Heating"
|
var statustext = "Heating"
|
||||||
} else {
|
} else {
|
||||||
var statustext = "Idle"
|
var statustext = "Idle"
|
||||||
}
|
}
|
||||||
var statusp = $("<p></p>").html(statustext);
|
var statusp = $("<p></p>").html(statustext);
|
||||||
var statusdiv = $("<div></div>").addClass("two columns").append(statusp);
|
var statusdiv = $("<div></div>").addClass("one columns").append(statusp);
|
||||||
rowdiv.append(statusdiv);
|
rowdiv.append(statusdiv);
|
||||||
|
|
||||||
// Display sensor config
|
// Make AJAX call to get current configuration of the sensor
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: jsconfig.baseurl + "/api/config/sensors/" + data[key].alias
|
url: jsconfig.baseurl + "/api/config/sensors/" + data.alias,
|
||||||
|
beforeSend: authHeaders
|
||||||
}).then(function(configData){
|
}).then(function(configData){
|
||||||
|
////////////////////////////////////////////////////////////////////////
|
||||||
|
// Display current configuration
|
||||||
if (jsconfig.fahrenheit) {
|
if (jsconfig.fahrenheit) {
|
||||||
var degUnit = "°F";
|
var degUnit = "°F";
|
||||||
var hightemp = celsiusToFahrenheit(parseFloat(configData.hightemp)).toFixed(1);
|
var hightemp = celsiusToFahrenheit(parseFloat(configData.hightemp)).toFixed(1);
|
||||||
|
|
@ -62,13 +116,45 @@ function renderThermostats() {
|
||||||
var hmIn = $("<input>").attr("id", "hm" + configData.alias).val(configData.heatminutes).attr("size", "2").attr("pattern", rp).on('input', function(){window.clearInterval(rtHandle)});
|
var hmIn = $("<input>").attr("id", "hm" + configData.alias).val(configData.heatminutes).attr("size", "2").attr("pattern", rp).on('input', function(){window.clearInterval(rtHandle)});
|
||||||
var ltIn = $("<input>").attr("id", "lt" + configData.alias).val(lowtemp).attr("size", "4").attr("pattern", rp).on('input', function(){window.clearInterval(rtHandle)});
|
var ltIn = $("<input>").attr("id", "lt" + configData.alias).val(lowtemp).attr("size", "4").attr("pattern", rp).on('input', function(){window.clearInterval(rtHandle)});
|
||||||
|
|
||||||
var configp = $("<p></p>").text("Chills for ").append(cmIn).append(" minutes when > ").append(htIn).append(degUnit).append($("<br>"));
|
var configp = $("<p></p>")
|
||||||
configp.append("Heats for ").append(hmIn).append(" minutes when < ").append(ltIn).append(degUnit);
|
if (!configData.cooldisable) {
|
||||||
|
configp.append("Chills for ").append(cmIn).append(" minutes when > ").append(htIn).append(degUnit);
|
||||||
|
}
|
||||||
|
|
||||||
var configdiv = $("<div></div>").addClass("five columns").append(configp);
|
if (!configData.cooldisable && !configData.heatdisable){
|
||||||
|
configp.append($("<br>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!configData.heatdisable) {
|
||||||
|
configp.append("Heats for ").append(hmIn).append(" minutes when < ").append(ltIn).append(degUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
var configdiv = $("<div></div>").addClass("six columns").append(configp);
|
||||||
rowdiv.append(configdiv);
|
rowdiv.append(configdiv);
|
||||||
|
|
||||||
var yesButton = $("<button></button>").addClass("button button-primary").text("✔").css("margin-right", "5px").click(function() {
|
////////////////////////////////////////////////////////////////////////
|
||||||
|
// Display options to turn heating/cooling on/off
|
||||||
|
var conChecked = false;
|
||||||
|
if (!configData.cooldisable) {
|
||||||
|
conChecked = true;
|
||||||
|
}
|
||||||
|
var con = $('<input type="checkbox">').attr("id", "con" + configData.alias).prop('checked', conChecked).on('input', function(){window.clearInterval(rtHandle)})
|
||||||
|
var coolCheck = $('<label></label>').text("❄️").prepend(con);
|
||||||
|
|
||||||
|
var honChecked = false;
|
||||||
|
if (!configData.heatdisable) {
|
||||||
|
honChecked = true;
|
||||||
|
}
|
||||||
|
var hon = $('<input type="checkbox">').attr("id", "hon" + configData.alias).prop('checked', honChecked).on('input', function(){window.clearInterval(rtHandle)})
|
||||||
|
var heatCheck = $('<label></label>').text("♨").prepend(hon);
|
||||||
|
|
||||||
|
|
||||||
|
var offOnDiv = $("<div></div>").addClass("one columns").append(coolCheck).append(heatCheck);
|
||||||
|
rowdiv.append(offOnDiv);
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////
|
||||||
|
// Create yes and no buttons
|
||||||
|
var yesButton = $("<button></button>").addClass("button button-primary").text("✔").click(function() {
|
||||||
if (jsconfig.fahrenheit) {
|
if (jsconfig.fahrenheit) {
|
||||||
var newHT = fahrenheitToCelsius(parseFloat(htIn.val()));
|
var newHT = fahrenheitToCelsius(parseFloat(htIn.val()));
|
||||||
var newLT = fahrenheitToCelsius(parseFloat(ltIn.val()));
|
var newLT = fahrenheitToCelsius(parseFloat(ltIn.val()));
|
||||||
|
|
@ -79,6 +165,7 @@ function renderThermostats() {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: "POST",
|
type: "POST",
|
||||||
url: jsconfig.baseurl + "/api/config/sensors",
|
url: jsconfig.baseurl + "/api/config/sensors",
|
||||||
|
beforeSend: authHeaders,
|
||||||
data: JSON.stringify([{
|
data: JSON.stringify([{
|
||||||
"id": configData.id,
|
"id": configData.id,
|
||||||
"alias": configData.alias,
|
"alias": configData.alias,
|
||||||
|
|
@ -87,42 +174,53 @@ function renderThermostats() {
|
||||||
"heatgpio": configData.heatgpio,
|
"heatgpio": configData.heatgpio,
|
||||||
"heatinvert": configData.heatInvert,
|
"heatinvert": configData.heatInvert,
|
||||||
"heatminutes": parseFloat(hmIn.val()),
|
"heatminutes": parseFloat(hmIn.val()),
|
||||||
|
"heatdisable": !hon.is(":checked"),
|
||||||
"coolgpio": configData.coolgpio,
|
"coolgpio": configData.coolgpio,
|
||||||
"coolinvert": configData.coolinvert,
|
"coolinvert": configData.coolinvert,
|
||||||
"coolminutes": parseFloat(cmIn.val()),
|
"coolminutes": parseFloat(cmIn.val()),
|
||||||
|
"cooldisable": !con.is(":checked"),
|
||||||
"verbose": configData.verbose
|
"verbose": configData.verbose
|
||||||
}])
|
}])
|
||||||
})
|
});
|
||||||
window.setInterval(renderThermostats, 60000);
|
window.clearInterval(rtHandle);
|
||||||
|
rtHandle = window.setInterval(renderThermostats, 60000);
|
||||||
renderThermostats();
|
renderThermostats();
|
||||||
});
|
});
|
||||||
|
|
||||||
var noButton = $("<button></button>").addClass("button").text("✘").click(function() {
|
var noButton = $("<button></button>").addClass("button").text("✘").click(function() {
|
||||||
window.setInterval(renderThermostats, 60000);
|
window.clearInterval(rtHandle);
|
||||||
|
rtHandle = window.setInterval(renderThermostats, 60000);
|
||||||
renderThermostats();
|
renderThermostats();
|
||||||
});
|
});
|
||||||
|
|
||||||
var buttonDiv = $("<div></div>").addClass("three columns").append(yesButton).append(noButton);
|
var buttonDiv = $("<div></div>").addClass("two columns").append(yesButton).append($("<br>")).append(noButton);
|
||||||
rowdiv.append(buttonDiv);
|
rowdiv.append(buttonDiv);
|
||||||
//var confForm = $("<form></form>").append(rowdiv);
|
|
||||||
|
|
||||||
// Add things back to the thermostat list
|
// Add things back to the thermostat list
|
||||||
$("#thermostats").append(titlediv);
|
$("#thermostats").append(titlediv);
|
||||||
$("#thermostats").append(rowdiv);
|
$("#thermostats").append(rowdiv);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderThermostats() {
|
||||||
|
$.ajax({
|
||||||
|
url: jsconfig.baseurl + "/api/status/",
|
||||||
|
beforeSend: authHeaders
|
||||||
|
}).then(function(data) {
|
||||||
|
$("#thermostats").empty();
|
||||||
|
|
||||||
|
// Sort by sensor alias
|
||||||
|
var sorted = [];
|
||||||
|
for(var key in data) {
|
||||||
|
sorted[sorted.length] = key;
|
||||||
|
}
|
||||||
|
sorted.sort();
|
||||||
|
|
||||||
|
for (var i in sorted) {
|
||||||
|
appendData(data[sorted[i]])
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderVersion() {
|
|
||||||
$.ajax({
|
|
||||||
url: jsconfig.baseurl + "/api/version"
|
|
||||||
}).then(function(data) {
|
|
||||||
var versionText = "TempGopher © 2018 Mike Shoup | Version: " + data.version;
|
|
||||||
$("#version").text(versionText);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
$(document).ready(renderVersion);
|
|
||||||
|
|
||||||
$(document).ready(renderThermostats);
|
$(document).ready(renderThermostats);
|
||||||
var rtHandle = window.setInterval(renderThermostats, 60000);
|
var rtHandle = window.setInterval(renderThermostats, 60000);
|
||||||
|
|
|
||||||
37
html/login.html
Normal file
37
html/login.html
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Temp Gopher</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link href="//fonts.googleapis.com/css?family=Raleway:400,300,600" rel="stylesheet" type="text/css">
|
||||||
|
<link rel="stylesheet" href="css/normalize.css">
|
||||||
|
<link rel="stylesheet" href="css/skeleton.css">
|
||||||
|
<link rel="icon" type="image/png" href="img/favicon.png">
|
||||||
|
<script src="js/jquery.min.js"></script>
|
||||||
|
<script src="/jsconfig.js"></script>
|
||||||
|
<script src="js/login.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row" style="margin-top: 5%">
|
||||||
|
<div class="four columns offset-by-four"><h3 style="text-align: center">Login</h3></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onsubmit="processLogin(); return false;">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="two columns offset-by-two" style="text-align: right">Username:</div>
|
||||||
|
<div class="four columns"><input type="text" style="width: 100%" id="loginName" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="two columns offset-by-two" style="text-align: right">Password:</div>
|
||||||
|
<div class="four columns"><input type="password" style="width: 100%" id="loginPassword" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="four columns offset-by-four"><button type="submit" id="loginButton" style="width: 100%" class="button button-primary">Login</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
46
influx.go
Normal file
46
influx.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
client "github.com/influxdata/influxdb/client/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteStateToInflux writes a State object to an Influx database
|
||||||
|
func WriteStateToInflux(s State, config Influx) error {
|
||||||
|
|
||||||
|
c, err := client.NewHTTPClient(client.HTTPConfig{
|
||||||
|
Addr: config.Addr,
|
||||||
|
Username: config.Username,
|
||||||
|
Password: config.Password,
|
||||||
|
UserAgent: config.UserAgent,
|
||||||
|
Timeout: time.Duration(config.Timeout * 1000000000),
|
||||||
|
InsecureSkipVerify: config.InsecureSkipVerify,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
bp, err := client.NewBatchPoints(client.BatchPointsConfig{
|
||||||
|
Database: config.Database,
|
||||||
|
Precision: "s",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := map[string]string{"alias": s.Alias}
|
||||||
|
fields := map[string]interface{}{"value": s.Temp}
|
||||||
|
pt, err := client.NewPoint("temperature", tags, fields, s.When)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
bp.AddPoint(pt)
|
||||||
|
if err := c.Write(bp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
26
influx_test.go
Normal file
26
influx_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_WriteStateToInflux(t *testing.T) {
|
||||||
|
influxAddr := os.Getenv("INFLUXDB_ADDR")
|
||||||
|
state := State{Temp: 32}
|
||||||
|
|
||||||
|
// Test failure with empty config
|
||||||
|
err := WriteStateToInflux(state, Influx{})
|
||||||
|
assert.NotEqual(t, nil, err)
|
||||||
|
|
||||||
|
// Test failure with missing database
|
||||||
|
err = WriteStateToInflux(state, Influx{Addr: influxAddr})
|
||||||
|
assert.NotEqual(t, nil, err)
|
||||||
|
|
||||||
|
// Test success with writing to database
|
||||||
|
config := Influx{Addr: influxAddr, Database: "db"}
|
||||||
|
err = WriteStateToInflux(state, config)
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,7 @@ sudo chmod +x $INSTALLBIN
|
||||||
sudo chown -R $INSTALLUSER: $INSTALLDIR
|
sudo chown -R $INSTALLUSER: $INSTALLDIR
|
||||||
|
|
||||||
# Generate a configuration file
|
# Generate a configuration file
|
||||||
sudo -u $INSTALLUSER $INSTALLBIN -c $CONFIGFILE config
|
sudo -u $INSTALLUSER $INSTALLBIN -c $CONFIGFILE config || true
|
||||||
|
|
||||||
# Create unit file
|
# Create unit file
|
||||||
sudo sh -c "cat > /etc/systemd/system/tempgopher.service" << EOM
|
sudo sh -c "cat > /etc/systemd/system/tempgopher.service" << EOM
|
||||||
|
|
|
||||||
2
main.go
2
main.go
|
|
@ -8,7 +8,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is the current code version of tempgopher
|
// Version is the current code version of tempgopher
|
||||||
const Version = "0.2.0"
|
const Version = "0.4.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var args struct {
|
var args struct {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ sensors:
|
||||||
coolinvert: false
|
coolinvert: false
|
||||||
coolminutes: 10
|
coolminutes: 10
|
||||||
verbose: true
|
verbose: true
|
||||||
|
users:
|
||||||
|
- foo: bar
|
||||||
baseurl: https://foo.bar
|
baseurl: https://foo.bar
|
||||||
listenaddr: 127.0.0.1:8080
|
|
||||||
displayfahrenheit: true
|
displayfahrenheit: true
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ sensors:
|
||||||
coolinvert: false
|
coolinvert: false
|
||||||
coolminutes: 10
|
coolminutes: 10
|
||||||
verbose: true
|
verbose: true
|
||||||
|
users:
|
||||||
|
- foo: bar
|
||||||
baseurl: https://foo.bar
|
baseurl: https://foo.bar
|
||||||
listenaddr: 127.0.0.1:8080
|
|
||||||
displayfahrenheit: true
|
displayfahrenheit: true
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ sensors:
|
||||||
coolinvert: false
|
coolinvert: false
|
||||||
coolminutes: 10
|
coolminutes: 10
|
||||||
verbose: true
|
verbose: true
|
||||||
|
users:
|
||||||
|
- name: foo
|
||||||
|
password: bar
|
||||||
baseurl: https://foo.bar
|
baseurl: https://foo.bar
|
||||||
listenaddr: 127.0.0.1:8080
|
|
||||||
displayfahrenheit: true
|
displayfahrenheit: true
|
||||||
|
influx:
|
||||||
|
addr: http://foo:8086
|
||||||
|
|
|
||||||
|
|
@ -70,11 +70,18 @@ func ProcessSensor(sensor Sensor, state State) (State, error) {
|
||||||
|
|
||||||
state.When = time.Now()
|
state.When = time.Now()
|
||||||
|
|
||||||
|
var cpin rpio.Pin
|
||||||
|
var hpin rpio.Pin
|
||||||
// Initialize the pins
|
// Initialize the pins
|
||||||
cpin := rpio.Pin(sensor.CoolGPIO)
|
if !sensor.CoolDisable {
|
||||||
|
cpin = rpio.Pin(sensor.CoolGPIO)
|
||||||
cpin.Output()
|
cpin.Output()
|
||||||
hpin := rpio.Pin(sensor.HeatGPIO)
|
}
|
||||||
|
|
||||||
|
if !sensor.HeatDisable {
|
||||||
|
hpin = rpio.Pin(sensor.HeatGPIO)
|
||||||
hpin.Output()
|
hpin.Output()
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate duration
|
// Calculate duration
|
||||||
duration := time.Since(state.Changed).Minutes()
|
duration := time.Since(state.Changed).Minutes()
|
||||||
|
|
@ -83,14 +90,14 @@ func ProcessSensor(sensor Sensor, state State) (State, error) {
|
||||||
case temp > sensor.HighTemp && temp < sensor.LowTemp:
|
case temp > sensor.HighTemp && temp < sensor.LowTemp:
|
||||||
log.Println("Invalid state! Temperature is too high AND too low!")
|
log.Println("Invalid state! Temperature is too high AND too low!")
|
||||||
// Temperature too high, start cooling
|
// Temperature too high, start cooling
|
||||||
case temp > sensor.HighTemp:
|
case temp > sensor.HighTemp && !sensor.CoolDisable:
|
||||||
PinSwitch(cpin, true, sensor.CoolInvert)
|
PinSwitch(cpin, true, sensor.CoolInvert)
|
||||||
state.Cooling = true
|
state.Cooling = true
|
||||||
PinSwitch(hpin, false, sensor.HeatInvert) // Ensure the heater is off
|
PinSwitch(hpin, false, sensor.HeatInvert) // Ensure the heater is off
|
||||||
state.Heating = false
|
state.Heating = false
|
||||||
state.Changed = future
|
state.Changed = future
|
||||||
// Temperature too low, start heating
|
// Temperature too low, start heating
|
||||||
case temp < sensor.LowTemp:
|
case temp < sensor.LowTemp && !sensor.HeatDisable:
|
||||||
PinSwitch(hpin, true, sensor.HeatInvert)
|
PinSwitch(hpin, true, sensor.HeatInvert)
|
||||||
state.Heating = true
|
state.Heating = true
|
||||||
PinSwitch(cpin, false, sensor.CoolInvert) // Ensure the chiller is off
|
PinSwitch(cpin, false, sensor.CoolInvert) // Ensure the chiller is off
|
||||||
|
|
@ -126,13 +133,16 @@ func ProcessSensor(sensor Sensor, state State) (State, error) {
|
||||||
|
|
||||||
// TurnOffSensor turns off all switches for an individual sensor
|
// TurnOffSensor turns off all switches for an individual sensor
|
||||||
func TurnOffSensor(sensor Sensor) {
|
func TurnOffSensor(sensor Sensor) {
|
||||||
|
if !sensor.CoolDisable {
|
||||||
cpin := rpio.Pin(sensor.CoolGPIO)
|
cpin := rpio.Pin(sensor.CoolGPIO)
|
||||||
cpin.Output()
|
cpin.Output()
|
||||||
PinSwitch(cpin, false, sensor.CoolInvert)
|
PinSwitch(cpin, false, sensor.CoolInvert)
|
||||||
|
}
|
||||||
|
if !sensor.HeatDisable {
|
||||||
hpin := rpio.Pin(sensor.HeatGPIO)
|
hpin := rpio.Pin(sensor.HeatGPIO)
|
||||||
hpin.Output()
|
hpin.Output()
|
||||||
PinSwitch(hpin, false, sensor.HeatInvert)
|
PinSwitch(hpin, false, sensor.HeatInvert)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TurnOffSensors turns off all sensors defined in the config
|
// TurnOffSensors turns off all sensors defined in the config
|
||||||
|
|
@ -216,6 +226,10 @@ func RunThermostat(path string, sc chan<- State, wg *sync.WaitGroup) {
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.Influx.Addr != "" {
|
||||||
|
go WriteStateToInflux(states[v.ID], config.Influx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
47
web.go
47
web.go
|
|
@ -40,6 +40,7 @@ func ConfigHandler(config *Config) gin.HandlerFunc {
|
||||||
} else if c.Param("alias") == "/" {
|
} else if c.Param("alias") == "/" {
|
||||||
c.JSON(http.StatusOK, config.Sensors)
|
c.JSON(http.StatusOK, config.Sensors)
|
||||||
} else {
|
} else {
|
||||||
|
config.Users = nil // Never return the users in GET requests
|
||||||
c.JSON(http.StatusOK, config)
|
c.JSON(http.StatusOK, config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -103,6 +104,15 @@ func VersionHandler(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, version{Version: Version})
|
c.JSON(http.StatusOK, version{Version: Version})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AppHandler returns 301 to /app
|
||||||
|
func AppHandler(config *Config) gin.HandlerFunc {
|
||||||
|
fn := func(c *gin.Context) {
|
||||||
|
c.Redirect(http.StatusPermanentRedirect, config.BaseURL+"/app/")
|
||||||
|
}
|
||||||
|
|
||||||
|
return fn
|
||||||
|
}
|
||||||
|
|
||||||
// SetupRouter initializes the gin router.
|
// SetupRouter initializes the gin router.
|
||||||
func SetupRouter(config *Config, states *map[string]State) *gin.Engine {
|
func SetupRouter(config *Config, states *map[string]State) *gin.Engine {
|
||||||
// If not specified, put gin in release mode
|
// If not specified, put gin in release mode
|
||||||
|
|
@ -124,26 +134,28 @@ func SetupRouter(config *Config, states *map[string]State) *gin.Engine {
|
||||||
// Ping
|
// Ping
|
||||||
r.GET("/ping", PingHandler)
|
r.GET("/ping", PingHandler)
|
||||||
|
|
||||||
// Status
|
// API Endpoints
|
||||||
r.GET("/api/status", StatusHandler(states))
|
var api *gin.RouterGroup
|
||||||
r.GET("/api/status/*alias", StatusHandler(states))
|
if len(config.Users) == 0 {
|
||||||
|
api = r.Group("/api")
|
||||||
|
} else {
|
||||||
|
api = r.Group("/api")
|
||||||
|
api.Use(BasicAuth(GetGinAccounts(config)))
|
||||||
|
}
|
||||||
|
|
||||||
// API Version
|
api.GET("/status", StatusHandler(states))
|
||||||
r.GET("/api/version", VersionHandler)
|
api.GET("/status/*alias", StatusHandler(states))
|
||||||
|
api.GET("/version", VersionHandler)
|
||||||
// Config
|
api.GET("/config", ConfigHandler(config))
|
||||||
r.GET("/api/config", ConfigHandler(config))
|
api.GET("/config/sensors/*alias", ConfigHandler(config))
|
||||||
r.GET("/api/config/sensors/*alias", ConfigHandler(config))
|
api.POST("/config/sensors", UpdateSensorsHandler)
|
||||||
r.POST("/api/config/sensors", UpdateSensorsHandler)
|
|
||||||
|
|
||||||
// App
|
// App
|
||||||
r.GET("/jsconfig.js", JSConfigHandler(config))
|
r.GET("/jsconfig.js", JSConfigHandler(config))
|
||||||
r.StaticFS("/app", GetBox())
|
r.StaticFS("/app", GetBox())
|
||||||
|
|
||||||
// Redirect / to /app
|
// Redirect / to /app
|
||||||
r.Any("/", func(c *gin.Context) {
|
r.Any("/", AppHandler(config))
|
||||||
c.Redirect(301, config.BaseURL+"/app/")
|
|
||||||
})
|
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
@ -160,6 +172,15 @@ func reloadWebConfig(c *Config, p string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetGinAccounts returns a gin.Accounts struct with values pulled from a Config struct
|
||||||
|
func GetGinAccounts(config *Config) gin.Accounts {
|
||||||
|
a := make(gin.Accounts)
|
||||||
|
for _, user := range config.Users {
|
||||||
|
a[user.Name] = user.Password
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
// RunWeb launches a web server. sc is used to update the states from the Thermostats.
|
// RunWeb launches a web server. sc is used to update the states from the Thermostats.
|
||||||
func RunWeb(configpath string, sc <-chan State, wg *sync.WaitGroup) {
|
func RunWeb(configpath string, sc <-chan State, wg *sync.WaitGroup) {
|
||||||
// Update sensor states when a new state comes back from the thermostat.
|
// Update sensor states when a new state comes back from the thermostat.
|
||||||
|
|
|
||||||
290
web_test.go
Normal file
290
web_test.go
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_PingHandler(t *testing.T) {
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/ping", PingHandler)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/ping", nil)
|
||||||
|
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "pong", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_ConfigHandler(t *testing.T) {
|
||||||
|
testConfig := Config{
|
||||||
|
Sensors: []Sensor{
|
||||||
|
Sensor{
|
||||||
|
Alias: "foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Users: []User{},
|
||||||
|
ListenAddr: ":8080",
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/config", ConfigHandler(&testConfig))
|
||||||
|
r.GET("/config/sensors/*alias", ConfigHandler(&testConfig))
|
||||||
|
|
||||||
|
// Validate GET request /config
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/config", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
jc, _ := json.Marshal(testConfig)
|
||||||
|
assert.Equal(t, string(jc), w.Body.String())
|
||||||
|
|
||||||
|
// Validate GET request to /config/sensors
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
req, _ = http.NewRequest("GET", "/config/sensors/", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
jc, _ = json.Marshal(testConfig.Sensors)
|
||||||
|
assert.Equal(t, string(jc), w.Body.String())
|
||||||
|
|
||||||
|
// Validate GET request /config/sensors/foo
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
req, _ = http.NewRequest("GET", "/config/sensors/foo", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
jc, _ = json.Marshal(testConfig.Sensors[0])
|
||||||
|
assert.Equal(t, string(jc), w.Body.String())
|
||||||
|
|
||||||
|
// Validate not ofund
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
req, _ = http.NewRequest("GET", "/config/sensors/DNE", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_UpdateSensorsHandler(t *testing.T) {
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/config/sensors", UpdateSensorsHandler)
|
||||||
|
|
||||||
|
// Test bad request
|
||||||
|
buf := bytes.NewBufferString("foobar")
|
||||||
|
reader := bufio.NewReader(buf)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("POST", "/config/sensors", reader)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
|
||||||
|
// Test good request
|
||||||
|
testConfig := Config{
|
||||||
|
Sensors: []Sensor{
|
||||||
|
Sensor{
|
||||||
|
Alias: "foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Users: []User{},
|
||||||
|
ListenAddr: ":8080",
|
||||||
|
}
|
||||||
|
newSensor := []Sensor{Sensor{Alias: "bar"}}
|
||||||
|
|
||||||
|
// Create a temp file
|
||||||
|
tmpfile, err := ioutil.TempFile("", "tempgopher")
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
defer os.Remove(tmpfile.Name()) // Remove the tempfile when done
|
||||||
|
configFilePath = tmpfile.Name()
|
||||||
|
|
||||||
|
// Save to tempfile
|
||||||
|
err = SaveConfig(tmpfile.Name(), testConfig)
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
|
||||||
|
// Create a channel to capture SIGHUP
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, syscall.SIGHUP)
|
||||||
|
|
||||||
|
// Test a POST call
|
||||||
|
j, _ := json.Marshal(newSensor)
|
||||||
|
buf = bytes.NewBufferString(string(j))
|
||||||
|
reader = bufio.NewReader(buf)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
req, _ = http.NewRequest("POST", "/config/sensors", reader)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
// Test internal server error
|
||||||
|
configFilePath = "/this/does/not/exist"
|
||||||
|
j, _ = json.Marshal(newSensor)
|
||||||
|
buf = bytes.NewBufferString(string(j))
|
||||||
|
reader = bufio.NewReader(buf)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
req, _ = http.NewRequest("POST", "/config/sensors", reader)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_StatusHandler(t *testing.T) {
|
||||||
|
states := make(map[string]State)
|
||||||
|
states["foo"] = State{Temp: 5}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/status", StatusHandler(&states))
|
||||||
|
r.GET("/status/*alias", StatusHandler(&states))
|
||||||
|
|
||||||
|
// Test all states retrieval
|
||||||
|
j, _ := (json.Marshal(states))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/status", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, string(j), w.Body.String())
|
||||||
|
|
||||||
|
// Test specific state
|
||||||
|
j, _ = (json.Marshal(states["foo"]))
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
req, _ = http.NewRequest("GET", "/status/foo", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, string(j), w.Body.String())
|
||||||
|
|
||||||
|
// Test not found
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
req, _ = http.NewRequest("GET", "/status/DNE", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_JSConfigHandler(t *testing.T) {
|
||||||
|
testConfig := Config{
|
||||||
|
BaseURL: "http://localhost:8080",
|
||||||
|
DisplayFahrenheit: true,
|
||||||
|
}
|
||||||
|
jsconfig := "var jsconfig={baseurl:\"" + testConfig.BaseURL +
|
||||||
|
"\",fahrenheit:" + strconv.FormatBool(testConfig.DisplayFahrenheit) + "};"
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/jsconfig.js", JSConfigHandler(&testConfig))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/jsconfig.js", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, jsconfig, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_VersionHandler(t *testing.T) {
|
||||||
|
type version struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
j, _ := json.Marshal(version{Version: Version})
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/version", VersionHandler)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/version", nil)
|
||||||
|
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, string(j), w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_AppHandler(t *testing.T) {
|
||||||
|
testConfig := Config{BaseURL: "http://localhost:8080"}
|
||||||
|
location := testConfig.BaseURL + "/app/"
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.Any("/", AppHandler(&testConfig))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusPermanentRedirect, w.Code)
|
||||||
|
assert.Equal(t, location, w.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_SetupRouter(t *testing.T) {
|
||||||
|
testConfig := Config{
|
||||||
|
Sensors: []Sensor{
|
||||||
|
Sensor{
|
||||||
|
Alias: "foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Users: []User{},
|
||||||
|
ListenAddr: ":8080",
|
||||||
|
BaseURL: "http://localhost:8080",
|
||||||
|
}
|
||||||
|
|
||||||
|
states := make(map[string]State)
|
||||||
|
states["foo"] = State{}
|
||||||
|
|
||||||
|
// Create a temp file
|
||||||
|
tmpfile, err := ioutil.TempFile("", "tempgopher")
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
defer os.Remove(tmpfile.Name()) // Remove the tempfile when done
|
||||||
|
configFilePath = tmpfile.Name()
|
||||||
|
|
||||||
|
// Setup a router
|
||||||
|
r := SetupRouter(&testConfig, &states)
|
||||||
|
assert.IsType(t, gin.New(), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_GetGinAccounts(t *testing.T) {
|
||||||
|
testConfig := Config{
|
||||||
|
Users: []User{
|
||||||
|
User{
|
||||||
|
Name: "mike",
|
||||||
|
Password: "12345",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
testUsers := make(gin.Accounts)
|
||||||
|
testUsers["mike"] = "12345"
|
||||||
|
|
||||||
|
actualUsers := GetGinAccounts(&testConfig)
|
||||||
|
|
||||||
|
assert.Equal(t, testUsers, actualUsers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_reloadWebConfig(t *testing.T) {
|
||||||
|
// Save zero-valued config
|
||||||
|
testConfig := Config{
|
||||||
|
Sensors: []Sensor{},
|
||||||
|
Users: []User{},
|
||||||
|
ListenAddr: ":8080",
|
||||||
|
}
|
||||||
|
|
||||||
|
newConfig := Config{
|
||||||
|
Sensors: []Sensor{},
|
||||||
|
Users: []User{},
|
||||||
|
BaseURL: "http://localhost:8080",
|
||||||
|
ListenAddr: ":8080",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temp file to store newConfig
|
||||||
|
tmpfile, err := ioutil.TempFile("", "tempgopher")
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
defer os.Remove(tmpfile.Name()) // Remove the tempfile when done
|
||||||
|
SaveConfig(tmpfile.Name(), newConfig)
|
||||||
|
|
||||||
|
// Test that newConfig is copied to testConfig
|
||||||
|
err = reloadWebConfig(&testConfig, tmpfile.Name())
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
assert.Equal(t, testConfig, newConfig)
|
||||||
|
|
||||||
|
// Test the error case
|
||||||
|
err = reloadWebConfig(&testConfig, "/does/not/exist")
|
||||||
|
assert.NotEqual(t, nil, err)
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue