1
0
Fork 0
mirror of https://github.com/shouptech/tempgopher.git synced 2026-02-03 16:49:42 +00:00

Compare commits

...

51 commits

Author SHA1 Message Date
209c3afc2a Bump to v0.4.0 2018-11-01 09:13:06 -06:00
a6196d5fa8 Use an emoji that's more portable 2018-11-01 07:03:56 -06:00
8aebae0ebe Add ability to enable/disable heating/cooling via UI
Also fixes a bug that the new value wasn't getting copied to the new config

Fixes #18
2018-11-01 06:45:38 -06:00
924473090e Allow configuration of InfluxDB address for tests 2018-10-27 19:27:23 -06:00
927f5b9043 Add tests for influx 2018-10-27 19:21:25 -06:00
1b946b9ce8 Add more tests for handlers 2018-10-26 16:06:08 -06:00
14fa9a78c4 Fix error in ci yaml 2018-10-26 13:42:04 -06:00
961a8de916 Add coverage report 2018-10-26 13:39:56 -06:00
7ad2619369 Add CLI tests 2018-10-26 10:12:22 -06:00
14b5cc4a8b Refactor PromptForConfiguration to accept io.Reader 2018-10-26 10:10:40 -06:00
281af2b255 Improve unit tests for auth.go 2018-10-26 09:45:16 -06:00
32f1c7fc9d Improve unit tests for config.go 2018-10-26 09:38:22 -06:00
1ce594d540 Clear window interval before setting a new one
Fixes #17
2018-10-26 09:08:16 -06:00
a167da2230 Always display by alias of sensor 2018-10-25 14:48:44 -06:00
195d167664 Bump to 0.4.0-dev 2018-10-25 14:36:32 -06:00
2107a7a3af Bump to version 0.3.1 2018-10-24 09:50:36 -06:00
071d0db161 Fix typo in cli configuration
Closes #14
2018-10-24 09:48:09 -06:00
e3ec4803de Move append logic to separate function
Fixes #15
2018-10-23 21:51:19 -06:00
f13c557b38 Bump to v0.3.0 2018-10-20 19:52:45 -06:00
f541654a86 Check if config file exists 2018-10-20 19:49:58 -06:00
bdba7afbfd Don't show config options if one is disabled 2018-10-20 08:35:16 -06:00
0bd6c9bf73 Backend for selectively disabling heating/cooling 2018-10-19 14:41:44 -06:00
64ca5a3bfd Add script and docs for auth 2018-10-13 19:23:49 -06:00
ffe11c1965 Make config_test.go look more consistent 2018-10-13 19:11:16 -06:00
c6c82c1308 Add logout 2018-10-13 19:06:34 -06:00
595751d5d4 Make enter on the login page do things 2018-10-13 19:06:34 -06:00
5fd5acc0cc Added login ability 2018-10-13 19:06:34 -06:00
dd3e78eb28 Succesfully redirect if 401 2018-10-13 19:06:34 -06:00
ac50b755d1 Add check for authorization header 2018-10-13 19:06:34 -06:00
6790b94b80 Add login UI 2018-10-13 19:06:34 -06:00
89657c381b Add auth to /api 2018-10-13 19:06:34 -06:00
c817f27eb2 Group api components 2018-10-12 20:05:52 -06:00
Mike Shoup
cfa7fc7fe8 Merge branch 'feature/influx' into 'develop'
Closes #11
Writes states to InfluxDB
Updated config utility and documentation to assist with Influx

See merge request shouptech/tempgopher!5
2018-10-12 20:59:30 +00:00
Mike Shoup
8aa73706e6 Write state to InfluxDB 2018-10-12 20:59:30 +00:00
fb4905a7e8 Bump version to 0.3.0-dev 2018-10-11 19:10:24 -06:00
9a9b9d6506 Update Changelog 2018-10-11 18:59:29 -06:00
e7f5ac433a Bump version to 0.2.0
Closes #9
2018-10-11 18:56:05 -06:00
a2a25c953b Update README.md with new instructions
This adds documentation for using the automated install script
2018-10-11 18:54:06 -06:00
8af4c3117b Add ability to configure sensors 2018-10-11 14:09:12 -06:00
0153e453dc Add skeleton for configuration prompter 2018-10-10 19:58:57 -06:00
66051179fc Add an installation script
Script will download the latest binary from master and create a systemd
unit file to start it.
2018-10-10 19:02:04 -06:00
Mike Shoup
345d308f85 Adds ability to update configuration through Web UI
Added input fields and buttons to thermostat.js
Closes #2

See merge request shouptech/tempgopher!3
2018-10-08 14:24:24 +00:00
a2f734ca44 It works I think 2018-10-08 06:00:52 -06:00
b18a11ecb3 Add POST that doesn't work 2018-10-07 22:04:31 -06:00
cfb14d490d Add ability to stop reloading whent yping in form 2018-10-07 21:30:34 -06:00
9aadc2007a Add form to UI 2018-10-07 19:42:01 -06:00
Mike Shoup
b4c4ce8650 Provide the ability to update configuration via the API
See merge request shouptech/tempgopher!2
2018-10-07 22:21:03 +00:00
Mike Shoup
31db668b64 Resolve "Update config via API" 2018-10-07 22:21:03 +00:00
48b36ba17d Release 0.1.1
* Changes temperature logic. See #8. Fixes a situation where temperature
  'floats' at the threshold and the switch is rapidly cycled.
2018-10-07 16:05:26 -06:00
Mike Shoup
b3d0ebd4b0 Merge branch '8-change-temperature-logic' into 'master'
Resolve "Change temperature logic"

Closes #8 

Changes temperature logic to:
If temperature exceeds threshold, enable cooling/heating.
If temperature is below desired threshold, turn off cooling/heating if duration has been exceeded.

See merge request shouptech/tempgopher!1
2018-10-07 21:57:41 +00:00
Mike Shoup
05db37343d Resolve "Change temperature logic" 2018-10-07 21:57:41 +00:00
25 changed files with 1643 additions and 242 deletions

View file

@ -20,9 +20,16 @@ test:
stage: test
variables:
GIN_MODE: debug
INFLUXDB_DB: db
INFLUXDB_ADDR: http://influxdb:8086
services:
- influxdb
script:
- go get -v -t ./...
- go test -v
- go test -v -coverprofile=$CI_PROJECT_DIR/coverage.out
artifacts:
paths:
- coverage.out
build:
stage: build
@ -34,3 +41,4 @@ build:
artifacts:
paths:
- tempgopher
- install.sh

View file

@ -1,5 +1,43 @@
# 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
Release: 2018-10-11
* You can now update the configuration using the UI or API
* A script to help install has been added
* The binary will now generate a usable configuration
## 0.1.1
Release: 2018-10-07
* Changes temperature logic. See #8. Fixes a situation where temperature 'floats' at the threshold and the switch is rapidly cycled.
## 0.1.0
Released: 2018-10-04

164
README.md
View file

@ -1,104 +1,94 @@
# Temp Gopher
# TempGopher
Temp Gopher is a thermostat application written in Go. It is written and tested using a Raspberry Pi, but any hardware platform meeting the requirements will probably work.
TempGopher is a thermostat application written in Go. It turns a Raspberry Pi into a thermostat you can control with your web browser.
## Requirements
You will need a computer (e.g., Raspberry Pi) with the following components:
You will need a Raspberry Pi with the following components:
* A network connection
* DS18B120, 1-wire temperature sensors
* GPIO pins
* Relays for powering on/off your equipment hooked up to the GPIO pins
## Installation
## Install
You can build on a Raspberry Pi, however, it can take a long time! I recommend building on a separate computer with a bit more processing power.
You will need to setup your Raspberry Pi for this to work.
1. Setup your DS18B20 temperature sensor. [Adafruit has a good tutorial for getting them working](https://learn.adafruit.com/adafruits-raspberry-pi-lesson-11-ds18b20-temperature-sensing/hardware)
2. Connect your relay switches to the GPIO pins
3. [Download the install.sh script to your Raspberry Pi](https://gitlab.com/shouptech/tempgopher/-/jobs/artifacts/master/raw/install.sh?job=build)
4. Run the script! The script will download the latest binary and configure the thermostat with some initial values.
5. After configuration, point your web browser to the configured URL.
## Configuration Script
You will be asked some questions during the initial configuration of TempGopher. You also see some defaults in brackets. If the brackets look good, just hit enter.
* `Listen address?` - The address & port TempGopher should listen on. Omitting the address and just specifying the port means it listens on all addresses. Default is `:8080`.
* `Base URL?` - This is what you will type in to your web browser to access TempGopher. If you don't have DNS configured, should probably be `http://<ipaddress>:8080`
* `Display temperature in fahrenheit?` - Set to true if you want fahrenheit, otherwise defaults to celsius.
* `Configure sensor w/ ID: 28-xxxxx` - If you set up your DS18B20 sensors correctly, you should see it's ID listed. Enter `Y` and answer the prompts to configure it. If you have multiple sensors, you will be asked this question multiple times.
* `Sensor alias:` - Name to display in the web browser for this sensor.
* `High temperature:` - The high temperature to kick the cooling on.
* `Cooling minutes:` - The number of minutes to run the cooler once the temperature is below the High temperature threshold.
* `Cooling GPIO:` - The pin your cooling relay switch is hooked into.
* `Invert cooling switch` - If set to `true`, the cooling will be ON when the switch is LOW. This should usually be `false`, so that is the default
* `Low temperature:` - The low temperature to kick the heating on.
* `Heating minutes:` - The number of minutes to run the heater once the temperature is below the Low temperature threshold.
* `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
* `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
```
go get -u github.com/gobuffalo/packr/...
go get gitea.shoup.io/mike/tempgopher
cd $GOPATH/src/gitea.shoup.io/mike/tempgopher
GOOS=linux GOARCH=arm GOARM=6 packr build -a -ldflags '-w -s -extldflags "-static"'
scp tempgopher <raspberrypi>:~/somepath
```
$ bash install.sh
## Configuration
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 147 100 147 0 0 357 0 --:--:-- --:--:-- --:--:-- 356
Create a `config.yml` file like this:
TempGopher v0.2.0
You will now be asked a series of questions to help configure your thermostat.
Don't worry, it will all be over quickly.
Default values will be in brackets. Just press enter if they look good.
=====
Listen address?
[:8080]:
Base URL? (This is what you type into your browser to get to the web UI)
[http://beerpi:8080]: http://10.30.14.130:8080
Display temperatures in fahrenheit? (Otherwise uses celsius)
[true]:
Configure sensor w/ ID: 28-000008083108
[Y/n]:
Sensor alias: fermenter
High temperature: 20
Cooling minutes: 4
Cooling GPIO: 19
Invert cooling switch [false]:
Low temperature: 19
Heating minutes: 0.5
Heating GPIO: 13
Invert heating switch [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
```
listenaddr: ":8080" # Address:port to listen on. If not specified, defaults to ":8080"
baseurl: http://<pihostname>:8080 # Base URL to find the app at. Usually your Pi's IP address or hostname, unless using a reverse proxy
sensors:
- id: 28-000008083108 # Id of the DS18b120 sensor
alias: fermenter # An alias for the sensor
hightemp: 8 # Maximum temperature you want the sensor to read
lowtemp: 4 # Minimum tempearture you want the sensor to read
heatgpio: 5 # GPIO pin the heater is hooked into
heatinvert: false # Probably false. If true, will set pin to High to turn the heater off
heatminutes: 1 # Number of minutes below the minimum before the heater turns on
coolgpio: 17 # GPIO pin the cooler is hooked into
coolinvert: false # Probably false. If true, will set pin to High to turn the cooler off
coolminutes: 10 # Number of minutes below the minimum before the cooler turns on
verbose: false # If true, outputs the current status at every read, approx once per second
```
## Running
You can run it directly in the comment line like:
```
./tempgopher -c config.yml run
```
You can run it in the background using `nohup`:
```
nohup ./tempgopher -c config.yml run &
```
Or use `systemctl` or some other process supervisor to run it.
## REST API
There is a very simple REST API for viewing the current configuration and status. The application launches and binds to `:8080`.
To view the current status, query `http://<pi>:8080/api/status`:
```
$ curl -s http://localhost:8080/api/status | jq .
{
"fermenter": {
"alias": "fermenter",
"temp": 19.812,
"cooling": false,
"heating": false,
"reading": "2018-10-03T08:43:05.795870992-06:00",
"changed": "2999-01-01T00:00:00Z"
}
}
```
To view the current configuration, query `http://<pi>:8080/api/config`:
```
$ curl -s http://localhost:8080/api/config | jq .
{
"Sensors": [
{
"id": "28-000008083108",
"alias": "fermenter",
"hightemp": 30,
"lowtemp": 27,
"heatgpio": 13,
"heatinvert": false,
"heatminutes": 1,
"coolgpio": 19,
"coolinvert": false,
"coolminutes": 4,
"verbose": false
}
]
}
```

76
auth.go Normal file
View 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
View 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)
}

250
cli.go Normal file
View file

@ -0,0 +1,250 @@
package main
import (
"bufio"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/howeyc/gopass"
"github.com/yryz/ds18b20"
)
// ReadInput reads the next line from a Reader. It will return 'def' if nothing
// was input, or it will return the response.
func ReadInput(r *bufio.Reader, def string) string {
resp, err := r.ReadString('\n')
if err != nil {
panic(err)
}
if resp == "\n" {
return def
}
return resp[:len(resp)-1]
}
// ParsePort returns the port number from a listen address
func ParsePort(addr string) (uint16, error) {
parts := strings.Split(addr, ":")
port, err := strconv.ParseUint(parts[len(parts)-1], 10, 16)
if err != nil {
return 0, err
}
return uint16(port), nil
}
// PromptForConfiguration walks a user through configuration
func PromptForConfiguration(in io.Reader) Config {
reader := bufio.NewReader(in)
var config Config
fmt.Printf("TempGopher v%s\n", Version)
fmt.Println("You will now be asked a series of questions to help configure your thermostat.")
fmt.Println("Don't worry, it will all be over quickly.")
fmt.Println("\nDefault values will be in brackets. Just press enter if they look good.")
fmt.Println("=====")
fmt.Print("Listen address?\n[:8080]: ")
config.ListenAddr = ReadInput(reader, ":8080")
port, err := ParsePort(config.ListenAddr)
if err != nil {
panic(err)
}
hostname, err := os.Hostname()
if err != nil {
panic(err)
}
fmt.Println("Base URL? (This is what you type into your browser to get to the web UI)")
defURL := fmt.Sprintf("http://%s:%d", hostname, port)
fmt.Printf("[%s]: ", defURL)
config.BaseURL = ReadInput(reader, defURL)
fmt.Println("Display temperatures in fahrenheit? (Otherwise uses celsius)")
fmt.Print("[true]: ")
config.DisplayFahrenheit, err = strconv.ParseBool(ReadInput(reader, "true"))
if err != nil {
panic(err)
}
// Configure sensors
sensors, err := ds18b20.Sensors()
if err != nil {
fmt.Println("Couldn't find any sensors. Did you enable the 1-wire bus?")
fmt.Printf("The error was: %s\n", err)
os.Exit(1)
}
for _, sensor := range sensors {
fmt.Printf("Configure sensor w/ ID: %s\n", sensor)
fmt.Print("[Y/n]: ")
choice := ReadInput(reader, "y")
if strings.ToLower(choice)[0] != 'y' {
continue
}
var s Sensor
s.ID = sensor
fmt.Print("Sensor alias: ")
s.Alias = ReadInput(reader, "")
if s.Alias == "" {
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: ")
s.HighTemp, err = strconv.ParseFloat(ReadInput(reader, ""), 64)
if err != nil {
panic(err)
}
fmt.Print("Cooling minutes: ")
s.CoolMinutes, err = strconv.ParseFloat(ReadInput(reader, ""), 64)
if err != nil {
panic(err)
}
fmt.Print("Cooling GPIO: ")
resp, err := strconv.ParseInt(ReadInput(reader, ""), 10, 32)
s.CoolGPIO = int32(resp)
if err != nil {
panic(err)
}
fmt.Print("Invert cooling switch [false]: ")
s.CoolInvert, err = strconv.ParseBool(ReadInput(reader, "false"))
if err != nil {
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: ")
s.LowTemp, err = strconv.ParseFloat(ReadInput(reader, ""), 64)
if err != nil {
panic(err)
}
fmt.Print("Heating minutes: ")
s.HeatMinutes, err = strconv.ParseFloat(ReadInput(reader, ""), 64)
if err != nil {
panic(err)
}
fmt.Print("Heating GPIO: ")
resp, err := strconv.ParseInt(ReadInput(reader, ""), 10, 32)
s.HeatGPIO = int32(resp)
if err != nil {
panic(err)
}
fmt.Print("Invert heating switch [false]: ")
s.HeatInvert, err = strconv.ParseBool(ReadInput(reader, "false"))
if err != nil {
panic(err)
}
}
fmt.Print("Enable verbose logging [false]: ")
s.Verbose, err = strconv.ParseBool(ReadInput(reader, "false"))
if err != nil {
panic(err)
}
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
}
// ConfigCLI prompts the user for configuration and writes to a config file
func ConfigCLI(path string) {
// 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)
}

39
cli_test.go Normal file
View 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)
}

View file

@ -3,31 +3,101 @@ package main
import (
"errors"
"io/ioutil"
"os"
"syscall"
"github.com/jinzhu/copier"
"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.
type Sensor struct {
ID string `json:"id" yaml:"id"`
Alias string `json:"alias" yaml:"alias"`
HighTemp float64 `json:"hightemp" yaml:"hightemp"`
LowTemp float64 `json:"lowtemp" yaml:"lowtemp"`
HeatDisable bool `json:"heatdisable" yaml:"heatdisable"`
HeatGPIO int32 `json:"heatgpio" yaml:"heatgpio"`
HeatInvert bool `json:"heatinvert" yaml:"heatinvert"`
HeatMinutes float64 `json:"heatminutes" yaml:"heatminutes"`
CoolDisable bool `json:"cooldisable" yaml:"cooldisable"`
CoolGPIO int32 `json:"coolgpio" yaml:"coolgpio"`
CoolInvert bool `json:"coolinvert" yaml:"coolinvert"`
CoolMinutes float64 `json:"coolminutes" yaml:"coolminutes"`
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
type Config struct {
Sensors []Sensor `yaml:"sensors"`
Users []User `yaml:"users"`
BaseURL string `yaml:"baseurl"`
ListenAddr string `yaml:"listenaddr"`
DisplayFahrenheit bool `yaml:"displayfahrenheit"`
Influx Influx `yaml:"influx"`
}
var configFilePath string
// UpdateSensorConfig updates the configuration of an individual sensor and writes to disk
func UpdateSensorConfig(s Sensor) error {
config, err := LoadConfig(configFilePath)
if err != nil {
return err
}
for i := range config.Sensors {
if config.Sensors[i].ID == s.ID {
copier.Copy(&config.Sensors[i], &s)
}
}
if err = SaveConfig(configFilePath, *config); err != nil {
return err
}
if err = SignalReload(); err != nil {
return err
}
return nil
}
// SignalReload sends a SIGHUP to the process, initiating a configuration reload
func SignalReload() error {
p := os.Process{Pid: os.Getpid()}
return p.Signal(syscall.SIGHUP)
}
// SaveConfig will write a new configuration file
func SaveConfig(path string, config Config) error {
d, err := yaml.Marshal(config)
if err != nil {
return err
}
if err = ioutil.WriteFile(path, d, 0644); err != nil {
return err
}
return nil
}
// LoadConfig will loads a file and parses it into a Config struct
@ -37,6 +107,7 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
configFilePath = path
var config Config
yaml.Unmarshal(data, &config)

View file

@ -1,13 +1,103 @@
package main
import (
"io/ioutil"
"os"
"os/signal"
"syscall"
"testing"
"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) {
testSensor := Sensor{
testConfig := Config{
Sensors: []Sensor{
Sensor{
ID: "28-000008083108",
Alias: "fermenter",
HighTemp: 8,
@ -19,21 +109,32 @@ func Test_LoadConfig(t *testing.T) {
CoolInvert: false,
CoolMinutes: 10,
Verbose: true,
}
testConfig := Config{
Sensors: []Sensor{testSensor},
},
},
Users: []User{
User{
Name: "foo",
Password: "bar",
},
},
BaseURL: "https://foo.bar",
ListenAddr: "127.0.0.1:8080",
ListenAddr: ":8080",
DisplayFahrenheit: true,
Influx: Influx{Addr: "http://foo:8086"},
}
// Test loading of config
loadedConfig, err := LoadConfig("tests/test_config.yml")
assert.Equal(t, nil, err)
assert.Equal(t, &testConfig, loadedConfig)
// Test for failures with duplicate IDs and Aliases
_, err = LoadConfig("tests/duplicate_id.yml")
assert.NotEqual(t, nil, err)
_, err = LoadConfig("tests/duplicate_alias.yml")
assert.NotEqual(t, nil, err)
// Test for non-existence
_, err = LoadConfig("DNE")
assert.NotEqual(t, nil, err)
}

7
html/css/custom.css Normal file
View file

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

View file

@ -7,12 +7,9 @@
<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="stylesheet" href="css/custom.css">
<link rel="icon" type="image/png" href="img/favicon.png">
<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="js/thermostat.js"></script>
</head>
@ -27,6 +24,9 @@
<div class="row" style="margin-top: 10rem">
<h6 id="version"></h6>
</div>
<div class="row">
<div class="one columns offset-by-eleven" id="logoutDiv"></div>
</div>
</div>
</body>
</html>

10
html/js/login.js Normal file
View 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");

View file

@ -1,72 +1,35 @@
function celsiusToFahrenheit(degree) {
return degree * 1.8 + 32;
// 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);
}
}
function renderThermostats() {
// Redirect if not authorized
function redirectIfNotAuthorized() {
$.ajax({
url: jsconfig.baseurl + "/api/status/"
}).then(function(data) {
$("#thermostats").empty();
for (var key in data) {
// Title of thermostat
var titleh = $("<h4></h4>").text(data[key].alias);
var titlediv = $("<div></div>").addClass("row").append(titleh);
// Thermostat status
var rowdiv = $("<div></div>");
rowdiv.addClass("row");
// Display temperature
if (jsconfig.fahrenheit) {
var temp = celsiusToFahrenheit(parseFloat(data[key].temp)).toFixed(1) + "°F";
} else {
var temp = parseFloat(data[key].temp).toFixed(1) + "°C";
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");
}
var temph = $("<h2></h2>").text(temp);
var tempdiv = $("<div></div>").addClass("two columns").append(temph);
rowdiv.append(tempdiv);
// Display status
if (data[key].cooling) {
var statustext = "Cooling"
} else if (data[key].heating) {
var statustext = "Heating"
} else {
var statustext = "Idle"
}
var statusp = $("<p></p>").html(statustext);
var statusdiv = $("<div></div>").addClass("three columns").append(statusp);
rowdiv.append(statusdiv);
// Display config
$.ajax({
url: jsconfig.baseurl + "/api/config/" + data[key].alias
}).then(function(configData){
if (jsconfig.fahrenheit) {
var hightemp = celsiusToFahrenheit(parseFloat(configData.hightemp)).toFixed(1) + "°F";
var lowtemp = celsiusToFahrenheit(parseFloat(configData.lowtemp)).toFixed(1) + "°F";
} else {
var hightemp = parseFloat(configData.hightemp).toFixed(1) + "°C";
var lowtemp = parseFloat(configData.lowtemp).toFixed(1) + "°C";
}
configText = "Chills when > " + hightemp + " for " + configData.coolminutes + " minutes.<br />";
configText += "Heats when < " + lowtemp + " for " + configData.heatminutes + " minutes.";
var configp = $("<p></p>").html(configText);
var configdiv = $("<div></div>").addClass("seven columns").append(configp);
rowdiv.append(configdiv);
});
// Add things back to the thermostat list
$("#thermostats").append(titlediv);
$("#thermostats").append(rowdiv);
};
});
};
// Call the redirect function
redirectIfNotAuthorized();
// Display version at bottom of page
function renderVersion() {
$.ajax({
url: jsconfig.baseurl + "/api/version"
url: jsconfig.baseurl + "/api/version",
beforeSend: authHeaders
}).then(function(data) {
var versionText = "TempGopher © 2018 Mike Shoup | Version: " + data.version;
$("#version").text(versionText);
@ -74,5 +37,190 @@ function renderVersion() {
};
$(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) {
return degree * 1.8 + 32;
}
function fahrenheitToCelsius(degree) {
return (degree - 32) * 5 / 9;
};
function appendData(data) {
// Title of thermostat
var titleh = $("<h4></h4>").text(data.alias);
var titlediv = $("<div></div>").addClass("row").append(titleh);
// Thermostat status
var rowdiv = $("<div></div>");
rowdiv.addClass("row");
////////////////////////////////////////////////////////////////////////////
// Display temperature
if (jsconfig.fahrenheit) {
var temp = celsiusToFahrenheit(parseFloat(data.temp)).toFixed(1) + "°F";
} else {
var temp = parseFloat(data.temp).toFixed(1) + "°C";
}
var temph = $("<h2></h2>").text(temp);
var tempdiv = $("<div></div>").addClass("two columns").append(temph);
rowdiv.append(tempdiv);
////////////////////////////////////////////////////////////////////////////
// Display status
if (data.cooling) {
var statustext = "Cooling"
} else if (data.heating) {
var statustext = "Heating"
} else {
var statustext = "Idle"
}
var statusp = $("<p></p>").html(statustext);
var statusdiv = $("<div></div>").addClass("one columns").append(statusp);
rowdiv.append(statusdiv);
// Make AJAX call to get current configuration of the sensor
$.ajax({
url: jsconfig.baseurl + "/api/config/sensors/" + data.alias,
beforeSend: authHeaders
}).then(function(configData){
////////////////////////////////////////////////////////////////////////
// Display current configuration
if (jsconfig.fahrenheit) {
var degUnit = "°F";
var hightemp = celsiusToFahrenheit(parseFloat(configData.hightemp)).toFixed(1);
var lowtemp = celsiusToFahrenheit(parseFloat(configData.lowtemp)).toFixed(1);
} else {
var hightemp = parseFloat(configData.hightemp).toFixed(1);
var lowtemp = parseFloat(configData.lowtemp).toFixed(1);
}
rp = '[0-9]+(\.[0-9]+)?'
var cmIn = $("<input>").attr("id", "cm" + configData.alias).val(configData.coolminutes).attr("size", "2").attr("pattern", rp).on('input', function(){window.clearInterval(rtHandle)});
var htIn = $("<input>").attr("id", "ht" + configData.alias).val(hightemp).attr("size", "4").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 configp = $("<p></p>")
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);
}
var configdiv = $("<div></div>").addClass("six columns").append(configp);
rowdiv.append(configdiv);
////////////////////////////////////////////////////////////////////////
// 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) {
var newHT = fahrenheitToCelsius(parseFloat(htIn.val()));
var newLT = fahrenheitToCelsius(parseFloat(ltIn.val()));
} else {
var newHT = parseFloat(htIn.val());
var newLT = parseFloat(ltIn.val());
}
$.ajax({
type: "POST",
url: jsconfig.baseurl + "/api/config/sensors",
beforeSend: authHeaders,
data: JSON.stringify([{
"id": configData.id,
"alias": configData.alias,
"hightemp": newHT,
"lowtemp": newLT,
"heatgpio": configData.heatgpio,
"heatinvert": configData.heatInvert,
"heatminutes": parseFloat(hmIn.val()),
"heatdisable": !hon.is(":checked"),
"coolgpio": configData.coolgpio,
"coolinvert": configData.coolinvert,
"coolminutes": parseFloat(cmIn.val()),
"cooldisable": !con.is(":checked"),
"verbose": configData.verbose
}])
});
window.clearInterval(rtHandle);
rtHandle = window.setInterval(renderThermostats, 60000);
renderThermostats();
});
var noButton = $("<button></button>").addClass("button").text("✘").click(function() {
window.clearInterval(rtHandle);
rtHandle = window.setInterval(renderThermostats, 60000);
renderThermostats();
});
var buttonDiv = $("<div></div>").addClass("two columns").append(yesButton).append($("<br>")).append(noButton);
rowdiv.append(buttonDiv);
// Add things back to the thermostat list
$("#thermostats").append(titlediv);
$("#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]])
};
});
};
$(document).ready(renderThermostats);
setInterval(renderThermostats, 60000)
var rtHandle = window.setInterval(renderThermostats, 60000);

View file

@ -1,4 +1,4 @@
var jsconfig = {
baseurl: "http://foo.bar",
baseurl: "http://beerpi.home.shoup.io:8080",
fahrenheit: true
};

37
html/login.html Normal file
View 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
View 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
View 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)
}

44
install.sh Normal file
View file

@ -0,0 +1,44 @@
#!/bin/bash
INSTALLDIR=/opt/tempgopher
INSTALLBIN=$INSTALLDIR/tempgopher
INSTALLUSER=pi
BINURL='https://gitlab.com/shouptech/tempgopher/-/jobs/artifacts/master/raw/tempgopher?job=build'
CONFIGFILE=$INSTALLDIR/config.yml
# Load w1_therm module
sudo /sbin/modprobe w1_therm
# Download binary
sudo mkdir -p $INSTALLDIR
sudo curl -L $BINURL -o $INSTALLBIN
sudo chmod +x $INSTALLBIN
sudo chown -R $INSTALLUSER: $INSTALLDIR
# Generate a configuration file
sudo -u $INSTALLUSER $INSTALLBIN -c $CONFIGFILE config || true
# Create unit file
sudo sh -c "cat > /etc/systemd/system/tempgopher.service" << EOM
[Unit]
Description=Temp Gopher
After=network.target
[Service]
Type=simple
WorkingDirectory=$INSTALLDIR
PermissionsStartOnly=true
User=$INSTALLUSER
Group=$INSTALLUSER
ExecStartPre=/sbin/modprobe w1_therm
ExecStart=$INSTALLBIN -c $CONFIGFILE run
ExecReload=/bin/kill -HUP \$MAINPID
[Install]
WantedBy=multi-user.target
EOM
# Enable and start the service
sudo systemctl daemon-reload
sudo systemctl enable tempgopher.service
sudo systemctl start tempgopher.service

13
main.go
View file

@ -8,17 +8,22 @@ import (
)
// Version is the current code version of tempgopher
const Version = "0.1.0"
const Version = "0.4.0"
func main() {
var args struct {
Action string `arg:"required,positional" help:"run"`
Action string `arg:"required,positional" help:"run config"`
ConfigFile string `arg:"-c,required" help:"path to config file"`
}
p := arg.MustParse(&args)
if args.Action != "run" {
p.Fail("ACTION must be run")
if args.Action != "run" && args.Action != "config" {
p.Fail("ACTION must be run or config")
}
if args.Action == "config" {
ConfigCLI(args.ConfigFile)
return
}
// Create a channel for receiving of state

View file

@ -21,6 +21,7 @@ sensors:
coolinvert: false
coolminutes: 10
verbose: true
users:
- foo: bar
baseurl: https://foo.bar
listenaddr: 127.0.0.1:8080
displayfahrenheit: true

View file

@ -21,6 +21,7 @@ sensors:
coolinvert: false
coolminutes: 10
verbose: true
users:
- foo: bar
baseurl: https://foo.bar
listenaddr: 127.0.0.1:8080
displayfahrenheit: true

View file

@ -10,6 +10,10 @@ sensors:
coolinvert: false
coolminutes: 10
verbose: true
users:
- name: foo
password: bar
baseurl: https://foo.bar
listenaddr: 127.0.0.1:8080
displayfahrenheit: true
influx:
addr: http://foo:8086

View file

@ -63,56 +63,61 @@ func ProcessSensor(sensor Sensor, state State) (State, error) {
log.Panicln(err)
}
state.When = time.Now()
// Initialize the pins
cpin := rpio.Pin(sensor.CoolGPIO)
cpin.Output()
hpin := rpio.Pin(sensor.HeatGPIO)
hpin.Output()
// Calculate duration
duration := time.Since(state.Changed).Minutes()
// When things reach the right temperature, set the duration to the future
// TODO: Better handling of this. Changed should maintain when the state changed.
// Probably need a new flag in the State struct.
future := time.Date(2999, 1, 1, 0, 0, 0, 0, time.UTC)
state.When = time.Now()
var cpin rpio.Pin
var hpin rpio.Pin
// Initialize the pins
if !sensor.CoolDisable {
cpin = rpio.Pin(sensor.CoolGPIO)
cpin.Output()
}
if !sensor.HeatDisable {
hpin = rpio.Pin(sensor.HeatGPIO)
hpin.Output()
}
// Calculate duration
duration := time.Since(state.Changed).Minutes()
switch {
case temp > sensor.HighTemp && temp < sensor.LowTemp:
log.Println("Invalid state! Temperature is too high AND too low!")
// Temperature is high enough, turn off
case temp > sensor.LowTemp && state.Heating:
PinSwitch(hpin, false, sensor.HeatInvert)
state.Heating = false
state.Changed = future
// Temperature is too high and the cooling switch is still on, do nothing
case temp > sensor.HighTemp && state.Cooling:
break
// Temperature is too high, but duration hasn't been counted
case temp > sensor.HighTemp && duration < 0:
state.Changed = time.Now()
// Temperature is too high and the duration has been long enough, start cooling
case temp > sensor.HighTemp && duration > sensor.CoolMinutes:
// Temperature too high, start cooling
case temp > sensor.HighTemp && !sensor.CoolDisable:
PinSwitch(cpin, true, sensor.CoolInvert)
state.Cooling = true
state.Changed = time.Now()
// Temperature is low enough, stop cooling
case temp < sensor.HighTemp && state.Cooling:
PinSwitch(hpin, false, sensor.HeatInvert) // Ensure the heater is off
state.Heating = false
state.Changed = future
// Temperature too low, start heating
case temp < sensor.LowTemp && !sensor.HeatDisable:
PinSwitch(hpin, true, sensor.HeatInvert)
state.Heating = true
PinSwitch(cpin, false, sensor.CoolInvert) // Ensure the chiller is off
state.Cooling = false
state.Changed = future
// Temperature is good and cooling has been happening long enough
case temp < sensor.HighTemp && state.Cooling && duration > sensor.CoolMinutes:
PinSwitch(cpin, false, sensor.CoolInvert)
state.Cooling = false
state.Changed = future
// Temperature is too low and the heating switch is on, do nothing
case temp < sensor.LowTemp && state.Heating:
break
// Temperature is too low, but duration hasn't been counted
case temp < sensor.LowTemp && duration < 0:
// Temperature is good and heating has been happening long enough
case temp > sensor.LowTemp && state.Heating && duration > sensor.HeatMinutes:
PinSwitch(hpin, false, sensor.HeatInvert)
state.Heating = false
state.Changed = future
// Temperature just crossed high threshold
case temp < sensor.HighTemp && state.Cooling && duration < 0:
state.Changed = time.Now()
// Temperature is too low and the duration has been long enough, start heating
case temp < sensor.LowTemp && duration > sensor.HeatMinutes:
PinSwitch(hpin, true, sensor.HeatInvert)
state.Heating = true
// Temperature just crossed low threshold
case temp > sensor.LowTemp && state.Heating && duration < 0:
state.Changed = time.Now()
default:
break
@ -128,13 +133,16 @@ func ProcessSensor(sensor Sensor, state State) (State, error) {
// TurnOffSensor turns off all switches for an individual sensor
func TurnOffSensor(sensor Sensor) {
if !sensor.CoolDisable {
cpin := rpio.Pin(sensor.CoolGPIO)
cpin.Output()
PinSwitch(cpin, false, sensor.CoolInvert)
}
if !sensor.HeatDisable {
hpin := rpio.Pin(sensor.HeatGPIO)
hpin.Output()
PinSwitch(hpin, false, sensor.HeatInvert)
}
}
// TurnOffSensors turns off all sensors defined in the config
@ -218,6 +226,10 @@ func RunThermostat(path string, sc chan<- State, wg *sync.WaitGroup) {
default:
break
}
if config.Influx.Addr != "" {
go WriteStateToInflux(states[v.ID], config.Influx)
}
}
}

94
web.go
View file

@ -14,14 +14,15 @@ import (
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/gobuffalo/packr"
"github.com/jinzhu/copier"
)
// PingHandler responds to get requests with the message "pong".
// PingHandler responds to GET requests with the message "pong".
func PingHandler(c *gin.Context) {
c.String(http.StatusOK, "pong")
}
// ConfigHandler responds to get requests with the current configuration.
// ConfigHandler responds to GET requests with the current configuration.
func ConfigHandler(config *Config) gin.HandlerFunc {
fn := func(c *gin.Context) {
if c.Param("alias") != "/" && c.Param("alias") != "" {
@ -34,16 +35,38 @@ func ConfigHandler(config *Config) gin.HandlerFunc {
}
}
if !found {
c.String(http.StatusNotFound, "Not found")
c.JSON(http.StatusNotFound, gin.H{"error": "Not Found"})
}
} else if c.Param("alias") == "/" {
c.JSON(http.StatusOK, config.Sensors)
} else {
c.JSON(http.StatusOK, *config)
config.Users = nil // Never return the users in GET requests
c.JSON(http.StatusOK, config)
}
}
return gin.HandlerFunc(fn)
}
// StatusHandler responds to get requests with the current status of a sensor
// UpdateSensorsHandler responds to POST requests by updating the stored configuration and issuing a reload to the app
func UpdateSensorsHandler(c *gin.Context) {
var sensors []Sensor
if err := c.ShouldBindJSON(&sensors); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
for _, s := range sensors {
if err := UpdateSensorConfig(s); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
return
}
}
c.JSON(http.StatusOK, gin.H{"status": "updated"})
}
// StatusHandler responds to GET requests with the current status of a sensor
func StatusHandler(states *map[string]State) gin.HandlerFunc {
fn := func(c *gin.Context) {
if c.Param("alias") == "/" || c.Param("alias") == "" {
@ -63,7 +86,7 @@ func GetBox() packr.Box {
return packr.NewBox("./html")
}
// JSConfigHandler responds to get requests with the current configuration for the JS app
// JSConfigHandler responds to GET requests with the current configuration for the JS app
func JSConfigHandler(config *Config) gin.HandlerFunc {
fn := func(c *gin.Context) {
jsconfig := "var jsconfig={baseurl:\"" + config.BaseURL + "\",fahrenheit:" + strconv.FormatBool(config.DisplayFahrenheit) + "};"
@ -81,6 +104,15 @@ func VersionHandler(c *gin.Context) {
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.
func SetupRouter(config *Config, states *map[string]State) *gin.Engine {
// If not specified, put gin in release mode
@ -102,29 +134,53 @@ func SetupRouter(config *Config, states *map[string]State) *gin.Engine {
// Ping
r.GET("/ping", PingHandler)
// Status
r.GET("/api/status", StatusHandler(states))
r.GET("/api/status/*alias", StatusHandler(states))
// API Endpoints
var api *gin.RouterGroup
if len(config.Users) == 0 {
api = r.Group("/api")
} else {
api = r.Group("/api")
api.Use(BasicAuth(GetGinAccounts(config)))
}
// API Version
r.GET("/api/version", VersionHandler)
// Config
r.GET("/api/config", ConfigHandler(config))
r.GET("/api/config/*alias", ConfigHandler(config))
api.GET("/status", StatusHandler(states))
api.GET("/status/*alias", StatusHandler(states))
api.GET("/version", VersionHandler)
api.GET("/config", ConfigHandler(config))
api.GET("/config/sensors/*alias", ConfigHandler(config))
api.POST("/config/sensors", UpdateSensorsHandler)
// App
r.GET("/jsconfig.js", JSConfigHandler(config))
r.StaticFS("/app", GetBox())
// Redirect / to /app
r.Any("/", func(c *gin.Context) {
c.Redirect(301, config.BaseURL+"/app/")
})
r.Any("/", AppHandler(config))
return r
}
// reloadWebConfig reloads the current copy of configuration
func reloadWebConfig(c *Config, p string) error {
nc, err := LoadConfig(p)
if err != nil {
return err
}
copier.Copy(&c, &nc)
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.
func RunWeb(configpath string, sc <-chan State, wg *sync.WaitGroup) {
// Update sensor states when a new state comes back from the thermostat.
@ -145,7 +201,7 @@ func RunWeb(configpath string, sc <-chan State, wg *sync.WaitGroup) {
go func() {
for {
<-hup
config, err = LoadConfig(configpath)
err = reloadWebConfig(config, configpath)
if err != nil {
log.Panicln(err)
}

290
web_test.go Normal file
View 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)
}