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
# Config shards
credentials.json
dynamic.json
response.json
config-credentials.json
config-dynamic.json
config-response.json

View file

@ -3,7 +3,7 @@
`spaceapid` serves a [SpaceAPI](https://spaceapi.io)-compatible JSON on port 8080:
```shell
$ curl -X GET http://[::1]:8080 | jq
$ curl http://[::1]:8080 | jq
{
"api_compatibility": [
"14"
@ -26,7 +26,7 @@ The config consists of three parts:
- `"response"`
- The static (pre-filled) parts of the response
See [Running](#running) for details.
See [Running](#Running) for details.
## 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"`.
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
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
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
env CONFIG_PATH=config-template.json go run .
# 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"
]
}
],
"beverage_supply": [
{
"sensor_data": {
"unit": "btl"
},
"allowed_credentials": [
"club-assistant"
]
}
]
},
"state": {
@ -66,8 +76,8 @@
"url": "https://hamburg.ccc.de/",
"location": {
"address": "Zeiseweg 9, 22765 Hamburg, Germany",
"lon": 9.9445899999999998,
"lat": 53.55836
"lon": 9.94444,
"lat": 53.5584
},
"contact": {
"phone": "+49 40 23830150",
@ -79,7 +89,7 @@
},
"feeds": {
"blog": {
"type": "application/atom+xml",
"type": "application/rss+xml",
"url": "https://hamburg.ccc.de/feed.xml"
},
"calendar": {

View file

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

View file

@ -1,7 +1,7 @@
package config
import (
"gitlab.hamburg.ccc.de/ccchh/spaceapid/types"
"git.hamburg.ccc.de/ccchh/spaceapid/types"
)
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

View file

@ -5,7 +5,7 @@ import (
"log"
"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) {

View file

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

View file

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

View file

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

29
main.go
View file

@ -2,18 +2,17 @@ package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/config"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/handlers"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/types"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/util"
"git.hamburg.ccc.de/ccchh/spaceapid/config"
"git.hamburg.ccc.de/ccchh/spaceapid/handlers"
"git.hamburg.ccc.de/ccchh/spaceapid/persistence"
"git.hamburg.ccc.de/ccchh/spaceapid/types"
"git.hamburg.ccc.de/ccchh/spaceapid/util"
)
var (
@ -39,7 +38,7 @@ func main() {
conf := config.ParseConfiguration()
// Merge old state if present
util.MergeOldState(&conf.Response)
persistence.MergeOldState(&conf.Response)
// Register signal handler
sc := make(chan os.Signal, 1)
@ -47,7 +46,7 @@ func main() {
go func(resp *types.SpaceAPIResponseV14) {
<-sc
log.Println("Saving state and shutting down...")
util.SaveCurrentState(*resp)
persistence.SaveCurrentState(*resp)
os.Exit(0)
}(&conf.Response)
@ -59,15 +58,15 @@ func main() {
handlers.StateOpen(conf.Credentials, conf.Dynamic.State.Open.AllowedCredentials, &conf.Response.State),
)
// Register handlers for Environmental Sensors
for key, envSensorConfigs := range conf.Dynamic.Sensors {
for sensorType, envSensorConfigs := range conf.Dynamic.Sensors {
for i, envSensorConfig := range envSensorConfigs {
pattern := fmt.Sprintf("/sensors/%s/%s", key, envSensorConfig.SensorData.Location)
if envSensorConfig.SensorData.Name != "" {
pattern += "/" + envSensorConfig.SensorData.Name
}
http.HandleFunc(strings.ToLower(pattern),
urlPath := util.GetSensorURLPath(
sensorType, envSensorConfig.SensorData.Location, envSensorConfig.SensorData.Name,
)
http.HandleFunc(
urlPath,
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 {
Value float64 `json:"value"`
Unit string `json:"unit"`
Location string `json:"location"`
Location string `json:"location,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
LastChange int64 `json:"lastchange,omitempty"`
@ -61,7 +61,7 @@ type PersistentStateV14 struct {
type PersistentEnvironmentSensor struct {
Value float64 `json:"value"`
Location string `json:"location"`
Location string `json:"location,omitempty"`
Name string `json:"name,omitempty"`
LastChange int64 `json:"lastchange"`
}

View file

@ -1,7 +1,7 @@
package util
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

View file

@ -1,119 +1,20 @@
package util
import (
"encoding/json"
"errors"
"log"
"os"
"path"
"gitlab.hamburg.ccc.de/ccchh/spaceapid/types"
"fmt"
"strings"
)
const (
savedStateJSONPath = "/var/lib/spaceapid/spaceapid-state.json"
)
// 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", 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
// GetSensorURLPath generates the URL path for the given sensor details.
// 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 {
path := fmt.Sprintf("/sensors/%s", sensorType)
if location != "" {
path += "/" + location
}
goto removeOld
}
// Read file and load persisted state
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)
if name != "" {
path += "/" + name
}
return strings.ToLower(path)
}