diff --git a/README.md b/README.md index b9d4f48..ceab6d5 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ Internal gRPC gateway for Home Assistant. - Talks to Home Assistant through the REST API - Implements entity state lookup and discovery - Implements light control and light discovery -- Implements switch discovery -- Stubs switch control and event streaming +- Implements switch control and switch discovery +- Stubs event streaming - Supports optional mTLS when `TLS_DIR` is set - Exposes gRPC health checks and reflection @@ -165,6 +165,9 @@ grpcurl -plaintext -d '{"domain":"light"}' \ grpcurl -plaintext -d '{"entity_id":"light.living_room","brightness_pct":80}' \ localhost:50051 ha.v1.LightService/TurnOn + +grpcurl -plaintext -d '{"entity_id":"switch.fan"}' \ + localhost:50051 ha.v1.SwitchService/TurnOn ``` With `ai-gateway` running locally: diff --git a/discord-bot/README.md b/discord-bot/README.md index 8d493da..c118ea9 100644 --- a/discord-bot/README.md +++ b/discord-bot/README.md @@ -35,10 +35,12 @@ AI-assisted commands. ```text /switch list +/switch on switch: +/switch off switch: +/switch toggle switch: ``` -Switch control commands are not exposed yet. `ha-gateway` currently only -implements switch discovery. +- `switch` is required for action commands and uses autocomplete. ### AI @@ -125,7 +127,6 @@ internal/telemetry/ # OpenTelemetry setup ## Limitations -- Switch commands are discovery-only. - The active AI model is not persisted. - The bot relies on Discord auth plus internal gateway/network controls; it does not implement per-user authorization. diff --git a/discord-bot/internal/adapters/primary/discord/handler.go b/discord-bot/internal/adapters/primary/discord/handler.go index 52096aa..f257fa0 100644 --- a/discord-bot/internal/adapters/primary/discord/handler.go +++ b/discord-bot/internal/adapters/primary/discord/handler.go @@ -26,6 +26,9 @@ type commandHandler interface { HandleLightOff(ctx context.Context, entityID string, transition *uint32) (string, error) HandleLightToggle(ctx context.Context, entityID string) (string, error) HandleSwitchList(ctx context.Context) (string, error) + HandleSwitchOn(ctx context.Context, entityID string) (string, error) + HandleSwitchOff(ctx context.Context, entityID string) (string, error) + HandleSwitchToggle(ctx context.Context, entityID string) (string, error) HandleAIQuery(ctx context.Context, text string) (string, error) HandleAIModelSet(ctx context.Context, name string) (string, error) HandleAIModelGet(ctx context.Context) (string, error) @@ -159,6 +162,36 @@ func (h *Handler) handleApplicationCommand(ctx context.Context, s *discordgo.Ses } msg, err := h.app.HandleLightToggle(ctx, requiredStringOption(sub, "light")) h.followup(ctx, s, i.Interaction, msg, true, start, err) + case "switch.on": + if err := h.deferResponse(s, i.Interaction, true); err != nil { + log.Error("discord response failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return + } + msg, err := h.app.HandleSwitchOn(ctx, requiredStringOption(sub, "switch")) + h.followup(ctx, s, i.Interaction, msg, true, start, err) + case "switch.off": + if err := h.deferResponse(s, i.Interaction, true); err != nil { + log.Error("discord response failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return + } + msg, err := h.app.HandleSwitchOff(ctx, requiredStringOption(sub, "switch")) + h.followup(ctx, s, i.Interaction, msg, true, start, err) + case "switch.toggle": + if err := h.deferResponse(s, i.Interaction, true); err != nil { + log.Error("discord response failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return + } + msg, err := h.app.HandleSwitchToggle(ctx, requiredStringOption(sub, "switch")) + h.followup(ctx, s, i.Interaction, msg, true, start, err) case "ai.query": if err := h.deferResponse(s, i.Interaction, true); err != nil { log.Error("discord response failed", diff --git a/discord-bot/internal/adapters/primary/discord/register.go b/discord-bot/internal/adapters/primary/discord/register.go index dc9f6a1..4b57c1a 100644 --- a/discord-bot/internal/adapters/primary/discord/register.go +++ b/discord-bot/internal/adapters/primary/discord/register.go @@ -72,13 +72,31 @@ func RegisterCommands(s *discordgo.Session, guildID string) error { }, { Name: "switch", - Description: "Inspect switches", + Description: "Control and inspect switches", Options: []*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list", Description: "List switches", }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "on", + Description: "Turn on a switch", + Options: []*discordgo.ApplicationCommandOption{switchOption()}, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "off", + Description: "Turn off a switch", + Options: []*discordgo.ApplicationCommandOption{switchOption()}, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "toggle", + Description: "Toggle a switch", + Options: []*discordgo.ApplicationCommandOption{switchOption()}, + }, }, }, { @@ -150,6 +168,17 @@ func lightOption() *discordgo.ApplicationCommandOption { } } +// switchOption centralizes the shared switch entity selector used by subcommands. +func switchOption() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionString, + Name: "switch", + Description: "Switch entity", + Required: true, + Autocomplete: true, + } +} + // ptrFloat keeps command option min/max values readable in the command spec. func ptrFloat(v float64) *float64 { return &v diff --git a/discord-bot/internal/adapters/secondary/gateway/client.go b/discord-bot/internal/adapters/secondary/gateway/client.go index 20dbb44..7c0a7d7 100644 --- a/discord-bot/internal/adapters/secondary/gateway/client.go +++ b/discord-bot/internal/adapters/secondary/gateway/client.go @@ -205,3 +205,48 @@ func (c *Client) ToggleLight(ctx context.Context, entityID string) error { log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds()) return nil } + +// TurnOnSwitch forwards a switch turn-on request over gRPC. +func (c *Client) TurnOnSwitch(ctx context.Context, entityID string) error { + start := time.Now() + log := logger.FromContext(ctx).With("grpc.method", "SwitchService/TurnOn") + if _, err := c.switchClient.TurnOn(ctx, &hav1.SwitchRequest{EntityId: entityID}); err != nil { + log.Error("grpc call failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return fmt.Errorf("turn on switch %s: %w", entityID, err) + } + log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds()) + return nil +} + +// TurnOffSwitch forwards a switch turn-off request over gRPC. +func (c *Client) TurnOffSwitch(ctx context.Context, entityID string) error { + start := time.Now() + log := logger.FromContext(ctx).With("grpc.method", "SwitchService/TurnOff") + if _, err := c.switchClient.TurnOff(ctx, &hav1.SwitchRequest{EntityId: entityID}); err != nil { + log.Error("grpc call failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return fmt.Errorf("turn off switch %s: %w", entityID, err) + } + log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds()) + return nil +} + +// ToggleSwitch forwards a switch toggle request over gRPC. +func (c *Client) ToggleSwitch(ctx context.Context, entityID string) error { + start := time.Now() + log := logger.FromContext(ctx).With("grpc.method", "SwitchService/Toggle") + if _, err := c.switchClient.Toggle(ctx, &hav1.SwitchRequest{EntityId: entityID}); err != nil { + log.Error("grpc call failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return fmt.Errorf("toggle switch %s: %w", entityID, err) + } + log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds()) + return nil +} diff --git a/discord-bot/internal/app/command.go b/discord-bot/internal/app/command.go index 32f9bfd..82094ed 100644 --- a/discord-bot/internal/app/command.go +++ b/discord-bot/internal/app/command.go @@ -126,6 +126,42 @@ func (a *CommandApp) HandleSwitchList(ctx context.Context) (string, error) { return strings.Join(lines, "\n"), nil } +// HandleSwitchOn issues a turn-on request and returns a user-facing confirmation. +func (a *CommandApp) HandleSwitchOn(ctx context.Context, entityID string) (string, error) { + name, err := a.lookupSwitchName(ctx, entityID) + if err != nil { + return "", fmt.Errorf("lookup switch name: %w", err) + } + if err := a.ha.TurnOnSwitch(ctx, entityID); err != nil { + return "", fmt.Errorf("handle switch on: %w", err) + } + return fmt.Sprintf("Turned on `%s`.", name), nil +} + +// HandleSwitchOff issues a turn-off request and returns a user-facing confirmation. +func (a *CommandApp) HandleSwitchOff(ctx context.Context, entityID string) (string, error) { + name, err := a.lookupSwitchName(ctx, entityID) + if err != nil { + return "", fmt.Errorf("lookup switch name: %w", err) + } + if err := a.ha.TurnOffSwitch(ctx, entityID); err != nil { + return "", fmt.Errorf("handle switch off: %w", err) + } + return fmt.Sprintf("Turned off `%s`.", name), nil +} + +// HandleSwitchToggle issues a toggle request and returns a user-facing confirmation. +func (a *CommandApp) HandleSwitchToggle(ctx context.Context, entityID string) (string, error) { + name, err := a.lookupSwitchName(ctx, entityID) + if err != nil { + return "", fmt.Errorf("lookup switch name: %w", err) + } + if err := a.ha.ToggleSwitch(ctx, entityID); err != nil { + return "", fmt.Errorf("handle switch toggle: %w", err) + } + return fmt.Sprintf("Toggled `%s`.", name), nil +} + // HandleAIQuery forwards a free-form request to ai-gateway. func (a *CommandApp) HandleAIQuery(ctx context.Context, text string) (string, error) { reply, modelUsed, err := a.ai.Query(ctx, text, a.models.Get()) @@ -251,6 +287,25 @@ func (a *CommandApp) lookupLightName(ctx context.Context, entityID string) (stri return lights[idx].FriendlyName, nil } +// lookupSwitchName falls back to the entity ID so confirmations remain useful +// even when Home Assistant does not expose a friendly name. +func (a *CommandApp) lookupSwitchName(ctx context.Context, entityID string) (string, error) { + switches, err := a.ha.ListSwitches(ctx) + if err != nil { + return "", fmt.Errorf("list switches: %w", err) + } + idx := slices.IndexFunc(switches, func(sw driven.Switch) bool { + return sw.EntityID == entityID + }) + if idx == -1 { + return entityID, nil + } + if switches[idx].FriendlyName == "" { + return entityID, nil + } + return switches[idx].FriendlyName, nil +} + // formatLightLine keeps list output compact because Discord code blocks are // easier to scan than rich embeds for dense discovery data. func formatLightLine(light driven.Light) string { diff --git a/discord-bot/internal/app/command_test.go b/discord-bot/internal/app/command_test.go index bff3d67..70524d0 100644 --- a/discord-bot/internal/app/command_test.go +++ b/discord-bot/internal/app/command_test.go @@ -15,9 +15,12 @@ import ( type mockHAGateway struct { listLightsFunc func(ctx context.Context) ([]driven.Light, error) listSwitchesFunc func(ctx context.Context) ([]driven.Switch, error) - turnOnLightFunc func(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error - turnOffLightFunc func(ctx context.Context, entityID string, transition *uint32) error - toggleLightFunc func(ctx context.Context, entityID string) error + turnOnLightFunc func(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error + turnOffLightFunc func(ctx context.Context, entityID string, transition *uint32) error + toggleLightFunc func(ctx context.Context, entityID string) error + turnOnSwitchFunc func(ctx context.Context, entityID string) error + turnOffSwitchFunc func(ctx context.Context, entityID string) error + toggleSwitchFunc func(ctx context.Context, entityID string) error } type mockAIGateway struct { @@ -60,6 +63,27 @@ func (m *mockHAGateway) ToggleLight(ctx context.Context, entityID string) error return m.toggleLightFunc(ctx, entityID) } +func (m *mockHAGateway) TurnOnSwitch(ctx context.Context, entityID string) error { + if m.turnOnSwitchFunc == nil { + return nil + } + return m.turnOnSwitchFunc(ctx, entityID) +} + +func (m *mockHAGateway) TurnOffSwitch(ctx context.Context, entityID string) error { + if m.turnOffSwitchFunc == nil { + return nil + } + return m.turnOffSwitchFunc(ctx, entityID) +} + +func (m *mockHAGateway) ToggleSwitch(ctx context.Context, entityID string) error { + if m.toggleSwitchFunc == nil { + return nil + } + return m.toggleSwitchFunc(ctx, entityID) +} + func (m *mockAIGateway) Query(ctx context.Context, text, model string) (string, string, error) { if m.queryFunc == nil { return "", "", nil @@ -400,6 +424,183 @@ func TestCommandAppHandleSwitchList(t *testing.T) { } } +func TestCommandAppHandleSwitchOn(t *testing.T) { + tests := []struct { + name string + switches []driven.Switch + entityID string + turnOnErr error + listErr error + want string + wantErr string + }{ + { + name: "happy path", + switches: []driven.Switch{{EntityID: "switch.fan", FriendlyName: "Fan"}}, + entityID: "switch.fan", + want: "Turned on `Fan`.", + }, + { + name: "switch not found falls back to entity id", + switches: []driven.Switch{{EntityID: "switch.other", FriendlyName: "Other"}}, + entityID: "switch.fan", + want: "Turned on `switch.fan`.", + }, + { + name: "TurnOnSwitch error", + switches: []driven.Switch{{EntityID: "switch.fan", FriendlyName: "Fan"}}, + entityID: "switch.fan", + turnOnErr: errors.New("boom"), + wantErr: "handle switch on: boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotEntityID string + app := newTestCommandApp(&mockHAGateway{ + listSwitchesFunc: func(ctx context.Context) ([]driven.Switch, error) { + if tt.listErr != nil { + return nil, tt.listErr + } + return tt.switches, nil + }, + turnOnSwitchFunc: func(ctx context.Context, entityID string) error { + gotEntityID = entityID + return tt.turnOnErr + }, + }, &mockAIGateway{}) + + got, err := app.HandleSwitchOn(context.Background(), tt.entityID) + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("HandleSwitchOn() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("HandleSwitchOn() error = %v", err) + } + if got != tt.want { + t.Fatalf("HandleSwitchOn() = %q, want %q", got, tt.want) + } + if gotEntityID != tt.entityID { + t.Fatalf("TurnOnSwitch entityID = %q, want %q", gotEntityID, tt.entityID) + } + }) + } +} + +func TestCommandAppHandleSwitchOff(t *testing.T) { + tests := []struct { + name string + switches []driven.Switch + entityID string + turnOffErr error + want string + wantErr string + }{ + { + name: "happy path", + switches: []driven.Switch{{EntityID: "switch.fan", FriendlyName: "Fan"}}, + entityID: "switch.fan", + want: "Turned off `Fan`.", + }, + { + name: "TurnOffSwitch error", + switches: []driven.Switch{{EntityID: "switch.fan", FriendlyName: "Fan"}}, + entityID: "switch.fan", + turnOffErr: errors.New("boom"), + wantErr: "handle switch off: boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := newTestCommandApp(&mockHAGateway{ + listSwitchesFunc: func(ctx context.Context) ([]driven.Switch, error) { + return tt.switches, nil + }, + turnOffSwitchFunc: func(ctx context.Context, entityID string) error { + return tt.turnOffErr + }, + }, &mockAIGateway{}) + + got, err := app.HandleSwitchOff(context.Background(), tt.entityID) + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("HandleSwitchOff() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("HandleSwitchOff() error = %v", err) + } + if got != tt.want { + t.Fatalf("HandleSwitchOff() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCommandAppHandleSwitchToggle(t *testing.T) { + tests := []struct { + name string + switches []driven.Switch + listErr error + toggleErr error + want string + wantErr string + }{ + { + name: "happy path", + switches: []driven.Switch{{EntityID: "switch.fan", FriendlyName: "Fan"}}, + want: "Toggled `Fan`.", + }, + { + name: "lookup error", + listErr: errors.New("lookup failed"), + wantErr: "lookup switch name: list switches: lookup failed", + }, + { + name: "toggle error", + switches: []driven.Switch{{EntityID: "switch.fan", FriendlyName: "Fan"}}, + toggleErr: errors.New("boom"), + wantErr: "handle switch toggle: boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := newTestCommandApp(&mockHAGateway{ + listSwitchesFunc: func(ctx context.Context) ([]driven.Switch, error) { + if tt.listErr != nil { + return nil, tt.listErr + } + return tt.switches, nil + }, + toggleSwitchFunc: func(ctx context.Context, entityID string) error { + return tt.toggleErr + }, + }, &mockAIGateway{}) + + got, err := app.HandleSwitchToggle(context.Background(), "switch.fan") + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("HandleSwitchToggle() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("HandleSwitchToggle() error = %v", err) + } + if got != tt.want { + t.Fatalf("HandleSwitchToggle() = %q, want %q", got, tt.want) + } + }) + } +} + func TestCommandAppAutocompleteLights(t *testing.T) { tests := []struct { name string diff --git a/discord-bot/internal/core/ports/driven/ha.go b/discord-bot/internal/core/ports/driven/ha.go index 048fee1..15b27e2 100644 --- a/discord-bot/internal/core/ports/driven/ha.go +++ b/discord-bot/internal/core/ports/driven/ha.go @@ -13,6 +13,12 @@ type HAGateway interface { TurnOffLight(ctx context.Context, entityID string, transition *uint32) error // ToggleLight forwards a light toggle request to ha-gateway. ToggleLight(ctx context.Context, entityID string) error + // TurnOnSwitch forwards a switch turn-on request to ha-gateway. + TurnOnSwitch(ctx context.Context, entityID string) error + // TurnOffSwitch forwards a switch turn-off request to ha-gateway. + TurnOffSwitch(ctx context.Context, entityID string) error + // ToggleSwitch forwards a switch toggle request to ha-gateway. + ToggleSwitch(ctx context.Context, entityID string) error } // Light is the discovery-oriented light view exposed by ha-gateway. diff --git a/ha-gateway/README.md b/ha-gateway/README.md index ab510c5..586ff4b 100644 --- a/ha-gateway/README.md +++ b/ha-gateway/README.md @@ -25,13 +25,13 @@ Implemented: - `LightService.TurnOff` - `LightService.Toggle` - `LightService.ListLights` +- `SwitchService.TurnOn` +- `SwitchService.TurnOff` +- `SwitchService.Toggle` - `SwitchService.ListSwitches` Stubbed: -- `SwitchService.TurnOn` -- `SwitchService.TurnOff` -- `SwitchService.Toggle` - `EventService` The server also registers gRPC health checks and reflection. @@ -122,7 +122,6 @@ internal/telemetry/ # OpenTelemetry setup ## Limitations -- Switch control RPCs return not implemented. - `EventService` is registered but not implemented. - Home Assistant WebSocket event streaming is still a TODO. - Keep this service internal or protect it with mTLS; it does not implement diff --git a/ha-gateway/internal/adapters/primary/grpc/switch.go b/ha-gateway/internal/adapters/primary/grpc/switch.go index 26b97d3..0eb0055 100644 --- a/ha-gateway/internal/adapters/primary/grpc/switch.go +++ b/ha-gateway/internal/adapters/primary/grpc/switch.go @@ -4,6 +4,7 @@ import ( "context" hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1" + "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/domain" "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/ports/driving" ) @@ -30,20 +31,29 @@ func (h *SwitchGRPC) ListSwitches(ctx context.Context, req *hav1.ListSwitchesReq return &hav1.ListSwitchesResponse{Switches: out}, nil } -// TurnOn is stubbed until switch control orchestration exists in the app layer. -// TODO: implement switch control RPCs — see plan.md for context +// TurnOn maps a protobuf turn-on request into a domain entity ID. func (h *SwitchGRPC) TurnOn(ctx context.Context, req *hav1.SwitchRequest) (*hav1.SwitchResponse, error) { - return nil, grpcError(ErrNotImplemented) + s, err := h.svc.TurnOn(ctx, domain.EntityID(req.EntityId)) + if err != nil { + return nil, grpcError(err) + } + return &hav1.SwitchResponse{State: domainStateToProto(s)}, nil } -// TurnOff is stubbed until switch control orchestration exists in the app layer. -// TODO: implement switch control RPCs — see plan.md for context +// TurnOff maps a protobuf turn-off request into a domain entity ID. func (h *SwitchGRPC) TurnOff(ctx context.Context, req *hav1.SwitchRequest) (*hav1.SwitchResponse, error) { - return nil, grpcError(ErrNotImplemented) + s, err := h.svc.TurnOff(ctx, domain.EntityID(req.EntityId)) + if err != nil { + return nil, grpcError(err) + } + return &hav1.SwitchResponse{State: domainStateToProto(s)}, nil } -// Toggle is stubbed until switch control orchestration exists in the app layer. -// TODO: implement switch control RPCs — see plan.md for context +// Toggle forwards a switch toggle request to the domain service. func (h *SwitchGRPC) Toggle(ctx context.Context, req *hav1.SwitchRequest) (*hav1.SwitchResponse, error) { - return nil, grpcError(ErrNotImplemented) + s, err := h.svc.Toggle(ctx, domain.EntityID(req.EntityId)) + if err != nil { + return nil, grpcError(err) + } + return &hav1.SwitchResponse{State: domainStateToProto(s)}, nil } diff --git a/ha-gateway/internal/adapters/primary/grpc/switch_test.go b/ha-gateway/internal/adapters/primary/grpc/switch_test.go new file mode 100644 index 0000000..6b643dd --- /dev/null +++ b/ha-gateway/internal/adapters/primary/grpc/switch_test.go @@ -0,0 +1,285 @@ +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 mockSwitchService struct { + turnOnFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + turnOffFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + toggleFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + listSwitchesFunc func(ctx context.Context) ([]domain.Switch, error) + refreshFunc func(ctx context.Context) error +} + +func (m *mockSwitchService) TurnOn(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + if m.turnOnFunc == nil { + return nil, nil + } + return m.turnOnFunc(ctx, id) +} + +func (m *mockSwitchService) TurnOff(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + if m.turnOffFunc == nil { + return nil, nil + } + return m.turnOffFunc(ctx, id) +} + +func (m *mockSwitchService) Toggle(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + if m.toggleFunc == nil { + return nil, nil + } + return m.toggleFunc(ctx, id) +} + +func (m *mockSwitchService) ListSwitches(ctx context.Context) ([]domain.Switch, error) { + if m.listSwitchesFunc == nil { + return nil, nil + } + return m.listSwitchesFunc(ctx) +} + +func (m *mockSwitchService) Refresh(ctx context.Context) error { + if m.refreshFunc == nil { + return nil + } + return m.refreshFunc(ctx) +} + +func TestSwitchGRPCTurnOn(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 := newSwitchTestClientConn(t, &mockSwitchService{ + 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: "switch.fan", + State: "on", + Attributes: map[string]string{"friendly_name": "Fan"}, + LastChanged: now, + LastUpdated: now, + }, nil + }, + }) + client := hav1.NewSwitchServiceClient(conn) + + resp, err := client.TurnOn(context.Background(), &hav1.SwitchRequest{EntityId: "switch.fan"}) + 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 != "switch.fan" { + t.Fatalf("TurnOn id = %q, want %q", gotID, "switch.fan") + } + if resp.GetState().GetEntityId() != "switch.fan" || resp.GetState().GetState() != "on" { + t.Fatalf("response state = %#v", resp.GetState()) + } + }) + } +} + +func TestSwitchGRPCTurnOff(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: "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 := newSwitchTestClientConn(t, &mockSwitchService{ + turnOffFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + gotID = id + if tt.err != nil { + return nil, tt.err + } + return &domain.EntityState{ + EntityID: "switch.fan", + State: "off", + Attributes: map[string]string{"friendly_name": "Fan"}, + LastChanged: now, + LastUpdated: now, + }, nil + }, + }) + client := hav1.NewSwitchServiceClient(conn) + + resp, err := client.TurnOff(context.Background(), &hav1.SwitchRequest{EntityId: "switch.fan"}) + 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 != "switch.fan" { + t.Fatalf("TurnOff id = %q, want %q", gotID, "switch.fan") + } + if resp.GetState().GetState() != "off" { + t.Fatalf("response state = %#v", resp.GetState()) + } + }) + } +} + +func TestSwitchGRPCToggle(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 implemented maps to codes.Unimplemented", err: ErrNotImplemented, wantCode: codes.Unimplemented}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotID domain.EntityID + conn := newSwitchTestClientConn(t, &mockSwitchService{ + toggleFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + gotID = id + if tt.err != nil { + return nil, tt.err + } + return &domain.EntityState{ + EntityID: "switch.fan", + State: "on", + Attributes: map[string]string{"friendly_name": "Fan"}, + LastChanged: now, + LastUpdated: now, + }, nil + }, + }) + client := hav1.NewSwitchServiceClient(conn) + + resp, err := client.Toggle(context.Background(), &hav1.SwitchRequest{EntityId: "switch.fan"}) + 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 != "switch.fan" { + t.Fatalf("Toggle id = %q, want %q", gotID, "switch.fan") + } + if resp.GetState().GetEntityId() != "switch.fan" { + t.Fatalf("response state = %#v", resp.GetState()) + } + }) + } +} + +func TestSwitchGRPCListSwitches(t *testing.T) { + tests := []struct { + name string + err error + wantCode codes.Code + }{ + {name: "happy path with multiple switches", 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) { + conn := newSwitchTestClientConn(t, &mockSwitchService{ + listSwitchesFunc: func(ctx context.Context) ([]domain.Switch, error) { + if tt.err != nil { + return nil, tt.err + } + return []domain.Switch{ + {EntityID: "switch.fan", FriendlyName: "Fan", State: "on", DeviceClass: "switch"}, + {EntityID: "switch.heater", FriendlyName: "Heater", State: "off"}, + }, nil + }, + }) + client := hav1.NewSwitchServiceClient(conn) + + resp, err := client.ListSwitches(context.Background(), &hav1.ListSwitchesRequest{}) + 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.GetSwitches()) != 2 { + t.Fatalf("len(switches) = %d, want 2", len(resp.GetSwitches())) + } + if resp.GetSwitches()[0].GetEntityId() != "switch.fan" || resp.GetSwitches()[1].GetEntityId() != "switch.heater" { + t.Fatalf("switches = %#v", resp.GetSwitches()) + } + }) + } +} + +func newSwitchTestClientConn(t *testing.T, svc *mockSwitchService) *grpc.ClientConn { + t.Helper() + + lis := bufconn.Listen(testBufSize) + server := grpc.NewServer() + hav1.RegisterSwitchServiceServer(server, NewSwitchGRPC(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 +} diff --git a/ha-gateway/internal/app/switch.go b/ha-gateway/internal/app/switch.go index dababba..6cf5784 100644 --- a/ha-gateway/internal/app/switch.go +++ b/ha-gateway/internal/app/switch.go @@ -57,6 +57,45 @@ func (a *SwitchApp) ListSwitches(ctx context.Context) ([]domain.Switch, error) { return c, nil } +// TurnOn maps application parameters into a Home Assistant switch.turn_on call. +func (a *SwitchApp) TurnOn(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + payload := map[string]any{"entity_id": string(id)} + return a.callService(ctx, "switch", "turn_on", payload) +} + +// TurnOff maps application parameters into a Home Assistant switch.turn_off call. +func (a *SwitchApp) TurnOff(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + payload := map[string]any{"entity_id": string(id)} + return a.callService(ctx, "switch", "turn_off", payload) +} + +// Toggle maps directly to Home Assistant switch.toggle. +func (a *SwitchApp) Toggle(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + payload := map[string]any{"entity_id": string(id)} + return a.callService(ctx, "switch", "toggle", payload) +} + +// callService falls back to GetState because Home Assistant may succeed without +// returning a full entity state list for the service call response. +func (a *SwitchApp) callService(ctx context.Context, svcDomain, service string, payload map[string]any) (*domain.EntityState, error) { + states, err := a.ha.CallService(ctx, svcDomain, service, payload) + if err != nil { + return nil, err + } + entityID, _ := payload["entity_id"].(string) + for _, s := range states { + if s.EntityID == entityID { + return haStateToDomain(s), nil + } + } + // HA may return an empty list on success; fall back to GetState. + s, err := a.ha.GetState(ctx, entityID) + if err != nil { + return nil, err + } + return haStateToDomain(s), nil +} + // haStateToSwitch extracts the subset of attributes needed for switch discovery. func haStateToSwitch(s *driven.HAState) domain.Switch { sw := domain.Switch{ diff --git a/ha-gateway/internal/app/switch_test.go b/ha-gateway/internal/app/switch_test.go new file mode 100644 index 0000000..dc98a2e --- /dev/null +++ b/ha-gateway/internal/app/switch_test.go @@ -0,0 +1,297 @@ +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 TestSwitchAppRefresh(t *testing.T) { + t.Run("filters non-switch entities and populates cache", func(t *testing.T) { + ha := &mockHAClient{ + listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { + return []*driven.HAState{ + { + EntityID: "switch.fan", + State: "on", + Attributes: map[string]any{ + "friendly_name": "Fan", + "device_class": "switch", + }, + }, + { + EntityID: "light.kitchen", + State: "on", + }, + { + EntityID: "switch.heater", + State: "off", + Attributes: map[string]any{ + "friendly_name": "Heater", + }, + }, + }, nil + }, + } + + app := NewSwitchApp(ha) + if err := app.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + got := app.cache + want := []domain.Switch{ + {EntityID: "switch.fan", FriendlyName: "Fan", State: "on", DeviceClass: "switch"}, + {EntityID: "switch.heater", FriendlyName: "Heater", State: "off"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("cache = %#v, want %#v", got, want) + } + }) +} + +func TestSwitchAppListSwitches(t *testing.T) { + t.Run("uses cache when populated", func(t *testing.T) { + calls := 0 + app := NewSwitchApp(&mockHAClient{ + listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { + calls++ + return nil, nil + }, + }) + app.cache = []domain.Switch{{EntityID: "switch.fan", State: "on"}} + + got, err := app.ListSwitches(context.Background()) + if err != nil { + t.Fatalf("ListSwitches() error = %v", err) + } + if calls != 0 { + t.Fatalf("ListStates() calls = %d, want 0", calls) + } + if !reflect.DeepEqual(got, app.cache) { + t.Fatalf("ListSwitches() = %#v, want %#v", got, app.cache) + } + }) + + t.Run("calls ListStates when cache is nil", func(t *testing.T) { + calls := 0 + app := NewSwitchApp(&mockHAClient{ + listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { + calls++ + return []*driven.HAState{ + {EntityID: "switch.fan", State: "on"}, + {EntityID: "sensor.temp", State: "21"}, + }, nil + }, + }) + + got, err := app.ListSwitches(context.Background()) + if err != nil { + t.Fatalf("ListSwitches() error = %v", err) + } + if calls != 1 { + t.Fatalf("ListStates() calls = %d, want 1", calls) + } + want := []domain.Switch{{EntityID: "switch.fan", State: "on"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ListSwitches() = %#v, want %#v", got, want) + } + }) + + t.Run("propagates refresh error", func(t *testing.T) { + wantErr := errors.New("list failed") + app := NewSwitchApp(&mockHAClient{ + listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { + return nil, wantErr + }, + }) + + _, err := app.ListSwitches(context.Background()) + if !errors.Is(err, wantErr) { + t.Fatalf("ListSwitches() error = %v, want %v", err, wantErr) + } + }) +} + +func TestSwitchAppTurnOn(t *testing.T) { + now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) + callState := &driven.HAState{ + EntityID: "switch.fan", + State: "on", + Attributes: map[string]any{"friendly_name": "Fan"}, + LastChanged: now, + LastUpdated: now, + } + fallbackState := &driven.HAState{ + EntityID: "switch.fan", + State: "on", + Attributes: map[string]any{"friendly_name": "Fan", "source": "fallback"}, + LastChanged: now, + LastUpdated: now, + } + + t.Run("happy path", func(t *testing.T) { + app := NewSwitchApp(&mockHAClient{ + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + if svcDomain != "switch" || service != "turn_on" { + t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service) + } + wantPayload := map[string]any{"entity_id": "switch.fan"} + if !reflect.DeepEqual(payload, wantPayload) { + t.Fatalf("payload = %#v, want %#v", payload, wantPayload) + } + return []*driven.HAState{callState}, nil + }, + }) + + got, err := app.TurnOn(context.Background(), "switch.fan") + if err != nil { + t.Fatalf("TurnOn() error = %v", err) + } + if !reflect.DeepEqual(got, haStateToDomain(callState)) { + t.Fatalf("TurnOn() = %#v, want %#v", got, haStateToDomain(callState)) + } + }) + + t.Run("falls back to GetState when service returns empty list", func(t *testing.T) { + getStateCalls := 0 + app := NewSwitchApp(&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++ + if entityID != "switch.fan" { + t.Fatalf("GetState() entityID = %q, want %q", entityID, "switch.fan") + } + return fallbackState, nil + }, + }) + + got, err := app.TurnOn(context.Background(), "switch.fan") + 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(fallbackState)) { + t.Fatalf("TurnOn() = %#v, want %#v", got, haStateToDomain(fallbackState)) + } + }) + + t.Run("returns CallService error", func(t *testing.T) { + wantErr := errors.New("call failed") + app := NewSwitchApp(&mockHAClient{ + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + return nil, wantErr + }, + }) + + _, err := app.TurnOn(context.Background(), "switch.fan") + if !errors.Is(err, wantErr) { + t.Fatalf("TurnOn() error = %v, want %v", err, wantErr) + } + }) +} + +func TestSwitchAppTurnOff(t *testing.T) { + now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) + state := &driven.HAState{ + EntityID: "switch.fan", + State: "off", + Attributes: map[string]any{"friendly_name": "Fan"}, + LastChanged: now, + LastUpdated: now, + } + + t.Run("happy path", func(t *testing.T) { + app := NewSwitchApp(&mockHAClient{ + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + if svcDomain != "switch" || service != "turn_off" { + t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service) + } + wantPayload := map[string]any{"entity_id": "switch.fan"} + if !reflect.DeepEqual(payload, wantPayload) { + t.Fatalf("payload = %#v, want %#v", payload, wantPayload) + } + return []*driven.HAState{state}, nil + }, + }) + + got, err := app.TurnOff(context.Background(), "switch.fan") + 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 := NewSwitchApp(&mockHAClient{ + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + return nil, wantErr + }, + }) + + _, err := app.TurnOff(context.Background(), "switch.fan") + if !errors.Is(err, wantErr) { + t.Fatalf("TurnOff() error = %v, want %v", err, wantErr) + } + }) +} + +func TestSwitchAppToggle(t *testing.T) { + now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) + state := &driven.HAState{ + EntityID: "switch.fan", + State: "on", + Attributes: map[string]any{"friendly_name": "Fan"}, + LastChanged: now, + LastUpdated: now, + } + + t.Run("happy path", func(t *testing.T) { + app := NewSwitchApp(&mockHAClient{ + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + if svcDomain != "switch" || service != "toggle" { + t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service) + } + wantPayload := map[string]any{"entity_id": "switch.fan"} + if !reflect.DeepEqual(payload, wantPayload) { + t.Fatalf("payload = %#v, want %#v", payload, wantPayload) + } + return []*driven.HAState{state}, nil + }, + }) + + got, err := app.Toggle(context.Background(), "switch.fan") + if err != nil { + t.Fatalf("Toggle() error = %v", err) + } + if !reflect.DeepEqual(got, haStateToDomain(state)) { + t.Fatalf("Toggle() = %#v, want %#v", got, haStateToDomain(state)) + } + }) + + t.Run("error path", func(t *testing.T) { + wantErr := errors.New("toggle failed") + app := NewSwitchApp(&mockHAClient{ + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + return nil, wantErr + }, + }) + + _, err := app.Toggle(context.Background(), "switch.fan") + if !errors.Is(err, wantErr) { + t.Fatalf("Toggle() error = %v, want %v", err, wantErr) + } + }) +} diff --git a/ha-gateway/internal/core/ports/driving/switch.go b/ha-gateway/internal/core/ports/driving/switch.go index 2d95821..9e8c9e7 100644 --- a/ha-gateway/internal/core/ports/driving/switch.go +++ b/ha-gateway/internal/core/ports/driving/switch.go @@ -7,6 +7,12 @@ import ( ) type SwitchService interface { + // TurnOn turns on a switch and returns the resulting entity state. + TurnOn(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + // TurnOff turns off a switch and returns the resulting entity state. + TurnOff(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + // Toggle toggles a switch and returns the resulting entity state. + Toggle(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) // ListSwitches returns cached switch metadata for discovery and UI use. ListSwitches(ctx context.Context) ([]domain.Switch, error) // Refresh repopulates the switch discovery cache from Home Assistant states.