Files
home_automatization/services/device-control-service/cmd/device-control-service/main.go
T
cacto 3eb84aae6c Реализация 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 по истечении таймаута.
2026-07-26 18:40:36 +05:00

172 lines
5.4 KiB
Go

// 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)
}
}
}