From fd3cae186603c94f6fa8b01a5473f2bb288e1887 Mon Sep 17 00:00:00 2001 From: Bennett Wetters Date: Sat, 25 Jul 2026 22:35:17 +0200 Subject: [PATCH] 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.) --- README.md | 9 +- config-template.json | 219 +++++++++++++++++---------------- config/config.go | 6 +- config/types.go | 2 +- handlers/root.go | 2 +- handlers/state.go | 6 +- main.go | 2 +- persistence/persistentState.go | 10 +- types/v14.go | 69 ----------- types/v15.go | 164 ++++++++++++++++++++++++ 10 files changed, 293 insertions(+), 196 deletions(-) delete mode 100644 types/v14.go create mode 100644 types/v15.go diff --git a/README.md b/README.md index 57d8581..da23ea6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ $ curl http://localhost:8080 | jq { "api_compatibility": [ - "14" + "14", + "15" ], "space": "CCCHH", ... @@ -22,9 +23,9 @@ 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. diff --git a/config-template.json b/config-template.json index 31cb500..aac6046 100644 --- a/config-template.json +++ b/config-template.json @@ -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" + } + ] + } } diff --git a/config/config.go b/config/config.go index bf81c0a..7ad632c 100644 --- a/config/config.go +++ b/config/config.go @@ -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 diff --git a/config/types.go b/config/types.go index 4a305ce..315307f 100644 --- a/config/types.go +++ b/config/types.go @@ -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"` } diff --git a/handlers/root.go b/handlers/root.go index 47091d5..88cdc55 100644 --- a/handlers/root.go +++ b/handlers/root.go @@ -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 { diff --git a/handlers/state.go b/handlers/state.go index d1ab9d9..d4962c1 100644 --- a/handlers/state.go +++ b/handlers/state.go @@ -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) diff --git a/main.go b/main.go index dc374bc..4b01eee 100644 --- a/main.go +++ b/main.go @@ -46,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) diff --git a/persistence/persistentState.go b/persistence/persistentState.go index 74fbdb2..87c3121 100644 --- a/persistence/persistentState.go +++ b/persistence/persistentState.go @@ -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"` diff --git a/types/v14.go b/types/v14.go deleted file mode 100644 index 3763fe4..0000000 --- a/types/v14.go +++ /dev/null @@ -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"` -} diff --git a/types/v15.go b/types/v15.go new file mode 100644 index 0000000..5180ba6 --- /dev/null +++ b/types/v15.go @@ -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"` +}