// Reports that arrive from devices over MQTT: presence and telemetry // (AGENTS.md §7, §11 Phase 3). // // The broker is transport only. Every report is written to SQLite here, because // SQLite is the source of truth (SYSTEM_MAP.md). A malformed or hostile report // must never take the service down: callers validate first, and a failed insert // is logged and dropped. package store import ( "context" "fmt" "time" ) // SetPresence records that a device was seen and whether it is online now. It // returns whether the online flag changed. // // Presence comes from the broker's Last Will and Testament: a device publishes // a retained "online" status, and the broker publishes its retained will // ("online": false) when the connection drops without a clean disconnect. // // last_seen is refreshed on every report. The audit log only gets an entry when // the online flag changes, so a chatty device does not flood the log (FR-15). func (s *Store) SetPresence(ctx context.Context, id int64, online bool, at time.Time, actor string) (bool, error) { if at.IsZero() { at = time.Now() } at = at.UTC() tx, err := s.db.BeginTx(ctx, nil) if err != nil { return false, fmt.Errorf("set presence: %w", err) } defer func() { _ = tx.Rollback() }() var current int if err := tx.QueryRowContext(ctx, `SELECT online FROM devices WHERE id = ?`, id).Scan(¤t); err != nil { return false, fmt.Errorf("set presence: %w", err) } if _, err := tx.ExecContext(ctx, `UPDATE devices SET online = ?, last_seen = ?, updated_at = datetime('now') WHERE id = ?`, boolToInt(online), at.Format(time.RFC3339), id); err != nil { return false, fmt.Errorf("set presence: %w", err) } changed := (current != 0) != online if changed { action, detail := "device.offline", "last will received" if online { action, detail = "device.online", "status report" } if err := insertAudit(ctx, tx, actor, action, &id, detail); err != nil { return false, err } } if err := tx.Commit(); err != nil { return false, fmt.Errorf("commit presence: %w", err) } return changed, nil } // TelemetryEntry is one app or domain row of a usage report. type TelemetryEntry struct { App string Domain string Seconds int } // InsertTelemetry upserts a rolling daily usage total per (device, day, app, // domain). Reports are repeats, not deltas: a later report for the same key // replaces the earlier total (migration 002). It returns the number of rows // written. func (s *Store) InsertTelemetry(ctx context.Context, id int64, day string, entries []TelemetryEntry) (int, error) { if len(entries) == 0 { return 0, nil } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return 0, fmt.Errorf("insert telemetry: %w", err) } defer func() { _ = tx.Rollback() }() n := 0 for _, e := range entries { if e.Seconds < 0 { continue } if _, err := tx.ExecContext(ctx, ` INSERT INTO telemetry (device_id, app, domain, seconds, day) VALUES (?, ?, ?, ?, ?) ON CONFLICT(device_id, day, app, domain) DO UPDATE SET seconds = excluded.seconds, created_at = datetime('now')`, id, e.App, e.Domain, e.Seconds, day); err != nil { return n, fmt.Errorf("insert telemetry: %w", err) } n++ } if err := tx.Commit(); err != nil { return n, fmt.Errorf("commit telemetry: %w", err) } return n, nil } // TelemetryForDay returns one device's usage rows for a day, biggest first. func (s *Store) TelemetryForDay(ctx context.Context, id int64, day string) ([]TelemetryEntry, error) { rows, err := s.db.QueryContext(ctx, ` SELECT app, domain, seconds FROM telemetry WHERE device_id = ? AND day = ? ORDER BY seconds DESC`, id, day) if err != nil { return nil, fmt.Errorf("telemetry for day: %w", err) } defer rows.Close() out := make([]TelemetryEntry, 0, 8) for rows.Next() { var e TelemetryEntry if err := rows.Scan(&e.App, &e.Domain, &e.Seconds); err != nil { return nil, fmt.Errorf("scan telemetry: %w", err) } out = append(out, e) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("telemetry for day: %w", err) } return out, nil }