From bd651da8ff44cf685bdd6536d795b4d5a8e7c045 Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 6 May 2026 15:41:07 +0200 Subject: [PATCH] HomeAssistant: dispatch CallSwitchService by entity domain (#29700) (#29706) --- util/homeassistant/connection.go | 20 ++++++-- util/homeassistant/connection_test.go | 67 +++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 util/homeassistant/connection_test.go diff --git a/util/homeassistant/connection.go b/util/homeassistant/connection.go index 1b7f6456e..7b3d0105c 100644 --- a/util/homeassistant/connection.go +++ b/util/homeassistant/connection.go @@ -233,16 +233,28 @@ func domain(entity string) (string, error) { return domain, nil } -// CallSwitchService is a convenience method for switch services +// CallSwitchService is a convenience method for switch-like services. The +// service name depends on the entity domain: stateless button domains expose +// only `press`, while switch-style domains use `turn_on` / `turn_off`. func (c *Connection) CallSwitchService(entity string, turnOn bool) error { domain, err := domain(entity) if err != nil { return err } - service := "turn_off" - if turnOn { - service = "turn_on" + var service string + switch domain { + case "button", "input_button": + // Buttons are stateless — they only have a press action. + if !turnOn { + return fmt.Errorf("entity %s (domain %s) has no off action", entity, domain) + } + service = "press" + default: + service = "turn_off" + if turnOn { + service = "turn_on" + } } data := map[string]any{ diff --git a/util/homeassistant/connection_test.go b/util/homeassistant/connection_test.go new file mode 100644 index 000000000..e801ecac1 --- /dev/null +++ b/util/homeassistant/connection_test.go @@ -0,0 +1,67 @@ +package homeassistant + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestConnection(baseURL string) *Connection { + return &Connection{ + Helper: request.NewHelper(util.NewLogger("test")), + instance: &proxyInstance{uri: baseURL}, + } +} + +// TestCallSwitchService_DomainDispatch verifies that CallSwitchService picks +// the correct service per Home Assistant domain — switches use turn_on / +// turn_off, but the stateless button / input_button domains expose only +// `press`. Regression test for evcc-io/evcc#29700. +func TestCallSwitchService_DomainDispatch(t *testing.T) { + tests := []struct { + name string + entity string + turnOn bool + wantPath string + wantErrText string + }{ + {"switch turn_on", "switch.foo", true, "/api/services/switch/turn_on", ""}, + {"switch turn_off", "switch.foo", false, "/api/services/switch/turn_off", ""}, + {"button press", "button.tesla_model_x_wake_up", true, "/api/services/button/press", ""}, + {"input_button press", "input_button.bar", true, "/api/services/input_button/press", ""}, + {"button no off", "button.foo", false, "", "entity button.foo (domain button) has no off action"}, + {"input_button no off", "input_button.bar", false, "", "entity input_button.bar (domain input_button) has no off action"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var gotPath, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + body, _ := io.ReadAll(r.Body) + gotBody = string(body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + err := newTestConnection(srv.URL).CallSwitchService(tc.entity, tc.turnOn) + + if tc.wantErrText != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErrText) + assert.Empty(t, gotPath, "must not call HA when erroring locally") + return + } + + require.NoError(t, err) + assert.Equal(t, tc.wantPath, gotPath) + assert.Contains(t, gotBody, `"entity_id":"`+tc.entity+`"`) + }) + } +}