Nik Afiq b327150d45
All checks were successful
CI / test (push) Successful in 5s
CI / build-ai-gateway (push) Successful in 39s
CI / build-ha-gateway (push) Successful in 39s
CI / build-discord-bot (push) Successful in 40s
feat: implement ClimateApp for managing HVAC entities
- 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.
2026-07-23 13:49:54 +09:00

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
}