Доп. сигналы — отдельные устройства, а не sensor_type у существующего

Раньше "добавить сигнал" дописывал ещё один sensor_type к sensor-1 —
концептуально неверно и для дискретных входов, и тем более для выходов.
Теперь и вход (датчик), и выход (актуатор) создаются как полноценное
отдельное MQTT-устройство со своим external_id/zone_id — так же, как
завели бы новое Device на платформе.

state.Store обобщён: 4 фиксированных слота (плата, GPIO-пины, нельзя
удалить) + произвольное число доп. устройств (свой slot "extra-N",
можно удалить). HTTP: POST /api/devices (создать, kind=sensor|actuator),
DELETE /api/devices/{slot}.

На схеме доп. устройства подвешены к пину EXT пунктирным проводом —
визуально отделены от жёстко распаянных GPIO4/16/17/18. Можно указать
и произвольный пин (просто текстовая метка на схеме, без валидации) —
подписывается у провода вместо общего EXT.

Карточки создаются/удаляются динамически при получении снапшота через
SSE, без перезагрузки страницы.
This commit is contained in:
2026-08-14 06:44:18 +05:00
parent da0f941d79
commit 5674917f95
3 changed files with 434 additions and 172 deletions
+120 -35
View File
@@ -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
}
}