Реализация 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:
2026-07-26 18:40:36 +05:00
parent 28ca67f42c
commit 3eb84aae6c
23 changed files with 1885 additions and 9 deletions
@@ -0,0 +1,86 @@
// Package server implements the DeviceControl gRPC service: it patches the
// device's desired state in Redis and dispatches the command over MQTT.
// It deliberately does not wait for the device to execute the command —
// see the Device Shadow note in the service README.
package server
import (
"context"
"encoding/json"
"fmt"
"log/slog"
devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control"
)
// ShadowPatcher is the subset of shadow.Store the server needs.
type ShadowPatcher interface {
PatchDesiredState(ctx context.Context, deviceID string, patch map[string]any) error
}
// CommandPublisher is the subset of mqttclient.Client the server needs.
type CommandPublisher interface {
Publish(topic string, qos byte, retained bool, payload []byte) error
}
type Server struct {
devicecontrol.UnimplementedDeviceControlServer
shadow ShadowPatcher
mqtt CommandPublisher
logger *slog.Logger
}
func New(shadow ShadowPatcher, mqtt CommandPublisher, logger *slog.Logger) *Server {
return &Server{shadow: shadow, mqtt: mqtt, logger: logger}
}
func (s *Server) TurnOn(ctx context.Context, req *devicecontrol.TurnOnRequest) (*devicecontrol.CommandResult, error) {
return s.dispatch(ctx, req.GetDeviceId(),
map[string]any{"power": "on"},
map[string]any{"action": "turn_on"},
)
}
func (s *Server) TurnOff(ctx context.Context, req *devicecontrol.TurnOffRequest) (*devicecontrol.CommandResult, error) {
return s.dispatch(ctx, req.GetDeviceId(),
map[string]any{"power": "off"},
map[string]any{"action": "turn_off"},
)
}
func (s *Server) SetLevel(ctx context.Context, req *devicecontrol.SetLevelRequest) (*devicecontrol.CommandResult, error) {
return s.dispatch(ctx, req.GetDeviceId(),
map[string]any{"level": req.GetLevel()},
map[string]any{"action": "set_level", "level": req.GetLevel()},
)
}
// dispatch is shared by all three RPCs: patch desired state first (so the UI
// gets an instant, optimistic view even if the device is offline), then hand
// the command to MQTT. Business failures come back as CommandResult.Error,
// not a gRPC error — the wire contract is success/error in one message.
func (s *Server) dispatch(ctx context.Context, deviceID string, desiredPatch, commandPayload map[string]any) (*devicecontrol.CommandResult, error) {
if deviceID == "" {
return &devicecontrol.CommandResult{Success: false, Error: "device_id is required"}, nil
}
if err := s.shadow.PatchDesiredState(ctx, deviceID, desiredPatch); err != nil {
s.logger.Error("patch desired state failed", "device_id", deviceID, "error", err)
return &devicecontrol.CommandResult{Success: false, Error: "failed to update desired state"}, nil
}
body, err := json.Marshal(commandPayload)
if err != nil {
s.logger.Error("encode command failed", "device_id", deviceID, "error", err)
return &devicecontrol.CommandResult{Success: false, Error: "failed to encode command"}, nil
}
topic := fmt.Sprintf("devices/%s/commands", deviceID)
if err := s.mqtt.Publish(topic, 1, false, body); err != nil {
s.logger.Error("publish command failed", "device_id", deviceID, "topic", topic, "error", err)
return &devicecontrol.CommandResult{Success: false, Error: "failed to dispatch command"}, nil
}
return &devicecontrol.CommandResult{Success: true}, nil
}
@@ -0,0 +1,124 @@
package server
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"testing"
devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control"
)
type fakeShadow struct {
err error
patches []map[string]any
}
func (f *fakeShadow) PatchDesiredState(_ context.Context, _ string, patch map[string]any) error {
if f.err != nil {
return f.err
}
f.patches = append(f.patches, patch)
return nil
}
type fakeMQTT struct {
err error
topic string
payload []byte
}
func (f *fakeMQTT) Publish(topic string, _ byte, _ bool, payload []byte) error {
if f.err != nil {
return f.err
}
f.topic = topic
f.payload = payload
return nil
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func TestTurnOn_Success(t *testing.T) {
sh := &fakeShadow{}
mq := &fakeMQTT{}
s := New(sh, mq, discardLogger())
res, err := s.TurnOn(context.Background(), &devicecontrol.TurnOnRequest{DeviceId: "d1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.Success {
t.Fatalf("expected success, got error %q", res.Error)
}
if len(sh.patches) != 1 || sh.patches[0]["power"] != "on" {
t.Fatalf("got desired patches %+v, want [{power: on}]", sh.patches)
}
if mq.topic != "devices/d1/commands" {
t.Fatalf("got topic %q, want devices/d1/commands", mq.topic)
}
var payload map[string]any
if err := json.Unmarshal(mq.payload, &payload); err != nil {
t.Fatalf("unmarshal published payload: %v", err)
}
if payload["action"] != "turn_on" {
t.Fatalf("got payload %+v, want action=turn_on", payload)
}
}
func TestSetLevel_Success(t *testing.T) {
sh := &fakeShadow{}
mq := &fakeMQTT{}
s := New(sh, mq, discardLogger())
res, err := s.SetLevel(context.Background(), &devicecontrol.SetLevelRequest{DeviceId: "d1", Level: 42.5})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.Success {
t.Fatalf("expected success, got error %q", res.Error)
}
if sh.patches[0]["level"] != 42.5 {
t.Fatalf("got desired patch %+v, want level=42.5", sh.patches[0])
}
}
func TestDispatch_MissingDeviceID(t *testing.T) {
s := New(&fakeShadow{}, &fakeMQTT{}, discardLogger())
res, err := s.TurnOff(context.Background(), &devicecontrol.TurnOffRequest{DeviceId: ""})
if err != nil {
t.Fatalf("unexpected transport error: %v", err)
}
if res.Success {
t.Fatal("expected failure for missing device_id")
}
}
func TestDispatch_ShadowFailure(t *testing.T) {
s := New(&fakeShadow{err: errors.New("redis down")}, &fakeMQTT{}, discardLogger())
res, err := s.TurnOn(context.Background(), &devicecontrol.TurnOnRequest{DeviceId: "d1"})
if err != nil {
t.Fatalf("unexpected transport error: %v", err)
}
if res.Success {
t.Fatal("expected failure when shadow patch errors")
}
}
func TestDispatch_PublishFailure(t *testing.T) {
s := New(&fakeShadow{}, &fakeMQTT{err: errors.New("broker unreachable")}, discardLogger())
res, err := s.TurnOn(context.Background(), &devicecontrol.TurnOnRequest{DeviceId: "d1"})
if err != nil {
t.Fatalf("unexpected transport error: %v", err)
}
if res.Success {
t.Fatal("expected failure when mqtt publish errors")
}
}