diff --git a/resources/statefulsensor/statefulsensor.go b/resources/statefulsensor/statefulsensor.go index 16c2137..76e5236 100644 --- a/resources/statefulsensor/statefulsensor.go +++ b/resources/statefulsensor/statefulsensor.go @@ -196,18 +196,43 @@ func (s *statefulSensor) Status(ctx context.Context) (map[string]interface{}, er }, nil } -// DoCommand supports a "set" command that replaces the value the sensor holds. +// DoCommand supports two commands: // -// Example: +// - "set" replaces the entire value the sensor holds: +// {"set": {"temperature": 72.5, "unit": "F"}} +// - "merge" overlays the given keys onto the existing value, leaving keys not +// mentioned untouched: {"merge": {"usage": 0}} // -// {"set": {"temperature": 72.5, "unit": "F"}} -// -// The provided object becomes the sensor's value, is persisted to disk, and is -// returned by Readings. +// In both cases the result is persisted to disk and returned by Readings. func (s *statefulSensor) DoCommand(ctx context.Context, cmd map[string]interface{}) (map[string]interface{}, error) { + if raw, ok := cmd["merge"]; ok { + patch, ok := raw.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("%q must be an object mapping keys to values, got %T", "merge", raw) + } + + s.mu.Lock() + // Guard against a nil map (sensor never set, empty state file): writing to + // a nil map panics, so initialize before overlaying the patch. + if s.value == nil { + s.value = make(map[string]interface{}, len(patch)) + } + for k, v := range patch { + s.value[k] = v + } + s.mu.Unlock() + + if err := s.saveToFile(); err != nil { + return nil, err + } + + s.logger.Infof("merged %+v", patch) + return map[string]interface{}{"merge": "ok"}, nil + } + raw, ok := cmd["set"] if !ok { - return nil, fmt.Errorf("unsupported command: expected a %q key", "set") + return nil, fmt.Errorf("unsupported command: expected a %q or %q key", "set", "merge") } value, ok := raw.(map[string]interface{}) diff --git a/resources/statefulsensor/statefulsensor_test.go b/resources/statefulsensor/statefulsensor_test.go index cf5e048..716984d 100644 --- a/resources/statefulsensor/statefulsensor_test.go +++ b/resources/statefulsensor/statefulsensor_test.go @@ -127,6 +127,63 @@ func TestStatus(t *testing.T) { } } +func TestMergeUpdatesOneKeyAndPreservesOthers(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "state.json") + s := newTestSensor(t, path) + + // Seed the full coffee-machine state. + full := map[string]interface{}{ + "usage": 11.5, + "regular_grinds": 9.0, + "decaf_grinds": 3.0, + "cleanings": 11.0, + } + if _, err := s.DoCommand(ctx, map[string]interface{}{"set": full}); err != nil { + t.Fatalf("DoCommand set: %v", err) + } + + // Reset only usage, as a "filled water tank" button would. + if _, err := s.DoCommand(ctx, map[string]interface{}{"merge": map[string]interface{}{"usage": 0.0}}); err != nil { + t.Fatalf("DoCommand merge: %v", err) + } + + got, err := s.Readings(ctx, nil) + if err != nil { + t.Fatalf("Readings: %v", err) + } + if got["usage"] != 0.0 { + t.Fatalf("usage = %v, want 0 after merge", got["usage"]) + } + // The whole point of merge: the other counters are untouched. + if got["regular_grinds"] != 9.0 || got["decaf_grinds"] != 3.0 || got["cleanings"] != 11.0 { + t.Fatalf("merge clobbered other keys: %+v", got) + } +} + +func TestMergeOnNilValueDoesNotPanic(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "state.json") + // A state file containing the literal "null" unmarshals to a nil map, which + // would panic on write without the guard in DoCommand. + if err := os.WriteFile(path, []byte("null"), 0o600); err != nil { + t.Fatalf("write state file: %v", err) + } + s := newTestSensor(t, path) + + if _, err := s.DoCommand(ctx, map[string]interface{}{"merge": map[string]interface{}{"usage": 0.0}}); err != nil { + t.Fatalf("DoCommand merge on nil value: %v", err) + } + + got, err := s.Readings(ctx, nil) + if err != nil { + t.Fatalf("Readings: %v", err) + } + if got["usage"] != 0.0 { + t.Fatalf("usage = %v, want 0", got["usage"]) + } +} + func TestDoCommandRejectsUnknownCommand(t *testing.T) { ctx := context.Background() s := newTestSensor(t, filepath.Join(t.TempDir(), "state.json"))