- 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.
539 lines
17 KiB
Go
539 lines
17 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/domain"
|
|
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/ports/driven"
|
|
)
|
|
|
|
func TestClimateAppRefresh(t *testing.T) {
|
|
t.Run("filters non-climate entities and populates cache", func(t *testing.T) {
|
|
current := 21.5
|
|
target := 22.0
|
|
ha := &mockHAClient{
|
|
listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) {
|
|
return []*driven.HAState{
|
|
{
|
|
EntityID: "climate.air_conditioner",
|
|
State: "cool",
|
|
Attributes: map[string]any{
|
|
"friendly_name": "Air Conditioner",
|
|
"hvac_modes": []any{"heat_cool", "cool", "dry", "fan_only", "heat", "off"},
|
|
"fan_mode": "auto",
|
|
"fan_modes": []any{"auto", "low", "high"},
|
|
"current_temperature": current,
|
|
"temperature": target,
|
|
"target_temp_step": float64(1),
|
|
"min_temp": float64(7),
|
|
"max_temp": float64(35),
|
|
},
|
|
},
|
|
{
|
|
EntityID: "switch.fan",
|
|
State: "off",
|
|
},
|
|
}, nil
|
|
},
|
|
}
|
|
|
|
app := NewClimateApp(ha)
|
|
if err := app.Refresh(context.Background()); err != nil {
|
|
t.Fatalf("Refresh() error = %v", err)
|
|
}
|
|
|
|
got := app.cache
|
|
want := []domain.Climate{
|
|
{
|
|
EntityID: "climate.air_conditioner",
|
|
FriendlyName: "Air Conditioner",
|
|
State: "cool",
|
|
HVACModes: []string{"heat_cool", "cool", "dry", "fan_only", "heat", "off"},
|
|
FanMode: "auto",
|
|
FanModes: []string{"auto", "low", "high"},
|
|
CurrentTemperature: ¤t,
|
|
TargetTemperature: &target,
|
|
TargetTempStep: 1,
|
|
MinTemp: 7,
|
|
MaxTemp: 35,
|
|
},
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("cache = %#v, want %#v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("nil current_temperature means no sensor, not zero", func(t *testing.T) {
|
|
ha := &mockHAClient{
|
|
listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) {
|
|
return []*driven.HAState{
|
|
{
|
|
EntityID: "climate.air_conditioner",
|
|
State: "off",
|
|
Attributes: map[string]any{"current_temperature": nil},
|
|
},
|
|
}, nil
|
|
},
|
|
}
|
|
|
|
app := NewClimateApp(ha)
|
|
if err := app.Refresh(context.Background()); err != nil {
|
|
t.Fatalf("Refresh() error = %v", err)
|
|
}
|
|
if app.cache[0].CurrentTemperature != nil {
|
|
t.Fatalf("CurrentTemperature = %v, want nil", *app.cache[0].CurrentTemperature)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestClimateAppListClimates(t *testing.T) {
|
|
t.Run("uses cache when populated", func(t *testing.T) {
|
|
calls := 0
|
|
app := NewClimateApp(&mockHAClient{
|
|
listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) {
|
|
calls++
|
|
return nil, nil
|
|
},
|
|
})
|
|
app.cache = []domain.Climate{{EntityID: "climate.air_conditioner", State: "cool"}}
|
|
|
|
got, err := app.ListClimates(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListClimates() error = %v", err)
|
|
}
|
|
if calls != 0 {
|
|
t.Fatalf("ListStates() calls = %d, want 0", calls)
|
|
}
|
|
if !reflect.DeepEqual(got, app.cache) {
|
|
t.Fatalf("ListClimates() = %#v, want %#v", got, app.cache)
|
|
}
|
|
})
|
|
|
|
t.Run("calls ListStates when cache is nil", func(t *testing.T) {
|
|
calls := 0
|
|
app := NewClimateApp(&mockHAClient{
|
|
listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) {
|
|
calls++
|
|
return []*driven.HAState{
|
|
{EntityID: "climate.air_conditioner", State: "cool"},
|
|
{EntityID: "sensor.temp", State: "21"},
|
|
}, nil
|
|
},
|
|
})
|
|
|
|
got, err := app.ListClimates(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListClimates() error = %v", err)
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("ListStates() calls = %d, want 1", calls)
|
|
}
|
|
want := []domain.Climate{{EntityID: "climate.air_conditioner", State: "cool"}}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("ListClimates() = %#v, want %#v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("propagates refresh error", func(t *testing.T) {
|
|
wantErr := errors.New("list failed")
|
|
app := NewClimateApp(&mockHAClient{
|
|
listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) {
|
|
return nil, wantErr
|
|
},
|
|
})
|
|
|
|
_, err := app.ListClimates(context.Background())
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("ListClimates() error = %v, want %v", err, wantErr)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestClimateAppTurnOn(t *testing.T) {
|
|
now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC)
|
|
state := &driven.HAState{
|
|
EntityID: "climate.air_conditioner",
|
|
State: "cool",
|
|
Attributes: map[string]any{"friendly_name": "Air Conditioner"},
|
|
LastChanged: now,
|
|
LastUpdated: now,
|
|
}
|
|
|
|
t.Run("happy path", func(t *testing.T) {
|
|
app := NewClimateApp(&mockHAClient{
|
|
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
if svcDomain != "climate" || service != "turn_on" {
|
|
t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service)
|
|
}
|
|
wantPayload := map[string]any{"entity_id": "climate.air_conditioner"}
|
|
if !reflect.DeepEqual(payload, wantPayload) {
|
|
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
|
|
}
|
|
return []*driven.HAState{state}, nil
|
|
},
|
|
})
|
|
|
|
got, err := app.TurnOn(context.Background(), "climate.air_conditioner")
|
|
if err != nil {
|
|
t.Fatalf("TurnOn() error = %v", err)
|
|
}
|
|
if !reflect.DeepEqual(got, haStateToDomain(state)) {
|
|
t.Fatalf("TurnOn() = %#v, want %#v", got, haStateToDomain(state))
|
|
}
|
|
})
|
|
|
|
t.Run("falls back to GetState when service returns empty list", func(t *testing.T) {
|
|
getStateCalls := 0
|
|
app := NewClimateApp(&mockHAClient{
|
|
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
return []*driven.HAState{}, nil
|
|
},
|
|
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
getStateCalls++
|
|
return state, nil
|
|
},
|
|
})
|
|
|
|
got, err := app.TurnOn(context.Background(), "climate.air_conditioner")
|
|
if err != nil {
|
|
t.Fatalf("TurnOn() error = %v", err)
|
|
}
|
|
if getStateCalls != 1 {
|
|
t.Fatalf("GetState() calls = %d, want 1", getStateCalls)
|
|
}
|
|
if !reflect.DeepEqual(got, haStateToDomain(state)) {
|
|
t.Fatalf("TurnOn() = %#v, want %#v", got, haStateToDomain(state))
|
|
}
|
|
})
|
|
|
|
t.Run("returns CallService error", func(t *testing.T) {
|
|
wantErr := errors.New("call failed")
|
|
app := NewClimateApp(&mockHAClient{
|
|
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
return nil, wantErr
|
|
},
|
|
})
|
|
|
|
_, err := app.TurnOn(context.Background(), "climate.air_conditioner")
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("TurnOn() error = %v, want %v", err, wantErr)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestClimateAppTurnOff(t *testing.T) {
|
|
now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC)
|
|
state := &driven.HAState{
|
|
EntityID: "climate.air_conditioner",
|
|
State: "off",
|
|
Attributes: map[string]any{"friendly_name": "Air Conditioner"},
|
|
LastChanged: now,
|
|
LastUpdated: now,
|
|
}
|
|
|
|
t.Run("happy path", func(t *testing.T) {
|
|
app := NewClimateApp(&mockHAClient{
|
|
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
if svcDomain != "climate" || service != "turn_off" {
|
|
t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service)
|
|
}
|
|
return []*driven.HAState{state}, nil
|
|
},
|
|
})
|
|
|
|
got, err := app.TurnOff(context.Background(), "climate.air_conditioner")
|
|
if err != nil {
|
|
t.Fatalf("TurnOff() error = %v", err)
|
|
}
|
|
if !reflect.DeepEqual(got, haStateToDomain(state)) {
|
|
t.Fatalf("TurnOff() = %#v, want %#v", got, haStateToDomain(state))
|
|
}
|
|
})
|
|
|
|
t.Run("error path", func(t *testing.T) {
|
|
wantErr := errors.New("turn off failed")
|
|
app := NewClimateApp(&mockHAClient{
|
|
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
return nil, wantErr
|
|
},
|
|
})
|
|
|
|
_, err := app.TurnOff(context.Background(), "climate.air_conditioner")
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("TurnOff() error = %v, want %v", err, wantErr)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestClimateAppSetHVACMode(t *testing.T) {
|
|
now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC)
|
|
state := &driven.HAState{
|
|
EntityID: "climate.air_conditioner",
|
|
State: "cool",
|
|
Attributes: map[string]any{"friendly_name": "Air Conditioner"},
|
|
LastChanged: now,
|
|
LastUpdated: now,
|
|
}
|
|
|
|
t.Run("happy path passes raw mode through with no validation", func(t *testing.T) {
|
|
app := NewClimateApp(&mockHAClient{
|
|
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
if svcDomain != "climate" || service != "set_hvac_mode" {
|
|
t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service)
|
|
}
|
|
wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "hvac_mode": "cool"}
|
|
if !reflect.DeepEqual(payload, wantPayload) {
|
|
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
|
|
}
|
|
return []*driven.HAState{state}, nil
|
|
},
|
|
})
|
|
|
|
got, err := app.SetHVACMode(context.Background(), "climate.air_conditioner", "cool")
|
|
if err != nil {
|
|
t.Fatalf("SetHVACMode() error = %v", err)
|
|
}
|
|
if !reflect.DeepEqual(got, haStateToDomain(state)) {
|
|
t.Fatalf("SetHVACMode() = %#v, want %#v", got, haStateToDomain(state))
|
|
}
|
|
})
|
|
|
|
t.Run("error path", func(t *testing.T) {
|
|
wantErr := errors.New("set hvac mode failed")
|
|
app := NewClimateApp(&mockHAClient{
|
|
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
return nil, wantErr
|
|
},
|
|
})
|
|
|
|
_, err := app.SetHVACMode(context.Background(), "climate.air_conditioner", "cool")
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("SetHVACMode() error = %v, want %v", err, wantErr)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestClimateAppIncreaseTemperature(t *testing.T) {
|
|
now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC)
|
|
resultState := &driven.HAState{
|
|
EntityID: "climate.air_conditioner",
|
|
State: "cool",
|
|
LastChanged: now,
|
|
LastUpdated: now,
|
|
}
|
|
|
|
t.Run("steps up by target_temp_step", func(t *testing.T) {
|
|
liveState := &driven.HAState{
|
|
EntityID: "climate.air_conditioner",
|
|
State: "cool",
|
|
Attributes: map[string]any{
|
|
"temperature": float64(21),
|
|
"target_temp_step": float64(1),
|
|
"min_temp": float64(7),
|
|
"max_temp": float64(35),
|
|
},
|
|
}
|
|
app := NewClimateApp(&mockHAClient{
|
|
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
return liveState, nil
|
|
},
|
|
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
if svcDomain != "climate" || service != "set_temperature" {
|
|
t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service)
|
|
}
|
|
wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(22)}
|
|
if !reflect.DeepEqual(payload, wantPayload) {
|
|
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
|
|
}
|
|
return []*driven.HAState{resultState}, nil
|
|
},
|
|
})
|
|
|
|
if _, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil {
|
|
t.Fatalf("IncreaseTemperature() error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("step defaults to 1 when target_temp_step is 0", func(t *testing.T) {
|
|
liveState := &driven.HAState{
|
|
EntityID: "climate.air_conditioner",
|
|
State: "cool",
|
|
Attributes: map[string]any{
|
|
"temperature": float64(21),
|
|
"min_temp": float64(7),
|
|
"max_temp": float64(35),
|
|
},
|
|
}
|
|
app := NewClimateApp(&mockHAClient{
|
|
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
return liveState, nil
|
|
},
|
|
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(22)}
|
|
if !reflect.DeepEqual(payload, wantPayload) {
|
|
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
|
|
}
|
|
return []*driven.HAState{resultState}, nil
|
|
},
|
|
})
|
|
|
|
if _, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil {
|
|
t.Fatalf("IncreaseTemperature() error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("clamps at max_temp", func(t *testing.T) {
|
|
liveState := &driven.HAState{
|
|
EntityID: "climate.air_conditioner",
|
|
State: "cool",
|
|
Attributes: map[string]any{
|
|
"temperature": float64(35),
|
|
"target_temp_step": float64(1),
|
|
"min_temp": float64(7),
|
|
"max_temp": float64(35),
|
|
},
|
|
}
|
|
app := NewClimateApp(&mockHAClient{
|
|
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
return liveState, nil
|
|
},
|
|
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(35)}
|
|
if !reflect.DeepEqual(payload, wantPayload) {
|
|
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
|
|
}
|
|
return []*driven.HAState{resultState}, nil
|
|
},
|
|
})
|
|
|
|
if _, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil {
|
|
t.Fatalf("IncreaseTemperature() error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("error when no target temperature is set", func(t *testing.T) {
|
|
liveState := &driven.HAState{
|
|
EntityID: "climate.air_conditioner",
|
|
State: "off",
|
|
Attributes: map[string]any{},
|
|
}
|
|
app := NewClimateApp(&mockHAClient{
|
|
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
return liveState, nil
|
|
},
|
|
})
|
|
|
|
_, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner")
|
|
if err == nil {
|
|
t.Fatal("IncreaseTemperature() error = nil, want error")
|
|
}
|
|
})
|
|
|
|
t.Run("propagates GetState error", func(t *testing.T) {
|
|
wantErr := errors.New("get state failed")
|
|
app := NewClimateApp(&mockHAClient{
|
|
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
return nil, wantErr
|
|
},
|
|
})
|
|
|
|
_, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner")
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("IncreaseTemperature() error = %v, want %v", err, wantErr)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestClimateAppDecreaseTemperature(t *testing.T) {
|
|
now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC)
|
|
resultState := &driven.HAState{
|
|
EntityID: "climate.air_conditioner",
|
|
State: "cool",
|
|
LastChanged: now,
|
|
LastUpdated: now,
|
|
}
|
|
|
|
t.Run("steps down by target_temp_step", func(t *testing.T) {
|
|
liveState := &driven.HAState{
|
|
EntityID: "climate.air_conditioner",
|
|
State: "cool",
|
|
Attributes: map[string]any{
|
|
"temperature": float64(21),
|
|
"target_temp_step": float64(1),
|
|
"min_temp": float64(7),
|
|
"max_temp": float64(35),
|
|
},
|
|
}
|
|
app := NewClimateApp(&mockHAClient{
|
|
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
return liveState, nil
|
|
},
|
|
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
if svcDomain != "climate" || service != "set_temperature" {
|
|
t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service)
|
|
}
|
|
wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(20)}
|
|
if !reflect.DeepEqual(payload, wantPayload) {
|
|
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
|
|
}
|
|
return []*driven.HAState{resultState}, nil
|
|
},
|
|
})
|
|
|
|
if _, err := app.DecreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil {
|
|
t.Fatalf("DecreaseTemperature() error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("clamps at min_temp", func(t *testing.T) {
|
|
liveState := &driven.HAState{
|
|
EntityID: "climate.air_conditioner",
|
|
State: "cool",
|
|
Attributes: map[string]any{
|
|
"temperature": float64(7),
|
|
"target_temp_step": float64(1),
|
|
"min_temp": float64(7),
|
|
"max_temp": float64(35),
|
|
},
|
|
}
|
|
app := NewClimateApp(&mockHAClient{
|
|
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
return liveState, nil
|
|
},
|
|
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(7)}
|
|
if !reflect.DeepEqual(payload, wantPayload) {
|
|
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
|
|
}
|
|
return []*driven.HAState{resultState}, nil
|
|
},
|
|
})
|
|
|
|
if _, err := app.DecreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil {
|
|
t.Fatalf("DecreaseTemperature() error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("error when no target temperature is set", func(t *testing.T) {
|
|
liveState := &driven.HAState{
|
|
EntityID: "climate.air_conditioner",
|
|
State: "off",
|
|
Attributes: map[string]any{},
|
|
}
|
|
app := NewClimateApp(&mockHAClient{
|
|
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
return liveState, nil
|
|
},
|
|
})
|
|
|
|
_, err := app.DecreaseTemperature(context.Background(), "climate.air_conditioner")
|
|
if err == nil {
|
|
t.Fatal("DecreaseTemperature() error = nil, want error")
|
|
}
|
|
})
|
|
}
|