Compare commits

...

10 commits

17 changed files with 197 additions and 150 deletions

6
.gitignore vendored
View file

@ -11,6 +11,6 @@ spaceapid
spaceapid-state.json spaceapid-state.json
# Config shards # Config shards
credentials.json config-credentials.json
dynamic.json config-dynamic.json
response.json config-response.json

View file

@ -3,7 +3,7 @@
`spaceapid` serves a [SpaceAPI](https://spaceapi.io)-compatible JSON on port 8080: `spaceapid` serves a [SpaceAPI](https://spaceapi.io)-compatible JSON on port 8080:
```shell ```shell
$ curl -X GET http://[::1]:8080 | jq $ curl http://[::1]:8080 | jq
{ {
"api_compatibility": [ "api_compatibility": [
"14" "14"
@ -26,7 +26,7 @@ The config consists of three parts:
- `"response"` - `"response"`
- The static (pre-filled) parts of the response - The static (pre-filled) parts of the response
See [Running](#running) for details. See [Running](#Running) for details.
## Updating values ## Updating values
@ -37,12 +37,18 @@ curl -X PUT -u user:password -d true http://[::1]:8080/state/open
``` ```
The same is true for the endpoints for sensors configured under `"dynamic"`. The same is true for the endpoints for sensors configured under `"dynamic"`.
Currently only `temperature` and `humidity` are implemented. Currently only the sensors with the `value/unit/location/name/description` schema are implemented.
At the time of writing this includes `temperature`, `barometer`, `humidity`, `beverage_supply`, `power_consumption`,
and `account_balance`.
Out-of-spec sensors may also be used as long as they share the same schema.
```shell ```shell
curl -X PUT -u user:password -d 23.42 http://[::1]:8080/sensors/{temperature,humidity}/location[/name] curl -X PUT -u user:password -d 23.42 http://[::1]:8080/sensors/{temperature,humidity,...}[/location[/name]]
``` ```
As can be seen in the example, the http urls are generated from sensor type and optionally `location` and `name`.
Depending on sensor type `location` might be required for your sensors, see the schema for details.
## Building ## Building
See the `go.mod` file for minimum required Go version. See the `go.mod` file for minimum required Go version.
@ -55,5 +61,5 @@ Set the environment variable to a comma-separated list of config files or pass t
```shell ```shell
env CONFIG_PATH=config-template.json go run . env CONFIG_PATH=config-template.json go run .
# OR # OR
go run . -c credentials.json,dynamic.json,response.json go run . -c config-credentials.json,config-dynamic.json,config-response.json
``` ```

View file

@ -46,6 +46,16 @@
"club-assistant" "club-assistant"
] ]
} }
],
"beverage_supply": [
{
"sensor_data": {
"unit": "btl"
},
"allowed_credentials": [
"club-assistant"
]
}
] ]
}, },
"state": { "state": {
@ -66,8 +76,8 @@
"url": "https://hamburg.ccc.de/", "url": "https://hamburg.ccc.de/",
"location": { "location": {
"address": "Zeiseweg 9, 22765 Hamburg, Germany", "address": "Zeiseweg 9, 22765 Hamburg, Germany",
"lon": 9.9445899999999998, "lon": 9.94444,
"lat": 53.55836 "lat": 53.5584
}, },
"contact": { "contact": {
"phone": "+49 40 23830150", "phone": "+49 40 23830150",
@ -79,7 +89,7 @@
}, },
"feeds": { "feeds": {
"blog": { "blog": {
"type": "application/atom+xml", "type": "application/rss+xml",
"url": "https://hamburg.ccc.de/feed.xml" "url": "https://hamburg.ccc.de/feed.xml"
}, },
"calendar": { "calendar": {

View file

@ -10,7 +10,7 @@ import (
"slices" "slices"
"strings" "strings"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/types" "git.hamburg.ccc.de/ccchh/spaceapid/types"
) )
const ( const (

View file

@ -1,7 +1,7 @@
package config package config
import ( import (
"gitlab.hamburg.ccc.de/ccchh/spaceapid/types" "git.hamburg.ccc.de/ccchh/spaceapid/types"
) )
type HTTPBACredentialID string type HTTPBACredentialID string

2
go.mod
View file

@ -1,3 +1,3 @@
module gitlab.hamburg.ccc.de/ccchh/spaceapid module git.hamburg.ccc.de/ccchh/spaceapid
go 1.21 go 1.21

View file

@ -5,7 +5,7 @@ import (
"log" "log"
"net/http" "net/http"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/types" "git.hamburg.ccc.de/ccchh/spaceapid/types"
) )
func Root(resp *types.SpaceAPIResponseV14) func(http.ResponseWriter, *http.Request) { func Root(resp *types.SpaceAPIResponseV14) func(http.ResponseWriter, *http.Request) {

View file

@ -7,8 +7,8 @@ import (
"strconv" "strconv"
"time" "time"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/config" "git.hamburg.ccc.de/ccchh/spaceapid/config"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/types" "git.hamburg.ccc.de/ccchh/spaceapid/types"
) )
func EnvironmentSensor( func EnvironmentSensor(

View file

@ -6,8 +6,8 @@ import (
"strconv" "strconv"
"time" "time"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/config" "git.hamburg.ccc.de/ccchh/spaceapid/config"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/types" "git.hamburg.ccc.de/ccchh/spaceapid/types"
) )
func StateOpen( func StateOpen(

View file

@ -6,8 +6,8 @@ import (
"io" "io"
"net/http" "net/http"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/config" "git.hamburg.ccc.de/ccchh/spaceapid/config"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/util" "git.hamburg.ccc.de/ccchh/spaceapid/util"
) )
// updateEndpointValidator checks BasicAuth credentials, // updateEndpointValidator checks BasicAuth credentials,

29
main.go
View file

@ -2,18 +2,17 @@ package main
import ( import (
"flag" "flag"
"fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/config" "git.hamburg.ccc.de/ccchh/spaceapid/config"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/handlers" "git.hamburg.ccc.de/ccchh/spaceapid/handlers"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/types" "git.hamburg.ccc.de/ccchh/spaceapid/persistence"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/util" "git.hamburg.ccc.de/ccchh/spaceapid/types"
"git.hamburg.ccc.de/ccchh/spaceapid/util"
) )
var ( var (
@ -39,7 +38,7 @@ func main() {
conf := config.ParseConfiguration() conf := config.ParseConfiguration()
// Merge old state if present // Merge old state if present
util.MergeOldState(&conf.Response) persistence.MergeOldState(&conf.Response)
// Register signal handler // Register signal handler
sc := make(chan os.Signal, 1) sc := make(chan os.Signal, 1)
@ -47,7 +46,7 @@ func main() {
go func(resp *types.SpaceAPIResponseV14) { go func(resp *types.SpaceAPIResponseV14) {
<-sc <-sc
log.Println("Saving state and shutting down...") log.Println("Saving state and shutting down...")
util.SaveCurrentState(*resp) persistence.SaveCurrentState(*resp)
os.Exit(0) os.Exit(0)
}(&conf.Response) }(&conf.Response)
@ -59,15 +58,15 @@ func main() {
handlers.StateOpen(conf.Credentials, conf.Dynamic.State.Open.AllowedCredentials, &conf.Response.State), handlers.StateOpen(conf.Credentials, conf.Dynamic.State.Open.AllowedCredentials, &conf.Response.State),
) )
// Register handlers for Environmental Sensors // Register handlers for Environmental Sensors
for key, envSensorConfigs := range conf.Dynamic.Sensors { for sensorType, envSensorConfigs := range conf.Dynamic.Sensors {
for i, envSensorConfig := range envSensorConfigs { for i, envSensorConfig := range envSensorConfigs {
pattern := fmt.Sprintf("/sensors/%s/%s", key, envSensorConfig.SensorData.Location) urlPath := util.GetSensorURLPath(
if envSensorConfig.SensorData.Name != "" { sensorType, envSensorConfig.SensorData.Location, envSensorConfig.SensorData.Name,
pattern += "/" + envSensorConfig.SensorData.Name )
} http.HandleFunc(
http.HandleFunc(strings.ToLower(pattern), urlPath,
handlers.EnvironmentSensor( handlers.EnvironmentSensor(
conf.Credentials, envSensorConfig.AllowedCredentials, &conf.Response.Sensors[key][i], conf.Credentials, envSensorConfig.AllowedCredentials, &conf.Response.Sensors[sensorType][i],
), ),
) )
} }

View file

@ -0,0 +1,119 @@
package persistence
import (
"encoding/json"
"errors"
"log"
"os"
"git.hamburg.ccc.de/ccchh/spaceapid/types"
)
const (
persistentStateFile = "spaceapid-state.json"
persistentStatePath = persistentStateDir + persistentStateFile
)
// MergeOldState merges a given SpaceAPIResponse with the state saved at the time of last program exit and then deletes
// the file containing the old state.
func MergeOldState(response *types.SpaceAPIResponseV14) {
var (
err error
oldState []byte
persistedState types.PersistentStateV14
)
log.Println("Merging old state from", persistentStatePath, "...")
// Check if state.json is present
_, err = os.Stat(persistentStatePath)
if err != nil {
log.Println("Old state json not accessible at", persistentStatePath, ", skipping merge... error:", err)
if errors.Is(err, os.ErrNotExist) {
return
}
goto removeOld
}
// Read file and load persisted state
oldState, err = os.ReadFile(persistentStatePath)
if err != nil {
log.Println("Error reading old state from", persistentStatePath, ", skipping merge... error:", err)
goto removeOld
}
err = json.Unmarshal(oldState, &persistedState)
if err != nil {
log.Println(persistentStatePath, "doesn't seem to contain valid data... error:", err)
goto removeOld
}
// Merge state
response.State = persistedState.State
// Merge sensors
for key, environmentSensors := range persistedState.Sensors {
for _, sensor := range environmentSensors {
// Order or amount of sensors might have changed, so check sensors already present in response
// and then look for matching values in persisted state
for i := range response.Sensors[key] {
if rs := &response.Sensors[key][i]; rs.Location == sensor.Location && rs.Name == sensor.Name {
rs.Value = sensor.Value
rs.LastChange = sensor.LastChange
}
}
}
}
// Delete old state json
removeOld:
err = os.RemoveAll(persistentStatePath)
if err != nil {
log.Println("Failed to remove", persistentStatePath, ", continuing... error:", err)
}
}
func SaveCurrentState(response types.SpaceAPIResponseV14) {
// Create state directory if not present
err := os.MkdirAll(persistentStateDir, 0750)
if err != nil {
log.Fatalln("Failed creating", persistentStateDir, ", aborting... error:", err)
}
// Open persistent state file
file, err := os.OpenFile(persistentStatePath, os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
log.Fatalln("Failed opening", persistentStatePath, "while trying to save current state... error:", err)
}
defer file.Close()
// Create persistent state
persistentStateV14 := types.PersistentStateV14{
State: struct {
Open bool `json:"open"`
LastChange int64 `json:"lastchange"`
}{Open: response.State.Open, LastChange: response.State.LastChange},
}
// Save sensor state
persistentStateV14.Sensors = make(map[string][]types.PersistentEnvironmentSensor)
for key, environmentSensors := range response.Sensors {
persistentStateV14.Sensors[key] = make([]types.PersistentEnvironmentSensor, len(environmentSensors))
for i, sensor := range environmentSensors {
persistentStateV14.Sensors[key][i].Value = sensor.Value
persistentStateV14.Sensors[key][i].Location = sensor.Location
persistentStateV14.Sensors[key][i].Name = sensor.Name
persistentStateV14.Sensors[key][i].LastChange = sensor.LastChange
}
}
// Serialize persistent state
marshal, err := json.MarshalIndent(persistentStateV14, "", "\t")
if err != nil {
log.Fatalln("Failed serializing persistent state... error:", err)
}
// Write to file
_, err = file.Write(marshal)
if err != nil {
log.Fatalln("Failed writing persistent state to file", file.Name(), "... error:", err)
}
}

View file

@ -0,0 +1,7 @@
//go:build !linux
package persistence
const (
persistentStateDir = "./"
)

View file

@ -0,0 +1,5 @@
package persistence
const (
persistentStateDir = "/var/lib/spaceapid/"
)

View file

@ -45,7 +45,7 @@ type SpaceState struct {
type EnvironmentSensor struct { type EnvironmentSensor struct {
Value float64 `json:"value"` Value float64 `json:"value"`
Unit string `json:"unit"` Unit string `json:"unit"`
Location string `json:"location"` Location string `json:"location,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
LastChange int64 `json:"lastchange,omitempty"` LastChange int64 `json:"lastchange,omitempty"`
@ -61,7 +61,7 @@ type PersistentStateV14 struct {
type PersistentEnvironmentSensor struct { type PersistentEnvironmentSensor struct {
Value float64 `json:"value"` Value float64 `json:"value"`
Location string `json:"location"` Location string `json:"location,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
LastChange int64 `json:"lastchange"` LastChange int64 `json:"lastchange"`
} }

View file

@ -1,7 +1,7 @@
package util package util
import ( import (
"gitlab.hamburg.ccc.de/ccchh/spaceapid/config" "git.hamburg.ccc.de/ccchh/spaceapid/config"
) )
// CheckCredentials validates whether a given username/password pair matches an // CheckCredentials validates whether a given username/password pair matches an

View file

@ -1,119 +1,20 @@
package util package util
import ( import (
"encoding/json" "fmt"
"errors" "strings"
"log"
"os"
"path"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/types"
) )
const ( // GetSensorURLPath generates the URL path for the given sensor details.
savedStateJSONPath = "/var/lib/spaceapid/spaceapid-state.json" // location and name may be optional depending on sensorType, see the schema definition for details.
) // The path is always all-lowercase.
func GetSensorURLPath(sensorType, location, name string) string {
// MergeOldState merges a given SpaceAPIResponse with the state saved at the time of last program exit and then deletes path := fmt.Sprintf("/sensors/%s", sensorType)
// the file containing the old state. if location != "" {
func MergeOldState(response *types.SpaceAPIResponseV14) { path += "/" + location
var (
err error
oldState []byte
persistedState types.PersistentStateV14
)
log.Println("Merging old state from", savedStateJSONPath, "...")
// Check if state.json is present
_, err = os.Stat(savedStateJSONPath)
if err != nil {
log.Println("Old state json not accessible at", savedStateJSONPath, ", skipping merge... error:", err)
if errors.Is(err, os.ErrNotExist) {
return
}
goto removeOld
} }
if name != "" {
// Read file and load persisted state path += "/" + name
oldState, err = os.ReadFile(savedStateJSONPath)
if err != nil {
log.Println("Error reading old state from", savedStateJSONPath, ", skipping merge... error:", err)
goto removeOld
}
err = json.Unmarshal(oldState, &persistedState)
if err != nil {
log.Println(savedStateJSONPath, "doesn't seem to contain valid data... error:", err)
goto removeOld
}
// Merge state
response.State = persistedState.State
// Merge sensors
for key, environmentSensors := range persistedState.Sensors {
for _, sensor := range environmentSensors {
// Order or amount of sensors might have changed, so check sensors already present in response
// and then look for matching values in persisted state
for i := range response.Sensors[key] {
if rs := &response.Sensors[key][i]; rs.Location == sensor.Location && rs.Name == sensor.Name {
rs.Value = sensor.Value
rs.LastChange = sensor.LastChange
}
}
}
}
// Delete old state json
removeOld:
err = os.RemoveAll(savedStateJSONPath)
if err != nil {
log.Println("Failed to remove", savedStateJSONPath, ", continuing... error:", err)
}
}
func SaveCurrentState(response types.SpaceAPIResponseV14) {
// Create state directory if not present
err := os.MkdirAll(path.Dir(savedStateJSONPath), 0750)
if err != nil {
log.Fatalln("Failed creating", savedStateJSONPath, ", aborting... error:", err)
}
// Open persistent state file
file, err := os.OpenFile(savedStateJSONPath, os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
log.Fatalln("Failed opening", savedStateJSONPath, "while trying to save current state... error:", err)
}
defer file.Close()
// Create persistent state
persistentStateV14 := types.PersistentStateV14{
State: struct {
Open bool `json:"open"`
LastChange int64 `json:"lastchange"`
}{Open: response.State.Open, LastChange: response.State.LastChange},
}
// Save sensor state
persistentStateV14.Sensors = make(map[string][]types.PersistentEnvironmentSensor)
for key, environmentSensors := range response.Sensors {
persistentStateV14.Sensors[key] = make([]types.PersistentEnvironmentSensor, len(environmentSensors))
for i, sensor := range environmentSensors {
persistentStateV14.Sensors[key][i].Value = sensor.Value
persistentStateV14.Sensors[key][i].Location = sensor.Location
persistentStateV14.Sensors[key][i].Name = sensor.Name
persistentStateV14.Sensors[key][i].LastChange = sensor.LastChange
}
}
// Serialize persistent state
marshal, err := json.MarshalIndent(persistentStateV14, "", "\t")
if err != nil {
log.Fatalln("Failed serializing persistent state... error:", err)
}
// Write to file
_, err = file.Write(marshal)
if err != nil {
log.Fatalln("Failed writing persistent state to file", file.Name(), "... error:", err)
} }
return strings.ToLower(path)
} }