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>
264 lines
8.2 KiB
Go
264 lines
8.2 KiB
Go
// 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)
|
|
}
|