feat: add switch control functionality to the Home Assistant integration
- 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:
parent
5c08e69bcb
commit
b54a747467
@ -42,8 +42,8 @@ Internal gRPC gateway for Home Assistant.
|
|||||||
- Talks to Home Assistant through the REST API
|
- Talks to Home Assistant through the REST API
|
||||||
- Implements entity state lookup and discovery
|
- Implements entity state lookup and discovery
|
||||||
- Implements light control and light discovery
|
- Implements light control and light discovery
|
||||||
- Implements switch discovery
|
- Implements switch control and switch discovery
|
||||||
- Stubs switch control and event streaming
|
- Stubs event streaming
|
||||||
- Supports optional mTLS when `TLS_DIR` is set
|
- Supports optional mTLS when `TLS_DIR` is set
|
||||||
- Exposes gRPC health checks and reflection
|
- 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}' \
|
grpcurl -plaintext -d '{"entity_id":"light.living_room","brightness_pct":80}' \
|
||||||
localhost:50051 ha.v1.LightService/TurnOn
|
localhost:50051 ha.v1.LightService/TurnOn
|
||||||
|
|
||||||
|
grpcurl -plaintext -d '{"entity_id":"switch.fan"}' \
|
||||||
|
localhost:50051 ha.v1.SwitchService/TurnOn
|
||||||
```
|
```
|
||||||
|
|
||||||
With `ai-gateway` running locally:
|
With `ai-gateway` running locally:
|
||||||
|
|||||||
@ -35,10 +35,12 @@ AI-assisted commands.
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
/switch list
|
/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
|
- `switch` is required for action commands and uses autocomplete.
|
||||||
implements switch discovery.
|
|
||||||
|
|
||||||
### AI
|
### AI
|
||||||
|
|
||||||
@ -125,7 +127,6 @@ internal/telemetry/ # OpenTelemetry setup
|
|||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- Switch commands are discovery-only.
|
|
||||||
- The active AI model is not persisted.
|
- The active AI model is not persisted.
|
||||||
- The bot relies on Discord auth plus internal gateway/network controls; it
|
- The bot relies on Discord auth plus internal gateway/network controls; it
|
||||||
does not implement per-user authorization.
|
does not implement per-user authorization.
|
||||||
|
|||||||
@ -26,6 +26,9 @@ type commandHandler interface {
|
|||||||
HandleLightOff(ctx context.Context, entityID string, transition *uint32) (string, error)
|
HandleLightOff(ctx context.Context, entityID string, transition *uint32) (string, error)
|
||||||
HandleLightToggle(ctx context.Context, entityID string) (string, error)
|
HandleLightToggle(ctx context.Context, entityID string) (string, error)
|
||||||
HandleSwitchList(ctx context.Context) (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)
|
HandleAIQuery(ctx context.Context, text string) (string, error)
|
||||||
HandleAIModelSet(ctx context.Context, name string) (string, error)
|
HandleAIModelSet(ctx context.Context, name string) (string, error)
|
||||||
HandleAIModelGet(ctx context.Context) (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"))
|
msg, err := h.app.HandleLightToggle(ctx, requiredStringOption(sub, "light"))
|
||||||
h.followup(ctx, s, i.Interaction, msg, true, start, err)
|
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":
|
case "ai.query":
|
||||||
if err := h.deferResponse(s, i.Interaction, true); err != nil {
|
if err := h.deferResponse(s, i.Interaction, true); err != nil {
|
||||||
log.Error("discord response failed",
|
log.Error("discord response failed",
|
||||||
|
|||||||
@ -72,13 +72,31 @@ func RegisterCommands(s *discordgo.Session, guildID string) error {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "switch",
|
Name: "switch",
|
||||||
Description: "Inspect switches",
|
Description: "Control and inspect switches",
|
||||||
Options: []*discordgo.ApplicationCommandOption{
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
{
|
{
|
||||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List switches",
|
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.
|
// ptrFloat keeps command option min/max values readable in the command spec.
|
||||||
func ptrFloat(v float64) *float64 {
|
func ptrFloat(v float64) *float64 {
|
||||||
return &v
|
return &v
|
||||||
|
|||||||
@ -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())
|
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -126,6 +126,42 @@ func (a *CommandApp) HandleSwitchList(ctx context.Context) (string, error) {
|
|||||||
return strings.Join(lines, "\n"), nil
|
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.
|
// HandleAIQuery forwards a free-form request to ai-gateway.
|
||||||
func (a *CommandApp) HandleAIQuery(ctx context.Context, text string) (string, error) {
|
func (a *CommandApp) HandleAIQuery(ctx context.Context, text string) (string, error) {
|
||||||
reply, modelUsed, err := a.ai.Query(ctx, text, a.models.Get())
|
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
|
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
|
// formatLightLine keeps list output compact because Discord code blocks are
|
||||||
// easier to scan than rich embeds for dense discovery data.
|
// easier to scan than rich embeds for dense discovery data.
|
||||||
func formatLightLine(light driven.Light) string {
|
func formatLightLine(light driven.Light) string {
|
||||||
|
|||||||
@ -15,9 +15,12 @@ import (
|
|||||||
type mockHAGateway struct {
|
type mockHAGateway struct {
|
||||||
listLightsFunc func(ctx context.Context) ([]driven.Light, error)
|
listLightsFunc func(ctx context.Context) ([]driven.Light, error)
|
||||||
listSwitchesFunc func(ctx context.Context) ([]driven.Switch, error)
|
listSwitchesFunc func(ctx context.Context) ([]driven.Switch, error)
|
||||||
turnOnLightFunc func(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error
|
turnOnLightFunc func(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error
|
||||||
turnOffLightFunc func(ctx context.Context, entityID string, transition *uint32) error
|
turnOffLightFunc func(ctx context.Context, entityID string, transition *uint32) error
|
||||||
toggleLightFunc func(ctx context.Context, entityID string) 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 {
|
type mockAIGateway struct {
|
||||||
@ -60,6 +63,27 @@ func (m *mockHAGateway) ToggleLight(ctx context.Context, entityID string) error
|
|||||||
return m.toggleLightFunc(ctx, entityID)
|
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) {
|
func (m *mockAIGateway) Query(ctx context.Context, text, model string) (string, string, error) {
|
||||||
if m.queryFunc == nil {
|
if m.queryFunc == nil {
|
||||||
return "", "", 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) {
|
func TestCommandAppAutocompleteLights(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@ -13,6 +13,12 @@ type HAGateway interface {
|
|||||||
TurnOffLight(ctx context.Context, entityID string, transition *uint32) error
|
TurnOffLight(ctx context.Context, entityID string, transition *uint32) error
|
||||||
// ToggleLight forwards a light toggle request to ha-gateway.
|
// ToggleLight forwards a light toggle request to ha-gateway.
|
||||||
ToggleLight(ctx context.Context, entityID string) error
|
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.
|
// Light is the discovery-oriented light view exposed by ha-gateway.
|
||||||
|
|||||||
@ -25,13 +25,13 @@ Implemented:
|
|||||||
- `LightService.TurnOff`
|
- `LightService.TurnOff`
|
||||||
- `LightService.Toggle`
|
- `LightService.Toggle`
|
||||||
- `LightService.ListLights`
|
- `LightService.ListLights`
|
||||||
|
- `SwitchService.TurnOn`
|
||||||
|
- `SwitchService.TurnOff`
|
||||||
|
- `SwitchService.Toggle`
|
||||||
- `SwitchService.ListSwitches`
|
- `SwitchService.ListSwitches`
|
||||||
|
|
||||||
Stubbed:
|
Stubbed:
|
||||||
|
|
||||||
- `SwitchService.TurnOn`
|
|
||||||
- `SwitchService.TurnOff`
|
|
||||||
- `SwitchService.Toggle`
|
|
||||||
- `EventService`
|
- `EventService`
|
||||||
|
|
||||||
The server also registers gRPC health checks and reflection.
|
The server also registers gRPC health checks and reflection.
|
||||||
@ -122,7 +122,6 @@ internal/telemetry/ # OpenTelemetry setup
|
|||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- Switch control RPCs return not implemented.
|
|
||||||
- `EventService` is registered but not implemented.
|
- `EventService` is registered but not implemented.
|
||||||
- Home Assistant WebSocket event streaming is still a TODO.
|
- Home Assistant WebSocket event streaming is still a TODO.
|
||||||
- Keep this service internal or protect it with mTLS; it does not implement
|
- Keep this service internal or protect it with mTLS; it does not implement
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
|
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"
|
"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
|
return &hav1.ListSwitchesResponse{Switches: out}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TurnOn is stubbed until switch control orchestration exists in the app layer.
|
// TurnOn maps a protobuf turn-on request into a domain entity ID.
|
||||||
// TODO: implement switch control RPCs — see plan.md for context
|
|
||||||
func (h *SwitchGRPC) TurnOn(ctx context.Context, req *hav1.SwitchRequest) (*hav1.SwitchResponse, error) {
|
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.
|
// TurnOff maps a protobuf turn-off request into a domain entity ID.
|
||||||
// TODO: implement switch control RPCs — see plan.md for context
|
|
||||||
func (h *SwitchGRPC) TurnOff(ctx context.Context, req *hav1.SwitchRequest) (*hav1.SwitchResponse, error) {
|
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.
|
// Toggle forwards a switch toggle request to the domain service.
|
||||||
// TODO: implement switch control RPCs — see plan.md for context
|
|
||||||
func (h *SwitchGRPC) Toggle(ctx context.Context, req *hav1.SwitchRequest) (*hav1.SwitchResponse, error) {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
285
ha-gateway/internal/adapters/primary/grpc/switch_test.go
Normal file
285
ha-gateway/internal/adapters/primary/grpc/switch_test.go
Normal 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
|
||||||
|
}
|
||||||
@ -57,6 +57,45 @@ func (a *SwitchApp) ListSwitches(ctx context.Context) ([]domain.Switch, error) {
|
|||||||
return c, nil
|
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.
|
// haStateToSwitch extracts the subset of attributes needed for switch discovery.
|
||||||
func haStateToSwitch(s *driven.HAState) domain.Switch {
|
func haStateToSwitch(s *driven.HAState) domain.Switch {
|
||||||
sw := domain.Switch{
|
sw := domain.Switch{
|
||||||
|
|||||||
297
ha-gateway/internal/app/switch_test.go
Normal file
297
ha-gateway/internal/app/switch_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -7,6 +7,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SwitchService interface {
|
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 returns cached switch metadata for discovery and UI use.
|
||||||
ListSwitches(ctx context.Context) ([]domain.Switch, error)
|
ListSwitches(ctx context.Context) ([]domain.Switch, error)
|
||||||
// Refresh repopulates the switch discovery cache from Home Assistant states.
|
// Refresh repopulates the switch discovery cache from Home Assistant states.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user