Files
home_automation_emulator/internal/state/state.go
cacto 0eed35289c 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 платформы.
2026-08-13 11:12:29 +05:00

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
}
}
}