1
0
Fork 0
mirror of https://github.com/shouptech/tempgopher.git synced 2026-02-04 00:59:44 +00:00

Compare commits

..

No commits in common. "master" and "v0.2.0" have entirely different histories.

24 changed files with 218 additions and 1269 deletions

View file

@ -20,16 +20,9 @@ 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 -coverprofile=$CI_PROJECT_DIR/coverage.out - go test -v
artifacts:
paths:
- coverage.out
build: build:
stage: build stage: build

View file

@ -1,29 +1,5 @@
# 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

View file

@ -39,8 +39,6 @@ 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
@ -75,20 +73,4 @@ 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
View file

@ -1,76 +0,0 @@
// 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))
}

View file

@ -1,141 +0,0 @@
// 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
View file

@ -3,12 +3,10 @@ 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"
) )
@ -41,8 +39,8 @@ func ParsePort(addr string) (uint16, error) {
} }
// PromptForConfiguration walks a user through configuration // PromptForConfiguration walks a user through configuration
func PromptForConfiguration(in io.Reader) Config { func PromptForConfiguration() Config {
reader := bufio.NewReader(in) reader := bufio.NewReader(os.Stdin)
var config Config var config Config
@ -101,14 +99,6 @@ func PromptForConfiguration(in io.Reader) 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 {
@ -133,15 +123,6 @@ func PromptForConfiguration(in io.Reader) 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)
@ -156,7 +137,7 @@ func PromptForConfiguration(in io.Reader) 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)
@ -167,7 +148,6 @@ func PromptForConfiguration(in io.Reader) 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"))
@ -178,73 +158,12 @@ func PromptForConfiguration(in io.Reader) 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) {
// Check if path exists config := PromptForConfiguration()
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)
} }

View file

@ -1,39 +0,0 @@
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)
}

View file

@ -3,55 +3,34 @@ 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
@ -65,7 +44,17 @@ 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 {
copier.Copy(&config.Sensors[i], &s) config.Sensors[i].Alias = s.Alias
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])
} }
} }
@ -73,6 +62,8 @@ 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
} }

View file

@ -1,103 +1,13 @@
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) {
testConfig := Config{ testSensor := Sensor{
Sensors: []Sensor{
Sensor{
ID: "28-000008083108", ID: "28-000008083108",
Alias: "fermenter", Alias: "fermenter",
HighTemp: 8, HighTemp: 8,
@ -109,32 +19,21 @@ func Test_LoadConfig(t *testing.T) {
CoolInvert: false, CoolInvert: false,
CoolMinutes: 10, CoolMinutes: 10,
Verbose: true, Verbose: true,
},
},
Users: []User{
User{
Name: "foo",
Password: "bar",
},
},
BaseURL: "https://foo.bar",
ListenAddr: ":8080",
DisplayFahrenheit: true,
Influx: Influx{Addr: "http://foo:8086"},
} }
// Test loading of config testConfig := Config{
Sensors: []Sensor{testSensor},
BaseURL: "https://foo.bar",
ListenAddr: "127.0.0.1:8080",
DisplayFahrenheit: true,
}
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)
} }

View file

@ -1,7 +0,0 @@
label {
font-weight: normal;
}
input[type="checkbox"] {
margin-right: 0.5em;
}

View file

@ -7,9 +7,12 @@
<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>
@ -24,9 +27,6 @@
<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>

View file

@ -1,10 +0,0 @@
// 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");

View file

@ -1,105 +1,51 @@
// 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 appendData(data) { function renderThermostats() {
$.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.alias); var titleh = $("<h4></h4>").text(data[key].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.temp)).toFixed(1) + "°F"; var temp = celsiusToFahrenheit(parseFloat(data[key].temp)).toFixed(1) + "°F";
} else { } else {
var temp = parseFloat(data.temp).toFixed(1) + "°C"; var temp = parseFloat(data[key].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.cooling) { if (data[key].cooling) {
var statustext = "Cooling" var statustext = "Cooling"
} else if (data.heating) { } else if (data[key].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("one columns").append(statusp); var statusdiv = $("<div></div>").addClass("two columns").append(statusp);
rowdiv.append(statusdiv); rowdiv.append(statusdiv);
// Make AJAX call to get current configuration of the sensor // Display sensor config
$.ajax({ $.ajax({
url: jsconfig.baseurl + "/api/config/sensors/" + data.alias, url: jsconfig.baseurl + "/api/config/sensors/" + data[key].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);
@ -116,45 +62,13 @@ function appendData(data) {
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>") var configp = $("<p></p>").text("Chills for ").append(cmIn).append(" minutes when &gt; ").append(htIn).append(degUnit).append($("<br>"));
if (!configData.cooldisable) {
configp.append("Chills for ").append(cmIn).append(" minutes when &gt; ").append(htIn).append(degUnit);
}
if (!configData.cooldisable && !configData.heatdisable){
configp.append($("<br>"));
}
if (!configData.heatdisable) {
configp.append("Heats for ").append(hmIn).append(" minutes when &lt; ").append(ltIn).append(degUnit); configp.append("Heats for ").append(hmIn).append(" minutes when &lt; ").append(ltIn).append(degUnit);
}
var configdiv = $("<div></div>").addClass("six columns").append(configp); var configdiv = $("<div></div>").addClass("five 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()));
@ -165,7 +79,6 @@ function appendData(data) {
$.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,
@ -174,53 +87,42 @@ function appendData(data) {
"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.clearInterval(rtHandle); window.setInterval(renderThermostats, 60000);
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.clearInterval(rtHandle); window.setInterval(renderThermostats, 60000);
rtHandle = window.setInterval(renderThermostats, 60000);
renderThermostats(); renderThermostats();
}); });
var buttonDiv = $("<div></div>").addClass("two columns").append(yesButton).append($("<br>")).append(noButton); var buttonDiv = $("<div></div>").addClass("three columns").append(yesButton).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);

View file

@ -1,37 +0,0 @@
<!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>

View file

@ -1,46 +0,0 @@
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
}

View file

@ -1,26 +0,0 @@
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)
}

View file

@ -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 || true sudo -u $INSTALLUSER $INSTALLBIN -c $CONFIGFILE config
# 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

View file

@ -8,7 +8,7 @@ import (
) )
// Version is the current code version of tempgopher // Version is the current code version of tempgopher
const Version = "0.4.0" const Version = "0.2.0"
func main() { func main() {
var args struct { var args struct {

View file

@ -21,7 +21,6 @@ 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

View file

@ -21,7 +21,6 @@ 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

View file

@ -10,10 +10,6 @@ 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

View file

@ -70,18 +70,11 @@ 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
if !sensor.CoolDisable { cpin := rpio.Pin(sensor.CoolGPIO)
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()
@ -90,14 +83,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 && !sensor.CoolDisable: case temp > sensor.HighTemp:
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 && !sensor.HeatDisable: case temp < sensor.LowTemp:
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
@ -133,16 +126,13 @@ 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
@ -226,10 +216,6 @@ 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
View file

@ -40,7 +40,6 @@ 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)
} }
} }
@ -104,15 +103,6 @@ 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
@ -134,28 +124,26 @@ func SetupRouter(config *Config, states *map[string]State) *gin.Engine {
// Ping // Ping
r.GET("/ping", PingHandler) r.GET("/ping", PingHandler)
// API Endpoints // Status
var api *gin.RouterGroup r.GET("/api/status", StatusHandler(states))
if len(config.Users) == 0 { r.GET("/api/status/*alias", StatusHandler(states))
api = r.Group("/api")
} else {
api = r.Group("/api")
api.Use(BasicAuth(GetGinAccounts(config)))
}
api.GET("/status", StatusHandler(states)) // API Version
api.GET("/status/*alias", StatusHandler(states)) r.GET("/api/version", VersionHandler)
api.GET("/version", VersionHandler)
api.GET("/config", ConfigHandler(config)) // Config
api.GET("/config/sensors/*alias", ConfigHandler(config)) r.GET("/api/config", ConfigHandler(config))
api.POST("/config/sensors", UpdateSensorsHandler) r.GET("/api/config/sensors/*alias", ConfigHandler(config))
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("/", AppHandler(config)) r.Any("/", func(c *gin.Context) {
c.Redirect(301, config.BaseURL+"/app/")
})
return r return r
} }
@ -172,15 +160,6 @@ 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.

View file

@ -1,290 +0,0 @@
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)
}