- Added ClimateApp to handle climate entity operations including turning on/off, adjusting temperature, and setting HVAC modes. - Implemented caching mechanism for climate entities to optimize state retrieval. - Created domain model for Climate with attributes such as current temperature, target temperature, and HVAC modes. - Developed unit tests for ClimateApp to ensure functionality and correctness. feat: add RemoteApp for SwitchBot commands - Introduced RemoteApp to facilitate sending commands to SwitchBot devices. - Implemented SendCommand method for executing device commands without state management. chore: update configuration for SwitchBot integration - Added SwitchBotToken and SwitchBotSecret to configuration for enabling SwitchBot Cloud commands. feat: define gRPC services for Climate and Remote operations - Created climate.proto and remote.proto files to define gRPC services for climate management and remote command execution. - Implemented corresponding request and response message structures for gRPC interactions.
194 lines
5.5 KiB
Go
194 lines
5.5 KiB
Go
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
|
|
}
|