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