// 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, "")) }