+
+// Action changes one field or reads a thermostat, and returns its complete
+// readback. It rejects a response for another MAC or more than one thermostat.
+func (c *HTTPClient) Action(ctx context.Context, mac string, action Action) (State, error) {
+ mac, err := normalizeMAC(mac)
+ if err != nil {
+ return State{}, fmt.Errorf("validate action MAC: %w", err)
+ }
+
+ if action.Power != nil && action.TargetTemperature != nil {
+ return State{}, fmt.Errorf("action must change at most one field")
+ }
+
+ query := url.Values{"mac": {mac}}
+ if action.Power != nil {
+ query.Set("power", "0")
+
+ if *action.Power {
+ query.Set("power", "1")
+ }
+ }
+
+ if action.TargetTemperature != nil {
+ if !finite(*action.TargetTemperature) {
+ return State{}, fmt.Errorf("target temperature must be finite")
+ }
+
+ query.Set("stemp", strconv.FormatFloat(*action.TargetTemperature, 'f', -1, 64))
+ }
+
+ states, err := c.exchange(ctx, "/acao", query)
+ if err != nil {
+ return State{}, fmt.Errorf("exchange action: %w", err)
+ }
+
+ if len(states) != 1 || states[0].MAC != mac {
+ return State{}, fmt.Errorf("action response must contain the requested thermostat only")
+ }
+
+ return states[0], nil
+}
+
+func safeRequestError(err error) error {
+ var joined interface{ Unwrap() []error }
+
+ if errors.As(err, &joined) {
+ var causes []error
+
+ for _, cause := range joined.Unwrap() {
+ if cause != nil {
+ causes = append(causes, safeRequestError(cause))
+ }
+ }
+
+ return errors.Join(causes...)
+ }
+
+ if errors.Is(err, context.Canceled) {
+ return context.Canceled
+ }
+
+ if errors.Is(err, context.DeadlineExceeded) {
+ return context.DeadlineExceeded
+ }
+
+ return fmt.Errorf("gateway transport failed")
+}