Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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,
Expand All @@ -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 `<key>_triggered` boolean for each rule indicating whether it is currently firing.
Returns the most recent readings from the monitored sensor, plus per rule a `<key>_triggered` boolean indicating whether it is currently firing and a `<key>_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
}
```

Expand All @@ -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 `<key>_snoozed` boolean per rule.
111 changes: 107 additions & 4 deletions resources/sensormonitor/sensormonitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -411,30 +434,80 @@ func (m *sensorMonitor) runActions(ctx context.Context, ruleIdx int, rule Rule,
}

// Readings returns the most recent sensor readings plus, per rule, a
// "<key>_triggered" boolean indicating whether the rule is currently firing.
// "<key>_triggered" boolean indicating whether the rule is currently firing and a
// "<key>_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": <name>, "duration": <duration>}} to suppress
// evaluation of the named rule for the given duration, e.g. "30s", "90m",
// "24h", "5d". Returns {"rule_name": <name>, "snoozed_until": <RFC3339 time>}.
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 {
m.poll(ctx)
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": <name>, "duration": <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\": <string>, \"duration\": <string>}, 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 {
Expand Down Expand Up @@ -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) {
Expand Down
141 changes: 141 additions & 0 deletions resources/sensormonitor/sensormonitor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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")
}
})
}
}