Доп. сигналы — отдельные устройства, а не 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:
+120
-35
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user