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 платформы.
194 lines
5.5 KiB
Go
194 lines
5.5 KiB
Go
// 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
|
|
}
|
|
}
|
|
}
|