Добавлен 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 по истечении таймаута.
172 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|