Phase 3: MQTT transport — retained config, status/telemetry, LWT presence, TLS/ACL design

- internal/mqtt: publish each device's desired state retained on
  restrict/<device>/config; subscribe to restrict/+/status and
  restrict/+/telemetry and write reports to SQLite; publish on every state
  change, republish on connect and every 15 minutes from SQLite, so the broker
  is repaired from the source of truth.
- store: presence (devices.online and last_seen, audited only on transitions),
  telemetry upsert per (device, day, app, domain) via migration 002, allow and
  block lists read into the config payload, device lookup by topic.
- config: MQTT settings. TLS plus per-device credentials are required; the
  pre-TLS shared broker needs an explicit CONTROL_MQTT_INSECURE escape hatch.
- web: republish config after lock, unlock, expiry, create and edit; broker card
  on the status partial.
- deploy/mosquitto: TLS listener, topic ACL, credential provisioning and a
  verifier that proves the policy on a throwaway broker. Not applied to the
  shared Mosquitto on .13 — that change needs Sam's approval.
- cmd/control: mqtt-clients prints the MQTT identity of every device.
- paho.mqtt.golang v1.5.1, which pulls golang.org/x/net and with it the
  golang.org/x/crypto bump to v0.42.0.

Verified on .13: retained config delivered to a fresh subscriber; a client
killed without DISCONNECT drove the broker's retained will and the service
flipped the device offline; telemetry landed in SQLite; make mqtt-verify passed
13/13; the service connected over TLS with credentials and a CA.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 14:37:43 +11:00
parent 7c88d3f52b
commit 14b304e0b0
31 changed files with 2624 additions and 47 deletions

414
internal/mqtt/link.go Normal file
View File

@@ -0,0 +1,414 @@
// The broker link: connect, subscribe to device reports, publish retained
// config. Everything that reaches SQLite is handled here (AGENTS.md §11
// Phase 3).
package mqtt
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"strings"
"sync"
"sync/atomic"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
// DeviceLookup finds a device from a topic it wrote to.
type DeviceLookup interface {
DeviceByConfigTopic(ctx context.Context, topic string) (*store.Device, error)
}
// ReportSink stores what a device reported.
type ReportSink interface {
SetPresence(ctx context.Context, id int64, online bool, at time.Time, actor string) (bool, error)
InsertTelemetry(ctx context.Context, id int64, day string, entries []store.TelemetryEntry) (int, error)
}
// ConfigSource reads the desired state that is published to devices.
type ConfigSource interface {
ListDevices(ctx context.Context) ([]store.Device, error)
DeviceLists(ctx context.Context, id int64) (allow, block []string, err error)
}
// Options configures the link.
type Options struct {
// Broker is a URL such as "tls://mosquitto.lab:8883" or, until the shared
// broker gets its TLS listener, "tcp://host.docker.internal:1883".
Broker string
// ClientID identifies the Control Service on the broker.
ClientID string
// Username and Password are the Control Service credentials. The broker's
// ACL gives this identity write access to every config topic and read
// access to every status and telemetry topic — and nothing else.
Username string
Password string
// CAFile is a PEM bundle used to verify the broker certificate.
CAFile string
// KeepAlive is the MQTT keepalive.
KeepAlive time.Duration
// ConnectTimeout bounds one connection attempt.
ConnectTimeout time.Duration
// Actor is the audit-log actor for reports that arrive over the broker.
Actor string
// Log receives structured logs.
Log *slog.Logger
// Devices and Reports are required.
Devices DeviceLookup
// Source is required to publish config.
Source ConfigSource
// Reports is required to store device reports.
Reports ReportSink
// OnConnected runs after every successful (re)connect. The service uses it
// to republish every device's retained config.
OnConnected func()
}
// Link is the live connection to the broker.
type Link struct {
opts Options
log *slog.Logger
client paho.Client
connected atomic.Bool
received atomic.Int64
published atomic.Int64
lastMsg atomic.Int64 // unix nanos
lastErrMu sync.Mutex
lastErrMsg string
}
// New builds the link. It does not connect; call Start.
func New(opts Options) (*Link, error) {
if opts.Devices == nil || opts.Source == nil || opts.Reports == nil {
return nil, errors.New("mqtt: Devices, Source and Reports are required")
}
if strings.TrimSpace(opts.Broker) == "" {
return nil, errors.New("mqtt: Broker is required")
}
if opts.ClientID == "" {
opts.ClientID = "control-service"
}
if opts.KeepAlive <= 0 {
opts.KeepAlive = 30 * time.Second
}
if opts.ConnectTimeout <= 0 {
opts.ConnectTimeout = 10 * time.Second
}
if opts.Actor == "" {
opts.Actor = "device"
}
if opts.Log == nil {
opts.Log = slog.Default()
}
l := &Link{opts: opts, log: opts.Log}
clientOpts := paho.NewClientOptions()
clientOpts.AddBroker(opts.Broker)
clientOpts.SetClientID(opts.ClientID)
clientOpts.SetUsername(opts.Username)
clientOpts.SetPassword(opts.Password)
clientOpts.SetKeepAlive(opts.KeepAlive)
clientOpts.SetPingTimeout(min(10*time.Second, opts.KeepAlive))
clientOpts.SetConnectTimeout(opts.ConnectTimeout)
clientOpts.SetWriteTimeout(opts.ConnectTimeout)
// The service is a publisher of retained truth and a listener for reports:
// reconnect forever, and re-subscribe on every connect.
clientOpts.SetAutoReconnect(true)
clientOpts.SetConnectRetry(true)
clientOpts.SetConnectRetryInterval(5 * time.Second)
clientOpts.SetMaxReconnectInterval(60 * time.Second)
// Clean session: the broker keeps the retained config, so the service does
// not need a queue of stale reports.
clientOpts.SetCleanSession(true)
clientOpts.SetOrderMatters(false)
if isTLSBroker(opts.Broker) {
tlsCfg, err := tlsConfig(opts.Broker, opts.CAFile)
if err != nil {
return nil, err
}
clientOpts.SetTLSConfig(tlsCfg)
}
clientOpts.SetOnConnectHandler(l.handleConnect)
clientOpts.SetConnectionLostHandler(func(_ paho.Client, err error) {
l.connected.Store(false)
l.setErr(err)
l.log.Warn("mqtt connection lost", "broker", opts.Broker, "err", err)
})
l.client = paho.NewClient(clientOpts)
return l, nil
}
// Start connects in the background. A broker outage must never stop the HTTP
// service: the dashboard keeps working and the link reconnects on its own.
func (l *Link) Start(ctx context.Context) {
l.log.Info("mqtt connecting", "broker", l.opts.Broker, "client_id", l.opts.ClientID,
"tls", isTLSBroker(l.opts.Broker), "username", l.opts.Username)
go func() {
if token := l.client.Connect(); token.Wait() && token.Error() != nil {
l.setErr(token.Error())
l.log.Error("mqtt connect", "err", token.Error())
}
}()
}
// Stop disconnects cleanly. The broker then publishes nothing on our behalf;
// the service's own will is not registered, because only the service publishes
// config and a stale config must survive a service restart.
func (l *Link) Stop() {
if l.client == nil {
return
}
if l.client.IsConnected() {
l.client.Unsubscribe(StatusSubscription, TelemetrySubscription)
}
l.client.Disconnect(250)
l.connected.Store(false)
}
// Broker returns the configured broker URL.
func (l *Link) Broker() string { return l.opts.Broker }
// Connected reports the current connection state.
func (l *Link) Connected() bool { return l.connected.Load() }
// Stats returns counters for the dashboard.
func (l *Link) Stats() (received, published int64, lastMessageAt time.Time, lastErr string) {
ns := l.lastMsg.Load()
if ns > 0 {
lastMessageAt = time.Unix(0, ns)
}
l.lastErrMu.Lock()
lastErr = l.lastErrMsg
l.lastErrMu.Unlock()
return l.received.Load(), l.published.Load(), lastMessageAt, lastErr
}
func (l *Link) setErr(err error) {
if err == nil {
return
}
l.lastErrMu.Lock()
l.lastErrMsg = err.Error()
l.lastErrMu.Unlock()
}
// handleConnect runs on every (re)connect, including the first one.
func (l *Link) handleConnect(c paho.Client) {
l.connected.Store(true)
l.setErr(nil)
l.log.Info("mqtt connected", "broker", l.opts.Broker)
for _, sub := range []struct {
topic string
handler paho.MessageHandler
}{
{StatusSubscription, l.handleStatusMessage},
{TelemetrySubscription, l.handleTelemetryMessage},
} {
token := c.Subscribe(sub.topic, 1, sub.handler)
if !token.WaitTimeout(l.opts.ConnectTimeout) {
l.log.Warn("mqtt subscribe timed out", "topic", sub.topic)
continue
}
if err := token.Error(); err != nil {
l.setErr(err)
l.log.Error("mqtt subscribe", "topic", sub.topic, "err", err)
continue
}
l.log.Info("mqtt subscribed", "topic", sub.topic)
}
if l.opts.OnConnected != nil {
l.opts.OnConnected()
}
}
// handleStatusMessage turns a device presence report into a store update.
func (l *Link) handleStatusMessage(_ paho.Client, msg paho.Message) {
l.noteMessage()
configTopic, ok := deviceFromTopic(msg.Topic(), SuffixStatus)
if !ok {
l.log.Warn("mqtt status on an unexpected topic", "topic", msg.Topic())
return
}
status, err := ParseStatus(msg.Payload())
if err != nil {
l.log.Warn("mqtt status rejected", "topic", msg.Topic(), "err", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
dev, err := l.opts.Devices.DeviceByConfigTopic(ctx, configTopic)
if err != nil {
l.log.Warn("mqtt status for an unknown device", "topic", msg.Topic(), "err", err)
return
}
online := status.IsOnline()
changed, err := l.opts.Reports.SetPresence(ctx, dev.ID, online, status.Timestamp(time.Now()), l.opts.Actor)
if err != nil {
l.log.Error("mqtt presence", "device", dev.Name, "err", err)
return
}
if changed {
l.log.Info("presence changed", "device", dev.Name, "topic", msg.Topic(),
"online", online, "retained", msg.Retained())
}
}
// handleTelemetryMessage stores a device usage report.
func (l *Link) handleTelemetryMessage(_ paho.Client, msg paho.Message) {
l.noteMessage()
configTopic, ok := deviceFromTopic(msg.Topic(), SuffixTelemetry)
if !ok {
l.log.Warn("mqtt telemetry on an unexpected topic", "topic", msg.Topic())
return
}
report, err := ParseTelemetry(msg.Payload())
if err != nil {
l.log.Warn("mqtt telemetry rejected", "topic", msg.Topic(), "err", err)
return
}
rows := report.Rows()
if len(rows) == 0 {
l.log.Warn("mqtt telemetry had no usable rows", "topic", msg.Topic())
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
dev, err := l.opts.Devices.DeviceByConfigTopic(ctx, configTopic)
if err != nil {
l.log.Warn("mqtt telemetry for an unknown device", "topic", msg.Topic(), "err", err)
return
}
day := report.DayOrDefault(time.Now())
n, err := l.opts.Reports.InsertTelemetry(ctx, dev.ID, day, rows)
if err != nil {
l.log.Error("mqtt telemetry", "device", dev.Name, "err", err)
return
}
l.log.Info("telemetry stored", "device", dev.Name, "day", day, "rows", n)
}
func (l *Link) noteMessage() {
l.received.Add(1)
l.lastMsg.Store(time.Now().UnixNano())
}
// PublishDeviceConfig publishes one device's desired state, retained, on the
// topic stored with the device. It is the single writer path for config
// (AGENTS.md §10 rule 5): only the Control Service publishes.
func (l *Link) PublishDeviceConfig(ctx context.Context, dev store.Device) error {
allow, block, err := l.opts.Source.DeviceLists(ctx, dev.ID)
if err != nil {
return err
}
payload, err := Config{
V: ConfigVersion,
DeviceID: dev.ID,
Name: dev.Name,
Platform: dev.Platform,
Locked: dev.Locked,
Until: dev.Until,
Allow: allow,
Block: block,
GeneratedAt: time.Now().UTC(),
}.Marshal()
if err != nil {
return err
}
return l.Publish(dev.Topic, payload)
}
// Publish sends one retained message and waits for the broker to accept it.
func (l *Link) Publish(topic string, payload []byte) error {
if !l.client.IsConnected() {
return errors.New("mqtt: not connected")
}
token := l.client.Publish(topic, 1, true, payload)
if !token.WaitTimeout(l.opts.ConnectTimeout) {
return fmt.Errorf("mqtt: publish to %s timed out", topic)
}
if err := token.Error(); err != nil {
return fmt.Errorf("mqtt: publish to %s: %w", topic, err)
}
l.published.Add(1)
return nil
}
// PublishAll republishes every device's retained config. It runs after every
// connect, so a broker that lost its retained store is repaired from SQLite —
// the database stays the source of truth.
func (l *Link) PublishAll(ctx context.Context) (int, error) {
devices, err := l.opts.Source.ListDevices(ctx)
if err != nil {
return 0, err
}
published := 0
var firstErr error
for _, dev := range devices {
if err := l.PublishDeviceConfig(ctx, dev); err != nil {
l.log.Error("mqtt publish config", "device", dev.Name, "err", err)
if firstErr == nil {
firstErr = err
}
continue
}
published++
}
return published, firstErr
}
// isTLSBroker reports whether the broker URL asks for TLS.
func isTLSBroker(broker string) bool {
return strings.HasPrefix(broker, "tls://") || strings.HasPrefix(broker, "ssl://")
}
// tlsConfig verifies the broker against the lab CA. Every device and the
// Control Service use the same CA, so a rogue broker on the LAN cannot take
// over a device's rules.
func tlsConfig(broker, caFile string) (*tls.Config, error) {
if caFile == "" {
return nil, errors.New("mqtt: a TLS broker needs CONTROL_MQTT_CA_FILE")
}
pem, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("mqtt: read CA %s: %w", caFile, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("mqtt: no certificates in %s", caFile)
}
cfg := &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
if u, err := url.Parse(broker); err == nil && u.Hostname() != "" {
cfg.ServerName = u.Hostname()
}
return cfg, nil
}

223
internal/mqtt/payload.go Normal file
View File

@@ -0,0 +1,223 @@
// Package mqtt is the Control Service's link to the broker (AGENTS.md §10
// rules 5–6, §11 Phase 3; TRANSPORT_DESIGN.md).
//
// The broker is transport only. This package publishes each device's desired
// state, retained, on restrict/<device>/config, and consumes the reports that
// devices write back. SQLite stays the source of truth (SYSTEM_MAP.md): nothing
// here is authoritative, and a report is handed straight to the store.
//
// Topics (TRANSPORT_DESIGN.md "Topic design"):
//
// restrict/<device>/config service → device, retained
// restrict/<device>/status device → service, retained (presence)
// restrict/<device>/telemetry device → service, not retained
//
// "config" keeps the exact topic the device table already stores, so a device's
// identity never changes when it is renamed.
package mqtt
import (
"encoding/json"
"fmt"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
// TopicPrefix is the first topic level for every message this service touches.
const TopicPrefix = "restrict"
// Topic suffixes, appended to "restrict/<device>".
const (
SuffixConfig = "/config"
SuffixStatus = "/status"
SuffixTelemetry = "/telemetry"
)
// StatusSubscription and TelemetrySubscription are the wildcard topics the
// Control Service reads. A device may write only its own status and telemetry
// (AGENTS.md §10 rule 6).
const (
StatusSubscription = TopicPrefix + "/+/status"
TelemetrySubscription = TopicPrefix + "/+/telemetry"
)
// StatusTopic returns the status topic for a device's config topic.
func StatusTopic(configTopic string) string {
return withSuffix(configTopic, SuffixStatus)
}
// TelemetryTopic returns the telemetry topic for a device's config topic.
func TelemetryTopic(configTopic string) string {
return withSuffix(configTopic, SuffixTelemetry)
}
func withSuffix(configTopic, suffix string) string {
return strings.TrimSuffix(configTopic, SuffixConfig) + suffix
}
// deviceFromTopic turns "restrict/harry_phone_2/status" back into the config
// topic "restrict/harry_phone_2/config" so the store can find the device.
func deviceFromTopic(topic, suffix string) (string, bool) {
if !strings.HasPrefix(topic, TopicPrefix+"/") || !strings.HasSuffix(topic, suffix) {
return "", false
}
base := strings.TrimSuffix(topic, suffix)
if strings.Count(base, "/") != 1 || len(base) <= len(TopicPrefix)+1 {
return "", false
}
return base + SuffixConfig, true
}
// Config is the desired state for one device. It is published retained on
// restrict/<device>/config: a device that reconnects is configured at once,
// with no poll and no waiting (TRANSPORT_DESIGN.md).
//
// The shape is versioned with "v" and is consumed by the kids' clients in
// Phases 4 and 5. Add fields; do not repurpose them.
type Config struct {
V int `json:"v"`
DeviceID int64 `json:"device_id"`
Name string `json:"name"`
Platform string `json:"platform"`
Locked bool `json:"locked"`
Until *time.Time `json:"until"`
Allow []string `json:"allow"`
Block []string `json:"block"`
GeneratedAt time.Time `json:"generated_at"`
}
// ConfigVersion is the current schema version of Config.
const ConfigVersion = 1
// Marshal renders the payload. Lists are always present, never null, so a
// client can parse without null checks.
func (c Config) Marshal() ([]byte, error) {
if c.Allow == nil {
c.Allow = []string{}
}
if c.Block == nil {
c.Block = []string{}
}
b, err := json.Marshal(c)
if err != nil {
return nil, fmt.Errorf("marshal config: %w", err)
}
return b, nil
}
// Status is a presence report from a device.
//
// The device publishes a retained {"online": true} when it comes up and
// registers the will {"online": false} on the same topic, so the broker
// publishes "offline" the moment the connection drops without a clean
// goodbye. That is the whole of presence detection (FR-6).
type Status struct {
Online *bool `json:"online"`
App string `json:"app"`
Locked *bool `json:"locked"`
At string `json:"at"`
}
// ParseStatus reads a status payload. A report with no "online" field means the
// device is talking, so it counts as online.
func ParseStatus(payload []byte) (Status, error) {
var st Status
if err := json.Unmarshal(payload, &st); err != nil {
return Status{}, fmt.Errorf("parse status: %w", err)
}
if len(payload) == 0 {
return Status{}, fmt.Errorf("parse status: empty payload")
}
return st, nil
}
// IsOnline reports the presence the payload asserts.
func (s Status) IsOnline() bool { return s.Online == nil || *s.Online }
// At parses the report timestamp, falling back to the current time.
func (s Status) Timestamp(now time.Time) time.Time {
if s.At == "" {
return now
}
for _, layout := range []string{time.RFC3339, time.RFC3339Nano} {
if t, err := time.Parse(layout, s.At); err == nil {
return t
}
}
return now
}
// UsageEntry is one app or domain total.
type UsageEntry struct {
Name string `json:"name"`
Seconds int `json:"seconds"`
}
// Telemetry is a rolling daily usage report from a device. Devices report
// totals for the day, not deltas, so a repeated report replaces the row
// (migration 002).
//
// Two shapes are accepted: the list form
//
// {"day":"2026-10-13","apps":[{"name":"com.spotify.music","seconds":600}],
// "domains":[{"name":"youtube.com","seconds":120}]}
//
// and the flat single-row form
//
// {"app":"com.spotify.music","seconds":600}
type Telemetry struct {
Day string `json:"day"`
Apps []UsageEntry `json:"apps"`
Domains []UsageEntry `json:"domains"`
App string `json:"app"`
Domain string `json:"domain"`
Seconds int `json:"seconds"`
}
// ParseTelemetry reads a telemetry payload.
func ParseTelemetry(payload []byte) (Telemetry, error) {
var t Telemetry
if len(payload) == 0 {
return Telemetry{}, fmt.Errorf("parse telemetry: empty payload")
}
if err := json.Unmarshal(payload, &t); err != nil {
return Telemetry{}, fmt.Errorf("parse telemetry: %w", err)
}
return t, nil
}
// DayOrDefault returns the day the report belongs to, in the given location. A
// device may send its own local day; anything that is not YYYY-MM-DD is
// ignored and today's date is used instead.
func (t Telemetry) DayOrDefault(now time.Time) string {
if t.Day != "" {
if d, err := time.Parse("2006-01-02", t.Day); err == nil {
return d.Format("2006-01-02")
}
}
return now.Format("2006-01-02")
}
// Rows flattens the payload into telemetry rows. The app and the domain are
// kept in separate columns; a row with neither is dropped.
func (t Telemetry) Rows() []store.TelemetryEntry {
out := make([]store.TelemetryEntry, 0, len(t.Apps)+len(t.Domains)+1)
for _, e := range t.Apps {
if e.Name == "" || e.Seconds < 0 {
continue
}
out = append(out, store.TelemetryEntry{App: e.Name, Seconds: e.Seconds})
}
for _, e := range t.Domains {
if e.Name == "" || e.Seconds < 0 {
continue
}
out = append(out, store.TelemetryEntry{Domain: e.Name, Seconds: e.Seconds})
}
if (t.App != "" || t.Domain != "") && t.Seconds >= 0 {
out = append(out, store.TelemetryEntry{App: t.App, Domain: t.Domain, Seconds: t.Seconds})
}
return out
}

View File

@@ -0,0 +1,211 @@
package mqtt
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
func TestTopicHelpers(t *testing.T) {
const config = "restrict/harry_phone_2/config"
if got := StatusTopic(config); got != "restrict/harry_phone_2/status" {
t.Errorf("StatusTopic = %q", got)
}
if got := TelemetryTopic(config); got != "restrict/harry_phone_2/telemetry" {
t.Errorf("TelemetryTopic = %q", got)
}
for _, tc := range []struct {
topic string
suffix string
want string
ok bool
}{
{"restrict/harry_phone_2/status", SuffixStatus, config, true},
{"restrict/harry_phone_2/telemetry", SuffixTelemetry, config, true},
{"restrict/harry_phone_2/config", SuffixStatus, "", false},
{"restrict/status", SuffixStatus, "", false},
{"restrict/a/b/status", SuffixStatus, "", false},
{"other/harry_phone_2/status", SuffixStatus, "", false},
} {
got, ok := deviceFromTopic(tc.topic, tc.suffix)
if ok != tc.ok || got != tc.want {
t.Errorf("deviceFromTopic(%q, %q) = (%q, %v), want (%q, %v)",
tc.topic, tc.suffix, got, ok, tc.want, tc.ok)
}
}
}
func TestConfigMarshal(t *testing.T) {
until := time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC)
b, err := Config{
V: ConfigVersion,
DeviceID: 2,
Name: "harry_phone",
Platform: store.PlatformAndroid,
Locked: true,
Until: &until,
GeneratedAt: time.Date(2026, 10, 13, 3, 0, 0, 0, time.UTC),
}.Marshal()
if err != nil {
t.Fatalf("Marshal: %v", err)
}
// The lists must be present as arrays, never null, so a client can parse
// this without null checks.
if !strings.Contains(string(b), `"allow":[]`) || !strings.Contains(string(b), `"block":[]`) {
t.Errorf("payload has null lists: %s", b)
}
var back Config
if err := json.Unmarshal(b, &back); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if !back.Locked || back.Until == nil || !back.Until.Equal(until) {
t.Errorf("round trip lost the lock: %+v", back)
}
if back.V != ConfigVersion || back.DeviceID != 2 {
t.Errorf("round trip lost the identity: %+v", back)
}
}
func TestParseStatus(t *testing.T) {
if _, err := ParseStatus(nil); err == nil {
t.Error("expected an error for an empty payload")
}
if _, err := ParseStatus([]byte("{oops")); err == nil {
t.Error("expected an error for malformed JSON")
}
// An explicit offline (the will payload the broker publishes on a dropped
// connection) must read as offline.
off, err := ParseStatus([]byte(`{"online":false}`))
if err != nil {
t.Fatalf("ParseStatus: %v", err)
}
if off.IsOnline() {
t.Error(`{"online":false} read as online`)
}
// A report with no "online" field means the device is talking.
on, err := ParseStatus([]byte(`{"app":"com.spotify.music"}`))
if err != nil {
t.Fatalf("ParseStatus: %v", err)
}
if !on.IsOnline() {
t.Error("a report with no online field read as offline")
}
if on.App != "com.spotify.music" {
t.Errorf("App = %q", on.App)
}
at := on.Timestamp(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
if !at.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) {
t.Errorf("Timestamp with no at field = %v, want the fallback", at)
}
at = Status{At: "2026-10-13T07:30:00Z"}.Timestamp(time.Now())
if at.UTC().Format(time.RFC3339) != "2026-10-13T07:30:00Z" {
t.Errorf("Timestamp = %v", at)
}
}
func TestTelemetryRows(t *testing.T) {
payload := []byte(`{
"day": "2026-10-13",
"apps": [{"name": "com.spotify.music", "seconds": 600}, {"name": "", "seconds": 5}],
"domains": [{"name": "youtube.com", "seconds": 120}]
}`)
report, err := ParseTelemetry(payload)
if err != nil {
t.Fatalf("ParseTelemetry: %v", err)
}
rows := report.Rows()
if len(rows) != 2 {
t.Fatalf("Rows = %+v, want 2 rows", rows)
}
// Apps first, then domains.
if rows[0].App != "com.spotify.music" || rows[0].Seconds != 600 {
t.Errorf("rows[0] = %+v", rows[0])
}
if rows[1].Domain != "youtube.com" || rows[1].Seconds != 120 {
t.Errorf("rows[1] = %+v", rows[1])
}
if got := report.DayOrDefault(time.Now()); got != "2026-10-13" {
t.Errorf("DayOrDefault = %q, want the reported day", got)
}
// The flat single-row form, and a bad day falling back to today.
flat, err := ParseTelemetry([]byte(`{"app":"roblox","seconds":60,"day":"yesterday"}`))
if err != nil {
t.Fatalf("ParseTelemetry flat: %v", err)
}
flatRows := flat.Rows()
if len(flatRows) != 1 || flatRows[0].App != "roblox" {
t.Fatalf("flat Rows = %+v", flatRows)
}
now := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
if got := flat.DayOrDefault(now); got != "2026-03-04" {
t.Errorf("DayOrDefault with a bad day = %q, want 2026-03-04", got)
}
// Negative totals are dropped, not stored.
bad, err := ParseTelemetry([]byte(`{"apps":[{"name":"x","seconds":-1}]}`))
if err != nil {
t.Fatalf("ParseTelemetry: %v", err)
}
if len(bad.Rows()) != 0 {
t.Errorf("negative usage stored: %+v", bad.Rows())
}
if _, err := ParseTelemetry([]byte("not json")); err == nil {
t.Error("expected an error for malformed JSON")
}
}
func TestNewRejectsIncompleteOptions(t *testing.T) {
if _, err := New(Options{Broker: "tcp://127.0.0.1:1883"}); err == nil {
t.Error("expected an error without a device lookup, source and sink")
}
_, err := New(Options{
Broker: "tls://broker:8883",
CAFile: "/nonexistent/ca.crt",
Devices: stubLookup{},
Source: stubSource{},
Reports: stubSink{},
})
if err == nil {
t.Fatal("expected an error for a missing CA bundle")
}
if !strings.Contains(err.Error(), "ca.crt") {
t.Errorf("error = %v, want it to name the CA file", err)
}
}
type stubLookup struct{}
func (stubLookup) DeviceByConfigTopic(ctx context.Context, topic string) (*store.Device, error) {
return nil, errors.New("not implemented")
}
type stubSource struct{}
func (stubSource) ListDevices(ctx context.Context) ([]store.Device, error) { return nil, nil }
func (stubSource) DeviceLists(ctx context.Context, id int64) ([]string, []string, error) {
return nil, nil, nil
}
type stubSink struct{}
func (stubSink) SetPresence(ctx context.Context, id int64, online bool, at time.Time, actor string) (bool, error) {
return false, nil
}
func (stubSink) InsertTelemetry(ctx context.Context, id int64, day string, e []store.TelemetryEntry) (int, error) {
return 0, nil
}