Phase 6: schedules, blocklists, allowlists (editors + effective state)
- Weekly schedule editor per device: a Monday-Sunday grid of blocking windows, including windows that cross midnight. - Blocklist/allowlist editor per device: app (Android package / Windows process) and site (hostname) entries, tagged by the device platform. - Effective desired state: a manual lock, an active schedule window, or neither. While blocked the allowlist is honoured (Spotify stays while other apps are blocked). Published retained with reason schedule/manual. - Boundary-aware republish loop so a window turns on and off on time, and manual lock expiry is cleared promptly. - CONTROL_TIMEZONE (default Australia/Sydney) with embedded tzdata, so wall-clock windows are evaluated in the family zone, not the container. - Every change is written to the append-only audit log (FR-15). Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -35,6 +35,12 @@ var version = "dev"
|
||||
// service repairs it from the database without a restart.
|
||||
const republishInterval = 15 * time.Minute
|
||||
|
||||
// republishPoll is how often the loop re-evaluates the next schedule boundary.
|
||||
// It bounds how late a transition can be after the schedule is edited, and it
|
||||
// keeps the service from sleeping past a boundary that did not exist yet when
|
||||
// the loop last looked.
|
||||
const republishPoll = 30 * time.Second
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
@@ -104,11 +110,12 @@ func run() error {
|
||||
Password: cfg.MQTTPassword,
|
||||
CAFile: cfg.MQTTCAFile,
|
||||
Actor: "device",
|
||||
Location: cfg.ScheduleLocation,
|
||||
Log: log,
|
||||
Devices: st,
|
||||
Source: st,
|
||||
Reports: st,
|
||||
OnConnected: func() { republish(ctx, log, link) },
|
||||
OnConnected: func() { republish(ctx, log, link, st) },
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -116,7 +123,7 @@ func run() error {
|
||||
pub = link
|
||||
link.Start(ctx)
|
||||
defer link.Stop()
|
||||
go republishLoop(ctx, log, link)
|
||||
go republishLoop(ctx, log, link, st, cfg.ScheduleLocation)
|
||||
if !cfg.MQTTTLS() {
|
||||
log.Warn("mqtt is running without TLS; the shared broker has no TLS listener yet (AGENTS.md §10 rule 6)",
|
||||
"broker", cfg.MQTTBroker)
|
||||
@@ -169,14 +176,20 @@ func run() error {
|
||||
}
|
||||
|
||||
// republish pushes every device's retained config. It runs once per connect and
|
||||
// then periodically, so the broker's view of the desired state is rebuilt from
|
||||
// SQLite, the source of truth.
|
||||
func republish(ctx context.Context, log *slog.Logger, link *mqtt.Link) {
|
||||
// whenever the desired state may have changed, so the broker's view is rebuilt
|
||||
// from SQLite, the source of truth. It first clears any manual lock that has
|
||||
// expired, so the published state is never stale.
|
||||
func republish(ctx context.Context, log *slog.Logger, link *mqtt.Link, st *store.Store) {
|
||||
if link == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
if n, err := st.ExpireLocks(ctx); err != nil {
|
||||
log.Error("expire locks", "err", err)
|
||||
} else if n > 0 {
|
||||
log.Info("expired timed locks", "count", n)
|
||||
}
|
||||
n, err := link.PublishAll(ctx)
|
||||
if err != nil {
|
||||
log.Error("mqtt publish all", "published", n, "err", err)
|
||||
@@ -185,19 +198,39 @@ func republish(ctx context.Context, log *slog.Logger, link *mqtt.Link) {
|
||||
log.Info("mqtt retained config refreshed", "devices", n)
|
||||
}
|
||||
|
||||
func republishLoop(ctx context.Context, log *slog.Logger, link *mqtt.Link) {
|
||||
ticker := time.NewTicker(republishInterval)
|
||||
defer ticker.Stop()
|
||||
// republishLoop refreshes the retained config when a schedule boundary is
|
||||
// crossed and otherwise on a long interval. It re-evaluates often instead of
|
||||
// sleeping until one computed boundary, so a schedule edited after the loop
|
||||
// started still turns on and off on time (FR-2) without a poll from the device.
|
||||
func republishLoop(ctx context.Context, log *slog.Logger, link *mqtt.Link, st *store.Store, loc *time.Location) {
|
||||
if loc == nil {
|
||||
loc = time.Local
|
||||
}
|
||||
lastEval := time.Now()
|
||||
lastPublish := lastEval
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if !link.Connected() {
|
||||
continue
|
||||
}
|
||||
republish(ctx, log, link)
|
||||
case <-time.After(republishPoll):
|
||||
}
|
||||
if !link.Connected() {
|
||||
lastEval = time.Now()
|
||||
continue
|
||||
}
|
||||
now := time.Now()
|
||||
shouldPublish := !now.Before(lastPublish.Add(republishInterval))
|
||||
// A boundary between the last evaluation and now has been crossed.
|
||||
if boundary, err := st.NextScheduleBoundary(ctx, lastEval.In(loc)); err != nil {
|
||||
log.Error("next schedule boundary", "err", err)
|
||||
} else if boundary != nil && !boundary.After(now) {
|
||||
shouldPublish = true
|
||||
}
|
||||
if shouldPublish {
|
||||
republish(ctx, log, link, st)
|
||||
lastPublish = time.Now()
|
||||
}
|
||||
lastEval = now
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user