package alamo import ( "context" "errors" "fmt" "time" "onehouse/driver" "onehouse/protocol" ) type commandHandler struct { gateway *gateway thermostat *thermostatState } func (h *commandHandler) handle(ctx context.Context, commands []driver.Command) error { for _, command := range commands { err := h.one(ctx, command) if err != nil { return fmt.Errorf("process thermostat command: %w", err) } } return nil } func (h *commandHandler) one(ctx context.Context, command driver.Command) error { s := h.thermostat if command.DeviceID != s.settings.DeviceID { return fmt.Errorf("command device is not owned by this thermostat") } record, exists := s.ledger.Lookup(command.Metadata.MessageID) if exists { err := s.publisher.Result(ctx, s.settings.DeviceID, command.Metadata.MessageID, record.Metadata, record.Payload) if err != nil { return fmt.Errorf("replay thermostat result: %w", err) } return nil } reason := "" settings, err := decodeControl(command.Payload.Value) switch { case command.Expired(time.Now()): reason = "Command expired" case command.Capability != "control": reason = "Capability is not the thermostat control" case err != nil: reason = "Thermostat settings are invalid" } if reason != "" { err = s.result(ctx, command, protocol.ResultPayload{ Status: "rejected", Reason: reason, }) if err != nil { return fmt.Errorf("reject thermostat command: %w", err) } return nil } state, observedAt, applyErr := h.apply(ctx, command, settings) payload := protocol.ResultPayload{Status: "completed"} if applyErr != nil { payload.Status = "failed" payload.Reason = "Thermostat request or readback failed" if command.Expired(time.Now()) { payload.Reason = "Command expired" } } record, err = s.ledger.Remember(command.Metadata.MessageID, payload) if err != nil { return fmt.Errorf("remember applied thermostat result: %w", err) } if ctx.Err() != nil { if applyErr != nil { return fmt.Errorf("stop thermostat command: %w", errors.Join(ctx.Err(), applyErr)) } return ctx.Err() } if !observedAt.IsZero() { err = s.record(ctx, state, observedAt, command.Metadata.MessageID) if err != nil { return fmt.Errorf("record command feedback: %w", err) } } err = s.publisher.Result(ctx, s.settings.DeviceID, command.Metadata.MessageID, record.Metadata, record.Payload) if err != nil { return fmt.Errorf("publish applied thermostat result: %w", err) } return nil } func (h *commandHandler) apply(ctx context.Context, command driver.Command, settings controlSettings) (State, time.Time, error) { ctx, cancel := context.WithDeadline(ctx, *command.Metadata.ExpiresAt) defer cancel() mac := h.thermostat.settings.MAC state, err := h.gateway.action(ctx, mac, Action{}) if err != nil { return State{}, time.Time{}, fmt.Errorf("read command baseline: %w", err) } observedAt := time.Now().UTC() var next State wantPower := settings.Mode == "heat" if state.Power && (!wantPower || state.TargetTemperature != settings.TargetTemperature) { power := false next, err = h.gateway.action(ctx, mac, Action{Power: &power}) if err != nil { return state, observedAt, fmt.Errorf("turn thermostat off: %w", err) } state = next observedAt = time.Now().UTC() if state.Power { return state, observedAt, fmt.Errorf("thermostat remains on before target change") } } if state.TargetTemperature != settings.TargetTemperature { next, err = h.gateway.action(ctx, mac, Action{TargetTemperature: &settings.TargetTemperature}) if err != nil { return state, observedAt, fmt.Errorf("set thermostat target: %w", err) } state = next observedAt = time.Now().UTC() if state.TargetTemperature != settings.TargetTemperature { return state, observedAt, fmt.Errorf("thermostat target readback differs") } } if wantPower && !state.Power { power := true next, err = h.gateway.action(ctx, mac, Action{Power: &power}) if err != nil { return state, observedAt, fmt.Errorf("turn thermostat on: %w", err) } state = next observedAt = time.Now().UTC() } if state.Power != wantPower || state.TargetTemperature != settings.TargetTemperature { return state, observedAt, fmt.Errorf("thermostat settings readback differs") } return state, observedAt, nil }