package switchbot import ( "bytes" "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "strconv" "time" "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/config" ) const defaultBaseURL = "https://api.switch-bot.com" // Client implements the SwitchBot driven port over SwitchBot Cloud's Open API. type Client struct { token string secret string baseURL string httpClient *http.Client log *slog.Logger } // NewClient constructs a SwitchBot Cloud REST client. Token/secret are // optional at this layer — SendCommand short-circuits with a clear error when // either is unset rather than failing gateway startup. func NewClient(cfg *config.Config, log *slog.Logger) *Client { return &Client{ token: cfg.SwitchBotToken, secret: cfg.SwitchBotSecret, baseURL: defaultBaseURL, httpClient: &http.Client{Timeout: 20 * time.Second}, log: log, } } type commandRequest struct { Command string `json:"command"` Parameter string `json:"parameter"` CommandType string `json:"commandType"` } // apiEnvelope is the outer response shape SwitchBot Cloud wraps every reply in. type apiEnvelope struct { StatusCode int `json:"statusCode"` Message string `json:"message"` Body json.RawMessage `json:"body"` } // apiError maps SwitchBot's documented statusCode values to clearer messages // than a raw passthrough of their (often terse) message field. type apiError struct { StatusCode int Message string } func (e *apiError) Error() string { switch e.StatusCode { case 151: return "SwitchBot device type error (151)" case 152: return "SwitchBot device not found (152)" case 160: return "SwitchBot command is not supported by this device (160)" case 161: return "SwitchBot device is offline (161)" case 171: return "SwitchBot hub is offline (171)" case 190: return "SwitchBot internal error or invalid command format (190)" default: return fmt.Sprintf("SwitchBot API error %d: %s", e.StatusCode, e.Message) } } // SendCommand builds a signed POST and sends one device command. parameter is // hardcoded to "default" because every command this gateway currently sends // uses it; only commandType varies per call ("command" for SwitchBot's // documented per-device commands, "customize" for a user-configured IR // button label). func (c *Client) SendCommand(ctx context.Context, deviceID, command, commandType string) error { if c.token == "" || c.secret == "" { return errors.New("switchbot: token/secret not configured") } body, err := json.Marshal(commandRequest{ Command: command, Parameter: "default", CommandType: commandType, }) if err != nil { return fmt.Errorf("encode request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1.1/devices/"+deviceID+"/commands", bytes.NewReader(body)) if err != nil { return fmt.Errorf("build request: %w", err) } timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) nonce, err := newNonce() if err != nil { return err } mac := hmac.New(sha256.New, []byte(c.secret)) _, _ = mac.Write([]byte(c.token + timestamp + nonce)) // SwitchBot's docs prose says to upper-case this signature, but the // reference CLI's tested implementation does not, and a working // implementation beats a doc paraphrase. signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) req.Header.Set("Authorization", c.token) req.Header.Set("sign", signature) req.Header.Set("nonce", nonce) req.Header.Set("t", timestamp) req.Header.Set("Content-Type", "application/json; charset=utf8") start := time.Now() resp, err := c.httpClient.Do(req) if err != nil { c.log.Error("switchbot request failed", "duration_ms", time.Since(start).Milliseconds(), "error", err.Error(), ) return fmt.Errorf("send command: %w", err) } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) if err != nil { return fmt.Errorf("read response: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { preview := string(data) if len(preview) > 200 { preview = preview[:200] } c.log.Error("switchbot request failed", "http.status", resp.StatusCode, "duration_ms", time.Since(start).Milliseconds(), "error", preview, ) return fmt.Errorf("switchbot HTTP %d: %s", resp.StatusCode, preview) } var envelope apiEnvelope if err := json.Unmarshal(data, &envelope); err != nil { return fmt.Errorf("decode response: %w", err) } if envelope.StatusCode != 100 { apiErr := &apiError{StatusCode: envelope.StatusCode, Message: envelope.Message} c.log.Error("switchbot request failed", "duration_ms", time.Since(start).Milliseconds(), "error", apiErr.Error(), ) return apiErr } c.log.Debug("switchbot request completed", "http.status", resp.StatusCode, "duration_ms", time.Since(start).Milliseconds(), ) return nil } // newNonce generates a dependency-free RFC 4122 v4 UUID, mirroring the // reference CLI so this implementation stays proven against the real API // instead of diverging with a new dependency. func newNonce() (string, error) { var value [16]byte if _, err := rand.Read(value[:]); err != nil { return "", fmt.Errorf("generate nonce: %w", err) } value[6] = (value[6] & 0x0f) | 0x40 value[8] = (value[8] & 0x3f) | 0x80 encoded := hex.EncodeToString(value[:]) return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32], nil }