diff --git a/internal/httpserver/server.go b/internal/httpserver/server.go index 72d21b3..d5b5d06 100644 --- a/internal/httpserver/server.go +++ b/internal/httpserver/server.go @@ -43,10 +43,60 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /api/snapshot", s.handleSnapshot) s.mux.HandleFunc("GET /api/events", s.handleEvents) + s.mux.HandleFunc("POST /api/devices", s.handleAddDevice) + s.mux.HandleFunc("DELETE /api/devices/{slot}", s.handleRemoveDevice) s.mux.HandleFunc("POST /api/devices/{slot}/identity", s.handleSetIdentity) s.mux.HandleFunc("POST /api/devices/{slot}/telemetry", s.handlePublishTelemetry) } +// handleAddDevice creates a new standalone sensor (input) or actuator +// (output) device — a separate MQTT device_id, not another sensor_type or +// a manual override tacked onto an existing device. Mirrors how you'd +// actually add a second physical device to the platform (a new Device row +// with its own external_id). +func (s *Server) handleAddDevice(w http.ResponseWriter, r *http.Request) { + var req struct { + Kind string `json:"kind"` // "sensor" | "actuator" + SignalName string `json:"signal_name"` + ExternalID string `json:"external_id"` + ZoneID string `json:"zone_id"` + Pin string `json:"pin"` + Discrete bool `json:"discrete"` + SupportsLevel bool `json:"supports_level"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ExternalID == "" || req.ZoneID == "" { + http.Error(w, "external_id and zone_id are required", http.StatusBadRequest) + return + } + + var slot string + switch req.Kind { + case "sensor": + if req.SignalName == "" { + http.Error(w, "signal_name is required for a sensor device", http.StatusBadRequest) + return + } + slot = s.store.AddSensorDevice(req.SignalName, req.ExternalID, req.ZoneID, req.Pin, req.Discrete) + case "actuator": + slot = s.store.AddActuatorDevice(req.ExternalID, req.ZoneID, req.Pin, req.SupportsLevel) + default: + http.Error(w, `kind must be "sensor" or "actuator"`, http.StatusBadRequest) + return + } + + writeJSON(w, http.StatusCreated, s.store.Snapshot()) + s.logger.Info("extra device added", "slot", slot, "kind", req.Kind, "device_id", req.ExternalID) +} + +func (s *Server) handleRemoveDevice(w http.ResponseWriter, r *http.Request) { + slot := r.PathValue("slot") + if !s.store.RemoveDevice(slot) { + http.Error(w, "unknown or non-removable slot", http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, s.store.Snapshot()) +} + func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.store.Snapshot()) } diff --git a/internal/httpserver/web/index.html b/internal/httpserver/web/index.html index eca9387..4f9cccf 100644 --- a/internal/httpserver/web/index.html +++ b/internal/httpserver/web/index.html @@ -20,6 +20,7 @@ --wire-fan: #5b7ba8; --wire-light: #e8b339; --wire-pump: #3aa0e8; + --wire-ext: #7a8ba8; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } * { box-sizing: border-box; } @@ -30,7 +31,7 @@ padding: 24px; } h1 { font-size: 1.3rem; font-weight: 600; margin: 0 0 4px; } - .subtitle { color: var(--muted); margin: 0 0 24px; font-size: 0.9rem; max-width: 640px; } + .subtitle { color: var(--muted); margin: 0 0 24px; font-size: 0.9rem; max-width: 680px; } .conn { display: inline-flex; align-items: center; gap: 6px; font-size: 0.8rem; color: var(--muted); margin-bottom: 20px; @@ -79,15 +80,18 @@ transition: box-shadow .3s, border-color .3s; } .card.flash { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(51,209,122,0.25); } + .card.add-device-card { border-style: dashed; } .card h2 { font-size: 0.95rem; margin: 0 0 12px; - display: flex; align-items: center; justify-content: space-between; + display: flex; align-items: center; justify-content: space-between; gap: 8px; } + .card h2 .title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .badge { font-size: 0.72rem; padding: 2px 8px; border-radius: 999px; - background: var(--panel-2); color: var(--muted); font-weight: 500; + background: var(--panel-2); color: var(--muted); font-weight: 500; flex-shrink: 0; } .badge.on { background: var(--accent-dim); color: var(--accent); } + .badge.extra { background: #2a2440; color: #b39ddb; } label { display: block; font-size: 0.78rem; color: var(--muted); margin: 10px 0 4px; } input[type=text], input[type=number] { @@ -105,6 +109,7 @@ font-weight: 600; font-size: 0.85rem; cursor: pointer; } button.secondary { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); } + button.danger { background: transparent; color: #e08a8a; border: 1px solid #4a2c2c; } button:active { transform: translateY(1px); } .status-line { display: flex; justify-content: space-between; font-size: 0.85rem; margin-top: 8px; } @@ -112,17 +117,10 @@ .value { color: var(--text); font-weight: 600; } .hint { color: var(--muted); font-size: 0.78rem; margin-top: 10px; } - .signal-group-label { - font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--muted); margin-top: 12px; margin-bottom: 2px; - } - .signal-group-label:first-child { margin-top: 0; } - .signal-row { display: flex; align-items: center; justify-content: space-between; - gap: 8px; padding: 6px 0; border-top: 1px solid var(--border); font-size: 0.85rem; + gap: 8px; padding: 6px 0; font-size: 0.85rem; } - .signal-row:first-of-type { border-top: none; } .signal-row .name { color: var(--muted); } .signal-row input[type=range] { flex: 1; } .signal-row .val { width: 44px; text-align: right; font-weight: 600; } @@ -137,22 +135,24 @@ } .toggle.on .knob { left: 20px; background: var(--accent); } - .add-signal { border-top: 1px dashed var(--border); margin-top: 12px; padding-top: 12px; } - .add-signal .row2 { display: flex; gap: 8px; margin-top: 6px; } select { - padding: 7px 9px; border-radius: 7px; border: 1px solid var(--border); + width: 100%; padding: 7px 9px; border-radius: 7px; border: 1px solid var(--border); background: var(--panel-2); color: var(--text); font-size: 0.85rem; } + .field-row { display: flex; gap: 8px; } + .field-row > div { flex: 1; } + .checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 0.85rem; } + .checkbox-row input { width: auto; margin: 0; }

ESP32 Emulator

-

Виртуальный ESP32-контроллер гроубокса. Слева — какой пин к чему подключён. Показания датчика вы задаёте и публикуете сами (входные сигналы → в автоматику); питание/уровень актуаторов меняются САМИ, когда платформа реально присылает команду (выход из автоматики → сюда).

+

Виртуальный ESP32-контроллер гроубокса. Слева — какой пин к чему подключён. Показания задаёте и публикуете сами (входные сигналы → в автоматику); питание/уровень актуаторов меняются САМИ, когда платформа реально присылает команду (выход из автоматики → сюда). Дополнительные сигналы — это отдельные устройства (свой external_id), а не довесок к существующим.

подключение…
- + @@ -172,6 +172,8 @@ + + @@ -186,6 +188,7 @@ GPIO16 GPIO17 GPIO18 + EXT @@ -230,125 +233,53 @@ Помпа GPIO18 · relay + + + + доп. устройства (EXT) +
diff --git a/internal/state/state.go b/internal/state/state.go index 607db49..9853b45 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -1,11 +1,16 @@ -// Package state holds the emulator's in-memory model of a growbox -// controller board: one sensor (reports readings) and up to three -// actuators (respond to power/level commands). Nothing here persists -// across restarts — the emulator is a throwaway dev/demo tool, not a -// real device. +// Package state holds the emulator's in-memory model of the controller: +// four fixed board slots wired to real GPIO pins (one sensor, three +// actuators) plus any number of extra standalone sensor devices the user +// adds at runtime — each its own MQTT device_id, the way a second physical +// sensor would show up in the real platform, not just another sensor_type +// bolted onto the first one. Nothing here persists across restarts — the +// emulator is a throwaway dev/demo tool, not a real device. package state -import "sync" +import ( + "fmt" + "sync" +) type Kind string @@ -14,30 +19,45 @@ const ( KindActuator Kind = "actuator" ) -// Device is one slot on the controller board. Sensor slots use Readings; -// actuator slots use Power/Level. A slot is inert (not published/commanded) -// when ExternalID is empty, so the board can represent fewer than four -// devices without special-casing "missing" ones. +// coreSlots are wired to a fixed GPIO pin on the board illustration and +// can't be removed — only their identity (external_id/zone_id) is editable. +var coreSlots = []string{"sensor", "fan", "light", "pump"} + +// Device is one board slot or standalone extra sensor. Sensor devices use +// Readings; actuator devices use Power/Level. Core is true for the four +// fixed board slots (rendered wired to a GPIO pin, can't be deleted) and +// false for user-added extra sensors (rendered as their own card, can be +// removed). Discrete only matters for single-reading extra sensors, telling +// the frontend to render a toggle instead of a slider. type Device struct { - Slot string `json:"slot"` // "sensor" | "fan" | "light" | "pump" — fixed board position + Slot string `json:"slot"` + Core bool `json:"core"` ExternalID string `json:"external_id"` ZoneID string `json:"zone_id"` Kind Kind `json:"kind"` Readings map[string]float64 `json:"readings,omitempty"` + Discrete bool `json:"discrete,omitempty"` Power bool `json:"power,omitempty"` SupportsLevel bool `json:"supports_level,omitempty"` Level float64 `json:"level,omitempty"` + // Pin is a free-text label the user assigns (e.g. "GPIO25") for extra + // devices — purely cosmetic (shown on the board/card), not validated + // against real ESP32 pin capabilities or checked for collisions with + // the four core pins. + Pin string `json:"pin,omitempty"` } -// Snapshot is the full board state pushed to the browser (initial load and -// every SSE update) — simpler than diffing for a board this small. +// Snapshot is the full state pushed to the browser (initial load and every +// SSE update) — simpler than diffing for a board this small. type Snapshot struct { Devices []Device `json:"devices"` } type Store struct { - mu sync.RWMutex - devices map[string]*Device // keyed by Slot + mu sync.RWMutex + devices map[string]*Device + order []string // rendering order: core slots first, then extras oldest-first + nextExtra int subMu sync.Mutex subs map[chan Snapshot]struct{} @@ -46,24 +66,26 @@ type Store struct { func NewStore() *Store { return &Store{ devices: map[string]*Device{ - "sensor": {Slot: "sensor", ExternalID: "sensor-1", ZoneID: "1", Kind: KindSensor, + "sensor": {Slot: "sensor", Core: true, ExternalID: "sensor-1", ZoneID: "1", Kind: KindSensor, Readings: map[string]float64{"temperature": 24, "humidity": 55}}, - "fan": {Slot: "fan", ExternalID: "fan-1", ZoneID: "1", Kind: KindActuator, SupportsLevel: true}, - "light": {Slot: "light", ExternalID: "light-1", ZoneID: "1", Kind: KindActuator, SupportsLevel: true}, - "pump": {Slot: "pump", ExternalID: "pump-1", ZoneID: "1", Kind: KindActuator}, + "fan": {Slot: "fan", Core: true, ExternalID: "fan-1", ZoneID: "1", Kind: KindActuator, SupportsLevel: true}, + "light": {Slot: "light", Core: true, ExternalID: "light-1", ZoneID: "1", Kind: KindActuator, SupportsLevel: true}, + "pump": {Slot: "pump", Core: true, ExternalID: "pump-1", ZoneID: "1", Kind: KindActuator}, }, - subs: make(map[chan Snapshot]struct{}), + order: append([]string(nil), coreSlots...), + nextExtra: 1, + subs: make(map[chan Snapshot]struct{}), } } -// Snapshot returns a deep-enough copy of the current board state (readings -// map is copied so callers can't mutate internal state through it). +// Snapshot returns a deep-enough copy of the current state (readings maps +// are copied so callers can't mutate internal state through them). func (s *Store) Snapshot() Snapshot { s.mu.RLock() defer s.mu.RUnlock() - devices := make([]Device, 0, len(s.devices)) - for _, slot := range []string{"sensor", "fan", "light", "pump"} { + devices := make([]Device, 0, len(s.order)) + for _, slot := range s.order { d := *s.devices[slot] if d.Readings != nil { readings := make(map[string]float64, len(d.Readings)) @@ -89,9 +111,72 @@ func (s *Store) Device(slot string) (Device, bool) { return *d, true } -// DeviceBySlotExternalID finds the slot name for a given external_id, used -// to route an incoming MQTT command (which only carries the external_id in -// its topic) back to a board slot. +// AddSensorDevice creates a new standalone sensor device — its own MQTT +// device_id, one named reading (analog or discrete, both just a float64 +// underneath — the wire format doesn't distinguish them). Returns the new +// slot name. Mirrors adding a second physical sensor to the platform (a +// new Device row with its own external_id), not another sensor_type +// tacked onto an existing device. +func (s *Store) AddSensorDevice(signalName, externalID, zoneID, pin string, discrete bool) string { + s.mu.Lock() + slot := s.newExtraSlot() + s.devices[slot] = &Device{ + Slot: slot, Core: false, ExternalID: externalID, ZoneID: zoneID, Kind: KindSensor, + Readings: map[string]float64{signalName: 0}, Discrete: discrete, Pin: pin, + } + s.order = append(s.order, slot) + s.mu.Unlock() + s.broadcast() + return slot +} + +// AddActuatorDevice creates a new standalone output device — same idea as +// AddSensorDevice but for the output side: it only reacts to real +// devices/{id}/commands from the platform, same as the four board actuators. +func (s *Store) AddActuatorDevice(externalID, zoneID, pin string, supportsLevel bool) string { + s.mu.Lock() + slot := s.newExtraSlot() + s.devices[slot] = &Device{ + Slot: slot, Core: false, ExternalID: externalID, ZoneID: zoneID, Kind: KindActuator, + SupportsLevel: supportsLevel, Pin: pin, + } + s.order = append(s.order, slot) + s.mu.Unlock() + s.broadcast() + return slot +} + +// newExtraSlot must be called with s.mu held. +func (s *Store) newExtraSlot() string { + slot := fmt.Sprintf("extra-%d", s.nextExtra) + s.nextExtra++ + return slot +} + +// RemoveDevice deletes an extra sensor or actuator device. Core board +// slots can't be removed (they're wired to a physical pin, not optional). +func (s *Store) RemoveDevice(slot string) bool { + s.mu.Lock() + d, ok := s.devices[slot] + if !ok || d.Core { + s.mu.Unlock() + return false + } + delete(s.devices, slot) + for i, sl := range s.order { + if sl == slot { + s.order = append(s.order[:i], s.order[i+1:]...) + break + } + } + s.mu.Unlock() + s.broadcast() + return true +} + +// SlotForExternalID finds the slot name for a given external_id, used to +// route an incoming MQTT command (which only carries the external_id in +// its topic) back to a device. func (s *Store) SlotForExternalID(externalID string) (string, bool) { s.mu.RLock() defer s.mu.RUnlock() @@ -117,7 +202,7 @@ func (s *Store) SetIdentity(slot, externalID, zoneID string) { s.broadcast() } -// SetReading updates one sensor_type's value for the sensor slot. +// SetReading updates one sensor_type's value on a sensor device. func (s *Store) SetReading(slot, sensorType string, value float64) { s.mu.Lock() if d, ok := s.devices[slot]; ok && d.Kind == KindSensor { @@ -130,17 +215,17 @@ func (s *Store) SetReading(slot, sensorType string, value float64) { // ApplyCommand applies an incoming {"action": ..., "level": ...} command // (as published by device-control-service to devices/{id}/commands) to the // actuator identified by externalID. Returns the resulting reported state -// (for the ack payload) and whether externalID matched a known slot. +// (for the ack payload) and whether externalID matched a known device. func (s *Store) ApplyCommand(externalID, action string, level float64, hasLevel bool) (map[string]any, bool) { s.mu.Lock() - var slot *Device + var target *Device for _, d := range s.devices { if d.ExternalID == externalID { - slot = d + target = d break } } - if slot == nil { + if target == nil { s.mu.Unlock() return nil, false } @@ -148,14 +233,14 @@ func (s *Store) ApplyCommand(externalID, action string, level float64, hasLevel reported := map[string]any{} switch action { case "turn_on": - slot.Power = true + target.Power = true reported["power"] = "on" case "turn_off": - slot.Power = false + target.Power = false reported["power"] = "off" case "set_level": if hasLevel { - slot.Level = level + target.Level = level reported["level"] = level } }