Compare commits

..
Author SHA1 Message Date
98d22b9f72 feat: Add flags to set listening address/port 2026-10-09 00:30:39 +02:00
11046cb501 feat: Allow setting state->open to unknown via DELETE
The schema defines the absence of state->open to represent the unknown
state. Previously it was not possible to explicitly set this state via
the HTTP endpoints.

types.SpaceAPIState.Open is now an enum which is either unknown, closed,
or open. Custom IsZero() and MarshalJSON() methods on the type ensure
marshaling the value yields a boolean for open/closed and an empty byte
slice for unknown (resulting in the field being absent from the final
response).

The handlers.StateOpenDELETE() function has been added to expose this
behavior.

Switching to encoding/json/v2 required raising the minimum Go version in
go.mod to 1.27.

Closes: CCCHH/spaceapid!37
Fixes:  CCCHH/spaceapid#38
2026-10-09 00:30:26 +02:00
a53a5c3517 feat: SpaceAPI v15 support
Delete types/v14.go, replace with v15.go.

Rename internal types to not include message version.
I don't currently see the need to support multiple message
versions that have incompatible layouts.
v15 is backwards compatible with v14, so the config example now
specifies both.

Rename the "response" field in the config file to "static" as
dynamic/static makes more sense from the user's perspective.
(Internally it's still SpaceapidConfig.{Dynamic,Response}
to align with type names and usage.)
2026-07-25 23:04:20 +02:00
de506fc8f3 feat: Log that version is unknown if version == "" 2026-07-25 23:04:20 +02:00
a163ba1deb style: Add .editorconfig 2026-07-25 23:04:20 +02:00
4540fc0c59 docs: Add missing backticks in README 2026-07-25 23:04:20 +02:00
b054b02be6 fix(handlers): Handle DELETE for environment sensors
It now works as advertised in the README! *ahem*...
2026-07-25 23:04:20 +02:00
b54d4a39cd fix(config): Mention what that list of paths in the log is 2026-07-25 22:49:58 +02:00
1d49948615 fix(config): Remove redundant space from log message 2026-07-25 21:16:49 +02:00
e714cca477 refactor(handlers): Use fmt.Errorf instead of errors.New(fmt.Sprintf()) 2026-07-25 21:16:40 +02:00
74fbef4ae8 refactor(handlers): Use http.Header.Set instead of direct map access 2026-07-25 21:16:06 +02:00
14 changed files with 442 additions and 234 deletions

9
.editorconfig Normal file
View file

@ -0,0 +1,9 @@
root = true
[*]
trim_trailing_whitespace = true
insert_final_newline = true
[*.json]
indent_style = tab
tab_width = 4

View file

@ -6,7 +6,8 @@
$ curl http://localhost:8080 | jq $ curl http://localhost:8080 | jq
{ {
"api_compatibility": [ "api_compatibility": [
"14" "14",
"15"
], ],
"space": "CCCHH", "space": "CCCHH",
... ...
@ -15,16 +16,16 @@ $ curl http://localhost:8080 | jq
## Configuring ## Configuring
spaceapid has to be configured via one or multiple json files. `spaceapid` has to be configured via one or multiple json files.
A sample configuration is provided as [config-template.json](./config-template.json). A sample configuration is provided as [config-template.json](./config-template.json).
The config consists of three parts: The config consists of three parts:
- `"credentials"` - `"credentials"`
- List of Username/Password credentials for HTTP BasicAuth - List of Username/Password credentials for HTTP BasicAuth
- `"dynamic"` - `"dynamic"`
- The configuration for the dynamic parts of the message - The dynamic parts of the message
- `"response"` - `"static"`
- The static (pre-filled) parts of the response - The static (pre-filled) parts of the message
See [Running](#Running) for details. See [Running](#Running) for details.

View file

@ -1,111 +1,112 @@
{ {
"credentials": { "credentials": {
"club-assistant": { "club-assistant": {
"username": "club-assistant", "username": "club-assistant",
"password": "clubmiau" "password": "clubmiau"
}, },
"dooris-hauptraum": { "dooris-hauptraum": {
"username": "dooris-hauptraum", "username": "dooris-hauptraum",
"password": "doorimiau" "password": "doorimiau"
} }
}, },
"dynamic": { "dynamic": {
"sensors": { "sensors": {
"temperature": [ "temperature": [
{ {
"sensor_data": { "sensor_data": {
"unit": "°C", "unit": "°C",
"location": "Hauptraum", "location": "Hauptraum",
"name": "Kueche", "name": "Kueche",
"description": "Sensor im Ofen" "description": "Sensor im Ofen"
}, },
"allowed_credentials": [ "allowed_credentials": [
"club-assistant" "club-assistant"
] ]
}, },
{ {
"sensor_data": { "sensor_data": {
"unit": "°C", "unit": "°C",
"location": "Hauptraum", "location": "Hauptraum",
"description": "Sensor im Hauptraum" "description": "Sensor im Hauptraum"
}, },
"allowed_credentials": [ "allowed_credentials": [
"club-assistant" "club-assistant"
] ]
} }
], ],
"humidity": [ "humidity": [
{ {
"sensor_data": { "sensor_data": {
"unit": "%", "unit": "%",
"location": "Hauptraum", "location": "Hauptraum",
"name": "Kueche", "name": "Kueche",
"description": "Sensor im Wasserhahn" "description": "Sensor im Wasserhahn"
}, },
"allowed_credentials": [ "allowed_credentials": [
"club-assistant" "club-assistant"
] ]
} }
], ],
"beverage_supply": [ "beverage_supply": [
{ {
"sensor_data": { "sensor_data": {
"unit": "btl" "unit": "btl"
}, },
"allowed_credentials": [ "allowed_credentials": [
"club-assistant" "club-assistant"
] ]
} }
] ]
}, },
"state": { "state": {
"open": { "open": {
"allowed_credentials": [ "allowed_credentials": [
"dooris-hauptraum" "dooris-hauptraum"
] ]
} }
} }
}, },
"response": { "static": {
"api_compatibility": [ "api_compatibility": [
"14" "14",
], "15"
"space": "CCCHH", ],
"logo": "https://hamburg.ccc.de/images/logo.svg", "space": "CCCHH",
"ext_ccc": "erfa", "logo": "https://hamburg.ccc.de/images/logo.svg",
"url": "https://hamburg.ccc.de/", "ext_ccc": "erfa",
"location": { "url": "https://hamburg.ccc.de/",
"address": "Zeiseweg 9, 22765 Hamburg, Germany", "location": {
"lon": 9.94444, "address": "Zeiseweg 9, 22765 Hamburg, Germany",
"lat": 53.5584 "lon": 9.94444,
}, "lat": 53.5584
"contact": { },
"phone": "+49 40 23830150", "contact": {
"irc": "ircs://irc.hackint.org:6697/#ccchh", "phone": "+49 40 23830150",
"mastodon": "@ccchh@chaos.social", "irc": "ircs://irc.hackint.org:6697/#ccchh",
"email": "mail@hamburg.ccc.de", "mastodon": "@ccchh@chaos.social",
"ml": "talk@hamburg.ccc.de", "email": "mail@hamburg.ccc.de",
"matrix": "#ccchh:hamburg.ccc.de" "ml": "talk@hamburg.ccc.de",
}, "matrix": "#ccchh:hamburg.ccc.de"
"feeds": { },
"blog": { "feeds": {
"type": "application/rss+xml", "blog": {
"url": "https://hamburg.ccc.de/feed.xml" "type": "application/rss+xml",
}, "url": "https://hamburg.ccc.de/feed.xml"
"calendar": { },
"type": "ical", "calendar": {
"url": "webcal://cloud.hamburg.ccc.de/remote.php/dav/public-calendars/QJAdExziSnNJEz5g/?export" "type": "ical",
} "url": "webcal://cloud.hamburg.ccc.de/remote.php/dav/public-calendars/QJAdExziSnNJEz5g/?export"
}, }
"links": [ },
{ "links": [
"name": "Wiki", {
"url": "https://wiki.hamburg.ccc.de" "name": "Wiki",
}, "url": "https://wiki.hamburg.ccc.de"
{ },
"name": "Git (Forgejo)", {
"url": "https://git.hamburg.ccc.de" "name": "Git (Forgejo)",
} "url": "https://git.hamburg.ccc.de"
] }
} ]
}
} }

View file

@ -2,7 +2,7 @@ package config
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json/v2"
"flag" "flag"
"fmt" "fmt"
"log" "log"
@ -59,7 +59,7 @@ func getConfigPaths() (paths pathsT) {
// If paths is still empty we are missing something // If paths is still empty we are missing something
if len(paths) == 0 { if len(paths) == 0 {
log.Fatalln("Error: Neither env: ", envConfigPath, "nor cli flag was specified, unable to start without config.") log.Fatalln("Error: Neither env:", envConfigPath, "nor cli flag was specified, unable to start without config.")
} }
return return
} }
@ -69,7 +69,7 @@ func ParseConfiguration() (conf SpaceapidConfig) {
log.Println("Parsing configuration files") log.Println("Parsing configuration files")
paths := getConfigPaths() paths := getConfigPaths()
log.Println(paths) log.Println("Config files:", paths)
for _, path := range paths { for _, path := range paths {
// Read file // Read file
file, err := os.ReadFile(path) file, err := os.ReadFile(path)
@ -78,17 +78,15 @@ func ParseConfiguration() (conf SpaceapidConfig) {
} }
// Parse JSON // Parse JSON
dec := json.NewDecoder(bytes.NewReader(file)) err = json.UnmarshalRead(bytes.NewReader(file), &conf, json.RejectUnknownMembers(true))
dec.DisallowUnknownFields()
err = dec.Decode(&conf)
if err != nil { if err != nil {
log.Fatalln("Could not parse spaceapid config file:", err) log.Fatalln("Could not parse spaceapid config file:", err)
} }
} }
// Check if compatible with v14 // Sanity check: Config should declare compatibility with current SpaceAPI message version
if !slices.Contains(conf.Response.APICompatibility, "14") { if !slices.Contains(conf.Response.APICompatibility, types.CurrentMessageVersion) {
log.Fatalln("Provided file doesn't specify compatibility with API version 14") log.Fatalln("Provided config doesn't specify compatibility with current API version", types.CurrentMessageVersion)
} }
// Initialize fields for environment sensors // Initialize fields for environment sensors

View file

@ -34,5 +34,5 @@ type SpaceapidConfig struct {
// The dynamic part of the spaceapi JSON // The dynamic part of the spaceapi JSON
Dynamic DynamicStateConfig `json:"dynamic"` Dynamic DynamicStateConfig `json:"dynamic"`
// The static part of the spaceapi JSON // The static part of the spaceapi JSON
Response types.SpaceAPIResponseV14 `json:"response"` Response types.SpaceAPIResponse `json:"static"`
} }

2
go.mod
View file

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

View file

@ -1,14 +1,14 @@
package handlers package handlers
import ( import (
"encoding/json" "encoding/json/v2"
"log" "log"
"net/http" "net/http"
"git.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.SpaceAPIResponse) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// Check if GET method // Check if GET method
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
@ -19,7 +19,7 @@ func Root(resp *types.SpaceAPIResponseV14) func(http.ResponseWriter, *http.Reque
} }
// Serialize response // Serialize response
response, err := json.Marshal(resp) response, err := json.Marshal(resp, json.Deterministic(true))
if err != nil { if err != nil {
log.Println("Failed to serialize JSON response:", err) log.Println("Failed to serialize JSON response:", err)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)

View file

@ -11,7 +11,7 @@ import (
"git.hamburg.ccc.de/ccchh/spaceapid/types" "git.hamburg.ccc.de/ccchh/spaceapid/types"
) )
func EnvironmentSensor( func EnvironmentSensorPUT(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID, authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.EnvironmentSensor, resp *types.EnvironmentSensor,
) func(http.ResponseWriter, *http.Request) { ) func(http.ResponseWriter, *http.Request) {
@ -35,3 +35,20 @@ func EnvironmentSensor(
resp.LastChange = time.Now().Unix() resp.LastChange = time.Now().Unix()
} }
} }
func EnvironmentSensorDELETE(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.EnvironmentSensor,
) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
_, err := updateEndpointValidator(authDB, validCredentials, w, r)
if err != nil {
log.Println(err)
return
}
// Set SpaceAPI response values
resp.Value = 0
resp.LastChange = time.Now().Unix()
}
}

View file

@ -10,9 +10,9 @@ import (
"git.hamburg.ccc.de/ccchh/spaceapid/types" "git.hamburg.ccc.de/ccchh/spaceapid/types"
) )
func StateOpen( func StateOpenPUT(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID, authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.SpaceState, resp *types.SpaceAPIState,
) func(http.ResponseWriter, *http.Request) { ) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
body, err := updateEndpointValidator(authDB, validCredentials, w, r) body, err := updateEndpointValidator(authDB, validCredentials, w, r)
@ -22,7 +22,7 @@ func StateOpen(
} }
// Parse request body // Parse request body
newState, err := strconv.ParseBool(string(body)) doorNowOpen, err := strconv.ParseBool(string(body))
if err != nil { if err != nil {
log.Println("Failed to parse request body from", r.RemoteAddr, "with error:", err) log.Println("Failed to parse request body from", r.RemoteAddr, "with error:", err)
http.Error(w, "HTTP request body should either be true or false", http.StatusBadRequest) http.Error(w, "HTTP request body should either be true or false", http.StatusBadRequest)
@ -30,14 +30,35 @@ func StateOpen(
} }
// Set SpaceAPI response values // Set SpaceAPI response values
resp.Open = newState if doorNowOpen {
resp.Open = types.SpaceAPIStateOpenIsOpen
} else {
resp.Open = types.SpaceAPIStateOpenIsClosed
}
resp.LastChange = time.Now().Unix()
}
}
func StateOpenDELETE(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.SpaceAPIState,
) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
_, err := updateEndpointValidator(authDB, validCredentials, w, r)
if err != nil {
log.Println(err)
return
}
// Set SpaceAPI response values
resp.Open = types.SpaceAPIStateOpenIsUnknown
resp.LastChange = time.Now().Unix() resp.LastChange = time.Now().Unix()
} }
} }
func StateMessagePUT( func StateMessagePUT(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID, authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.SpaceState, resp *types.SpaceAPIState,
) func(http.ResponseWriter, *http.Request) { ) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
body, err := updateEndpointValidator(authDB, validCredentials, w, r) body, err := updateEndpointValidator(authDB, validCredentials, w, r)
@ -54,7 +75,7 @@ func StateMessagePUT(
func StateMessageDELETE( func StateMessageDELETE(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID, authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.SpaceState, resp *types.SpaceAPIState,
) func(http.ResponseWriter, *http.Request) { ) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
_, err := updateEndpointValidator(authDB, validCredentials, w, r) _, err := updateEndpointValidator(authDB, validCredentials, w, r)

View file

@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -18,16 +17,16 @@ func updateEndpointValidator(
// Check BasicAuth credentials // Check BasicAuth credentials
username, password, ok := r.BasicAuth() username, password, ok := r.BasicAuth()
if !ok || !util.CheckCredentials(authDB, validCredentials, username, password) { if !ok || !util.CheckCredentials(authDB, validCredentials, username, password) {
w.Header()["WWW-Authenticate"] = []string{"Basic realm=\"spaceapid\""} w.Header().Set("WWW-Authenticate", "Basic realm=\"spaceapid\"")
http.Error(w, "", http.StatusUnauthorized) http.Error(w, "", http.StatusUnauthorized)
return []byte{}, errors.New(fmt.Sprintf("Unauthorized request from %s Username: %s Password: %s", r.RemoteAddr, username, password)) return []byte{}, fmt.Errorf("Unauthorized request from %s Username: %s Password: %s", r.RemoteAddr, username, password)
} }
// Read request body // Read request body
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
http.Error(w, "", http.StatusInternalServerError) http.Error(w, "", http.StatusInternalServerError)
return []byte{}, errors.New(fmt.Sprintf("Failed to read request body from %s with error: %s", r.RemoteAddr, err)) return []byte{}, fmt.Errorf("Failed to read request body from %s with error: %s", r.RemoteAddr, err)
} }
return body, nil return body, nil

33
main.go
View file

@ -2,6 +2,7 @@ package main
import ( import (
"flag" "flag"
"fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
@ -16,15 +17,22 @@ import (
) )
var ( var (
flagHelp bool flagHelp bool
version string listenAddress string
listenPort string
version string
) )
func init() { func init() {
flag.StringVar(&listenAddress, "i", "", "IP address the HTTP server should bind to (all available addresses by default)")
flag.StringVar(&listenPort, "p", "8080", "Port the HTTP server should listen on")
flag.BoolVar(&flagHelp, "h", false, "Print help output") flag.BoolVar(&flagHelp, "h", false, "Print help output")
} }
func main() { func main() {
if version == "" {
version = "unknown"
}
log.Println("SpaceAPId version", version) log.Println("SpaceAPId version", version)
flag.Parse() flag.Parse()
@ -43,7 +51,7 @@ func main() {
// Register signal handler // Register signal handler
sc := make(chan os.Signal, 1) sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM)
go func(resp *types.SpaceAPIResponseV14) { go func(resp *types.SpaceAPIResponse) {
<-sc <-sc
log.Println("Saving state and shutting down...") log.Println("Saving state and shutting down...")
persistence.SaveCurrentState(*resp) persistence.SaveCurrentState(*resp)
@ -56,7 +64,10 @@ func main() {
) )
// state->open // state->open
http.HandleFunc("PUT /state/open", http.HandleFunc("PUT /state/open",
handlers.StateOpen(conf.Credentials, conf.Dynamic.State.Open.AllowedCredentials, &conf.Response.State), handlers.StateOpenPUT(conf.Credentials, conf.Dynamic.State.Open.AllowedCredentials, &conf.Response.State),
)
http.HandleFunc("DELETE /state/open",
handlers.StateOpenDELETE(conf.Credentials, conf.Dynamic.State.Open.AllowedCredentials, &conf.Response.State),
) )
// state->message // state->message
http.HandleFunc("PUT /state/message", http.HandleFunc("PUT /state/message",
@ -68,12 +79,18 @@ func main() {
// Register handlers for environmental sensors // Register handlers for environmental sensors
for sensorType, envSensorConfigs := range conf.Dynamic.Sensors { for sensorType, envSensorConfigs := range conf.Dynamic.Sensors {
for i, envSensorConfig := range envSensorConfigs { for i, envSensorConfig := range envSensorConfigs {
urlPattern := "PUT " + util.GetSensorURLPath( urlPattern := util.GetSensorURLPath(
sensorType, envSensorConfig.SensorData.Location, envSensorConfig.SensorData.Name, sensorType, envSensorConfig.SensorData.Location, envSensorConfig.SensorData.Name,
) )
http.HandleFunc( http.HandleFunc(
urlPattern, "PUT "+urlPattern,
handlers.EnvironmentSensor( handlers.EnvironmentSensorPUT(
conf.Credentials, envSensorConfig.AllowedCredentials, &conf.Response.Sensors[sensorType][i],
),
)
http.HandleFunc(
"DELETE "+urlPattern,
handlers.EnvironmentSensorDELETE(
conf.Credentials, envSensorConfig.AllowedCredentials, &conf.Response.Sensors[sensorType][i], conf.Credentials, envSensorConfig.AllowedCredentials, &conf.Response.Sensors[sensorType][i],
), ),
) )
@ -82,5 +99,5 @@ func main() {
// Start webserver // Start webserver
log.Println("Starting HTTP server...") log.Println("Starting HTTP server...")
log.Fatalln(http.ListenAndServe(":8080", nil)) log.Fatalln(http.ListenAndServe(fmt.Sprintf("%s:%s", listenAddress, listenPort), nil))
} }

View file

@ -1,7 +1,8 @@
package persistence package persistence
import ( import (
"encoding/json" "encoding/json/jsontext"
"encoding/json/v2"
"errors" "errors"
"log" "log"
"os" "os"
@ -16,11 +17,11 @@ const (
// MergeOldState merges a given SpaceAPIResponse with the state saved at the time of last program exit and then deletes // 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. // the file containing the old state.
func MergeOldState(response *types.SpaceAPIResponseV14) { func MergeOldState(response *types.SpaceAPIResponse) {
var ( var (
err error err error
oldState []byte oldState []byte
persistedState types.PersistentStateV14 persistedState types.PersistentState
) )
log.Println("Merging old state from", persistentStatePath, "...") log.Println("Merging old state from", persistentStatePath, "...")
@ -48,7 +49,7 @@ func MergeOldState(response *types.SpaceAPIResponseV14) {
} }
// Merge state // Merge state
response.State = persistedState.State response.State.FromPersistentSpaceState(persistedState)
// Merge sensors // Merge sensors
for key, environmentSensors := range persistedState.Sensors { for key, environmentSensors := range persistedState.Sensors {
@ -72,7 +73,7 @@ removeOld:
} }
} }
func SaveCurrentState(response types.SpaceAPIResponseV14) { func SaveCurrentState(response types.SpaceAPIResponse) {
// Create state directory if not present // Create state directory if not present
err := os.MkdirAll(persistentStateDir, 0750) err := os.MkdirAll(persistentStateDir, 0750)
if err != nil { if err != nil {
@ -87,27 +88,38 @@ func SaveCurrentState(response types.SpaceAPIResponseV14) {
defer file.Close() defer file.Close()
// Create persistent state // Create persistent state
persistentStateV14 := types.PersistentStateV14{ var (
State: struct { isOpen bool
Open bool `json:"open"` isUnknown bool
LastChange int64 `json:"lastchange"` )
Message string `json:"message,omitempty"` switch response.State.Open {
}{Open: response.State.Open, LastChange: response.State.LastChange, Message: response.State.Message}, case types.SpaceAPIStateOpenIsUnknown:
isUnknown = true
case types.SpaceAPIStateOpenIsClosed:
break
case types.SpaceAPIStateOpenIsOpen:
isOpen = true
}
persistentState := types.PersistentState{
State: types.PersistentSpaceState{
Open: isOpen, IsUnknown: isUnknown,
LastChange: response.State.LastChange, Message: response.State.Message,
},
Sensors: make(map[string][]types.PersistentEnvironmentSensor),
} }
// Save sensor state // Save sensor state
persistentStateV14.Sensors = make(map[string][]types.PersistentEnvironmentSensor)
for key, environmentSensors := range response.Sensors { for key, environmentSensors := range response.Sensors {
persistentStateV14.Sensors[key] = make([]types.PersistentEnvironmentSensor, len(environmentSensors)) persistentState.Sensors[key] = make([]types.PersistentEnvironmentSensor, len(environmentSensors))
for i, sensor := range environmentSensors { for i, sensor := range environmentSensors {
persistentStateV14.Sensors[key][i].Value = sensor.Value persistentState.Sensors[key][i].Value = sensor.Value
persistentStateV14.Sensors[key][i].Location = sensor.Location persistentState.Sensors[key][i].Location = sensor.Location
persistentStateV14.Sensors[key][i].Name = sensor.Name persistentState.Sensors[key][i].Name = sensor.Name
persistentStateV14.Sensors[key][i].LastChange = sensor.LastChange persistentState.Sensors[key][i].LastChange = sensor.LastChange
} }
} }
// Serialize persistent state // Serialize persistent state
marshal, err := json.MarshalIndent(persistentStateV14, "", "\t") marshal, err := json.Marshal(persistentState, json.JoinOptions(jsontext.Multiline(true), jsontext.SpaceAfterComma(true)))
if err != nil { if err != nil {
log.Fatalln("Failed serializing persistent state... error:", err) log.Fatalln("Failed serializing persistent state... error:", err)
} }

View file

@ -1,69 +0,0 @@
package types
type SpaceAPIResponseV14 struct {
APICompatibility []string `json:"api_compatibility"`
Space string `json:"space"`
Logo string `json:"logo"`
ExtCCC string `json:"ext_ccc"`
URL string `json:"url"`
Location struct {
Address string `json:"address"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
} `json:"location"`
State SpaceState `json:"state"`
Contact struct {
Phone string `json:"phone"`
IRC string `json:"irc"`
Mastodon string `json:"mastodon"`
Email string `json:"email"`
ML string `json:"ml"`
Matrix string `json:"matrix"`
} `json:"contact"`
Sensors map[string][]EnvironmentSensor `json:"sensors"`
Feeds struct {
Blog struct {
Type string `json:"type"`
URL string `json:"url"`
} `json:"blog"`
Calendar struct {
Type string `json:"type"`
URL string `json:"url"`
} `json:"calendar"`
} `json:"feeds"`
Links []struct {
Name string `json:"name"`
URL string `json:"url"`
} `json:"links"`
}
type SpaceState struct {
Open bool `json:"open"`
LastChange int64 `json:"lastchange"`
Message string `json:"message,omitempty"`
}
type EnvironmentSensor struct {
Value float64 `json:"value"`
Unit string `json:"unit"`
Location string `json:"location,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
LastChange int64 `json:"lastchange,omitempty"`
}
type PersistentStateV14 struct {
State struct {
Open bool `json:"open"`
LastChange int64 `json:"lastchange"`
Message string `json:"message,omitempty"`
} `json:"state"`
Sensors map[string][]PersistentEnvironmentSensor `json:"sensors,omitempty"`
}
type PersistentEnvironmentSensor struct {
Value float64 `json:"value"`
Location string `json:"location,omitempty"`
Name string `json:"name,omitempty"`
LastChange int64 `json:"lastchange"`
}

202
types/v15.go Normal file
View file

@ -0,0 +1,202 @@
package types
import (
"encoding/json/v2"
)
const CurrentMessageVersion = "15"
// SpaceAPIResponse represents the contents of the SpaceAPI message
//
// Currently implemented version: 15
type SpaceAPIResponse struct {
APICompatibility []string `json:"api_compatibility"` // REQUIRED
Space string `json:"space"` // REQUIRED
Logo string `json:"logo"` // REQUIRED
URL string `json:"url"` // REQUIRED
// Minimum number of properties: 1
Location struct {
Address string `json:"address,omitzero"`
Lat float64 `json:"lat,omitzero"`
Lon float64 `json:"lon,omitzero"`
Timezone string `json:"timezone,omitzero"`
CountryCode string `json:"country_code,omitzero"`
Hint string `json:"hint,omitzero"`
// Minimum number of items: 1
Areas []SpaceAPILocationAreas `json:"areas,omitempty"`
} `json:"location,omitzero"`
SpaceFed struct {
SpaceNet bool `json:"spacenet,omitzero"`
SpaceSAML bool `json:"spacesaml,omitzero"`
} `json:"spacefed,omitzero"`
Cam []string `json:"cam,omitempty"`
State SpaceAPIState `json:"state,omitzero"`
Events []SpaceAPIEvents `json:"events,omitempty"`
Contact struct {
Phone string `json:"phone,omitzero"`
Sip string `json:"sip,omitzero"`
// keymasters
Keymasters []struct {
Name string `json:"name,omitzero"`
IRCNick string `json:"irc_nick,omitzero"`
Phone string `json:"phone,omitzero"`
Email string `json:"email,omitzero"`
Twitter string `json:"twitter,omitzero"`
Xmpp string `json:"xmpp,omitzero"`
Mastodon string `json:"mastodon,omitzero"`
Matrix string `json:"matrix,omitzero"`
} `json:"keymasters,omitempty"`
IRC string `json:"irc,omitzero"`
Twitter string `json:"twitter,omitzero"`
Mastodon string `json:"mastodon,omitzero"`
Facebook string `json:"facebook,omitzero"`
Identica string `json:"identica,omitzero"`
Foursquare string `json:"foursquare,omitzero"`
Email string `json:"email,omitzero"`
ML string `json:"ml,omitzero"`
XMPP string `json:"xmpp,omitzero"`
IssueMail string `json:"issue_mail,omitzero"`
Gopher string `json:"gopher,omitzero"`
Matrix string `json:"matrix,omitzero"`
Mumble string `json:"mumble,omitzero"`
} `json:"contact"`
Sensors map[string][]EnvironmentSensor `json:"sensors,omitempty"`
Feeds struct {
Blog struct {
Type string `json:"type,omitzero"`
URL string `json:"url"` // REQUIRED
} `json:"blog,omitzero"`
Wiki struct {
Type string `json:"type,omitzero"`
URL string `json:"url"` // REQUIRED
} `json:"wiki,omitzero"`
Calendar struct {
Type string `json:"type,omitzero"`
URL string `json:"url"` // REQUIRED
} `json:"calendar,omitzero"`
Flickr struct {
Type string `json:"type,omitzero"`
URL string `json:"url"` // REQUIRED
} `json:"flickr,omitzero"`
} `json:"feeds,omitzero"`
Projects []string `json:"projects,omitempty"`
Links []struct {
Name string `json:"name"` // REQUIRED
URL string `json:"url"` // REQUIRED
Description string `json:"description,omitzero"`
} `json:"links,omitempty"`
MembershipPlans []struct {
Name string `json:"name"` // REQUIRED
Value int64 `json:"value"` // REQUIRED
Currency string `json:"currency"` // REQUIRED
BillingInterval string `json:"billing_interval"` // REQUIRED
Description string `json:"description,omitzero"`
} `json:"membership_plans,omitempty"`
LinkedSpaces []struct {
Endpoint string `json:"endpoint,omitzero"`
Website string `json:"website,omitzero"`
} `json:"linked_spaces,omitempty"`
ExtCCC string `json:"ext_ccc,omitzero"`
}
type SpaceAPILocationAreas struct {
Name string `json:"name,omitzero"`
Description string `json:"description,omitzero"`
SquareMeters float64 `json:"square_meters"` // REQUIRED
}
type SpaceAPIStateOpen int
const (
SpaceAPIStateOpenIsUnknown = iota
SpaceAPIStateOpenIsClosed
SpaceAPIStateOpenIsOpen
)
func (state SpaceAPIStateOpen) MarshalJSON() ([]byte, error) {
switch state {
case SpaceAPIStateOpenIsClosed:
return json.Marshal(false)
case SpaceAPIStateOpenIsOpen:
return json.Marshal(true)
default:
return []byte{}, nil
}
}
func (state SpaceAPIStateOpen) IsZero() bool {
return state == SpaceAPIStateOpenIsUnknown
}
type SpaceAPIState struct {
Open SpaceAPIStateOpen `json:"open,omitzero"`
LastChange int64 `json:"lastchange,omitempty"`
TriggerPerson string `json:"trigger_person,omitempty"`
Message string `json:"message,omitempty"`
Icon struct {
Open string `json:"open,omitempty"` // REQUIRED
Closed string `json:"closed,omitempty"` // REQUIRED
} `json:"icon,omitzero"`
}
func (s *SpaceAPIState) FromPersistentSpaceState(ps PersistentState) {
if ps.State.IsUnknown {
s.Open = SpaceAPIStateOpenIsUnknown
} else {
if ps.State.Open {
s.Open = SpaceAPIStateOpenIsOpen
} else {
s.Open = SpaceAPIStateOpenIsClosed
}
}
s.LastChange = ps.State.LastChange
s.Message = ps.State.Message
}
type SpaceAPIEvents struct {
Name string `json:"name"` // REQUIRED
Type string `json:"type"` // REQUIRED
Timestamp int64 `json:"timestamp"` // REQUIRED
Extra string `json:"extra,omitzero"`
}
type EnvironmentSensor struct {
Value float64 `json:"value"`
Unit string `json:"unit"`
Location string `json:"location,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
LastChange int64 `json:"lastchange,omitempty"`
}
type PersistentState struct {
State PersistentSpaceState `json:"state"`
Sensors map[string][]PersistentEnvironmentSensor `json:"sensors,omitempty"`
}
type PersistentSpaceState struct {
Open bool `json:"open"`
IsUnknown bool `json:"is_unknown"`
LastChange int64 `json:"lastchange"`
Message string `json:"message,omitempty"`
}
type PersistentEnvironmentSensor struct {
Value float64 `json:"value"`
Location string `json:"location,omitempty"`
Name string `json:"name,omitempty"`
LastChange int64 `json:"lastchange"`
}