diff --git a/resources/statefulsensor/statefulsensor.go b/resources/statefulsensor/statefulsensor.go index e3a7665..16c2137 100644 --- a/resources/statefulsensor/statefulsensor.go +++ b/resources/statefulsensor/statefulsensor.go @@ -49,7 +49,6 @@ type statefulSensor struct { resource.AlwaysRebuild resource.Named - name resource.Name logger logging.Logger cfg *Config @@ -91,7 +90,7 @@ func New(ctx context.Context, deps resource.Dependencies, name resource.Name, co } s := &statefulSensor{ - name: name, + Named: name.AsNamed(), logger: logger, cfg: conf, filePath: filePath, @@ -169,10 +168,6 @@ func (s *statefulSensor) saveToFile() error { return nil } -func (s *statefulSensor) Name() resource.Name { - return s.name -} - func (s *statefulSensor) Readings(ctx context.Context, extra map[string]interface{}) (map[string]interface{}, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -185,6 +180,22 @@ func (s *statefulSensor) Readings(ctx context.Context, extra map[string]interfac return readings, nil } +// Status reports operational metadata for the sensor, served via the sensor +// service's GetStatus RPC. It is defined explicitly (rather than relying on the +// empty default promoted from the embedded resource.Named) so the model both +// exposes useful state and is guaranteed to have a non-nil receiver. Unlike +// Readings, which returns the stored value itself, Status reports where the +// value is persisted and how many keys it holds. +func (s *statefulSensor) Status(ctx context.Context) (map[string]interface{}, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + return map[string]interface{}{ + "file_path": s.filePath, + "num_keys": len(s.value), + }, nil +} + // DoCommand supports a "set" command that replaces the value the sensor holds. // // Example: diff --git a/resources/statefulsensor/statefulsensor_test.go b/resources/statefulsensor/statefulsensor_test.go index 504d95d..cf5e048 100644 --- a/resources/statefulsensor/statefulsensor_test.go +++ b/resources/statefulsensor/statefulsensor_test.go @@ -93,6 +93,40 @@ func TestDefaultPathUsesModuleDataDir(t *testing.T) { } } +// TestStatus verifies the explicit Status API. It also guards against a +// regression where the embedded resource.Named was left uninitialized: the +// sensor service's GetStatus RPC calls Status on the resource, and with a nil +// embedded Named the promoted default Status dereferenced a nil receiver and +// crashed the module (SIGSEGV). +func TestStatus(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "state.json") + s := newTestSensor(t, path) + + status, err := s.Status(ctx) + if err != nil { + t.Fatalf("Status: %v", err) + } + if status["file_path"] != path { + t.Fatalf("Status file_path = %v, want %q", status["file_path"], path) + } + if status["num_keys"] != 0 { + t.Fatalf("Status num_keys = %v, want 0", status["num_keys"]) + } + + if _, err := s.DoCommand(ctx, map[string]interface{}{"set": map[string]interface{}{"a": 1.0, "b": 2.0}}); err != nil { + t.Fatalf("DoCommand set: %v", err) + } + + status, err = s.Status(ctx) + if err != nil { + t.Fatalf("Status after set: %v", err) + } + if status["num_keys"] != 2 { + t.Fatalf("Status num_keys = %v, want 2", status["num_keys"]) + } +} + func TestDoCommandRejectsUnknownCommand(t *testing.T) { ctx := context.Background() s := newTestSensor(t, filepath.Join(t.TempDir(), "state.json"))