Дашборд с реальными данными + ручное управление устройствами
device-control-service: HTTP/JSON API (internal/httpapi) рядом с gRPC —
POST /devices/{id}/turn-on|turn-off|set-level, тот же server.Server внутри,
без дублирования логики. Решение вместо gRPC-клиента на PHP: grpc/grpc
через PECL компилируется в Alpine 10-20+ минут и утяжеляет образ, а
диаграмма архитектуры в ТЗ и так допускала HTTP для Laravel→device-control.
Laravel: DeviceShadow (чтение Device Shadow из Redis, MGET одним запросом
для списка устройств), ClickHouseClient (HTTP-интерфейс ClickHouse,
параметризованные {name:Type}-запросы), DeviceControlClient (HTTP-вызовы
к новому Go-эндпоинту). Redis-клиент — predis (чистый PHP), а не phpredis,
по той же причине, что и решение по gRPC — не добавлять ещё одну
C-компиляцию в образ.
DeviceController::show — страница устройства: live-статус/last_seen/
desired-reported state из Redis, история показаний из ClickHouse (для
сенсоров), кнопки ручного управления (для актуаторов, только owner,
с проверкой capability устройства). В devices/index — бейдж online/
offline/unknown. Дашборд дополнен счётчиком онлайн-устройств.
Два реальных бага найдены и исправлены при сквозной проверке:
1. Пустой action_params сериализовался в JSON-массив "[]" (PHP не
различает пустой список и пустой объект), а Go ждёт объект —
rule-engine-service падал на unmarshal. Фикс — JsonObjectCast
(JSON_FORCE_OBJECT) на AutomationRule::action_params.
2. Redis-ключи device shadow — общее пространство имён с Go-сервisами
(сырые ключи без префикса), а Laravel по умолчанию добавляет ко всем
ключам префикс "app-name-database-" — Laravel никогда не видел
реальные данные. Фикс — REDIS_PREFIX="" в окружении контейнера
(важно: пустое значение в docker-compose YAML нужно задавать явно
через "", просто "KEY:" означает "взять из окружения хоста").
Проверено сквозным тестом через docker compose: полный цикл телеметрия →
правило → команда воспроизведён вживую с реальным исправлением на лету;
ручное управление (turn_on/turn_off/set_level) из Laravel UI подтверждено
через браузер — HTTP-вызов к device-control-service, обновление
desired_state (merge-patch), реальная MQTT-команда поймана мониторингом
топика. 38/38 тестов Laravel, все Go-тесты device-control-service зелёные.
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
type Config struct {
|
||||
GRPCPort int
|
||||
HTTPPort int
|
||||
|
||||
MQTTBrokerURL string
|
||||
MQTTClientID string
|
||||
@@ -45,6 +46,9 @@ func Load() (Config, error) {
|
||||
if cfg.GRPCPort, err = getEnvInt("DEVICE_CONTROL_GRPC_PORT", 50051); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.HTTPPort, err = getEnvInt("DEVICE_CONTROL_HTTP_PORT", 8090); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.RedisDB, err = getEnvInt("REDIS_DB", 0); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Package httpapi exposes the same device commands as the gRPC server over
|
||||
// plain HTTP/JSON, for callers where a full gRPC client is impractical
|
||||
// (Laravel/PHP, in this platform's case — see the service README for why).
|
||||
// It's a thin transport: all the actual logic (Device Shadow patch + MQTT
|
||||
// publish) lives in internal/server and is reused as-is.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control"
|
||||
)
|
||||
|
||||
// Dispatcher is the subset of server.Server this package needs.
|
||||
type Dispatcher interface {
|
||||
TurnOn(ctx context.Context, req *devicecontrol.TurnOnRequest) (*devicecontrol.CommandResult, error)
|
||||
TurnOff(ctx context.Context, req *devicecontrol.TurnOffRequest) (*devicecontrol.CommandResult, error)
|
||||
SetLevel(ctx context.Context, req *devicecontrol.SetLevelRequest) (*devicecontrol.CommandResult, error)
|
||||
}
|
||||
|
||||
type commandResult struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func NewRouter(dispatcher Dispatcher, logger *slog.Logger) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("POST /devices/{device_id}/turn-on", func(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := r.PathValue("device_id")
|
||||
res, err := dispatcher.TurnOn(r.Context(), &devicecontrol.TurnOnRequest{DeviceId: deviceID})
|
||||
writeResult(w, logger, res, err)
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /devices/{device_id}/turn-off", func(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := r.PathValue("device_id")
|
||||
res, err := dispatcher.TurnOff(r.Context(), &devicecontrol.TurnOffRequest{DeviceId: deviceID})
|
||||
writeResult(w, logger, res, err)
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /devices/{device_id}/set-level", func(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := r.PathValue("device_id")
|
||||
|
||||
var body struct {
|
||||
Level float64 `json:"level"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(commandResult{Success: false, Error: "invalid JSON body: expected {\"level\": number}"})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := dispatcher.SetLevel(r.Context(), &devicecontrol.SetLevelRequest{DeviceId: deviceID, Level: body.Level})
|
||||
writeResult(w, logger, res, err)
|
||||
})
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// writeResult always answers 200 with the command's success/error in the
|
||||
// body — the wire contract mirrors devicecontrol.CommandResult exactly, the
|
||||
// same as the gRPC transport. A non-nil err here is a transport-layer bug in
|
||||
// the dispatcher (it isn't supposed to return one), logged and surfaced as a
|
||||
// generic failure rather than leaking internals to the caller.
|
||||
func writeResult(w http.ResponseWriter, logger *slog.Logger, res *devicecontrol.CommandResult, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err != nil {
|
||||
logger.Error("dispatcher returned unexpected error", "error", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(commandResult{Success: false, Error: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(commandResult{Success: res.GetSuccess(), Error: res.GetError()})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control"
|
||||
)
|
||||
|
||||
type fakeDispatcher struct {
|
||||
turnOnReq *devicecontrol.TurnOnRequest
|
||||
turnOffReq *devicecontrol.TurnOffRequest
|
||||
setLevelReq *devicecontrol.SetLevelRequest
|
||||
result *devicecontrol.CommandResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDispatcher) TurnOn(_ context.Context, req *devicecontrol.TurnOnRequest) (*devicecontrol.CommandResult, error) {
|
||||
f.turnOnReq = req
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func (f *fakeDispatcher) TurnOff(_ context.Context, req *devicecontrol.TurnOffRequest) (*devicecontrol.CommandResult, error) {
|
||||
f.turnOffReq = req
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func (f *fakeDispatcher) SetLevel(_ context.Context, req *devicecontrol.SetLevelRequest) (*devicecontrol.CommandResult, error) {
|
||||
f.setLevelReq = req
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func decodeResult(t *testing.T, rec *httptest.ResponseRecorder) commandResult {
|
||||
t.Helper()
|
||||
var res commandResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("decode response body %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func TestTurnOn_Success(t *testing.T) {
|
||||
fake := &fakeDispatcher{result: &devicecontrol.CommandResult{Success: true}}
|
||||
router := NewRouter(fake, discardLogger())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/devices/fan-1/turn-on", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got status %d, want 200", rec.Code)
|
||||
}
|
||||
if fake.turnOnReq == nil || fake.turnOnReq.DeviceId != "fan-1" {
|
||||
t.Fatalf("got TurnOn request %+v, want device_id=fan-1", fake.turnOnReq)
|
||||
}
|
||||
if res := decodeResult(t, rec); !res.Success {
|
||||
t.Fatalf("got %+v, want success", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnOff_Success(t *testing.T) {
|
||||
fake := &fakeDispatcher{result: &devicecontrol.CommandResult{Success: true}}
|
||||
router := NewRouter(fake, discardLogger())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/devices/fan-1/turn-off", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got status %d, want 200", rec.Code)
|
||||
}
|
||||
if fake.turnOffReq == nil || fake.turnOffReq.DeviceId != "fan-1" {
|
||||
t.Fatalf("got TurnOff request %+v, want device_id=fan-1", fake.turnOffReq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLevel_Success(t *testing.T) {
|
||||
fake := &fakeDispatcher{result: &devicecontrol.CommandResult{Success: true}}
|
||||
router := NewRouter(fake, discardLogger())
|
||||
|
||||
body := bytes.NewBufferString(`{"level": 42.5}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/devices/light-1/set-level", body)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got status %d, want 200", rec.Code)
|
||||
}
|
||||
if fake.setLevelReq == nil || fake.setLevelReq.Level != 42.5 {
|
||||
t.Fatalf("got SetLevel request %+v, want level=42.5", fake.setLevelReq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLevel_InvalidBody(t *testing.T) {
|
||||
fake := &fakeDispatcher{}
|
||||
router := NewRouter(fake, discardLogger())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/devices/light-1/set-level", bytes.NewBufferString(`not json`))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("got status %d, want 400", rec.Code)
|
||||
}
|
||||
if fake.setLevelReq != nil {
|
||||
t.Fatal("dispatcher should not have been called with an invalid body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessFailure_PassesThroughAs200(t *testing.T) {
|
||||
fake := &fakeDispatcher{result: &devicecontrol.CommandResult{Success: false, Error: "device offline"}}
|
||||
router := NewRouter(fake, discardLogger())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/devices/fan-1/turn-on", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got status %d, want 200 (business failures aren't transport errors)", rec.Code)
|
||||
}
|
||||
res := decodeResult(t, rec)
|
||||
if res.Success || res.Error != "device offline" {
|
||||
t.Fatalf("got %+v, want business failure passed through", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherError_Returns500(t *testing.T) {
|
||||
fake := &fakeDispatcher{err: errors.New("boom")}
|
||||
router := NewRouter(fake, discardLogger())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/devices/fan-1/turn-on", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("got status %d, want 500", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownRoute_404(t *testing.T) {
|
||||
router := NewRouter(&fakeDispatcher{}, discardLogger())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/devices/fan-1/turn-on", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed && rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("got status %d for GET on a POST-only route, want 404 or 405", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user