- 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.
223 lines
6.1 KiB
Go
223 lines
6.1 KiB
Go
package switchbot
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/config"
|
|
)
|
|
|
|
func testLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
func TestClientSendCommandSignsRequestAndSucceeds(t *testing.T) {
|
|
const token = "test-token"
|
|
const secret = "test-secret"
|
|
|
|
var gotBody map[string]any
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/v1.1/devices/dyson-id/commands" {
|
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != token {
|
|
t.Fatalf("Authorization header = %q, want %q", got, token)
|
|
}
|
|
if got := r.Header.Get("Content-Type"); got != "application/json; charset=utf8" {
|
|
t.Fatalf("Content-Type header = %q", got)
|
|
}
|
|
|
|
timestamp := r.Header.Get("t")
|
|
nonce := r.Header.Get("nonce")
|
|
if timestamp == "" || nonce == "" {
|
|
t.Fatalf("missing t/nonce headers: t=%q nonce=%q", timestamp, nonce)
|
|
}
|
|
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
_, _ = mac.Write([]byte(token + timestamp + nonce))
|
|
wantSignature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
|
if got := r.Header.Get("sign"); got != wantSignature {
|
|
t.Fatalf("sign header = %q, want %q", got, wantSignature)
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
|
t.Fatalf("decode request body: %v", err)
|
|
}
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"statusCode": 100,
|
|
"message": "success",
|
|
"body": map[string]any{},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
c := &Client{
|
|
token: token,
|
|
secret: secret,
|
|
baseURL: server.URL,
|
|
httpClient: server.Client(),
|
|
log: testLogger(),
|
|
}
|
|
|
|
if err := c.SendCommand(context.Background(), "dyson-id", "turnOn", "command"); err != nil {
|
|
t.Fatalf("SendCommand() error = %v", err)
|
|
}
|
|
|
|
wantBody := map[string]any{"command": "turnOn", "parameter": "default", "commandType": "command"}
|
|
if len(gotBody) != len(wantBody) {
|
|
t.Fatalf("body = %#v, want %#v", gotBody, wantBody)
|
|
}
|
|
for k, v := range wantBody {
|
|
if gotBody[k] != v {
|
|
t.Fatalf("body[%q] = %#v, want %#v", k, gotBody[k], v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClientSendCommandCustomizeType(t *testing.T) {
|
|
var gotCommandType string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var body map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
gotCommandType, _ = body["commandType"].(string)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"statusCode": 100, "message": "success", "body": map[string]any{}})
|
|
}))
|
|
defer server.Close()
|
|
|
|
c := &Client{
|
|
token: "token",
|
|
secret: "secret",
|
|
baseURL: server.URL,
|
|
httpClient: server.Client(),
|
|
log: testLogger(),
|
|
}
|
|
|
|
if err := c.SendCommand(context.Background(), "dyson-id", "Cool", "customize"); err != nil {
|
|
t.Fatalf("SendCommand() error = %v", err)
|
|
}
|
|
if gotCommandType != "customize" {
|
|
t.Fatalf("commandType = %q, want %q", gotCommandType, "customize")
|
|
}
|
|
}
|
|
|
|
func TestClientSendCommandAPIError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"statusCode": 190,
|
|
"message": "invalid format",
|
|
"body": map[string]any{},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
c := &Client{
|
|
token: "token",
|
|
secret: "secret",
|
|
baseURL: server.URL,
|
|
httpClient: server.Client(),
|
|
log: testLogger(),
|
|
}
|
|
|
|
err := c.SendCommand(context.Background(), "dyson-id", "turnOn", "command")
|
|
wantErr := "SwitchBot internal error or invalid command format (190)"
|
|
if err == nil || err.Error() != wantErr {
|
|
t.Fatalf("SendCommand() error = %v, want %q", err, wantErr)
|
|
}
|
|
}
|
|
|
|
func TestClientSendCommandNon2xxHTTPError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte("unauthorized"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
c := &Client{
|
|
token: "token",
|
|
secret: "secret",
|
|
baseURL: server.URL,
|
|
httpClient: server.Client(),
|
|
log: testLogger(),
|
|
}
|
|
|
|
err := c.SendCommand(context.Background(), "dyson-id", "turnOn", "command")
|
|
if err == nil {
|
|
t.Fatal("SendCommand() error = nil, want error")
|
|
}
|
|
}
|
|
|
|
func TestClientSendCommandNotConfigured(t *testing.T) {
|
|
calls := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls++
|
|
}))
|
|
defer server.Close()
|
|
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
secret string
|
|
}{
|
|
{name: "both empty"},
|
|
{name: "token only", token: "token"},
|
|
{name: "secret only", secret: "secret"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := &Client{
|
|
token: tt.token,
|
|
secret: tt.secret,
|
|
baseURL: server.URL,
|
|
httpClient: server.Client(),
|
|
log: testLogger(),
|
|
}
|
|
|
|
err := c.SendCommand(context.Background(), "dyson-id", "turnOn", "command")
|
|
if err == nil || err.Error() != "switchbot: token/secret not configured" {
|
|
t.Fatalf("SendCommand() error = %v, want not-configured error", err)
|
|
}
|
|
})
|
|
}
|
|
if calls != 0 {
|
|
t.Fatalf("requests reached test server = %d, want 0", calls)
|
|
}
|
|
}
|
|
|
|
func TestNewClient(t *testing.T) {
|
|
cfg := &config.Config{SwitchBotToken: "tok", SwitchBotSecret: "sec"}
|
|
c := NewClient(cfg, testLogger())
|
|
if c.token != "tok" || c.secret != "sec" {
|
|
t.Fatalf("NewClient() token/secret = %q/%q, want %q/%q", c.token, c.secret, "tok", "sec")
|
|
}
|
|
if c.baseURL != defaultBaseURL {
|
|
t.Fatalf("NewClient() baseURL = %q, want %q", c.baseURL, defaultBaseURL)
|
|
}
|
|
}
|
|
|
|
func TestNewNonceIsUniqueAndFormatted(t *testing.T) {
|
|
a, err := newNonce()
|
|
if err != nil {
|
|
t.Fatalf("newNonce() error = %v", err)
|
|
}
|
|
b, err := newNonce()
|
|
if err != nil {
|
|
t.Fatalf("newNonce() error = %v", err)
|
|
}
|
|
if a == b {
|
|
t.Fatalf("newNonce() returned duplicate values: %q", a)
|
|
}
|
|
if len(a) != len("00000000-0000-0000-0000-000000000000") {
|
|
t.Fatalf("newNonce() length = %d, want 36", len(a))
|
|
}
|
|
}
|