diff --git a/internal/config/config.go b/internal/config/config.go index 423ed4f..1cb8fe8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } @@ -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 @@ -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 } diff --git a/internal/schema/schema.go b/internal/schema/schema.go index fad73c5..d8f2b32 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "strings" ) type EnumValue struct { @@ -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) { diff --git a/internal/service/service.go b/internal/service/service.go index 9aa4bf0..0585ccf 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "os" - "strings" "sync" "github.com/librescoot/settings-service/internal/config" @@ -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) + } } } diff --git a/settings.schema.json b/settings.schema.json index d27a970..b68a887 100644 --- a/settings.schema.json +++ b/settings.schema.json @@ -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