Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 28 additions & 61 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,13 @@ import (

var TomlFilePath = "/data/settings.toml"

type Config struct {
Scooter map[string]interface{} `toml:"scooter"`
Cellular map[string]interface{} `toml:"cellular"`
Updates map[string]interface{} `toml:"updates"`
Dashboard map[string]interface{} `toml:"dashboard"`
Alarm map[string]interface{} `toml:"alarm"`
EngineECU map[string]interface{} `toml:"engine-ecu"`
Keycard map[string]interface{} `toml:"keycard"`
PM map[string]interface{} `toml:"pm"`
}
// Config is a generic two-level map: section name -> field name -> value.
// Values can be strings (for flat leaves) or nested map[string]interface{}
// (for sub-tables like [dashboard.saved-locations.0]).
type Config map[string]map[string]interface{}

// LoadFromFile reads the TOML configuration file
func LoadFromFile() (*Config, error) {
func LoadFromFile() (Config, error) {
if _, err := os.Stat(TomlFilePath); os.IsNotExist(err) {
return nil, os.ErrNotExist
}
Expand All @@ -34,59 +28,44 @@ func LoadFromFile() (*Config, error) {
return nil, fmt.Errorf("failed to read TOML file: %w", err)
}

var config Config
if err := toml.Unmarshal(data, &config); err != nil {
var cfg Config
if err := toml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse TOML file: %w", err)
}

return &config, nil
return cfg, nil
}

// SaveToFile writes the configuration to the TOML file
func SaveToFile(config *Config) error {
func SaveToFile(cfg Config) error {
if err := os.MkdirAll(filepath.Dir(TomlFilePath), 0755); err != nil {
return fmt.Errorf("failed to create settings directory: %w", err)
}

return fileutil.AtomicWrite(TomlFilePath, 0644, func(f *os.File) error {
return toml.NewEncoder(f).Encode(config)
return toml.NewEncoder(f).Encode(cfg)
})
}

// ParseRedisSettings converts Redis hash fields to Config structure
func ParseRedisSettings(settings map[string]string) *Config {
config := &Config{
Scooter: make(map[string]interface{}),
Cellular: make(map[string]interface{}),
Updates: make(map[string]interface{}),
Dashboard: make(map[string]interface{}),
Alarm: make(map[string]interface{}),
EngineECU: make(map[string]interface{}),
Keycard: make(map[string]interface{}),
PM: make(map[string]interface{}),
}

// ParseRedisSettings converts Redis hash fields to Config structure.
// Splits field names at the first dot: everything before becomes the top-level
// section, everything after is kept verbatim as the section's flat key so the
// TOML encoder emits existing-style quoted dotted keys.
func ParseRedisSettings(settings map[string]string) Config {
cfg := Config{}
for field, value := range settings {
if strings.HasPrefix(field, "scooter.") {
config.Scooter[strings.TrimPrefix(field, "scooter.")] = value
} else if strings.HasPrefix(field, "cellular.") {
config.Cellular[strings.TrimPrefix(field, "cellular.")] = value
} else if strings.HasPrefix(field, "updates.") {
config.Updates[strings.TrimPrefix(field, "updates.")] = value
} else if strings.HasPrefix(field, "dashboard.") {
config.Dashboard[strings.TrimPrefix(field, "dashboard.")] = value
} else if strings.HasPrefix(field, "alarm.") {
config.Alarm[strings.TrimPrefix(field, "alarm.")] = value
} else if strings.HasPrefix(field, "engine-ecu.") {
config.EngineECU[strings.TrimPrefix(field, "engine-ecu.")] = value
} else if strings.HasPrefix(field, "keycard.") {
config.Keycard[strings.TrimPrefix(field, "keycard.")] = value
} else if strings.HasPrefix(field, "pm.") {
config.PM[strings.TrimPrefix(field, "pm.")] = value
dot := strings.IndexByte(field, '.')
if dot < 1 {
continue
}
section := field[:dot]
key := field[dot+1:]
if _, ok := cfg[section]; !ok {
cfg[section] = map[string]interface{}{}
}
cfg[section][key] = value
}

return config
return cfg
}

// flattenSection walks a section map, handling both flat string leaves and
Expand All @@ -104,22 +83,10 @@ func flattenSection(prefix string, m map[string]interface{}, out map[string]inte
}

// ToRedisFields converts Config to Redis hash fields
func (c *Config) ToRedisFields() map[string]interface{} {
func (c Config) ToRedisFields() map[string]interface{} {
fields := make(map[string]interface{})

sections := map[string]map[string]interface{}{
"scooter": c.Scooter,
"cellular": c.Cellular,
"updates": c.Updates,
"dashboard": c.Dashboard,
"alarm": c.Alarm,
"engine-ecu": c.EngineECU,
"keycard": c.Keycard,
"pm": c.PM,
}
for prefix, section := range sections {
for prefix, section := range c {
flattenSection(prefix, section, fields)
}

return fields
}
70 changes: 65 additions & 5 deletions internal/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"strings"
)

type EnumValue struct {
Expand All @@ -27,20 +28,79 @@ type Setting struct {
Pattern string `json:"pattern,omitempty"`
}

// patternSetting is a schema entry whose key contains wildcard segments.
type patternSetting struct {
segments []string
setting Setting
}

type Schema struct {
// Settings holds exact-match entries, keyed by fully-dotted key.
Settings map[string]Setting
// Patterns holds entries whose key has at least one "*" segment, e.g.
// "dashboard.saved-locations.*.latitude". Exact matches in Settings
// take precedence over patterns during lookup.
Patterns []patternSetting
Raw []byte
}

func Parse(data []byte) (*Schema, error) {
var settings map[string]Setting
if err := json.Unmarshal(data, &settings); err != nil {
var raw map[string]Setting
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parsing schema: %w", err)
}
return &Schema{
Settings: settings,
s := &Schema{
Settings: make(map[string]Setting),
Raw: data,
}, nil
}
for key, setting := range raw {
segs := strings.Split(key, ".")
wildcard := false
for _, seg := range segs {
if seg == "*" {
wildcard = true
break
}
}
if wildcard {
s.Patterns = append(s.Patterns, patternSetting{segments: segs, setting: setting})
} else {
s.Settings[key] = setting
}
}
return s, nil
}

// Lookup finds the Setting matching key, preferring exact matches over patterns.
func (s *Schema) Lookup(key string) (Setting, bool) {
if setting, ok := s.Settings[key]; ok {
return setting, true
}
segs := strings.Split(key, ".")
for _, p := range s.Patterns {
if matchSegments(p.segments, segs) {
return p.setting, true
}
}
return Setting{}, false
}

// Has reports whether key is covered by the schema (exact or wildcard).
func (s *Schema) Has(key string) bool {
_, ok := s.Lookup(key)
return ok
}

func matchSegments(pattern, key []string) bool {
if len(pattern) != len(key) {
return false
}
for i, p := range pattern {
if p != "*" && p != key[i] {
return false
}
}
return true
}

func LoadFile(path string) (*Schema, error) {
Expand Down
12 changes: 7 additions & 5 deletions internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"fmt"
"log"
"os"
"strings"
"sync"

"github.com/librescoot/settings-service/internal/config"
Expand Down Expand Up @@ -137,10 +136,13 @@ func (s *SettingsService) SaveSettingsToTOML() error {
log.Printf(" %s = %s", k, v)
}

// Log any fields that don't match expected patterns
for field := range settings {
if !strings.HasPrefix(field, "scooter.") && !strings.HasPrefix(field, "cellular.") && !strings.HasPrefix(field, "updates.") && !strings.HasPrefix(field, "dashboard.") && !strings.HasPrefix(field, "alarm.") && !strings.HasPrefix(field, "engine-ecu.") && !strings.HasPrefix(field, "keycard.") && !strings.HasPrefix(field, "pm.") {
log.Printf("Warning: Ignoring field '%s' - must be prefixed with 'scooter.', 'cellular.', 'updates.', 'dashboard.', 'alarm.', 'engine-ecu.', 'keycard.', or 'pm.'", field)
// Warn about fields that the schema doesn't declare. Only runs when a
// schema is loaded; unknown fields are still persisted.
if s.schema != nil {
for field := range settings {
if !s.schema.Has(field) {
log.Printf("Warning: field '%s' is not declared in the schema", field)
}
}
}

Expand Down
15 changes: 5 additions & 10 deletions settings.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -795,51 +795,46 @@
"max": 65535,
"example": 15000
},
"dashboard.saved-locations.0.created-at": {
"dashboard.saved-locations.*.created-at": {
"type": "string",
"description": "Creation timestamp",
"label": "Saved Location Created At",
"user-visible": false,
"service": "scootui",
"pattern": "indexed",
"example": "2025-01-15T10:30:00Z"
},
"dashboard.saved-locations.0.label": {
"dashboard.saved-locations.*.label": {
"type": "string",
"description": "Location label",
"label": "Saved Location Label",
"user-visible": false,
"service": "scootui",
"pattern": "indexed",
"example": "Home"
},
"dashboard.saved-locations.0.last-used-at": {
"dashboard.saved-locations.*.last-used-at": {
"type": "string",
"description": "Last used timestamp",
"label": "Saved Location Last Used",
"user-visible": false,
"service": "scootui",
"pattern": "indexed",
"example": "2025-01-15T12:00:00Z"
},
"dashboard.saved-locations.0.latitude": {
"dashboard.saved-locations.*.latitude": {
"type": "float",
"description": "Latitude",
"label": "Saved Location Latitude",
"user-visible": false,
"service": "scootui",
"pattern": "indexed",
"min": -90,
"max": 90,
"example": 52.52
},
"dashboard.saved-locations.0.longitude": {
"dashboard.saved-locations.*.longitude": {
"type": "float",
"description": "Longitude",
"label": "Saved Location Longitude",
"user-visible": false,
"service": "scootui",
"pattern": "indexed",
"min": -180,
"max": 180,
"example": 13.405
Expand Down