feat: add switch control functionality to the Home Assistant integration
All checks were successful
CI / test (push) Successful in 5s
CI / build-ai-gateway (push) Successful in 53s
CI / build-ha-gateway (push) Successful in 42s
CI / build-discord-bot (push) Successful in 40s

- Implemented TurnOn, TurnOff, and Toggle methods in the gRPC client for switch control.
- Added corresponding methods in the CommandApp to handle user commands for turning switches on, off, and toggling.
- Created unit tests for the new switch control methods in CommandApp and SwitchApp.
- Updated the HAGateway interface to include switch control methods.
- Enhanced the SwitchGRPC service to handle switch control requests and return appropriate responses.
- Added integration tests for the switch service to ensure correct behavior and error handling.
This commit is contained in:
Nik Afiq 2026-07-22 23:44:35 +09:00
parent 5c08e69bcb
commit b54a747467
14 changed files with 1031 additions and 22 deletions

View File

@ -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:

View File

@ -35,10 +35,12 @@ AI-assisted commands.
```text
/switch list
/switch on switch:<entity>
/switch off switch:<entity>
/switch toggle switch:<entity>
```
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.

View File

@ -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",

View File

@ -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

View File

@ -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
}

View File

@ -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 {

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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{

View File

@ -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)
}
})
}

View File

@ -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.