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