-
Notifications
You must be signed in to change notification settings - Fork 10
feat(notifications): lock-screen when a device goes quiet or the fuse is over #983
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "ftw": minor | ||
| --- | ||
|
|
||
| The phone app can now notify you when a device goes quiet or the house draws more than the fuse allows. Same thresholds as before (ten minutes of silence, thirty seconds over the rating) so a blip is not a lock-screen. |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -97,6 +97,11 @@ func DefaultRules() []config.NotificationRule { | |
| {Type: PushChargingSessionComplete, Enabled: false, Priority: 3}, | ||
| {Type: PushChargingInterrupted, Enabled: false, Priority: 4, CooldownS: 3600}, | ||
| {Type: PushUpdateInstalled, Enabled: false, Priority: 2}, | ||
| // Phone lock-screen counterparts of the operator rules above. | ||
| // Same thresholds so a blip or a kettle is not a notification; | ||
| // the sentences come from the catalogue, never from templates. | ||
| {Type: PushDriverOffline, Enabled: false, ThresholdS: DefaultThresholdS, Priority: 4, CooldownS: DefaultCooldownS}, | ||
| {Type: PushFuseOverLimit, Enabled: false, ThresholdS: 30, Priority: 5, CooldownS: 900}, | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -110,7 +115,7 @@ func KnownRuleTypes() []string { | |
| EventDriverOffline, EventDriverRecovered, EventUpdateAvailable, | ||
| EventFuseOverLimit, EventConcurrentDriversOffline, | ||
| PushChargingSessionComplete, PushChargingInterrupted, | ||
| PushUpdateInstalled, | ||
| PushUpdateInstalled, PushDriverOffline, PushFuseOverLimit, | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -496,11 +501,15 @@ func (s *Service) handleUpdateAvailable(ev events.UpdateAvailable) { | |
| } | ||
|
|
||
| // evaluateFuse reads the live per-phase current snapshot and fires the | ||
| // fuse_over_limit rule when a phase has been over its rating for at | ||
| // least threshold_s. State is per-phase: firstOverAt records when the | ||
| // over-window started, alreadyFired latches to fire once per outage, | ||
| // and cooldownS (shared lastFired map) rate-limits re-fires across | ||
| // quick recover/over cycles. | ||
| // fuse over-limit rules when a phase has been over its rating for at | ||
| // least that rule's threshold_s. State is per-phase: firstOverAt records | ||
| // when the over-window started, alreadyFired latches to fire once per | ||
| // outage per rule, and cooldownS rate-limits re-fires across quick | ||
| // recover/over cycles. | ||
| // | ||
| // Two rule names share this check: the operator template (fuse_over_limit) | ||
| // and the phone catalogue (fuse.over_limit). Each is gated on its own | ||
| // enabled bit so ntfy and the lock screen can be opted into separately. | ||
| func (s *Service) evaluateFuse(now time.Time) { | ||
| if s == nil { | ||
| return | ||
|
|
@@ -510,11 +519,6 @@ func (s *Service) evaluateFuse(now time.Time) { | |
| s.mu.Unlock() | ||
| return | ||
| } | ||
| rule, ok := findRule(s.cfg.Events, EventFuseOverLimit) | ||
| if !ok || !rule.Enabled { | ||
| s.mu.Unlock() | ||
| return | ||
| } | ||
| reader := s.fuseReader | ||
| if reader == nil { | ||
| s.mu.Unlock() | ||
|
|
@@ -526,53 +530,67 @@ func (s *Service) evaluateFuse(now time.Time) { | |
| return | ||
| } | ||
| cfg := s.cfg | ||
| threshold := time.Duration(rule.ThresholdS) * time.Second | ||
| if threshold == 0 { | ||
| threshold = 30 * time.Second | ||
| } | ||
| type toFire struct { | ||
| rule config.NotificationRule | ||
| data templateData | ||
| } | ||
| var pending []toFire | ||
|
|
||
| // Shared over-window: a phase is over or it isn't, independent of | ||
| // which rule is watching. Reset once, then each enabled rule decides | ||
| // against its own threshold and latch. | ||
| for phase, a := range amps { | ||
| key := EventFuseOverLimit + "|" + phase | ||
| if a <= limitA { | ||
| // Back under: reset the over-window and per-outage latch. | ||
| delete(s.fuseFirstOverAt, phase) | ||
| delete(s.alreadyFired, key) | ||
| continue | ||
| } | ||
| first, ok := s.fuseFirstOverAt[phase] | ||
| if !ok { | ||
| if _, seen := s.fuseFirstOverAt[phase]; !seen { | ||
| s.fuseFirstOverAt[phase] = now | ||
|
Comment on lines
+547
to
548
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When both fuse rules are disabled, this unconditional write still records how long each phase has been over its limit. If either rule is later enabled while the phase remains over, Useful? React with 👍 / 👎. |
||
| continue | ||
| } | ||
| if now.Sub(first) < threshold { | ||
| } | ||
|
|
||
| for _, typ := range []string{EventFuseOverLimit, PushFuseOverLimit} { | ||
| rule, found := findRule(cfg.Events, typ) | ||
| if !found || !rule.Enabled { | ||
| continue | ||
| } | ||
| if s.alreadyFired[key] { | ||
| continue | ||
| threshold := time.Duration(rule.ThresholdS) * time.Second | ||
| if threshold == 0 { | ||
| threshold = 30 * time.Second | ||
| } | ||
| if rule.CooldownS > 0 { | ||
| if last, ok := s.lastFired[key]; ok && now.Sub(last) < time.Duration(rule.CooldownS)*time.Second { | ||
| for phase, a := range amps { | ||
| key := typ + "|" + phase | ||
| if a <= limitA { | ||
| delete(s.alreadyFired, key) | ||
| continue | ||
| } | ||
| first, seen := s.fuseFirstOverAt[phase] | ||
| if !seen || now.Sub(first) < threshold { | ||
| continue | ||
| } | ||
| if s.alreadyFired[key] { | ||
| continue | ||
| } | ||
| if rule.CooldownS > 0 { | ||
| if last, ok := s.lastFired[key]; ok && now.Sub(last) < time.Duration(rule.CooldownS)*time.Second { | ||
| continue | ||
| } | ||
| } | ||
| s.alreadyFired[key] = true | ||
| s.lastFired[key] = now | ||
| pending = append(pending, toFire{ | ||
| rule: rule, | ||
| data: templateData{ | ||
| EventType: typ, | ||
| Timestamp: now.UTC().Format(time.RFC3339), | ||
| Duration: humanDuration(now.Sub(first)), | ||
| DurationS: int(now.Sub(first) / time.Second), | ||
| Phase: phase, | ||
| Amps: a, | ||
| LimitA: limitA, | ||
| }, | ||
| }) | ||
| } | ||
| s.alreadyFired[key] = true | ||
| s.lastFired[key] = now | ||
| pending = append(pending, toFire{ | ||
| rule: rule, | ||
| data: templateData{ | ||
| EventType: EventFuseOverLimit, | ||
| Timestamp: now.UTC().Format(time.RFC3339), | ||
| Duration: humanDuration(now.Sub(first)), | ||
| DurationS: int(now.Sub(first) / time.Second), | ||
| Phase: phase, | ||
| Amps: a, | ||
| LimitA: limitA, | ||
| }, | ||
| }) | ||
| } | ||
| s.mu.Unlock() | ||
|
|
||
|
|
@@ -670,7 +688,7 @@ func (s *Service) observeAt(health map[string]telemetry.DriverHealth, now time.T | |
| key := rule.Type + "|" + driver | ||
|
|
||
| switch rule.Type { | ||
| case EventDriverOffline: | ||
| case EventDriverOffline, PushDriverOffline: | ||
| threshold := time.Duration(rule.ThresholdS) * time.Second | ||
| if threshold == 0 { | ||
| threshold = time.Duration(DefaultThresholdS) * time.Second | ||
|
|
@@ -914,7 +932,42 @@ func (s *Service) buildData(driver, eventType string, since time.Duration, now t | |
| return td | ||
| } | ||
|
|
||
| func (s *Service) dispatchCatalogue(cfg *config.Notifications, rule config.NotificationRule, data templateData) { | ||
| title, body, err := RenderPush(rule.Type, catalogueArgs(rule.Type, data)) | ||
| if err != nil { | ||
| slog.Warn("notifications: catalogue render failed", "event", rule.Type, "err", err) | ||
| s.bumpFailed() | ||
| s.emitDispatched(rule.Type, data.Device, Message{Priority: rule.Priority}, "failed", err.Error()) | ||
| return | ||
| } | ||
| prio := rule.Priority | ||
| if prio == 0 { | ||
| prio = cfg.DefaultPriority | ||
| } | ||
| s.deliver(rule.Type, data.Device, Message{ | ||
| Title: title, | ||
| Body: body, | ||
| Priority: prio, | ||
| Tags: splitTags(rule.Tags), | ||
| }) | ||
| } | ||
|
|
||
| func catalogueArgs(kind string, data templateData) map[string]string { | ||
| switch kind { | ||
| case PushDriverOffline: | ||
| return map[string]string{"name": data.Device} | ||
| case PushFuseOverLimit: | ||
| return map[string]string{"phase": data.Phase} | ||
| default: | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| func (s *Service) dispatch(cfg *config.Notifications, rule config.NotificationRule, data templateData) { | ||
| if _, ok := PushSentences[rule.Type]; ok { | ||
| s.dispatchCatalogue(cfg, rule, data) | ||
| return | ||
| } | ||
| titleTpl := rule.TitleTemplate | ||
| if strings.TrimSpace(titleTpl) == "" { | ||
| titleTpl = defaultTitleFor(rule.Type) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a site already has
driver_offlineorfuse_over_limitenabled and the phone enables the corresponding new catalogue toggle, both rule entries independently dispatch whiledeliversends every dispatch to every installed publisher. The same outage therefore produces two web pushes and two ntfy posts, rather than one notification per selected transport; coalesce these aliases or route catalogue events only to web push.Useful? React with 👍 / 👎.