// 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 ( "fmt" "sync" ) type Kind string const ( KindSensor Kind = "sensor" KindActuator Kind = "actuator" ) // 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"` 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 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 order []string // rendering order: core slots first, then extras oldest-first nextExtra int subMu sync.Mutex subs map[chan Snapshot]struct{} } func NewStore() *Store { return &Store{ devices: map[string]*Device{ "sensor": {Slot: "sensor", Core: true, ExternalID: "sensor-1", ZoneID: "1", Kind: KindSensor, Readings: map[string]float64{"temperature": 24, "humidity": 55}}, "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}, }, order: append([]string(nil), coreSlots...), nextExtra: 1, subs: make(map[chan Snapshot]struct{}), } } // 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.order)) for _, slot := range s.order { 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 } // 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() 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 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 { 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 device. func (s *Store) ApplyCommand(externalID, action string, level float64, hasLevel bool) (map[string]any, bool) { s.mu.Lock() var target *Device for _, d := range s.devices { if d.ExternalID == externalID { target = d break } } if target == nil { s.mu.Unlock() return nil, false } reported := map[string]any{} switch action { case "turn_on": target.Power = true reported["power"] = "on" case "turn_off": target.Power = false reported["power"] = "off" case "set_level": if hasLevel { target.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 } } }