14 // Driver owns one gateway's shared polling, request budget, and command work.
15 // Run has one owner. HTTP failures count toward missed polls or command failure
16 // results. Publication failures return to the runner. No method logs. The
17 // caller runs the shared publisher alongside the driver and owns the Client.
18 // Refresh is safe during Run. Ledger protection is memory-only and ends at
19 // eviction or process restart. Construct one driver per physical gateway.
22 pollInterval time.Duration
23 thermostats []*thermostatState
25 pending chan queuedCommands
26 publisher *driver.Publisher
29 // New validates settings and command queues without I/O. The caller supplies
30 // valid publisher, router, and client dependencies, and starts router intake
31 // separately. The constructor retains no caller-owned settings slice.
32 func New(settings Settings, publisher *driver.Publisher, router *driver.Router, client Client) (*Driver, error) {
33 if len(settings.Thermostats) == 0 {
34 return nil, fmt.Errorf("gateway requires at least one thermostat")
37 interval := settings.PollInterval
39 interval = 15 * time.Second
42 if interval < 10*time.Second || interval > 5*time.Minute {
43 return nil, fmt.Errorf("poll interval must be from ten seconds through five minutes")
46 integration := &Driver{
47 gateway: gateway{client: client},
48 pollInterval: interval,
49 refresh: make(chan struct{}, 1),
50 pending: make(chan queuedCommands),
53 ids := make(map[string]bool)
54 macs := make(map[string]bool)
56 for _, thermostat := range settings.Thermostats {
57 if !protocol.ValidID(thermostat.DeviceID) || ids[thermostat.DeviceID] {
58 return nil, fmt.Errorf("thermostat device IDs must be unique canonical ULIDs")
61 mac, err := normalizeMAC(thermostat.MAC)
63 return nil, fmt.Errorf("validate thermostat MAC: %w", err)
67 return nil, fmt.Errorf("thermostat MAC values must be unique")
70 queue, exists := router.Queue(thermostat.DeviceID)
72 return nil, fmt.Errorf("thermostat has no command queue")
75 ledger, err := driver.NewLedger(thermostat.DeviceID)
77 return nil, fmt.Errorf("create thermostat ledger: %w", err)
80 ids[thermostat.DeviceID] = true
83 integration.thermostats = append(integration.thermostats, &thermostatState{
91 return integration, nil
94 // Run publishes descriptors, then shares each poll across the thermostat list.
95 // Three missed observations mark a thermostat offline. A full observation
96 // restores it. Shutdown does not publish offline. A command and its actions run
97 // in order through the gateway budget and the shared result ledger.
98 func (d *Driver) Run(ctx context.Context) error {
99 ctx, cancel := context.WithCancel(ctx)
102 var workers sync.WaitGroup
109 for _, thermostat := range d.thermostats {
110 err := thermostat.descriptor(ctx)
112 return fmt.Errorf("publish thermostat descriptor: %w", err)
116 d.forward(ctx, thermostat)
122 return fmt.Errorf("poll initial thermostat states: %w", err)
125 ticker := time.NewTicker(d.pollInterval)
135 return fmt.Errorf("refresh thermostat states: %w", err)
140 return fmt.Errorf("poll thermostat states: %w", err)
142 case pending := <-d.pending:
143 handler := commandHandler{
145 thermostat: pending.thermostat,
148 err = handler.handle(ctx, pending.commands)
150 return fmt.Errorf("process thermostat commands: %w", err)
156 func (d *Driver) forward(ctx context.Context, thermostat *thermostatState) {
161 case commands := <-thermostat.queue:
165 case d.pending <- queuedCommands{
166 thermostat: thermostat,
174 func (d *Driver) poll(ctx context.Context) error {
175 states, err := d.gateway.status(ctx)
176 if ctx.Err() != nil {
178 return fmt.Errorf("stop gateway poll: %w", errors.Join(ctx.Err(), err))
184 observedAt := time.Now().UTC()
185 observed := make(map[string]State, len(states))
187 for _, state := range states {
188 observed[state.MAC] = state
192 for _, thermostat := range d.thermostats {
193 state, exists := observed[thermostat.settings.MAC]
195 err = thermostat.record(ctx, state, observedAt, "")
197 err = thermostat.miss(ctx)
201 return fmt.Errorf("record thermostat poll: %w", err)
208 // Refresh coalesces one rate-limited status poll and asks the shared publisher
209 // to resend its snapshots. It does not repeat physical command actions.
210 func (d *Driver) Refresh() {
211 d.publisher.Refresh()
214 case d.refresh <- struct{}{}: