- 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.
380 lines
12 KiB
Go
380 lines
12 KiB
Go
package grpc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
|
|
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
|
|
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/domain"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/grpc/test/bufconn"
|
|
)
|
|
|
|
type mockClimateService struct {
|
|
turnOnFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
|
|
turnOffFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
|
|
increaseTemperatureFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
|
|
decreaseTemperatureFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
|
|
setHVACModeFunc func(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error)
|
|
listClimatesFunc func(ctx context.Context) ([]domain.Climate, error)
|
|
refreshFunc func(ctx context.Context) error
|
|
}
|
|
|
|
func (m *mockClimateService) TurnOn(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
|
|
if m.turnOnFunc == nil {
|
|
return nil, nil
|
|
}
|
|
return m.turnOnFunc(ctx, id)
|
|
}
|
|
|
|
func (m *mockClimateService) TurnOff(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
|
|
if m.turnOffFunc == nil {
|
|
return nil, nil
|
|
}
|
|
return m.turnOffFunc(ctx, id)
|
|
}
|
|
|
|
func (m *mockClimateService) IncreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
|
|
if m.increaseTemperatureFunc == nil {
|
|
return nil, nil
|
|
}
|
|
return m.increaseTemperatureFunc(ctx, id)
|
|
}
|
|
|
|
func (m *mockClimateService) DecreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
|
|
if m.decreaseTemperatureFunc == nil {
|
|
return nil, nil
|
|
}
|
|
return m.decreaseTemperatureFunc(ctx, id)
|
|
}
|
|
|
|
func (m *mockClimateService) SetHVACMode(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error) {
|
|
if m.setHVACModeFunc == nil {
|
|
return nil, nil
|
|
}
|
|
return m.setHVACModeFunc(ctx, id, hvacMode)
|
|
}
|
|
|
|
func (m *mockClimateService) ListClimates(ctx context.Context) ([]domain.Climate, error) {
|
|
if m.listClimatesFunc == nil {
|
|
return nil, nil
|
|
}
|
|
return m.listClimatesFunc(ctx)
|
|
}
|
|
|
|
func (m *mockClimateService) Refresh(ctx context.Context) error {
|
|
if m.refreshFunc == nil {
|
|
return nil
|
|
}
|
|
return m.refreshFunc(ctx)
|
|
}
|
|
|
|
func TestClimateGRPCTurnOn(t *testing.T) {
|
|
now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC)
|
|
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
wantCode codes.Code
|
|
}{
|
|
{name: "happy path", wantCode: codes.OK},
|
|
{name: "not found maps to codes.NotFound", err: ErrNotFound, wantCode: codes.NotFound},
|
|
{name: "generic error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var gotID domain.EntityID
|
|
conn := newClimateTestClientConn(t, &mockClimateService{
|
|
turnOnFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
|
|
gotID = id
|
|
if tt.err != nil {
|
|
return nil, tt.err
|
|
}
|
|
return &domain.EntityState{
|
|
EntityID: "climate.air_conditioner",
|
|
State: "cool",
|
|
Attributes: map[string]string{"friendly_name": "Air Conditioner"},
|
|
LastChanged: now,
|
|
LastUpdated: now,
|
|
}, nil
|
|
},
|
|
})
|
|
client := hav1.NewClimateServiceClient(conn)
|
|
|
|
resp, err := client.TurnOn(context.Background(), &hav1.ClimateRequest{EntityId: "climate.air_conditioner"})
|
|
if status.Code(err) != tt.wantCode {
|
|
t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode)
|
|
}
|
|
if tt.wantCode != codes.OK {
|
|
return
|
|
}
|
|
|
|
if gotID != "climate.air_conditioner" {
|
|
t.Fatalf("TurnOn id = %q, want %q", gotID, "climate.air_conditioner")
|
|
}
|
|
if resp.GetState().GetEntityId() != "climate.air_conditioner" || resp.GetState().GetState() != "cool" {
|
|
t.Fatalf("response state = %#v", resp.GetState())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClimateGRPCTurnOff(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
wantCode codes.Code
|
|
}{
|
|
{name: "happy path", wantCode: codes.OK},
|
|
{name: "error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
conn := newClimateTestClientConn(t, &mockClimateService{
|
|
turnOffFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
|
|
if tt.err != nil {
|
|
return nil, tt.err
|
|
}
|
|
return &domain.EntityState{EntityID: "climate.air_conditioner", State: "off"}, nil
|
|
},
|
|
})
|
|
client := hav1.NewClimateServiceClient(conn)
|
|
|
|
resp, err := client.TurnOff(context.Background(), &hav1.ClimateRequest{EntityId: "climate.air_conditioner"})
|
|
if status.Code(err) != tt.wantCode {
|
|
t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode)
|
|
}
|
|
if tt.wantCode != codes.OK {
|
|
return
|
|
}
|
|
if resp.GetState().GetState() != "off" {
|
|
t.Fatalf("response state = %#v", resp.GetState())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClimateGRPCIncreaseTemperature(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
wantCode codes.Code
|
|
}{
|
|
{name: "happy path", wantCode: codes.OK},
|
|
{name: "error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var gotID domain.EntityID
|
|
conn := newClimateTestClientConn(t, &mockClimateService{
|
|
increaseTemperatureFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
|
|
gotID = id
|
|
if tt.err != nil {
|
|
return nil, tt.err
|
|
}
|
|
return &domain.EntityState{EntityID: "climate.air_conditioner", State: "cool"}, nil
|
|
},
|
|
})
|
|
client := hav1.NewClimateServiceClient(conn)
|
|
|
|
resp, err := client.IncreaseTemperature(context.Background(), &hav1.ClimateRequest{EntityId: "climate.air_conditioner"})
|
|
if status.Code(err) != tt.wantCode {
|
|
t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode)
|
|
}
|
|
if tt.wantCode != codes.OK {
|
|
return
|
|
}
|
|
if gotID != "climate.air_conditioner" {
|
|
t.Fatalf("IncreaseTemperature id = %q, want %q", gotID, "climate.air_conditioner")
|
|
}
|
|
if resp.GetState().GetEntityId() != "climate.air_conditioner" {
|
|
t.Fatalf("response state = %#v", resp.GetState())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClimateGRPCDecreaseTemperature(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
wantCode codes.Code
|
|
}{
|
|
{name: "happy path", wantCode: codes.OK},
|
|
{name: "error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
conn := newClimateTestClientConn(t, &mockClimateService{
|
|
decreaseTemperatureFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
|
|
if tt.err != nil {
|
|
return nil, tt.err
|
|
}
|
|
return &domain.EntityState{EntityID: "climate.air_conditioner", State: "cool"}, nil
|
|
},
|
|
})
|
|
client := hav1.NewClimateServiceClient(conn)
|
|
|
|
resp, err := client.DecreaseTemperature(context.Background(), &hav1.ClimateRequest{EntityId: "climate.air_conditioner"})
|
|
if status.Code(err) != tt.wantCode {
|
|
t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode)
|
|
}
|
|
if tt.wantCode != codes.OK {
|
|
return
|
|
}
|
|
if resp.GetState().GetEntityId() != "climate.air_conditioner" {
|
|
t.Fatalf("response state = %#v", resp.GetState())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClimateGRPCSetHVACMode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
wantCode codes.Code
|
|
}{
|
|
{name: "happy path", wantCode: codes.OK},
|
|
{name: "error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var gotID domain.EntityID
|
|
var gotMode string
|
|
conn := newClimateTestClientConn(t, &mockClimateService{
|
|
setHVACModeFunc: func(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error) {
|
|
gotID = id
|
|
gotMode = hvacMode
|
|
if tt.err != nil {
|
|
return nil, tt.err
|
|
}
|
|
return &domain.EntityState{EntityID: "climate.air_conditioner", State: "cool"}, nil
|
|
},
|
|
})
|
|
client := hav1.NewClimateServiceClient(conn)
|
|
|
|
resp, err := client.SetHVACMode(context.Background(), &hav1.SetHVACModeRequest{EntityId: "climate.air_conditioner", HvacMode: "cool"})
|
|
if status.Code(err) != tt.wantCode {
|
|
t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode)
|
|
}
|
|
if tt.wantCode != codes.OK {
|
|
return
|
|
}
|
|
if gotID != "climate.air_conditioner" || gotMode != "cool" {
|
|
t.Fatalf("SetHVACMode id/mode = %q/%q, want %q/%q", gotID, gotMode, "climate.air_conditioner", "cool")
|
|
}
|
|
if resp.GetState().GetEntityId() != "climate.air_conditioner" {
|
|
t.Fatalf("response state = %#v", resp.GetState())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClimateGRPCListClimates(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
wantCode codes.Code
|
|
}{
|
|
{name: "happy path with multiple climates", wantCode: codes.OK},
|
|
{name: "error path", err: errors.New("boom"), wantCode: codes.Internal},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
current := 21.5
|
|
target := 22.0
|
|
conn := newClimateTestClientConn(t, &mockClimateService{
|
|
listClimatesFunc: func(ctx context.Context) ([]domain.Climate, error) {
|
|
if tt.err != nil {
|
|
return nil, tt.err
|
|
}
|
|
return []domain.Climate{
|
|
{
|
|
EntityID: "climate.air_conditioner",
|
|
FriendlyName: "Air Conditioner",
|
|
State: "cool",
|
|
HVACModes: []string{"cool", "heat", "off"},
|
|
FanMode: "auto",
|
|
FanModes: []string{"auto", "low"},
|
|
CurrentTemperature: ¤t,
|
|
TargetTemperature: &target,
|
|
TargetTempStep: 1,
|
|
MinTemp: 7,
|
|
MaxTemp: 35,
|
|
},
|
|
{EntityID: "climate.bedroom", State: "off"},
|
|
}, nil
|
|
},
|
|
})
|
|
client := hav1.NewClimateServiceClient(conn)
|
|
|
|
resp, err := client.ListClimates(context.Background(), &hav1.ListClimatesRequest{})
|
|
if status.Code(err) != tt.wantCode {
|
|
t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode)
|
|
}
|
|
if tt.wantCode != codes.OK {
|
|
return
|
|
}
|
|
|
|
if len(resp.GetClimates()) != 2 {
|
|
t.Fatalf("len(climates) = %d, want 2", len(resp.GetClimates()))
|
|
}
|
|
first := resp.GetClimates()[0]
|
|
if first.GetEntityId() != "climate.air_conditioner" {
|
|
t.Fatalf("climates[0].EntityId = %q, want %q", first.GetEntityId(), "climate.air_conditioner")
|
|
}
|
|
if first.GetCurrentTemperature() != 21.5 || first.GetTargetTemperature() != 22.0 {
|
|
t.Fatalf("climates[0] temperatures = (%v, %v), want (21.5, 22.0)", first.GetCurrentTemperature(), first.GetTargetTemperature())
|
|
}
|
|
second := resp.GetClimates()[1]
|
|
if second.CurrentTemperature != nil || second.TargetTemperature != nil {
|
|
t.Fatalf("climates[1] should have no temperature set, got current=%v target=%v", second.GetCurrentTemperature(), second.GetTargetTemperature())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newClimateTestClientConn(t *testing.T, svc *mockClimateService) *grpc.ClientConn {
|
|
t.Helper()
|
|
|
|
lis := bufconn.Listen(testBufSize)
|
|
server := grpc.NewServer()
|
|
hav1.RegisterClimateServiceServer(server, NewClimateGRPC(svc))
|
|
go func() {
|
|
_ = server.Serve(lis)
|
|
}()
|
|
t.Cleanup(func() {
|
|
server.Stop()
|
|
_ = lis.Close()
|
|
})
|
|
|
|
conn, err := grpc.DialContext(
|
|
context.Background(),
|
|
"bufnet",
|
|
grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) {
|
|
return lis.Dial()
|
|
}),
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("grpc.DialContext() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = conn.Close()
|
|
})
|
|
return conn
|
|
}
|