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