package mqtt import ( "fmt" "sync" "time" "github.com/dpup/prefab/logging" mqtt "github.com/eclipse/paho.mqtt.golang" "meshstream/decoder" meshtreampb "meshstream/generated/meshstream" ) // Config holds configuration for the MQTT client type Config struct { // Connection settings Broker string Username string Password string ClientID string Topic string // Connection tuning parameters KeepAlive int // Keep alive interval in seconds (default: 60) ConnectTimeout time.Duration // Connection timeout (default: 30s) PingTimeout time.Duration // Ping timeout (default: 10s) MaxReconnectTime time.Duration // Maximum time between reconnect attempts (default: 5m) UseTLS bool // Whether to use TLS/SSL (default: false) TLSPort int // TLS port to use if UseTLS is true (default: 8883) } // Client manages the MQTT connection and message processing type Client struct { config Config client mqtt.Client decodedMessages chan *meshtreampb.Packet done chan struct{} logger logging.Logger isConnected bool connectionMutex sync.RWMutex healthCheckStop chan struct{} } // NewClient creates a new MQTT client with the provided configuration func NewClient(config Config, logger logging.Logger) *Client { return &Client{ config: config, decodedMessages: make(chan *meshtreampb.Packet, 100), done: make(chan struct{}), logger: logger.Named("mqtt.client"), isConnected: false, } } // Connect establishes a connection to the MQTT broker func (c *Client) Connect() error { // Set default values for optional parameters keepAlive := 60 if c.config.KeepAlive > 0 { keepAlive = c.config.KeepAlive } connectTimeout := 30 * time.Second if c.config.ConnectTimeout > 0 { connectTimeout = c.config.ConnectTimeout } pingTimeout := 10 * time.Second if c.config.PingTimeout > 0 { pingTimeout = c.config.PingTimeout } maxReconnectTime := 5 * time.Minute if c.config.MaxReconnectTime > 0 { maxReconnectTime = c.config.MaxReconnectTime } // Determine protocol and port protocol := "tcp" port := 1883 if c.config.UseTLS { protocol = "ssl" port = 8883 if c.config.TLSPort > 0 { port = c.config.TLSPort } } // Log detailed connection settings c.logger.Infow("Connecting to MQTT broker with settings", "broker", c.config.Broker, "port", port, "protocol", protocol, "clientID", c.config.ClientID, "username", c.config.Username, "passwordLength", len(c.config.Password), "topic", c.config.Topic, "keepAlive", keepAlive, "connectTimeout", connectTimeout, "pingTimeout", pingTimeout, "maxReconnectTime", maxReconnectTime, "useTLS", c.config.UseTLS, ) opts := mqtt.NewClientOptions() opts.AddBroker(fmt.Sprintf("%s://%s:%d", protocol, c.config.Broker, port)) opts.SetClientID(c.config.ClientID) opts.SetUsername(c.config.Username) opts.SetPassword(c.config.Password) opts.SetDefaultPublishHandler(c.messageHandler) opts.SetKeepAlive(time.Duration(keepAlive) * time.Second) opts.SetConnectTimeout(connectTimeout) opts.SetPingTimeout(pingTimeout) opts.SetConnectRetryInterval(1 * time.Second) // Start with 1 second retry opts.SetMaxReconnectInterval(1 * time.Minute) // Cap at 1 minute instead of 5 opts.SetAutoReconnect(true) opts.SetCleanSession(true) opts.SetOrderMatters(false) opts.OnConnect = c.connectHandler opts.OnConnectionLost = c.connectionLostHandler opts.OnReconnecting = c.reconnectingHandler // Create and start the client c.client = mqtt.NewClient(opts) if token := c.client.Connect(); token.Wait() && token.Error() != nil { c.logger.Errorw("Failed to connect to MQTT broker", "error", token.Error(), "broker", c.config.Broker, "clientID", c.config.ClientID) return fmt.Errorf("error connecting to MQTT broker: %v", token.Error()) } // Initial connection status c.connectionMutex.Lock() c.isConnected = true c.connectionMutex.Unlock() // Start health check c.healthCheckStop = make(chan struct{}) go c.monitorConnectionHealth(30 * time.Second) // Check every 30 seconds // Note: We don't need to subscribe to the topic here anymore // as it's now handled in the connectHandler which is called // after each successful connection and reconnection return nil } // Disconnect cleanly disconnects from the MQTT broker func (c *Client) Disconnect() { // Stop health check if c.healthCheckStop != nil { close(c.healthCheckStop) } close(c.done) token := c.client.Unsubscribe(c.config.Topic) token.Wait() c.client.Disconnect(250) } // Messages returns a channel of decoded messages // The consumer should read from this channel to receive decoded messages func (c *Client) Messages() <-chan *meshtreampb.Packet { return c.decodedMessages } // IsConnected returns the current connection status func (c *Client) IsConnected() bool { c.connectionMutex.RLock() defer c.connectionMutex.RUnlock() return c.isConnected } // monitorConnectionHealth periodically checks the connection status // and logs warnings if the connection appears to be down func (c *Client) monitorConnectionHealth(interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() consecutiveFailures := 0 maxConsecutiveFailures := 3 for { select { case <-ticker.C: if !c.client.IsConnected() { consecutiveFailures++ c.logger.Warnw("MQTT connection appears to be down despite auto-reconnect", "broker", c.config.Broker, "clientID", c.config.ClientID, "consecutiveFailures", consecutiveFailures) // Update our internal connection state c.connectionMutex.Lock() c.isConnected = false c.connectionMutex.Unlock() // If we've had too many consecutive failures, try to force reconnection if consecutiveFailures >= maxConsecutiveFailures { c.logger.Warnw("Too many consecutive connection failures, forcing reconnection attempt", "consecutiveFailures", consecutiveFailures, "maxFailures", maxConsecutiveFailures) // Since we can't force reconnection directly, we can try to disconnect // and let the auto-reconnect logic handle it if c.client.IsConnectionOpen() { c.client.Disconnect(100) } consecutiveFailures = 0 } } else { // Reset failure counter when connection is good if consecutiveFailures > 0 { c.logger.Infow("MQTT connection restored", "broker", c.config.Broker, "clientID", c.config.ClientID) consecutiveFailures = 0 } // Update our internal connection state c.connectionMutex.Lock() c.isConnected = true c.connectionMutex.Unlock() } case <-c.healthCheckStop: return } } } // messageHandler processes incoming MQTT messages func (c *Client) messageHandler(client mqtt.Client, msg mqtt.Message) { c.logger.Debugf("Received message from topic: %s", msg.Topic()) // Parse the topic structure topicInfo, err := decoder.ParseTopic(msg.Topic()) if err != nil { c.logger.Errorw("Error parsing topic", "error", err, "topic", msg.Topic(), "payload_hex", fmt.Sprintf("%x", msg.Payload()), ) return } // Process different message formats switch topicInfo.Format { case "e", "c", "map": // Binary encoded protobuf message data := decoder.DecodeMessage(msg.Payload(), topicInfo) // Create packet with both the data and topic info packet := NewPacket(data, topicInfo) // Send the decoded message to the channel, but don't block if buffer is full select { case c.decodedMessages <- packet: // Message sent successfully case <-c.done: // Client is shutting down return default: // Channel buffer is full, log a warning and drop the message c.logger.Warn("Message buffer full, dropping message") } case "json": // TODO: Add support for JSON format messages in the future c.logger.Debugf("Ignoring JSON format message from topic: %s", msg.Topic()) default: // Unsupported format, log and ignore c.logger.Infow("Unsupported format", "format", topicInfo.Format, "topic", msg.Topic()) } } // connectHandler is called when the client connects to the broker func (c *Client) connectHandler(client mqtt.Client) { c.logger.Infow("Connected to MQTT Broker", "broker", c.config.Broker, "clientID", c.config.ClientID, "topic", c.config.Topic) // Subscribe to the configured topic after each reconnection token := client.Subscribe(c.config.Topic, 0, nil) if token.Wait() && token.Error() != nil { c.logger.Errorw("Failed to subscribe to topic on reconnect", "error", token.Error(), "topic", c.config.Topic) } else { c.logger.Infof("Successfully (re)subscribed to topic: %s", c.config.Topic) } } // connectionLostHandler is called when the client loses connection func (c *Client) connectionLostHandler(client mqtt.Client, err error) { c.logger.Errorw("Connection lost", "error", err, "errorType", fmt.Sprintf("%T", err), "broker", c.config.Broker, "clientID", c.config.ClientID) // Update connection status c.connectionMutex.Lock() c.isConnected = false c.connectionMutex.Unlock() } // reconnectingHandler is called when the client is attempting to reconnect func (c *Client) reconnectingHandler(client mqtt.Client, opts *mqtt.ClientOptions) { c.logger.Infow("Attempting to reconnect to MQTT broker", "broker", c.config.Broker, "clientID", c.config.ClientID) }