Files
home_automatization/services/device-control-service/internal/httpapi/router.go
T
cacto 3ade5c2512 Дашборд с реальными данными + ручное управление устройствами
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 зелёные.
2026-08-11 19:48:17 +05:00

81 lines
3.1 KiB
Go

// 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()})
}