Реализация 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,93 @@
// Package healthcheck periodically flips devices to offline when they stop
// sending telemetry/acks, per the platform's Device Shadow health-check
// design (a goroutine + graceful shutdown, deliberately simple).
package healthcheck
import (
"context"
"log/slog"
"time"
)
// DeviceStore is the subset of shadow.Store the checker needs, kept as an
// interface so tests don't require a real Redis.
type DeviceStore interface {
KnownDevices(ctx context.Context) ([]string, error)
MarkOfflineIfStale(ctx context.Context, deviceID string, timeout time.Duration) (bool, error)
}
// OnOfflineFunc is called once per device that just transitioned to offline.
type OnOfflineFunc func(deviceID string)
type Checker struct {
store DeviceStore
timeout time.Duration
interval time.Duration
onOffline OnOfflineFunc
logger *slog.Logger
stop chan struct{}
done chan struct{}
}
func New(store DeviceStore, timeout, interval time.Duration, onOffline OnOfflineFunc, logger *slog.Logger) *Checker {
return &Checker{
store: store,
timeout: timeout,
interval: interval,
onOffline: onOffline,
logger: logger,
}
}
// Start begins the periodic scan loop. Call once.
func (c *Checker) Start() {
c.stop = make(chan struct{})
c.done = make(chan struct{})
go c.loop()
}
// Stop signals the loop to exit and waits for it to finish.
func (c *Checker) Stop() {
close(c.stop)
<-c.done
}
func (c *Checker) loop() {
defer close(c.done)
ticker := time.NewTicker(c.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.checkAll()
case <-c.stop:
return
}
}
}
func (c *Checker) checkAll() {
ctx, cancel := context.WithTimeout(context.Background(), c.interval)
defer cancel()
ids, err := c.store.KnownDevices(ctx)
if err != nil {
c.logger.Error("healthcheck: list known devices failed", "error", err)
return
}
for _, id := range ids {
changed, err := c.store.MarkOfflineIfStale(ctx, id, c.timeout)
if err != nil {
c.logger.Error("healthcheck: check device failed", "device_id", id, "error", err)
continue
}
if changed {
c.logger.Info("device marked offline", "device_id", id)
if c.onOffline != nil {
c.onOffline(id)
}
}
}
}