Phase 9: Hub API and Apprise alerts (/capabilities, /ask, /notify)
Build on Phases 1-8. Adds the project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md) and the alert engine from
AGENTS.md §4 FR-12/FR-13, §8, §11 Phase 9.
- hub: internal/hub implements AskRequest/AskResponse/NotifyRequest and a
deterministic, read-only Answerer. It answers screen time today/yesterday,
what is blocked now, and device presence, scoped to one child or all kids.
Screen time is the foreground-app total per device-day; the domain total is
used only when a device reports no apps, so a report carrying both is never
double-counted.
- apprise: internal/apprise is a small Apprise API client (stored key or
stateless URL mode, optional bearer token, typed notifications).
- alert: internal/alert evaluates device state on an interval and raises one
Apprise alert per transition: a limit hit (a device becomes blocked) or a
device offline longer than the threshold. A never-seen device is never
alerted, and the first observation seeds state without replaying an old
limit. The alert is recorded before delivery, with its delivery outcome.
- store: migration 005 adds `alerts` (delivery outcome, audited) and
`alert_state` (per-device dedup). New usage reads from the telemetry detail
table so the daily answer can be checked independently.
- web: GET /capabilities, POST /ask (read-only) and POST /notify behind the
shared CONTROL_HUB_TOKEN, separate from the session and admin API tokens.
/notify accepts the contract's {from,level,message,person} and delivers it
through Apprise (the future tamper-report path, FR-17).
- cmd/control: start the alert engine (CONTROL_ALERTS_ENABLED,
CONTROL_ALERT_OFFLINE_AFTER, CONTROL_ALERT_INTERVAL) and add `alerts` and
`usage` subcommands for read-only inspection.
- deploy/verify-phase9.sh + `make verify-phase9` prove the definition of done;
version 0.7.0-phase9.
Measured on .13 (2026-10-13):
- GET /capabilities -> internet_use_control topics.
- POST /ask "how long have the kids been on screens today?" -> "The kids have
had 1h 22m of screen time today." (total_seconds=4925); the independent
count from the telemetry detail table is also 4925 (app=4925, domain=4800).
- A manual lock raised a delivered limit alert (id 4); an MQTT offline status
raised a delivered offline alert (id 5); POST /notify delivered (id 6). All
three report delivered=1 in `alerts`.
Tests: gofmt, go vet ./... and go test ./... pass in golang:1.25-alpine.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
263
internal/alert/alert.go
Normal file
263
internal/alert/alert.go
Normal file
@@ -0,0 +1,263 @@
|
||||
// Package alert watches device state and tells Sam when something needs his
|
||||
// attention (AGENTS.md §4 FR-13, §5 screen 6, §11 Phase 9).
|
||||
//
|
||||
// Two conditions raise an alert:
|
||||
//
|
||||
// - a limit is hit: a device becomes blocked (a weekly window turned on, or a
|
||||
// manual lock was applied), and
|
||||
// - a device is offline too long: presence is false and the last report is
|
||||
// older than the configured threshold.
|
||||
//
|
||||
// Alerts fire once per transition, not once per poll. The last observed
|
||||
// condition is kept in `alert_state`; the first time the engine sees a device it
|
||||
// seeds that state without alerting, so a restart never replays an old limit.
|
||||
//
|
||||
// Delivery goes through Apprise. The `alerts` row is written before delivery, so
|
||||
// a delivery failure leaves a visible, auditable record rather than silence.
|
||||
package alert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/apprise"
|
||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
||||
)
|
||||
|
||||
// DefaultOfflineAfter is how long a device may be unreachable before Sam is
|
||||
// told. Overridable with CONTROL_ALERT_OFFLINE_AFTER.
|
||||
const DefaultOfflineAfter = 30 * time.Minute
|
||||
|
||||
// DefaultInterval is how often the engine re-evaluates. It bounds how late an
|
||||
// alert can be, and it is short enough that a limit hit feels immediate.
|
||||
const DefaultInterval = 30 * time.Second
|
||||
|
||||
// Store is the data surface the engine needs. *store.Store satisfies it.
|
||||
type Store interface {
|
||||
ListDevices(ctx context.Context) ([]store.Device, error)
|
||||
EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error)
|
||||
AlertStates(ctx context.Context) (map[int64]store.AlertState, error)
|
||||
SetAlertState(ctx context.Context, deviceID int64, locked, offline bool) error
|
||||
CreateAlert(ctx context.Context, deviceID *int64, kind, message string) (*store.Alert, error)
|
||||
MarkAlertDelivered(ctx context.Context, id int64, errMsg string) error
|
||||
}
|
||||
|
||||
// Notifier delivers one alert. *apprise.Client satisfies it; a nil Notifier is
|
||||
// not configured and the engine records the alert as undelivered.
|
||||
type Notifier interface {
|
||||
Configured() bool
|
||||
SendType(ctx context.Context, title, body, notifyType string) error
|
||||
}
|
||||
|
||||
// Engine evaluates device state on an interval and raises transition alerts.
|
||||
type Engine struct {
|
||||
Store Store
|
||||
Notifier Notifier
|
||||
Log *slog.Logger
|
||||
OfflineAfter time.Duration
|
||||
Interval time.Duration
|
||||
Location *time.Location
|
||||
// Now is injectable so tests are deterministic.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// New builds an engine. A zero offlineAfter uses DefaultOfflineAfter; a zero
|
||||
// interval uses DefaultInterval.
|
||||
func New(st Store, notifier Notifier, log *slog.Logger, offlineAfter time.Duration, loc *time.Location) *Engine {
|
||||
if offlineAfter <= 0 {
|
||||
offlineAfter = DefaultOfflineAfter
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
if loc == nil {
|
||||
loc = time.Local
|
||||
}
|
||||
return &Engine{
|
||||
Store: st,
|
||||
Notifier: notifier,
|
||||
Log: log,
|
||||
OfflineAfter: offlineAfter,
|
||||
Interval: DefaultInterval,
|
||||
Location: loc,
|
||||
Now: func() time.Time { return time.Now().In(loc) },
|
||||
}
|
||||
}
|
||||
|
||||
// Run evaluates once at start-up (to seed state without alerting) and then on
|
||||
// every tick until ctx is cancelled.
|
||||
func (e *Engine) Run(ctx context.Context) {
|
||||
interval := e.Interval
|
||||
if interval <= 0 {
|
||||
interval = DefaultInterval
|
||||
}
|
||||
e.Evaluate(ctx)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
e.Evaluate(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate checks every device once and raises an alert for each condition that
|
||||
// just became true.
|
||||
func (e *Engine) Evaluate(ctx context.Context) {
|
||||
now := e.now()
|
||||
devs, err := e.Store.ListDevices(ctx)
|
||||
if err != nil {
|
||||
e.Log.Error("alert evaluate: list devices", "err", err)
|
||||
return
|
||||
}
|
||||
states, err := e.Store.AlertStates(ctx)
|
||||
if err != nil {
|
||||
e.Log.Error("alert evaluate: alert states", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, dev := range devs {
|
||||
eff, err := e.Store.EffectiveState(ctx, dev, now)
|
||||
if err != nil {
|
||||
e.Log.Error("alert evaluate: effective state", "device", dev.Name, "err", err)
|
||||
continue
|
||||
}
|
||||
locked := eff.Locked
|
||||
offline := e.offlineTooLong(dev, now)
|
||||
|
||||
prev, known := states[dev.ID]
|
||||
if !known {
|
||||
// First observation: remember the condition, alert on nothing.
|
||||
if err := e.Store.SetAlertState(ctx, dev.ID, locked, offline); err != nil {
|
||||
e.Log.Error("alert seed state", "device", dev.Name, "err", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if locked && !prev.Locked {
|
||||
e.fire(ctx, dev, store.AlertLimit, limitMessage(dev, eff, e.loc()))
|
||||
}
|
||||
if offline && !prev.Offline {
|
||||
e.fire(ctx, dev, store.AlertOffline, offlineMessage(dev, now))
|
||||
}
|
||||
if locked != prev.Locked || offline != prev.Offline {
|
||||
if err := e.Store.SetAlertState(ctx, dev.ID, locked, offline); err != nil {
|
||||
e.Log.Error("alert save state", "device", dev.Name, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fire records an alert and delivers it. A delivery failure is recorded on the
|
||||
// row and logged; it is never retried here, so a broken Apprise cannot spin.
|
||||
func (e *Engine) fire(ctx context.Context, dev store.Device, kind, message string) {
|
||||
deviceID := dev.ID
|
||||
alert, err := e.Store.CreateAlert(ctx, &deviceID, kind, message)
|
||||
if err != nil {
|
||||
e.Log.Error("alert create", "device", dev.Name, "kind", kind, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
if e.Notifier == nil || !e.Notifier.Configured() {
|
||||
if err := e.Store.MarkAlertDelivered(ctx, alert.ID, "apprise is not configured"); err != nil {
|
||||
e.Log.Error("alert record", "id", alert.ID, "err", err)
|
||||
}
|
||||
e.Log.Warn("alert not delivered: apprise is not configured",
|
||||
"device", dev.Name, "kind", kind, "message", message)
|
||||
return
|
||||
}
|
||||
|
||||
title := "Internet Use Control — " + alertTitle(kind)
|
||||
dctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
if err := e.Notifier.SendType(dctx, title, message, apprise.TypeWarning); err != nil {
|
||||
if err := e.Store.MarkAlertDelivered(ctx, alert.ID, err.Error()); err != nil {
|
||||
e.Log.Error("alert record", "id", alert.ID, "err", err)
|
||||
}
|
||||
e.Log.Error("alert delivery failed", "device", dev.Name, "kind", kind, "err", err)
|
||||
return
|
||||
}
|
||||
if err := e.Store.MarkAlertDelivered(ctx, alert.ID, ""); err != nil {
|
||||
e.Log.Error("alert record", "id", alert.ID, "err", err)
|
||||
}
|
||||
e.Log.Info("alert delivered", "device", dev.Name, "kind", kind, "alert_id", alert.ID)
|
||||
}
|
||||
|
||||
// offlineTooLong reports whether a device has been unreachable past the
|
||||
// threshold. A device that has never reported is never "offline too long": a
|
||||
// freshly registered device is not an alert.
|
||||
func (e *Engine) offlineTooLong(dev store.Device, now time.Time) bool {
|
||||
if dev.Online || dev.LastSeen == nil {
|
||||
return false
|
||||
}
|
||||
return now.Sub(*dev.LastSeen) > e.OfflineAfter
|
||||
}
|
||||
|
||||
func (e *Engine) now() time.Time {
|
||||
if e.Now == nil {
|
||||
return time.Now().In(e.loc())
|
||||
}
|
||||
return e.Now()
|
||||
}
|
||||
|
||||
func (e *Engine) loc() *time.Location {
|
||||
if e.Location == nil {
|
||||
return time.Local
|
||||
}
|
||||
return e.Location
|
||||
}
|
||||
|
||||
// limitMessage is the one sentence Sam receives when a device is blocked.
|
||||
func limitMessage(dev store.Device, eff store.EffectiveState, loc *time.Location) string {
|
||||
reason := "its schedule"
|
||||
if eff.Reason == "manual" {
|
||||
reason = "a manual lock"
|
||||
}
|
||||
if eff.Until != nil {
|
||||
return fmt.Sprintf("%s reached its limit and is blocked by %s (until %s).",
|
||||
dev.Name, reason, eff.Until.In(loc).Format("15:04"))
|
||||
}
|
||||
return fmt.Sprintf("%s reached its limit and is blocked by %s.", dev.Name, reason)
|
||||
}
|
||||
|
||||
// offlineMessage is the one sentence Sam receives when a device is unreachable.
|
||||
func offlineMessage(dev store.Device, now time.Time) string {
|
||||
if dev.LastSeen == nil {
|
||||
return fmt.Sprintf("%s has been offline for a while.", dev.Name)
|
||||
}
|
||||
return fmt.Sprintf("%s has been offline for %s.", dev.Name, humanDuration(now.Sub(*dev.LastSeen)))
|
||||
}
|
||||
|
||||
func alertTitle(kind string) string {
|
||||
switch kind {
|
||||
case store.AlertLimit:
|
||||
return "limit hit"
|
||||
case store.AlertOffline:
|
||||
return "device offline"
|
||||
default:
|
||||
return "notification"
|
||||
}
|
||||
}
|
||||
|
||||
// humanDuration renders a duration compactly: "2h 10m", "45m", "under a minute".
|
||||
func humanDuration(d time.Duration) string {
|
||||
seconds := int(d.Seconds())
|
||||
if seconds <= 0 {
|
||||
return "0m"
|
||||
}
|
||||
if seconds < 60 {
|
||||
return "under a minute"
|
||||
}
|
||||
m := seconds / 60
|
||||
h := m / 60
|
||||
m %= 60
|
||||
if h == 0 {
|
||||
return fmt.Sprintf("%dm", m)
|
||||
}
|
||||
return fmt.Sprintf("%dh %02dm", h, m)
|
||||
}
|
||||
Reference in New Issue
Block a user