package alamo import ( "context" "errors" "fmt" "sync" "time" "onehouse/driver" "onehouse/protocol" ) // Driver owns one gateway's shared polling, request budget, and command work. // Run has one owner. HTTP failures count toward missed polls or command failure // results. Publication failures return to the runner. No method logs. The // caller runs the shared publisher alongside the driver and owns the Client. // Refresh is safe during Run. Ledger protection is memory-only and ends at // eviction or process restart. Construct one driver per physical gateway. type Driver struct { gateway gateway pollInterval time.Duration thermostats []*thermostatState refresh chan struct{} pending chan queuedCommands publisher *driver.Publisher } // New validates settings and command queues without I/O. The caller supplies // valid publisher, router, and client dependencies, and starts router intake // separately. The constructor retains no caller-owned settings slice. func New(settings Settings, publisher *driver.Publisher, router *driver.Router, client Client) (*Driver, error) { if len(settings.Thermostats) == 0 { return nil, fmt.Errorf("gateway requires at least one thermostat") } interval := settings.PollInterval if interval == 0 { interval = 15 * time.Second } if interval < 10*time.Second || interval > 5*time.Minute { return nil, fmt.Errorf("poll interval must be from ten seconds through five minutes") } integration := &Driver{ gateway: gateway{client: client}, pollInterval: interval, refresh: make(chan struct{}, 1), pending: make(chan queuedCommands), publisher: publisher, } ids := make(map[string]bool) macs := make(map[string]bool) for _, thermostat := range settings.Thermostats { if !protocol.ValidID(thermostat.DeviceID) || ids[thermostat.DeviceID] { return nil, fmt.Errorf("thermostat device IDs must be unique canonical ULIDs") } mac, err := normalizeMAC(thermostat.MAC) if err != nil { return nil, fmt.Errorf("validate thermostat MAC: %w", err) } if macs[mac] { return nil, fmt.Errorf("thermostat MAC values must be unique") } queue, exists := router.Queue(thermostat.DeviceID) if !exists { return nil, fmt.Errorf("thermostat has no command queue") } ledger, err := driver.NewLedger(thermostat.DeviceID) if err != nil { return nil, fmt.Errorf("create thermostat ledger: %w", err) } ids[thermostat.DeviceID] = true macs[mac] = true thermostat.MAC = mac integration.thermostats = append(integration.thermostats, &thermostatState{ settings: thermostat, queue: queue, ledger: ledger, publisher: publisher, }) } return integration, nil } // Run publishes descriptors, then shares each poll across the thermostat list. // Three missed observations mark a thermostat offline. A full observation // restores it. Shutdown does not publish offline. A command and its actions run // in order through the gateway budget and the shared result ledger. func (d *Driver) Run(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) defer cancel() var workers sync.WaitGroup defer func() { cancel() workers.Wait() }() for _, thermostat := range d.thermostats { err := thermostat.descriptor(ctx) if err != nil { return fmt.Errorf("publish thermostat descriptor: %w", err) } workers.Go(func() { d.forward(ctx, thermostat) }) } err := d.poll(ctx) if err != nil { return fmt.Errorf("poll initial thermostat states: %w", err) } ticker := time.NewTicker(d.pollInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return ctx.Err() case <-d.refresh: err = d.poll(ctx) if err != nil { return fmt.Errorf("refresh thermostat states: %w", err) } case <-ticker.C: err = d.poll(ctx) if err != nil { return fmt.Errorf("poll thermostat states: %w", err) } case pending := <-d.pending: handler := commandHandler{ gateway: &d.gateway, thermostat: pending.thermostat, } err = handler.handle(ctx, pending.commands) if err != nil { return fmt.Errorf("process thermostat commands: %w", err) } } } } func (d *Driver) forward(ctx context.Context, thermostat *thermostatState) { for { select { case <-ctx.Done(): return case commands := <-thermostat.queue: select { case <-ctx.Done(): return case d.pending <- queuedCommands{ thermostat: thermostat, commands: commands, }: } } } } func (d *Driver) poll(ctx context.Context) error { states, err := d.gateway.status(ctx) if ctx.Err() != nil { if err != nil { return fmt.Errorf("stop gateway poll: %w", errors.Join(ctx.Err(), err)) } return ctx.Err() } observedAt := time.Now().UTC() observed := make(map[string]State, len(states)) if err == nil { for _, state := range states { observed[state.MAC] = state } } for _, thermostat := range d.thermostats { state, exists := observed[thermostat.settings.MAC] if exists { err = thermostat.record(ctx, state, observedAt, "") } else { err = thermostat.miss(ctx) } if err != nil { return fmt.Errorf("record thermostat poll: %w", err) } } return nil } // Refresh coalesces one rate-limited status poll and asks the shared publisher // to resend its snapshots. It does not repeat physical command actions. func (d *Driver) Refresh() { d.publisher.Refresh() select { case d.refresh <- struct{}{}: default: } }