Добавлен gRPC-контракт proto/device_control (TurnOn/TurnOff/SetLevel), общий Go-модуль proto/ для переиспользования сгенерированного кода. device-control-service принимает команды по gRPC, обновляет desired_state в Redis и публикует их в MQTT; слушает devices/+/telemetry и devices/+/ack для отметки живости устройства и обновления reported_state; горутина health-check переводит устройство в offline по таймауту и публикует событие в RabbitMQ (device.status_changed) для будущего notification-service. Проверено сквозным тестом через docker compose: grpcurl TurnOn/SetLevel меняет desired_state и уходит в MQTT, mosquitto_pub с ack обновляет reported_state и статус, health-check автоматически переводит устройство в offline по истечении таймаута.
108 lines
2.9 KiB
Go
108 lines
2.9 KiB
Go
// Package mqttclient wraps paho for device-control-service's two-way MQTT
|
|
// use: subscribing to telemetry/ack topics for liveness and reported state,
|
|
// and publishing commands to devices.
|
|
package mqttclient
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
|
)
|
|
|
|
// Handler receives one message's topic, raw payload, and arrival time.
|
|
type Handler func(topic string, payload []byte, receivedAt time.Time)
|
|
|
|
type Config struct {
|
|
BrokerURL string // e.g. tcp://mosquitto:1883
|
|
ClientID string
|
|
}
|
|
|
|
type subscription struct {
|
|
topic string
|
|
qos byte
|
|
handler Handler
|
|
}
|
|
|
|
// Client wraps a paho client, replaying every registered subscription on
|
|
// each (re)connect so a dropped connection resubscribes automatically.
|
|
type Client struct {
|
|
client mqtt.Client
|
|
logger *slog.Logger
|
|
|
|
mu sync.Mutex
|
|
subs []subscription
|
|
}
|
|
|
|
func Connect(cfg Config, logger *slog.Logger) (*Client, error) {
|
|
c := &Client{logger: logger}
|
|
|
|
opts := mqtt.NewClientOptions().
|
|
AddBroker(cfg.BrokerURL).
|
|
SetClientID(cfg.ClientID).
|
|
SetAutoReconnect(true).
|
|
SetConnectRetry(true).
|
|
SetOnConnectHandler(func(mc mqtt.Client) {
|
|
c.resubscribeAll(mc)
|
|
}).
|
|
SetConnectionLostHandler(func(_ mqtt.Client, err error) {
|
|
logger.Warn("mqtt connection lost", "error", err)
|
|
})
|
|
|
|
mqttClient := mqtt.NewClient(opts)
|
|
token := mqttClient.Connect()
|
|
token.Wait()
|
|
if err := token.Error(); err != nil {
|
|
return nil, fmt.Errorf("connect to mqtt broker %s: %w", cfg.BrokerURL, err)
|
|
}
|
|
c.client = mqttClient
|
|
return c, nil
|
|
}
|
|
|
|
// Subscribe registers a handler for topic. It subscribes immediately if
|
|
// already connected, and will be replayed on every future reconnect.
|
|
func (c *Client) Subscribe(topic string, qos byte, handler Handler) error {
|
|
c.mu.Lock()
|
|
c.subs = append(c.subs, subscription{topic: topic, qos: qos, handler: handler})
|
|
c.mu.Unlock()
|
|
|
|
if !c.client.IsConnectionOpen() {
|
|
return nil
|
|
}
|
|
return c.subscribeOne(c.client, subscription{topic: topic, qos: qos, handler: handler})
|
|
}
|
|
|
|
func (c *Client) resubscribeAll(mc mqtt.Client) {
|
|
c.mu.Lock()
|
|
subs := append([]subscription(nil), c.subs...)
|
|
c.mu.Unlock()
|
|
|
|
for _, s := range subs {
|
|
if err := c.subscribeOne(mc, s); err != nil {
|
|
c.logger.Error("mqtt subscribe failed", "topic", s.topic, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Client) subscribeOne(mc mqtt.Client, s subscription) error {
|
|
token := mc.Subscribe(s.topic, s.qos, func(_ mqtt.Client, msg mqtt.Message) {
|
|
s.handler(msg.Topic(), msg.Payload(), time.Now())
|
|
})
|
|
token.Wait()
|
|
return token.Error()
|
|
}
|
|
|
|
// Publish sends payload to topic and waits for the publish to complete.
|
|
func (c *Client) Publish(topic string, qos byte, retained bool, payload []byte) error {
|
|
token := c.client.Publish(topic, qos, retained, payload)
|
|
token.Wait()
|
|
return token.Error()
|
|
}
|
|
|
|
// Close disconnects, waiting up to 250ms for in-flight work to settle.
|
|
func (c *Client) Close() {
|
|
c.client.Disconnect(250)
|
|
}
|