245 lines
7.4 KiB
Go
245 lines
7.4 KiB
Go
// devices.go — CRUD устройств на PostgreSQL.
|
||
//
|
||
// Endpoints:
|
||
//
|
||
// POST /v1/namespaces/{ns}/iot/devices — создать
|
||
// GET /v1/namespaces/{ns}/iot/devices — список (без паролей)
|
||
// GET /v1/namespaces/{ns}/iot/devices/{name} — одно (с паролем)
|
||
// DELETE /v1/namespaces/{ns}/iot/devices/{name} — удалить
|
||
// PATCH /v1/namespaces/{ns}/iot/devices/{name} — enabled
|
||
package handler
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"time"
|
||
|
||
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/store"
|
||
)
|
||
|
||
// iotDeviceCreateRequest — тело POST.
|
||
type iotDeviceCreateRequest struct {
|
||
Name string `json:"name"`
|
||
DeviceID string `json:"device_id"`
|
||
Enabled *bool `json:"enabled"`
|
||
Metadata map[string]string `json:"metadata,omitempty"`
|
||
}
|
||
|
||
// iotDeviceUpdateRequest — тело PATCH.
|
||
type iotDeviceUpdateRequest struct {
|
||
Enabled *bool `json:"enabled"`
|
||
}
|
||
|
||
// iotDeviceResponse — ответ API. MQTTPassword — только в GET по имени.
|
||
type iotDeviceResponse struct {
|
||
Name string `json:"name"`
|
||
Namespace string `json:"namespace"`
|
||
DeviceID string `json:"device_id"`
|
||
Enabled bool `json:"enabled"`
|
||
Phase string `json:"phase"`
|
||
MQTTUsername string `json:"mqtt_username,omitempty"`
|
||
MQTTPassword string `json:"mqtt_password,omitempty"`
|
||
TopicPrefix string `json:"topic_prefix,omitempty"`
|
||
LastConnected string `json:"last_connected,omitempty"`
|
||
Metadata map[string]string `json:"metadata,omitempty"`
|
||
CreatedAt string `json:"created_at,omitempty"`
|
||
}
|
||
|
||
// deviceToResponse конвертирует устройство из БД в ответ API.
|
||
// password отдаётся только если непустая (передаётся явно из Get).
|
||
func deviceToResponse(d *store.Device, password string) iotDeviceResponse {
|
||
resp := iotDeviceResponse{
|
||
Name: d.Name,
|
||
Namespace: d.Namespace,
|
||
DeviceID: d.DeviceID,
|
||
Enabled: d.Enabled,
|
||
Phase: d.Phase,
|
||
MQTTUsername: d.Namespace + "_" + d.DeviceID,
|
||
MQTTPassword: password,
|
||
TopicPrefix: d.Namespace + "/",
|
||
Metadata: d.Metadata,
|
||
}
|
||
if d.LastConnected != nil && !d.LastConnected.IsZero() {
|
||
resp.LastConnected = d.LastConnected.UTC().Format(time.RFC3339)
|
||
}
|
||
if !d.CreatedAt.IsZero() {
|
||
resp.CreatedAt = d.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
|
||
}
|
||
return resp
|
||
}
|
||
|
||
// generateMQTTPassword — 32 случайных байта в hex (как старый контроллер).
|
||
func generateMQTTPassword() (string, error) {
|
||
buf := make([]byte, 32)
|
||
if _, err := rand.Read(buf); err != nil {
|
||
return "", err
|
||
}
|
||
return hex.EncodeToString(buf), nil
|
||
}
|
||
|
||
// CreateIoTDevice — POST /v1/namespaces/{ns}/iot/devices.
|
||
func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
|
||
ns := pathVar(r, "namespace")
|
||
if ns == "" {
|
||
ns = "default"
|
||
}
|
||
|
||
var req iotDeviceCreateRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
|
||
return
|
||
}
|
||
if req.Name == "" {
|
||
writeJSON(w, http.StatusBadRequest, errResp("name is required"))
|
||
return
|
||
}
|
||
if req.DeviceID == "" {
|
||
writeJSON(w, http.StatusBadRequest, errResp("device_id is required"))
|
||
return
|
||
}
|
||
|
||
enabled := true
|
||
if req.Enabled != nil {
|
||
enabled = *req.Enabled
|
||
}
|
||
|
||
password, err := generateMQTTPassword()
|
||
if err != nil {
|
||
h.Log.Error("generate device password", "err", err)
|
||
writeJSON(w, http.StatusInternalServerError, errResp("failed to generate credentials"))
|
||
return
|
||
}
|
||
|
||
phase := "Active"
|
||
if !enabled {
|
||
phase = "Disabled"
|
||
}
|
||
|
||
device := &store.Device{
|
||
Namespace: ns,
|
||
Name: req.Name,
|
||
DeviceID: req.DeviceID,
|
||
Enabled: enabled,
|
||
MQTTPassword: password,
|
||
Metadata: req.Metadata,
|
||
Phase: phase,
|
||
}
|
||
|
||
if err := h.Devices.Create(r.Context(), device); err != nil {
|
||
if errors.Is(err, store.ErrAlreadyExists) {
|
||
writeJSON(w, http.StatusConflict, errResp("iot device already exists"))
|
||
return
|
||
}
|
||
h.Log.Error("create device", "namespace", ns, "name", req.Name, "err", err)
|
||
writeJSON(w, http.StatusInternalServerError, errResp("failed to create device"))
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusCreated, deviceToResponse(device, ""))
|
||
}
|
||
|
||
// ListIoTDevices — GET /v1/namespaces/{ns}/iot/devices (без паролей).
|
||
func (h *Handler) ListIoTDevices(w http.ResponseWriter, r *http.Request) {
|
||
ns := pathVar(r, "namespace")
|
||
if ns == "" {
|
||
ns = "default"
|
||
}
|
||
|
||
devices, err := h.Devices.List(r.Context(), ns)
|
||
if err != nil {
|
||
h.Log.Error("list devices", "namespace", ns, "err", err)
|
||
writeJSON(w, http.StatusInternalServerError, errResp("failed to list devices"))
|
||
return
|
||
}
|
||
|
||
result := make([]iotDeviceResponse, 0, len(devices))
|
||
for i := range devices {
|
||
result = append(result, deviceToResponse(&devices[i], ""))
|
||
}
|
||
writeJSON(w, http.StatusOK, result)
|
||
}
|
||
|
||
// GetIoTDevice — GET /v1/namespaces/{ns}/iot/devices/{name} (с паролем).
|
||
func (h *Handler) GetIoTDevice(w http.ResponseWriter, r *http.Request) {
|
||
ns := pathVar(r, "namespace")
|
||
if ns == "" {
|
||
ns = "default"
|
||
}
|
||
name := pathVar(r, "name")
|
||
|
||
device, err := h.Devices.GetByName(r.Context(), ns, name)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
|
||
return
|
||
}
|
||
h.Log.Error("get device", "namespace", ns, "name", name, "err", err)
|
||
writeJSON(w, http.StatusInternalServerError, errResp("failed to get device"))
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, deviceToResponse(device, device.MQTTPassword))
|
||
}
|
||
|
||
// DeleteIoTDevice — DELETE /v1/namespaces/{ns}/iot/devices/{name}.
|
||
func (h *Handler) DeleteIoTDevice(w http.ResponseWriter, r *http.Request) {
|
||
ns := pathVar(r, "namespace")
|
||
if ns == "" {
|
||
ns = "default"
|
||
}
|
||
name := pathVar(r, "name")
|
||
|
||
err := h.Devices.Delete(r.Context(), ns, name)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
|
||
return
|
||
}
|
||
h.Log.Error("delete device", "namespace", ns, "name", name, "err", err)
|
||
writeJSON(w, http.StatusInternalServerError, errResp("failed to delete device"))
|
||
return
|
||
}
|
||
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// UpdateIoTDevice — PATCH /v1/namespaces/{ns}/iot/devices/{name} (enabled).
|
||
func (h *Handler) UpdateIoTDevice(w http.ResponseWriter, r *http.Request) {
|
||
ns := pathVar(r, "namespace")
|
||
if ns == "" {
|
||
ns = "default"
|
||
}
|
||
name := pathVar(r, "name")
|
||
|
||
var req iotDeviceUpdateRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
|
||
return
|
||
}
|
||
if req.Enabled == nil {
|
||
writeJSON(w, http.StatusBadRequest, errResp("enabled field is required"))
|
||
return
|
||
}
|
||
|
||
err := h.Devices.UpdateEnabled(r.Context(), ns, name, *req.Enabled)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
|
||
return
|
||
}
|
||
h.Log.Error("update device", "namespace", ns, "name", name, "err", err)
|
||
writeJSON(w, http.StatusInternalServerError, errResp("failed to update device"))
|
||
return
|
||
}
|
||
|
||
device, err := h.Devices.GetByName(r.Context(), ns, name)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, errResp("failed to reload device"))
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, deviceToResponse(device, ""))
|
||
}
|