Compare commits

...

5 commits

Author SHA1 Message Date
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
12 changed files with 333 additions and 201 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
{
"api_compatibility": [
"14"
"14",
"15"
],
"space": "CCCHH",
...
@ -15,16 +16,16 @@ $ curl http://localhost:8080 | jq
## 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).
The config consists of three parts:
- `"credentials"`
- List of Username/Password credentials for HTTP BasicAuth
- `"dynamic"`
- The configuration for the dynamic parts of the message
- `"response"`
- The static (pre-filled) parts of the response
- The dynamic parts of the message
- `"static"`
- The static (pre-filled) parts of the message
See [Running](#Running) for details.

View file

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

View file

@ -86,9 +86,9 @@ func ParseConfiguration() (conf SpaceapidConfig) {
}
}
// Check if compatible with v14
if !slices.Contains(conf.Response.APICompatibility, "14") {
log.Fatalln("Provided file doesn't specify compatibility with API version 14")
// Sanity check: Config should declare compatibility with current SpaceAPI message version
if !slices.Contains(conf.Response.APICompatibility, types.CurrentMessageVersion) {
log.Fatalln("Provided config doesn't specify compatibility with current API version", types.CurrentMessageVersion)
}
// Initialize fields for environment sensors

View file

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

View file

@ -8,7 +8,7 @@ import (
"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) {
// Check if GET method
if r.Method != http.MethodGet {

View file

@ -11,7 +11,7 @@ import (
"git.hamburg.ccc.de/ccchh/spaceapid/types"
)
func EnvironmentSensor(
func EnvironmentSensorPUT(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.EnvironmentSensor,
) func(http.ResponseWriter, *http.Request) {
@ -35,3 +35,20 @@ func EnvironmentSensor(
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

@ -12,7 +12,7 @@ import (
func StateOpen(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.SpaceState,
resp *types.SpaceAPIState,
) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
body, err := updateEndpointValidator(authDB, validCredentials, w, r)
@ -37,7 +37,7 @@ func StateOpen(
func StateMessagePUT(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.SpaceState,
resp *types.SpaceAPIState,
) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
body, err := updateEndpointValidator(authDB, validCredentials, w, r)
@ -54,7 +54,7 @@ func StateMessagePUT(
func StateMessageDELETE(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.SpaceState,
resp *types.SpaceAPIState,
) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
_, err := updateEndpointValidator(authDB, validCredentials, w, r)

17
main.go
View file

@ -25,6 +25,9 @@ func init() {
}
func main() {
if version == "" {
version = "unknown"
}
log.Println("SpaceAPId version", version)
flag.Parse()
@ -43,7 +46,7 @@ func main() {
// Register signal handler
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM)
go func(resp *types.SpaceAPIResponseV14) {
go func(resp *types.SpaceAPIResponse) {
<-sc
log.Println("Saving state and shutting down...")
persistence.SaveCurrentState(*resp)
@ -68,12 +71,18 @@ func main() {
// Register handlers for environmental sensors
for sensorType, envSensorConfigs := range conf.Dynamic.Sensors {
for i, envSensorConfig := range envSensorConfigs {
urlPattern := "PUT " + util.GetSensorURLPath(
urlPattern := util.GetSensorURLPath(
sensorType, envSensorConfig.SensorData.Location, envSensorConfig.SensorData.Name,
)
http.HandleFunc(
urlPattern,
handlers.EnvironmentSensor(
"PUT "+urlPattern,
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],
),
)

View file

@ -16,11 +16,11 @@ const (
// 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) {
func MergeOldState(response *types.SpaceAPIResponse) {
var (
err error
oldState []byte
persistedState types.PersistentStateV14
persistedState types.PersistentSpaceState
)
log.Println("Merging old state from", persistentStatePath, "...")
@ -48,7 +48,7 @@ func MergeOldState(response *types.SpaceAPIResponseV14) {
}
// Merge state
response.State = persistedState.State
response.State.FromPersistentSpaceState(persistedState)
// Merge sensors
for key, environmentSensors := range persistedState.Sensors {
@ -72,7 +72,7 @@ removeOld:
}
}
func SaveCurrentState(response types.SpaceAPIResponseV14) {
func SaveCurrentState(response types.SpaceAPIResponse) {
// Create state directory if not present
err := os.MkdirAll(persistentStateDir, 0750)
if err != nil {
@ -87,7 +87,7 @@ func SaveCurrentState(response types.SpaceAPIResponseV14) {
defer file.Close()
// Create persistent state
persistentStateV14 := types.PersistentStateV14{
persistentStateV14 := types.PersistentSpaceState{
State: struct {
Open bool `json:"open"`
LastChange int64 `json:"lastchange"`

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"`
}

164
types/v15.go Normal file
View file

@ -0,0 +1,164 @@
package types
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 SpaceAPIState struct {
Open bool `json:"open,omitempty"`
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 PersistentSpaceState) {
s.Open = ps.State.Open
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 PersistentSpaceState 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"`
}