Реализация device-control-service: gRPC + MQTT + Device Shadow в Redis
Добавлен 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 по истечении таймаута.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
// Command device-control-service exposes a gRPC API for issuing device
|
||||
// commands, dispatches them over MQTT, maintains the Device Shadow in
|
||||
// Redis, and runs a health-check loop that flips stale devices offline.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
|
||||
devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/config"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/healthcheck"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/mqttclient"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/rabbitmq"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/server"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/shadow"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
|
||||
if err := run(logger); err != nil {
|
||||
logger.Error("device-control-service exited with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(logger *slog.Logger) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
shadowStore, err := shadow.Connect(ctx, cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer shadowStore.Close()
|
||||
|
||||
publisher, err := rabbitmq.Connect(cfg.RabbitMQURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer publisher.Close()
|
||||
|
||||
mqttClient, err := mqttclient.Connect(mqttclient.Config{
|
||||
BrokerURL: cfg.MQTTBrokerURL,
|
||||
ClientID: cfg.MQTTClientID,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer mqttClient.Close()
|
||||
|
||||
if err := mqttClient.Subscribe("devices/+/telemetry", 1, telemetryHandler(shadowStore, publisher, logger)); err != nil {
|
||||
return fmt.Errorf("subscribe telemetry: %w", err)
|
||||
}
|
||||
if err := mqttClient.Subscribe("devices/+/ack", 1, ackHandler(shadowStore, publisher, logger)); err != nil {
|
||||
return fmt.Errorf("subscribe ack: %w", err)
|
||||
}
|
||||
|
||||
checker := healthcheck.New(shadowStore, cfg.HealthCheckTimeout, cfg.HealthCheckInterval, func(deviceID string) {
|
||||
pubCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := publisher.PublishStatusChanged(pubCtx, deviceID, shadow.StatusOffline); err != nil {
|
||||
logger.Error("publish offline event failed", "device_id", deviceID, "error", err)
|
||||
}
|
||||
}, logger)
|
||||
checker.Start()
|
||||
defer checker.Stop()
|
||||
|
||||
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.GRPCPort))
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on grpc port %d: %w", cfg.GRPCPort, err)
|
||||
}
|
||||
grpcServer := grpc.NewServer()
|
||||
devicecontrol.RegisterDeviceControlServer(grpcServer, server.New(shadowStore, mqttClient, logger))
|
||||
|
||||
go func() {
|
||||
logger.Info("device-control-service started", "grpc_port", cfg.GRPCPort)
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
logger.Error("grpc server stopped", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
logger.Info("shutting down")
|
||||
grpcServer.GracefulStop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// deviceIDFromTopic extracts {device_id} from "devices/{device_id}/<suffix>".
|
||||
func deviceIDFromTopic(topic string) (string, bool) {
|
||||
parts := strings.Split(topic, "/")
|
||||
if len(parts) != 3 || parts[0] != "devices" || parts[1] == "" {
|
||||
return "", false
|
||||
}
|
||||
return parts[1], true
|
||||
}
|
||||
|
||||
// telemetryHandler only tracks liveness: ingest-service owns storing and
|
||||
// interpreting telemetry, this service just needs to know the device is alive.
|
||||
func telemetryHandler(store *shadow.Store, publisher *rabbitmq.Publisher, logger *slog.Logger) mqttclient.Handler {
|
||||
return func(topic string, _ []byte, _ time.Time) {
|
||||
deviceID, ok := deviceIDFromTopic(topic)
|
||||
if !ok {
|
||||
logger.Warn("dropping telemetry from unparseable topic", "topic", topic)
|
||||
return
|
||||
}
|
||||
touch(context.Background(), store, publisher, deviceID, logger)
|
||||
}
|
||||
}
|
||||
|
||||
type ackPayload struct {
|
||||
State map[string]any `json:"state"`
|
||||
}
|
||||
|
||||
func ackHandler(store *shadow.Store, publisher *rabbitmq.Publisher, logger *slog.Logger) mqttclient.Handler {
|
||||
return func(topic string, payload []byte, _ time.Time) {
|
||||
deviceID, ok := deviceIDFromTopic(topic)
|
||||
if !ok {
|
||||
logger.Warn("dropping ack from unparseable topic", "topic", topic)
|
||||
return
|
||||
}
|
||||
|
||||
var ack ackPayload
|
||||
if err := json.Unmarshal(payload, &ack); err != nil {
|
||||
logger.Warn("dropping invalid ack payload", "device_id", deviceID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if len(ack.State) > 0 {
|
||||
if err := store.PatchReportedState(ctx, deviceID, ack.State); err != nil {
|
||||
logger.Error("patch reported state failed", "device_id", deviceID, "error", err)
|
||||
}
|
||||
}
|
||||
touch(ctx, store, publisher, deviceID, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func touch(ctx context.Context, store *shadow.Store, publisher *rabbitmq.Publisher, deviceID string, logger *slog.Logger) {
|
||||
becameOnline, err := store.Touch(ctx, deviceID)
|
||||
if err != nil {
|
||||
logger.Error("touch device failed", "device_id", deviceID, "error", err)
|
||||
return
|
||||
}
|
||||
if becameOnline {
|
||||
pubCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := publisher.PublishStatusChanged(pubCtx, deviceID, shadow.StatusOnline); err != nil {
|
||||
logger.Error("publish online event failed", "device_id", deviceID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user