package main import ( "context" "encoding/json" "fmt" "log" "os" "os/signal" "sync/atomic" "syscall" "time" "example.com/mqtt5-learning/internal/config" "example.com/mqtt5-learning/internal/contracts" "example.com/mqtt5-learning/internal/dispatch" mqtt "example.com/mqtt5-learning/internal/mqtt" "github.com/eclipse/paho.golang/autopaho" ) func main() { // Convert a returned application error into a visible nonzero process exit. if err := run(); err != nil { log.Print(err) os.Exit(1) } } func run() error { // Load settings and bind cancellation to Ctrl+C or the container stop signal. cfg, err := config.Load(os.Args[1:]) if err != nil { return err } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() transportCtx, stopTransport := context.WithCancel(context.Background()) defer stopTransport() // Build concrete names for this device and a filter for its command flow. commandFilter, err := contracts.DeviceSubscription(cfg.MQTT.DeviceID, contracts.KindCommands) if err != nil { return err } telemetryTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindTelemetry) if err != nil { return err } inbox := make(chan mqtt.Message, 16) manager, err := mqtt.Start(transportCtx, cfg, []contracts.TopicFilter{commandFilter}, inbox) if err != nil { return err } defer mqtt.Stop(manager) // Keep worker contexts alive during the drain; the signal context stops intake. runID := time.Now().UnixNano() telemetryPool := dispatch.New(context.Background(), 1, 1) importantPool := dispatch.New(context.Background(), 2, 16) var resultSequence atomic.Uint64 receiver := &dispatch.Receiver{ Telemetry: telemetryPool, Important: importantPool, Process: func(jobCtx context.Context, message mqtt.Message) error { resultID := fmt.Sprintf("result-%d-%d", runID, resultSequence.Add(1)) return respond(jobCtx, manager, cfg.MQTT.DeviceID, resultID, message) }, } defer func() { drainCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() started := time.Now() if err := dispatch.Shutdown(drainCtx, telemetryPool, importantPool); err != nil { log.Printf("dispatcher shutdown incomplete: %v", err) } log.Printf("dispatcher drain finished elapsed=%s telemetry=%+v important=%+v", time.Since(started), telemetryPool.Stats(), importantPool.Stats()) }() // Heartbeats are periodic; command handling remains active between ticks. ticker := time.NewTicker(3 * time.Second) defer ticker.Stop() var heartbeatSequence uint64 for { // Handle cancellation, telemetry publication, and inbound commands together. select { case <-ctx.Done(): log.Print("shutting down; draining accepted command work before MQTT disconnect") return nil case <-ticker.C: heartbeatSequence++ now := time.Now().UTC() payload, err := json.Marshal(contracts.Heartbeat{ SchemaVersion: contracts.SchemaVersionOne, MessageID: fmt.Sprintf("heartbeat-%d-%d", runID, heartbeatSequence), DeviceID: cfg.MQTT.DeviceID, OccurredAt: now.Format(time.RFC3339), }) if err != nil { return err } if err = mqtt.Publish(ctx, manager, telemetryTopic, payload); err != nil { log.Printf("heartbeat: %v", err) } case message := <-inbox: outcome, submitErr := receiver.Submit(ctx, message) if submitErr != nil || outcome != dispatch.Accepted { log.Printf("command admission rejected outcome=%d topic=%q error=%v", outcome, message.Topic, submitErr) } } } } func respond(ctx context.Context, manager *autopaho.ConnectionManager, deviceID, resultMessageID string, message mqtt.Message) error { if message.Retain { return fmt.Errorf("retained command rejected") } topic, err := contracts.ParseTopic(message.Topic) if err != nil { return err } if topic.Kind != contracts.KindCommands || topic.DeviceID != deviceID { return fmt.Errorf("command topic is not authorized for this device") } command, err := contracts.Decode[contracts.Command](message.Payload, topic, time.Now().UTC()) if err != nil { return err } response, err := json.Marshal(contracts.CommandResult{ SchemaVersion: contracts.SchemaVersionOne, MessageID: resultMessageID, DeviceID: deviceID, OccurredAt: time.Now().UTC().Format(time.RFC3339), CommandID: command.CommandID, Status: "completed", Detail: "pong", }) if err != nil { return fmt.Errorf("encode command result: %w", err) } responseTopic, err := contracts.DeviceTopic(deviceID, contracts.KindResponses) if err != nil { return fmt.Errorf("build response topic: %w", err) } return mqtt.Publish(ctx, manager, responseTopic, response) }