Nik Afiq 8f7024edfa
All checks were successful
CI / changes (push) Successful in 19s
CI / test (push) Successful in 24s
CI / build-ai-gateway (push) Successful in 1m6s
CI / build-ha-gateway (push) Successful in 1m3s
CI / build-discord-bot (push) Successful in 1m4s
CI / build-alexa-bridge (push) Successful in 1m15s
CI / build-tts-gateway (push) Successful in 1m5s
CI / build-tts-sidecar (push) Has been skipped
CI / build-tts-model (push) Has been skipped
feat(climate): add SetTemperature method to ClimateService
- Implemented SetTemperature in ClimateService for setting an absolute target temperature.
- Updated ClimateServiceClient and ClimateServiceServer interfaces to include SetTemperature.
- Added corresponding handler and tests for SetTemperature in ClimateGRPC.
- Modified ClimateApp to handle SetTemperature requests without clamping.
- Updated climate.proto to define SetTemperatureRequest message.
- Adjusted Dockerfiles to include alexa-bridge dependencies.
2026-07-25 12:08:24 +09:00

462 lines
17 KiB
Go

package haclient
import (
"context"
"errors"
"testing"
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
"google.golang.org/grpc"
)
type fakeLightServiceClient struct {
turnOnFunc func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error)
turnOffFunc func(ctx context.Context, in *hav1.TurnOffRequest) (*hav1.LightResponse, error)
toggleFunc func(ctx context.Context, in *hav1.ToggleRequest) (*hav1.LightResponse, error)
}
func (f *fakeLightServiceClient) TurnOn(ctx context.Context, in *hav1.TurnOnRequest, _ ...grpc.CallOption) (*hav1.LightResponse, error) {
if f.turnOnFunc == nil {
return &hav1.LightResponse{}, nil
}
return f.turnOnFunc(ctx, in)
}
func (f *fakeLightServiceClient) TurnOff(ctx context.Context, in *hav1.TurnOffRequest, _ ...grpc.CallOption) (*hav1.LightResponse, error) {
if f.turnOffFunc == nil {
return &hav1.LightResponse{}, nil
}
return f.turnOffFunc(ctx, in)
}
func (f *fakeLightServiceClient) Toggle(ctx context.Context, in *hav1.ToggleRequest, _ ...grpc.CallOption) (*hav1.LightResponse, error) {
if f.toggleFunc == nil {
return &hav1.LightResponse{}, nil
}
return f.toggleFunc(ctx, in)
}
func (f *fakeLightServiceClient) ListLights(ctx context.Context, in *hav1.ListLightsRequest, _ ...grpc.CallOption) (*hav1.ListLightsResponse, error) {
return &hav1.ListLightsResponse{}, nil
}
type fakeSwitchServiceClient struct {
turnOnFunc func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error)
turnOffFunc func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error)
toggleFunc func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error)
}
func (f *fakeSwitchServiceClient) TurnOn(ctx context.Context, in *hav1.SwitchRequest, _ ...grpc.CallOption) (*hav1.SwitchResponse, error) {
if f.turnOnFunc == nil {
return &hav1.SwitchResponse{}, nil
}
return f.turnOnFunc(ctx, in)
}
func (f *fakeSwitchServiceClient) TurnOff(ctx context.Context, in *hav1.SwitchRequest, _ ...grpc.CallOption) (*hav1.SwitchResponse, error) {
if f.turnOffFunc == nil {
return &hav1.SwitchResponse{}, nil
}
return f.turnOffFunc(ctx, in)
}
func (f *fakeSwitchServiceClient) Toggle(ctx context.Context, in *hav1.SwitchRequest, _ ...grpc.CallOption) (*hav1.SwitchResponse, error) {
if f.toggleFunc == nil {
return &hav1.SwitchResponse{}, nil
}
return f.toggleFunc(ctx, in)
}
func (f *fakeSwitchServiceClient) ListSwitches(ctx context.Context, in *hav1.ListSwitchesRequest, _ ...grpc.CallOption) (*hav1.ListSwitchesResponse, error) {
return &hav1.ListSwitchesResponse{}, nil
}
type fakeClimateServiceClient struct {
turnOnFunc func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error)
turnOffFunc func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error)
increaseTemperatureFunc func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error)
decreaseTemperatureFunc func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error)
setTemperatureFunc func(ctx context.Context, in *hav1.SetTemperatureRequest) (*hav1.ClimateResponse, error)
setHVACModeFunc func(ctx context.Context, in *hav1.SetHVACModeRequest) (*hav1.ClimateResponse, error)
}
func (f *fakeClimateServiceClient) TurnOn(ctx context.Context, in *hav1.ClimateRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
if f.turnOnFunc == nil {
return &hav1.ClimateResponse{}, nil
}
return f.turnOnFunc(ctx, in)
}
func (f *fakeClimateServiceClient) TurnOff(ctx context.Context, in *hav1.ClimateRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
if f.turnOffFunc == nil {
return &hav1.ClimateResponse{}, nil
}
return f.turnOffFunc(ctx, in)
}
func (f *fakeClimateServiceClient) IncreaseTemperature(ctx context.Context, in *hav1.ClimateRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
if f.increaseTemperatureFunc == nil {
return &hav1.ClimateResponse{}, nil
}
return f.increaseTemperatureFunc(ctx, in)
}
func (f *fakeClimateServiceClient) DecreaseTemperature(ctx context.Context, in *hav1.ClimateRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
if f.decreaseTemperatureFunc == nil {
return &hav1.ClimateResponse{}, nil
}
return f.decreaseTemperatureFunc(ctx, in)
}
func (f *fakeClimateServiceClient) SetTemperature(ctx context.Context, in *hav1.SetTemperatureRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
if f.setTemperatureFunc == nil {
return &hav1.ClimateResponse{}, nil
}
return f.setTemperatureFunc(ctx, in)
}
func (f *fakeClimateServiceClient) SetHVACMode(ctx context.Context, in *hav1.SetHVACModeRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
if f.setHVACModeFunc == nil {
return &hav1.ClimateResponse{}, nil
}
return f.setHVACModeFunc(ctx, in)
}
func (f *fakeClimateServiceClient) ListClimates(ctx context.Context, in *hav1.ListClimatesRequest, _ ...grpc.CallOption) (*hav1.ListClimatesResponse, error) {
return &hav1.ListClimatesResponse{}, nil
}
func TestExecuteActionLight(t *testing.T) {
t.Run("turn_on forwards optional params", func(t *testing.T) {
var got *hav1.TurnOnRequest
c := &Client{lightClient: &fakeLightServiceClient{
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
got = in
return &hav1.LightResponse{}, nil
},
}}
err := c.ExecuteAction(context.Background(), "light.living_room", "turn_on", map[string]any{
"brightness_pct": "80",
"color_temp_kelvin": "2700",
"rgb_color": map[string]any{"r": "255", "g": "0", "b": "0"},
})
if err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetEntityId() != "light.living_room" {
t.Fatalf("EntityId = %q, want %q", got.GetEntityId(), "light.living_room")
}
if got.GetBrightnessPct() != 80 {
t.Fatalf("BrightnessPct = %v, want 80", got.GetBrightnessPct())
}
if got.GetColorTempKelvin() != 2700 {
t.Fatalf("ColorTempKelvin = %v, want 2700", got.GetColorTempKelvin())
}
if got.GetRgbColor().GetR() != 255 {
t.Fatalf("RgbColor.R = %v, want 255", got.GetRgbColor().GetR())
}
})
t.Run("turn_on with no params sets no optional fields", func(t *testing.T) {
var got *hav1.TurnOnRequest
c := &Client{lightClient: &fakeLightServiceClient{
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
got = in
return &hav1.LightResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "light.living_room", "turn_on", nil); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.BrightnessPct != nil || got.ColorTempKelvin != nil || got.RgbColor != nil {
t.Fatalf("expected no optional fields set, got %#v", got)
}
})
t.Run("turn_off forwards transition", func(t *testing.T) {
var got *hav1.TurnOffRequest
c := &Client{lightClient: &fakeLightServiceClient{
turnOffFunc: func(ctx context.Context, in *hav1.TurnOffRequest) (*hav1.LightResponse, error) {
got = in
return &hav1.LightResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "light.living_room", "turn_off", map[string]any{"transition": "3"}); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetTransition() != 3 {
t.Fatalf("Transition = %v, want 3", got.GetTransition())
}
})
t.Run("toggle", func(t *testing.T) {
var got *hav1.ToggleRequest
c := &Client{lightClient: &fakeLightServiceClient{
toggleFunc: func(ctx context.Context, in *hav1.ToggleRequest) (*hav1.LightResponse, error) {
got = in
return &hav1.LightResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "light.living_room", "toggle", nil); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetEntityId() != "light.living_room" {
t.Fatalf("EntityId = %q, want %q", got.GetEntityId(), "light.living_room")
}
})
t.Run("set_brightness reuses TurnOn with only brightness set", func(t *testing.T) {
var got *hav1.TurnOnRequest
c := &Client{lightClient: &fakeLightServiceClient{
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
got = in
return &hav1.LightResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "light.living_room", "set_brightness", map[string]any{"brightness_pct": "50"}); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetBrightnessPct() != 50 {
t.Fatalf("BrightnessPct = %v, want 50", got.GetBrightnessPct())
}
if got.ColorTempKelvin != nil || got.RgbColor != nil {
t.Fatalf("expected only brightness set, got %#v", got)
}
})
t.Run("set_brightness missing param errors without calling RPC", func(t *testing.T) {
called := false
c := &Client{lightClient: &fakeLightServiceClient{
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
called = true
return &hav1.LightResponse{}, nil
},
}}
err := c.ExecuteAction(context.Background(), "light.living_room", "set_brightness", nil)
if err == nil {
t.Fatal("ExecuteAction() error = nil, want error")
}
if called {
t.Fatal("TurnOn should not be called when the required param is missing")
}
})
t.Run("set_color_temp", func(t *testing.T) {
var got *hav1.TurnOnRequest
c := &Client{lightClient: &fakeLightServiceClient{
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
got = in
return &hav1.LightResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "light.living_room", "set_color_temp", map[string]any{"color_temp_kelvin": "4000"}); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetColorTempKelvin() != 4000 {
t.Fatalf("ColorTempKelvin = %v, want 4000", got.GetColorTempKelvin())
}
})
t.Run("set_color", func(t *testing.T) {
var got *hav1.TurnOnRequest
c := &Client{lightClient: &fakeLightServiceClient{
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
got = in
return &hav1.LightResponse{}, nil
},
}}
err := c.ExecuteAction(context.Background(), "light.living_room", "set_color", map[string]any{
"rgb_color": map[string]any{"r": "10", "g": "20", "b": "30"},
})
if err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetRgbColor().GetR() != 10 || got.GetRgbColor().GetG() != 20 || got.GetRgbColor().GetB() != 30 {
t.Fatalf("RgbColor = %#v, want (10,20,30)", got.GetRgbColor())
}
})
t.Run("unsupported action", func(t *testing.T) {
c := &Client{lightClient: &fakeLightServiceClient{}}
if err := c.ExecuteAction(context.Background(), "light.living_room", "explode", nil); err == nil {
t.Fatal("ExecuteAction() error = nil, want error")
}
})
t.Run("propagates RPC error", func(t *testing.T) {
wantErr := errors.New("boom")
c := &Client{lightClient: &fakeLightServiceClient{
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
return nil, wantErr
},
}}
err := c.ExecuteAction(context.Background(), "light.living_room", "turn_on", nil)
if !errors.Is(err, wantErr) {
t.Fatalf("ExecuteAction() error = %v, want %v", err, wantErr)
}
})
}
func TestExecuteActionSwitch(t *testing.T) {
tests := []struct {
action string
}{{"turn_on"}, {"turn_off"}, {"toggle"}}
for _, tt := range tests {
t.Run(tt.action, func(t *testing.T) {
var gotOn, gotOff, gotToggle *hav1.SwitchRequest
c := &Client{switchClient: &fakeSwitchServiceClient{
turnOnFunc: func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error) {
gotOn = in
return &hav1.SwitchResponse{}, nil
},
turnOffFunc: func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error) {
gotOff = in
return &hav1.SwitchResponse{}, nil
},
toggleFunc: func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error) {
gotToggle = in
return &hav1.SwitchResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "switch.fan", tt.action, nil); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
for _, got := range []*hav1.SwitchRequest{gotOn, gotOff, gotToggle} {
if got != nil && got.GetEntityId() != "switch.fan" {
t.Fatalf("EntityId = %q, want %q", got.GetEntityId(), "switch.fan")
}
}
})
}
t.Run("unsupported action", func(t *testing.T) {
c := &Client{switchClient: &fakeSwitchServiceClient{}}
if err := c.ExecuteAction(context.Background(), "switch.fan", "explode", nil); err == nil {
t.Fatal("ExecuteAction() error = nil, want error")
}
})
}
func TestExecuteActionClimate(t *testing.T) {
t.Run("set_hvac_mode", func(t *testing.T) {
var got *hav1.SetHVACModeRequest
c := &Client{climateClient: &fakeClimateServiceClient{
setHVACModeFunc: func(ctx context.Context, in *hav1.SetHVACModeRequest) (*hav1.ClimateResponse, error) {
got = in
return &hav1.ClimateResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "set_hvac_mode", map[string]any{"hvac_mode": "cool"}); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetHvacMode() != "cool" {
t.Fatalf("HvacMode = %q, want %q", got.GetHvacMode(), "cool")
}
})
t.Run("set_hvac_mode missing param errors", func(t *testing.T) {
c := &Client{climateClient: &fakeClimateServiceClient{}}
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "set_hvac_mode", nil); err == nil {
t.Fatal("ExecuteAction() error = nil, want error")
}
})
t.Run("increase_temperature", func(t *testing.T) {
var got *hav1.ClimateRequest
c := &Client{climateClient: &fakeClimateServiceClient{
increaseTemperatureFunc: func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error) {
got = in
return &hav1.ClimateResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "increase_temperature", nil); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetEntityId() != "climate.air_conditioner" {
t.Fatalf("EntityId = %q, want %q", got.GetEntityId(), "climate.air_conditioner")
}
})
t.Run("decrease_temperature", func(t *testing.T) {
called := false
c := &Client{climateClient: &fakeClimateServiceClient{
decreaseTemperatureFunc: func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error) {
called = true
return &hav1.ClimateResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "decrease_temperature", nil); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if !called {
t.Fatal("DecreaseTemperature was not called")
}
})
t.Run("set_temperature forwards absolute target", func(t *testing.T) {
var got *hav1.SetTemperatureRequest
c := &Client{climateClient: &fakeClimateServiceClient{
setTemperatureFunc: func(ctx context.Context, in *hav1.SetTemperatureRequest) (*hav1.ClimateResponse, error) {
got = in
return &hav1.ClimateResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "set_temperature", map[string]any{"target_temperature": "23.5"}); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetEntityId() != "climate.air_conditioner" || got.GetTargetTemperature() != 23.5 {
t.Fatalf("SetTemperatureRequest = %#v, want entity=climate.air_conditioner target=23.5", got)
}
})
t.Run("set_temperature missing param errors without calling RPC", func(t *testing.T) {
called := false
c := &Client{climateClient: &fakeClimateServiceClient{
setTemperatureFunc: func(ctx context.Context, in *hav1.SetTemperatureRequest) (*hav1.ClimateResponse, error) {
called = true
return &hav1.ClimateResponse{}, nil
},
}}
err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "set_temperature", nil)
if err == nil {
t.Fatal("ExecuteAction() error = nil, want error")
}
if called {
t.Fatal("SetTemperature should not be called when the required param is missing")
}
})
t.Run("unsupported action", func(t *testing.T) {
c := &Client{climateClient: &fakeClimateServiceClient{}}
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "explode", nil); err == nil {
t.Fatal("ExecuteAction() error = nil, want error")
}
})
}
func TestExecuteActionDomainDispatch(t *testing.T) {
t.Run("unsupported domain", func(t *testing.T) {
c := &Client{}
if err := c.ExecuteAction(context.Background(), "sensor.temp", "turn_on", nil); err == nil {
t.Fatal("ExecuteAction() error = nil, want error")
}
})
t.Run("entity id with no domain prefix", func(t *testing.T) {
c := &Client{}
if err := c.ExecuteAction(context.Background(), "no-domain-here", "turn_on", nil); err == nil {
t.Fatal("ExecuteAction() error = nil, want error")
}
})
}