From 90de383efb618306db95fe00a2ffafc049a30ff1 Mon Sep 17 00:00:00 2001 From: Nicolas Palpacuer Date: Sat, 11 Jul 2026 15:07:38 -0400 Subject: [PATCH] sensor-monitor: add per-rule snooze DoCommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "snooze" DoCommand that suppresses evaluation of a single named rule for a duration: {"snooze": {"rule_name": , "duration": }}. Duration is a string with a seconds/minutes/hours/days unit (e.g. "30s", "90m", "24h", "5d") — a custom parser is used because time.ParseDuration does not support a "d" unit. Rules gain an optional unique "name" so they can be targeted; snooze state is tracked per rule, so snoozing one rule leaves the others evaluating. While snoozed a rule is not evaluated and its actions do not fire, but readings stay current and its state is frozen, so a condition still breaching when the snooze ends fires on the next poll. Readings expose "_snoozed" per rule alongside the existing "_triggered". Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 22 ++- resources/sensormonitor/sensormonitor.go | 111 +++++++++++++- resources/sensormonitor/sensormonitor_test.go | 141 ++++++++++++++++++ 3 files changed, 268 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a98afee..306b15b 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ Each entry in `rules`: | Name | Type | Required | Description | | ----------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | string | No | Identifies the rule so it can be targeted by the `snooze` DoCommand. When set, must be unique across rules. Only named rules can be snoozed. | | `key` | string | **Yes** | The reading key to watch, e.g. `"temperature"`. Non-numeric or missing values are skipped. | | `operator` | string | **Yes** | Comparison applied between the reading and `threshold`. One of `>`, `>=`, `<`, `<=`, `==`, `!=` (aliases: `gt`, `gte`, `lt`, `lte`, `eq`, `ne`). | | `threshold` | number | **Yes** | The value the reading is compared against. | @@ -149,6 +150,7 @@ A low-bean alert that posts to Slack on trigger and adds a ✅ reaction to that ] }, { + "name": "too-hot", "key": "temperature", "operator": ">", "threshold": 90, @@ -161,14 +163,16 @@ A low-bean alert that posts to Slack on trigger and adds a ✅ reaction to that ### Readings -Returns the most recent readings from the monitored sensor, plus a `_triggered` boolean for each rule indicating whether it is currently firing. +Returns the most recent readings from the monitored sensor, plus per rule a `_triggered` boolean indicating whether it is currently firing and a `_snoozed` boolean indicating whether its evaluation is currently suppressed (see the `snooze` DoCommand). ```json { "temperature": 95.0, "humidity": 35.0, "temperature_triggered": true, - "humidity_triggered": false + "temperature_snoozed": false, + "humidity_triggered": false, + "humidity_snoozed": false } ``` @@ -185,3 +189,17 @@ Returns: ```json {"check": "ok"} ``` + +**`snooze`** - Suppress evaluation of a single named rule for a duration. While a rule is snoozed the sensor keeps polling and its `Readings` stay current, but that rule is not evaluated and its actions do not fire (other rules are unaffected). A condition still breaching when the snooze ends fires on the next poll. `rule_name` must match a rule's `name`; `duration` is a string with a unit of seconds (`s`), minutes (`m`), hours (`h`), or days (`d`), e.g. `30s`, `90m`, `24h`, `5d`. + +```json +{"snooze": {"rule_name": "too-hot", "duration": "30m"}} +``` + +Returns the rule and the time the snooze expires (RFC 3339): + +```json +{"rule_name": "too-hot", "snoozed_until": "2026-07-11T15:52:04-04:00"} +``` + +The current snooze state is also exposed in `Readings` as a `_snoozed` boolean per rule. diff --git a/resources/sensormonitor/sensormonitor.go b/resources/sensormonitor/sensormonitor.go index b04deee..f48f3fe 100644 --- a/resources/sensormonitor/sensormonitor.go +++ b/resources/sensormonitor/sensormonitor.go @@ -49,6 +49,9 @@ type Action struct { // Rule describes a single numeric trigger on one reading key. type Rule struct { + // Name optionally identifies the rule so it can be targeted by the "snooze" + // DoCommand. Names, when set, must be unique across rules. + Name string `json:"name,omitempty"` // Key is the reading key to watch, e.g. "temperature". Key string `json:"key"` // Operator is the comparison to apply between the reading value and Threshold. @@ -95,10 +98,17 @@ func (cfg *Config) Validate(path string) ([]string, []string, error) { } } + seenNames := map[string]bool{} for i, r := range cfg.Rules { if r.Key == "" { return nil, nil, fmt.Errorf("%s: rules[%d] missing required field 'key'", path, i) } + if r.Name != "" { + if seenNames[r.Name] { + return nil, nil, fmt.Errorf("%s: rules[%d] duplicate rule name %q", path, i, r.Name) + } + seenNames[r.Name] = true + } if _, err := parseOperator(r.Operator); err != nil { return nil, nil, fmt.Errorf("%s: rules[%d] %w", path, i, err) } @@ -134,6 +144,9 @@ type ruleState struct { triggered bool lastFired time.Time lastValue float64 + // snoozeUntil suppresses evaluation of this rule until this time. The zero value + // means not snoozed. + snoozeUntil time.Time // vars holds the responses of actions that set "capture", keyed by capture // name, so later actions can reference values produced. Reset when the rule resolves. vars map[string]interface{} @@ -298,6 +311,16 @@ func (m *sensorMonitor) poll(ctx context.Context) { for i := range m.cfg.Rules { rule := m.cfg.Rules[i] + // Skip snoozed rules. Readings stay current (updated above) but the rule is + // not evaluated, so no actions fire and its state is frozen — a condition + // still breaching when the snooze ends fires on the next poll. + m.mu.RLock() + snoozed := now.Before(m.ruleStates[i].snoozeUntil) + m.mu.RUnlock() + if snoozed { + continue + } + raw, ok := readings[rule.Key] if !ok { m.logger.Debugf("reading key %q not present; skipping rule %d", rule.Key, i) @@ -411,22 +434,30 @@ func (m *sensorMonitor) runActions(ctx context.Context, ruleIdx int, rule Rule, } // Readings returns the most recent sensor readings plus, per rule, a -// "_triggered" boolean indicating whether the rule is currently firing. +// "_triggered" boolean indicating whether the rule is currently firing and a +// "_snoozed" boolean indicating whether the rule's evaluation is currently +// suppressed by a snooze. func (m *sensorMonitor) Readings(ctx context.Context, extra map[string]interface{}) (map[string]interface{}, error) { m.mu.RLock() defer m.mu.RUnlock() - out := make(map[string]interface{}, len(m.lastReadings)+len(m.cfg.Rules)) + now := time.Now() + out := make(map[string]interface{}, len(m.lastReadings)+2*len(m.cfg.Rules)) for k, v := range m.lastReadings { out[k] = v } for i := range m.cfg.Rules { out[m.cfg.Rules[i].Key+"_triggered"] = m.ruleStates[i].triggered + out[m.cfg.Rules[i].Key+"_snoozed"] = now.Before(m.ruleStates[i].snoozeUntil) } return out, nil } -// DoCommand supports {"check": true} to force an immediate poll. +// DoCommand supports: +// - {"check": true} to force an immediate poll. +// - {"snooze": {"rule_name": , "duration": }} to suppress +// evaluation of the named rule for the given duration, e.g. "30s", "90m", +// "24h", "5d". Returns {"rule_name": , "snoozed_until": }. func (m *sensorMonitor) DoCommand(ctx context.Context, cmd map[string]interface{}) (map[string]interface{}, error) { if check, ok := cmd["check"]; ok { if b, ok := check.(bool); ok && b { @@ -434,7 +465,49 @@ func (m *sensorMonitor) DoCommand(ctx context.Context, cmd map[string]interface{ return map[string]interface{}{"check": "ok"}, nil } } - return nil, fmt.Errorf("unsupported command: expected {%q: true}", "check") + if raw, ok := cmd["snooze"]; ok { + return m.snooze(raw) + } + return nil, fmt.Errorf("unsupported command: expected {%q: true} or {%q: {...}}", "check", "snooze") +} + +// snooze handles the "snooze" DoCommand, suppressing evaluation of a single named +// rule for a duration. The payload is {"rule_name": , "duration": }. +func (m *sensorMonitor) snooze(raw interface{}) (map[string]interface{}, error) { + args, ok := raw.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("snooze: expected an object {\"rule_name\": , \"duration\": }, got %T", raw) + } + ruleName, ok := args["rule_name"].(string) + if !ok || ruleName == "" { + return nil, fmt.Errorf("snooze: missing or invalid \"rule_name\" (want a non-empty string)") + } + durStr, ok := args["duration"].(string) + if !ok { + return nil, fmt.Errorf("snooze: missing or invalid \"duration\" (want a string like \"30s\", \"90m\", \"24h\", \"5d\")") + } + d, err := parseSnoozeDuration(durStr) + if err != nil { + return nil, fmt.Errorf("snooze: %w", err) + } + + idx := -1 + for i := range m.cfg.Rules { + if m.cfg.Rules[i].Name == ruleName { + idx = i + break + } + } + if idx < 0 { + return nil, fmt.Errorf("snooze: no rule named %q", ruleName) + } + + until := time.Now().Add(d) + m.mu.Lock() + m.ruleStates[idx].snoozeUntil = until + m.mu.Unlock() + m.logger.Infof("snoozing rule %q until %s", ruleName, until.Format(time.RFC3339)) + return map[string]interface{}{"rule_name": ruleName, "snoozed_until": until.Format(time.RFC3339)}, nil } func (m *sensorMonitor) Close(context.Context) error { @@ -464,6 +537,36 @@ func parseOperator(op string) (func(a, b float64) bool, error) { } } +// snoozeDurationRe matches a duration like "30s", "90m", "24h", or "5d": a +// non-negative number followed by a unit of seconds, minutes, hours, or days. +var snoozeDurationRe = regexp.MustCompile(`^(\d+(?:\.\d+)?)\s*([smhd])$`) + +// parseSnoozeDuration parses a snooze duration string. Unlike time.ParseDuration +// it accepts a "d" (day) unit — and only the units s, m, h, d — matching the +// coarse granularity a snooze needs, e.g. "30s", "90m", "24h", "5d". +func parseSnoozeDuration(s string) (time.Duration, error) { + mm := snoozeDurationRe.FindStringSubmatch(strings.ToLower(strings.TrimSpace(s))) + if mm == nil { + return 0, fmt.Errorf("invalid duration %q: want a number followed by s, m, h, or d (e.g. \"30s\", \"90m\", \"24h\", \"5d\")", s) + } + n, err := strconv.ParseFloat(mm[1], 64) + if err != nil { + return 0, fmt.Errorf("invalid duration %q: %w", s, err) + } + var unit time.Duration + switch mm[2] { + case "s": + unit = time.Second + case "m": + unit = time.Minute + case "h": + unit = time.Hour + case "d": + unit = 24 * time.Hour + } + return time.Duration(n * float64(unit)), nil +} + // toFloat64 coerces a JSON-decoded reading value to a float64. func toFloat64(v interface{}) (float64, bool) { switch n := v.(type) { diff --git a/resources/sensormonitor/sensormonitor_test.go b/resources/sensormonitor/sensormonitor_test.go index 71e0dc4..11ea4e3 100644 --- a/resources/sensormonitor/sensormonitor_test.go +++ b/resources/sensormonitor/sensormonitor_test.go @@ -140,6 +140,14 @@ func TestValidate(t *testing.T) { }}}, reqDeps: []string{"s", "r1", "r2"}, }, + { + name: "duplicate rule names", + cfg: Config{Sensor: "s", Rules: []Rule{ + {Name: "dup", Key: "t", Operator: ">", Threshold: 1}, + {Name: "dup", Key: "h", Operator: "<", Threshold: 2}, + }}, + wantErr: true, + }, { name: "action missing resource", cfg: Config{Sensor: "s", Rules: []Rule{{Key: "t", Operator: ">", Threshold: 1, @@ -217,6 +225,44 @@ func TestParseOperator(t *testing.T) { } } +func TestParseSnoozeDuration(t *testing.T) { + tests := []struct { + in string + want time.Duration + wantErr bool + }{ + {in: "30s", want: 30 * time.Second}, + {in: "90m", want: 90 * time.Minute}, + {in: "24h", want: 24 * time.Hour}, + {in: "5d", want: 5 * 24 * time.Hour}, + {in: "1.5h", want: 90 * time.Minute}, + {in: " 5D ", want: 5 * 24 * time.Hour}, // trimmed and case-insensitive + {in: "0s", want: 0}, + {in: "", wantErr: true}, + {in: "5", wantErr: true}, // missing unit + {in: "5w", wantErr: true}, // unsupported unit + {in: "-5s", wantErr: true}, // negative + {in: "abc", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + got, err := parseSnoozeDuration(tt.in) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("parseSnoozeDuration(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + func TestToFloat64(t *testing.T) { cases := map[string]struct { in interface{} @@ -503,3 +549,98 @@ func TestDoCommandCheckForcesPoll(t *testing.T) { t.Fatal("expected error for unknown command") } } + +func TestDoCommandSnoozeSuppressesRule(t *testing.T) { + ctx := context.Background() + src := newFakeSensor("src") + src.set(map[string]interface{}{"temperature": 95.0, "humidity": 20.0}) + hot := newFakeTarget("hot") + dry := newFakeTarget("dry") + // Two rules; snoozing one must not affect the other. + m := newTestMonitor(t, &Config{ + Sensor: "src", + Rules: []Rule{ + {Name: "too-hot", Key: "temperature", Operator: ">", Threshold: 90, + OnTrigger: []Action{{Resource: "hot", Command: map[string]interface{}{"go": 1}}}}, + {Name: "too-dry", Key: "humidity", Operator: "<", Threshold: 30, + OnTrigger: []Action{{Resource: "dry", Command: map[string]interface{}{"go": 1}}}}, + }, + }, src, map[string]*fakeTarget{"hot": hot, "dry": dry}) + + // Snooze only the temperature rule. + resp, err := m.DoCommand(ctx, map[string]interface{}{"snooze": map[string]interface{}{ + "rule_name": "too-hot", "duration": "1h", + }}) + if err != nil { + t.Fatalf("DoCommand snooze: %v", err) + } + if resp["rule_name"] != "too-hot" { + t.Fatalf("expected rule_name in response, got %v", resp) + } + if _, ok := resp["snoozed_until"].(string); !ok { + t.Fatalf("expected snoozed_until in response, got %v", resp) + } + + // Poll while both are breaching: the snoozed rule stays silent, the other fires. + m.poll(ctx) + if got := len(hot.commands()); got != 0 { + t.Fatalf("expected no actions from snoozed rule, got %d", got) + } + if got := len(dry.commands()); got != 1 { + t.Fatalf("expected the un-snoozed rule to fire, got %d", got) + } + + // Readings report the snooze per rule while readings stay current. + got, err := m.Readings(ctx, nil) + if err != nil { + t.Fatalf("Readings: %v", err) + } + if snoozed, _ := got["temperature_snoozed"].(bool); !snoozed { + t.Fatalf("expected temperature_snoozed=true, got %v", got["temperature_snoozed"]) + } + if snoozed, _ := got["humidity_snoozed"].(bool); snoozed { + t.Fatalf("expected humidity_snoozed=false, got %v", got["humidity_snoozed"]) + } + if got["temperature"] != 95.0 { + t.Fatalf("expected readings to stay current while snoozed, got %v", got["temperature"]) + } + + // Backdate the snooze so it has expired; the still-breaching rule now fires. + m.mu.Lock() + m.ruleStates[0].snoozeUntil = m.ruleStates[0].snoozeUntil.Add(-2 * time.Hour) + m.mu.Unlock() + m.poll(ctx) + if got := len(hot.commands()); got != 1 { + t.Fatalf("expected one action after snooze expired, got %d", got) + } + got, _ = m.Readings(ctx, nil) + if snoozed, _ := got["temperature_snoozed"].(bool); snoozed { + t.Fatalf("expected temperature_snoozed=false after expiry, got %v", got["temperature_snoozed"]) + } +} + +func TestDoCommandSnoozeInvalid(t *testing.T) { + ctx := context.Background() + src := newFakeSensor("src") + m := newTestMonitor(t, &Config{ + Sensor: "src", + Rules: []Rule{{Name: "too-hot", Key: "temperature", Operator: ">", Threshold: 90}}, + }, src, nil) + + cases := map[string]interface{}{ + "non-object payload": "1h", + "missing rule_name": map[string]interface{}{"duration": "1h"}, + "empty rule_name": map[string]interface{}{"rule_name": "", "duration": "1h"}, + "unknown rule_name": map[string]interface{}{"rule_name": "ghost", "duration": "1h"}, + "missing duration": map[string]interface{}{"rule_name": "too-hot"}, + "non-string duration": map[string]interface{}{"rule_name": "too-hot", "duration": 30}, + "invalid duration": map[string]interface{}{"rule_name": "too-hot", "duration": "5w"}, + } + for name, payload := range cases { + t.Run(name, func(t *testing.T) { + if _, err := m.DoCommand(ctx, map[string]interface{}{"snooze": payload}); err == nil { + t.Fatal("expected error, got nil") + } + }) + } +}