ESP32 Emulator: веб-морда контроллера гроубокса поверх MQTT
Go-сервис без внешних фронтенд-зависимостей: embedded HTML-страница
с SVG-изображением платы (датчик + вентилятор + свет + помпа) и
Server-Sent Events для live-обновления. Слушает devices/+/commands
и реально отвечает ack'ом, публикует devices/{id}/telemetry —
протокол зеркалит ingest-service/device-control-service основного
репозитория, общего кода между репозиториями нет. Дефолтные
external_id (sensor-1/fan-1/light-1/pump-1) совпадают с
DemoGrowboxSeeder платформы.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
// 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
|
||||
|
||||
import "sync"
|
||||
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindSensor Kind = "sensor"
|
||||
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.
|
||||
type Device struct {
|
||||
Slot string `json:"slot"` // "sensor" | "fan" | "light" | "pump" — fixed board position
|
||||
ExternalID string `json:"external_id"`
|
||||
ZoneID string `json:"zone_id"`
|
||||
Kind Kind `json:"kind"`
|
||||
Readings map[string]float64 `json:"readings,omitempty"`
|
||||
Power bool `json:"power,omitempty"`
|
||||
SupportsLevel bool `json:"supports_level,omitempty"`
|
||||
Level float64 `json:"level,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.
|
||||
type Snapshot struct {
|
||||
Devices []Device `json:"devices"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
devices map[string]*Device // keyed by Slot
|
||||
|
||||
subMu sync.Mutex
|
||||
subs map[chan Snapshot]struct{}
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{
|
||||
devices: map[string]*Device{
|
||||
"sensor": {Slot: "sensor", 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},
|
||||
},
|
||||
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).
|
||||
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"} {
|
||||
d := *s.devices[slot]
|
||||
if d.Readings != nil {
|
||||
readings := make(map[string]float64, len(d.Readings))
|
||||
for k, v := range d.Readings {
|
||||
readings[k] = v
|
||||
}
|
||||
d.Readings = readings
|
||||
}
|
||||
devices = append(devices, d)
|
||||
}
|
||||
return Snapshot{Devices: devices}
|
||||
}
|
||||
|
||||
// Device returns a copy of one slot's device, or ok=false if the slot name
|
||||
// is unknown.
|
||||
func (s *Store) Device(slot string) (Device, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
d, ok := s.devices[slot]
|
||||
if !ok {
|
||||
return Device{}, false
|
||||
}
|
||||
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.
|
||||
func (s *Store) SlotForExternalID(externalID string) (string, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for slot, d := range s.devices {
|
||||
if d.ExternalID == externalID {
|
||||
return slot, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// SetIdentity updates which external_id/zone_id a slot publishes as —
|
||||
// this is itself one of the "parameters" the web UI lets you set, so the
|
||||
// emulator can stand in for whatever device row exists in the platform's
|
||||
// database.
|
||||
func (s *Store) SetIdentity(slot, externalID, zoneID string) {
|
||||
s.mu.Lock()
|
||||
if d, ok := s.devices[slot]; ok {
|
||||
d.ExternalID = externalID
|
||||
d.ZoneID = zoneID
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.broadcast()
|
||||
}
|
||||
|
||||
// SetReading updates one sensor_type's value for the sensor slot.
|
||||
func (s *Store) SetReading(slot, sensorType string, value float64) {
|
||||
s.mu.Lock()
|
||||
if d, ok := s.devices[slot]; ok && d.Kind == KindSensor {
|
||||
d.Readings[sensorType] = value
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.broadcast()
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (s *Store) ApplyCommand(externalID, action string, level float64, hasLevel bool) (map[string]any, bool) {
|
||||
s.mu.Lock()
|
||||
var slot *Device
|
||||
for _, d := range s.devices {
|
||||
if d.ExternalID == externalID {
|
||||
slot = d
|
||||
break
|
||||
}
|
||||
}
|
||||
if slot == nil {
|
||||
s.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
|
||||
reported := map[string]any{}
|
||||
switch action {
|
||||
case "turn_on":
|
||||
slot.Power = true
|
||||
reported["power"] = "on"
|
||||
case "turn_off":
|
||||
slot.Power = false
|
||||
reported["power"] = "off"
|
||||
case "set_level":
|
||||
if hasLevel {
|
||||
slot.Level = level
|
||||
reported["level"] = level
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.broadcast()
|
||||
return reported, true
|
||||
}
|
||||
|
||||
// Subscribe registers a channel that receives a snapshot on every state
|
||||
// change. Call the returned func to unsubscribe.
|
||||
func (s *Store) Subscribe() (chan Snapshot, func()) {
|
||||
ch := make(chan Snapshot, 4)
|
||||
s.subMu.Lock()
|
||||
s.subs[ch] = struct{}{}
|
||||
s.subMu.Unlock()
|
||||
|
||||
return ch, func() {
|
||||
s.subMu.Lock()
|
||||
delete(s.subs, ch)
|
||||
s.subMu.Unlock()
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) broadcast() {
|
||||
snap := s.Snapshot()
|
||||
s.subMu.Lock()
|
||||
defer s.subMu.Unlock()
|
||||
for ch := range s.subs {
|
||||
select {
|
||||
case ch <- snap:
|
||||
default: // slow subscriber — drop, next change will resync it
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user