From d9cfff39035aaa2835b6ff22846239d5573636dd Mon Sep 17 00:00:00 2001 From: danielbotros Date: Thu, 25 Jun 2026 11:32:16 -0400 Subject: [PATCH 1/2] sensor-monitor: use {{...}} reference syntax instead of ${...} The Viam config reader reserves ${...} for its own placeholder replacement and rejects unknown placeholders before the module sees the config. Switch the action command reference delimiter to {{...}} (mustache-style) to avoid the collision; also trim whitespace inside references. Updates code, tests, and README. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 12 +++++---- resources/sensormonitor/sensormonitor.go | 26 +++++++++++-------- resources/sensormonitor/sensormonitor_test.go | 18 ++++++------- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 90c3719..241455b 100644 --- a/README.md +++ b/README.md @@ -77,10 +77,12 @@ Monitors the readings of another sensor and, when a numeric reading crosses a co - `on_resolve` fires once when the reading clears the threshold. - Actions are best-effort: a failure is logged and never affects monitoring. Each named resource is automatically added as a dependency. -**References and captures.** String values in a `command` may embed `${...}` references, resolved just before the command is sent: +**References and captures.** String values in a `command` may embed `{{...}}` references, resolved just before the command is sent: -- Rule/reading context: `${value}` (the current reading), `${key}`, `${threshold}`, `${operator}`. -- Captured responses: set `"capture": ""` on an action to store its `DoCommand` response, then reference its fields from later actions (including `on_resolve`) as `${.}`. +- Rule/reading context: `{{value}}` (the current reading), `{{key}}`, `{{threshold}}`, `{{operator}}`. +- Captured responses: set `"capture": ""` on an action to store its `DoCommand` response, then reference its fields from later actions (including `on_resolve`) as `{{.}}`. + +`{{...}}` is used rather than `${...}` because the Viam config reader reserves `${...}` for its own placeholder replacement and would reject these references before the module sees the config. A value that is exactly one reference keeps the referenced value's type; a reference embedded in a larger string is substituted as text. If a reference can't be resolved (e.g. nothing was captured), that action is skipped. A capture is overwritten on each cooldown re-fire (so resolve references the most recent response) and cleared once the rule resolves. @@ -137,14 +139,14 @@ A low-bean alert that posts to Slack on trigger and adds a ✅ reaction to that "on_trigger": [ { "resource": "slack-notifier", - "command": { "command": "send", "text": "☕ Regular beans are low (${value}), please refill." }, + "command": { "command": "send", "text": "☕ Regular beans are low ({{value}}), please refill." }, "capture": "msg" } ], "on_resolve": [ { "resource": "slack-notifier", - "command": { "command": "react", "name": "white_check_mark", "ts": "${msg.ts}", "channel": "${msg.channel}" } + "command": { "command": "react", "name": "white_check_mark", "ts": "{{msg.ts}}", "channel": "{{msg.channel}}" } } ] }, diff --git a/resources/sensormonitor/sensormonitor.go b/resources/sensormonitor/sensormonitor.go index fe49f31..5a35375 100644 --- a/resources/sensormonitor/sensormonitor.go +++ b/resources/sensormonitor/sensormonitor.go @@ -39,11 +39,11 @@ const defaultPollInterval = 10 * time.Second type Action struct { // Resource is the name of the resource to call. Resource string `json:"resource"` - // Command is the DoCommand payload. String values may contain ${...} + // Command is the DoCommand payload. String values may contain {{...}} // references that are resolved before the command is sent — see resolveValue. Command map[string]interface{} `json:"command"` // Capture, when set, stores this action's response under this name so later - // actions can reference its fields as ${name.field}. + // actions can reference its fields as {{name.field}}. Capture string `json:"capture,omitempty"` } @@ -485,14 +485,17 @@ func toFloat64(v interface{}) (float64, bool) { } } -// refExact matches a string that is exactly one reference, e.g. "${msg.ts}". -// refAny matches every reference embedded anywhere in a string. +// refExact matches a string that is exactly one reference, e.g. "{{msg.ts}}". +// refAny matches every reference embedded anywhere in a string. We use {{...}} +// rather than ${...} because the Viam config reader reserves ${...} for its own +// placeholder replacement and rejects unknown ones before the module sees the +// config. var ( - refExact = regexp.MustCompile(`^\$\{([^}]+)\}$`) - refAny = regexp.MustCompile(`\$\{([^}]+)\}`) + refExact = regexp.MustCompile(`^\{\{([^{}]+)\}\}$`) + refAny = regexp.MustCompile(`\{\{([^{}]+)\}\}`) ) -// resolveValue walks a command value and substitutes ${name} / ${name.path} +// resolveValue walks a command value and substitutes {{name}} / {{name.path}} // references against ctx. A value that is exactly one reference keeps the // referenced value's type (so a captured map or number passes through intact); a // reference embedded in a larger string is substituted textually. An unresolved @@ -501,18 +504,19 @@ func resolveValue(v interface{}, ctx map[string]interface{}) (interface{}, error switch x := v.(type) { case string: if mm := refExact.FindStringSubmatch(x); mm != nil { - val, ok := lookupPath(ctx, mm[1]) + path := strings.TrimSpace(mm[1]) + val, ok := lookupPath(ctx, path) if !ok { - return nil, fmt.Errorf("unresolved reference ${%s}", mm[1]) + return nil, fmt.Errorf("unresolved reference {{%s}}", path) } return val, nil } var refErr error out := refAny.ReplaceAllStringFunc(x, func(match string) string { - path := refAny.FindStringSubmatch(match)[1] + path := strings.TrimSpace(refAny.FindStringSubmatch(match)[1]) val, ok := lookupPath(ctx, path) if !ok { - refErr = fmt.Errorf("unresolved reference ${%s}", path) + refErr = fmt.Errorf("unresolved reference {{%s}}", path) return match } return stringify(val) diff --git a/resources/sensormonitor/sensormonitor_test.go b/resources/sensormonitor/sensormonitor_test.go index ff6b36b..71e0dc4 100644 --- a/resources/sensormonitor/sensormonitor_test.go +++ b/resources/sensormonitor/sensormonitor_test.go @@ -248,7 +248,7 @@ func TestResolveValue(t *testing.T) { } t.Run("exact reference keeps type", func(t *testing.T) { - got, err := resolveValue("${value}", ctx) + got, err := resolveValue("{{value}}", ctx) if err != nil { t.Fatal(err) } @@ -258,7 +258,7 @@ func TestResolveValue(t *testing.T) { }) t.Run("exact reference into capture", func(t *testing.T) { - got, err := resolveValue("${msg.ts}", ctx) + got, err := resolveValue("{{msg.ts}}", ctx) if err != nil { t.Fatal(err) } @@ -268,7 +268,7 @@ func TestResolveValue(t *testing.T) { }) t.Run("embedded references are stringified", func(t *testing.T) { - got, err := resolveValue("temp ${value} over ${threshold}", ctx) + got, err := resolveValue("temp {{value}} over {{threshold}}", ctx) if err != nil { t.Fatal(err) } @@ -279,8 +279,8 @@ func TestResolveValue(t *testing.T) { t.Run("recurses into maps and slices", func(t *testing.T) { got, err := resolveValue(map[string]interface{}{ - "a": "${value}", - "b": []interface{}{"${key}"}, + "a": "{{value}}", + "b": []interface{}{"{{key}}"}, }, ctx) if err != nil { t.Fatal(err) @@ -295,7 +295,7 @@ func TestResolveValue(t *testing.T) { }) t.Run("unresolved reference errors", func(t *testing.T) { - if _, err := resolveValue("${nope.field}", ctx); err == nil { + if _, err := resolveValue("{{nope.field}}", ctx); err == nil { t.Fatal("expected error for unresolved reference") } }) @@ -387,14 +387,14 @@ func TestCaptureCarriesAcrossTriggerAndResolve(t *testing.T) { Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15, OnTrigger: []Action{{ Resource: "notifier", - Command: map[string]interface{}{"command": "send", "text": "low (${value})"}, + Command: map[string]interface{}{"command": "send", "text": "low ({{value}})"}, Capture: "msg", }}, OnResolve: []Action{{ Resource: "notifier", Command: map[string]interface{}{ "command": "react", "name": "white_check_mark", - "ts": "${msg.ts}", "channel": "${msg.channel}", + "ts": "{{msg.ts}}", "channel": "{{msg.channel}}", }, }}, }}, @@ -428,7 +428,7 @@ func TestResolveActionSkippedWhenCaptureMissing(t *testing.T) { Sensor: "src", Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15, OnTrigger: []Action{{Resource: "notifier", Command: map[string]interface{}{"command": "send"}}}, - OnResolve: []Action{{Resource: "notifier", Command: map[string]interface{}{"command": "react", "ts": "${msg.ts}"}}}, + OnResolve: []Action{{Resource: "notifier", Command: map[string]interface{}{"command": "react", "ts": "{{msg.ts}}"}}}, }}, }, src, map[string]*fakeTarget{"notifier": notifier}) From 1c56477480259a652dd5ab7a4f38cff1b48e6420 Mon Sep 17 00:00:00 2001 From: danielbotros Date: Thu, 25 Jun 2026 11:49:25 -0400 Subject: [PATCH 2/2] sensor-monitor: drop ${...} rationale comment Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 -- resources/sensormonitor/sensormonitor.go | 5 +---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/README.md b/README.md index 241455b..a98afee 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,6 @@ Monitors the readings of another sensor and, when a numeric reading crosses a co - Rule/reading context: `{{value}}` (the current reading), `{{key}}`, `{{threshold}}`, `{{operator}}`. - Captured responses: set `"capture": ""` on an action to store its `DoCommand` response, then reference its fields from later actions (including `on_resolve`) as `{{.}}`. -`{{...}}` is used rather than `${...}` because the Viam config reader reserves `${...}` for its own placeholder replacement and would reject these references before the module sees the config. - A value that is exactly one reference keeps the referenced value's type; a reference embedded in a larger string is substituted as text. If a reference can't be resolved (e.g. nothing was captured), that action is skipped. A capture is overwritten on each cooldown re-fire (so resolve references the most recent response) and cleared once the rule resolves. ### Configuration diff --git a/resources/sensormonitor/sensormonitor.go b/resources/sensormonitor/sensormonitor.go index 5a35375..b04deee 100644 --- a/resources/sensormonitor/sensormonitor.go +++ b/resources/sensormonitor/sensormonitor.go @@ -486,10 +486,7 @@ func toFloat64(v interface{}) (float64, bool) { } // refExact matches a string that is exactly one reference, e.g. "{{msg.ts}}". -// refAny matches every reference embedded anywhere in a string. We use {{...}} -// rather than ${...} because the Viam config reader reserves ${...} for its own -// placeholder replacement and rejects unknown ones before the module sees the -// config. +// refAny matches every reference embedded anywhere in a string. var ( refExact = regexp.MustCompile(`^\{\{([^{}]+)\}\}$`) refAny = regexp.MustCompile(`\{\{([^{}]+)\}\}`)