Home Assistant: configurable charge status values (BC) (#32136)

This commit is contained in:
andig 2026-07-25 12:14:42 +02:00 • committed by GitHub
parent 36a789b5c0
commit c8b27650ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 204 additions and 35 deletions

View file

@ -167,55 +167,72 @@ func (c *Connection) GetTimeState(entity string) (time.Time, error) {
return time.Parse(time.RFC3339, state.State)
}
// chargeStatusMap maps Home Assistant states to EVCC charge status
// chargeStatusMap maps unambiguous Home Assistant states to evcc charge status.
// Vendor-specific states are configured per device, see NewStatusMap.
var chargeStatusMap = map[string]api.ChargeStatus{
// Status C - Charging
"c": api.StatusC,
"charging": api.StatusC,
"on": api.StatusC,
"true": api.StatusC,
"active": api.StatusC,
"1": api.StatusC,
// Status B - Connected/Ready
"a": api.StatusA,
"disconnected": api.StatusA,
"not_plugged": api.StatusA,
"b": api.StatusB,
"connected": api.StatusB,
"ready": api.StatusB,
"plugged": api.StatusB,
"charging_completed": api.StatusB,
"initialising": api.StatusB,
"preparing": api.StatusB,
"2": api.StatusB,
"no_power": api.StatusB,
"complete": api.StatusB,
"stopped": api.StatusB,
"starting": api.StatusB,
"stopped": api.StatusB,
"paused": api.StatusB,
// Status A - Disconnected
"a": api.StatusA,
"disconnected": api.StatusA,
"off": api.StatusA,
"none": api.StatusA,
"unavailable": api.StatusA,
"unknown": api.StatusA,
"notreadyforcharging": api.StatusA,
"not_plugged": api.StatusA,
"0": api.StatusA,
"complete": api.StatusB,
"charging_completed": api.StatusB,
"c": api.StatusC,
"charging": api.StatusC,
}
// GetChargeStatus maps Home Assistant states to api.ChargeStatus
func (c *Connection) GetChargeStatus(entity string) (api.ChargeStatus, error) {
// StatusMap maps device-specific Home Assistant states to evcc charge status
type StatusMap map[string]api.ChargeStatus
// NewStatusMap creates a status map from comma-separated, case-insensitive lists
// of states. It extends the built-in mapping, overriding it only for states
// explicitly mapped to a different status.
func NewStatusMap(a, b, c string) (StatusMap, error) {
res := make(StatusMap)
for _, e := range []struct {
status api.ChargeStatus
states string
}{
{api.StatusA, a},
{api.StatusB, b},
{api.StatusC, c},
} {
for _, s := range strings.Split(e.states, ",") {
if s = strings.ToLower(strings.TrimSpace(s)); s != "" {
if status, ok := res[s]; ok {
return nil, fmt.Errorf("status %s: duplicate state '%s', already mapped to %s", e.status, s, status)
}
res[s] = e.status
}
}
}
return res, nil
}
// GetChargeStatus maps Home Assistant states to api.ChargeStatus. The
// device-specific status map extends the built-in mapping and takes precedence.
func (c *Connection) GetChargeStatus(entity string, states StatusMap) (api.ChargeStatus, error) {
state, err := c.GetState(entity)
if err != nil {
return api.StatusNone, err
}
if status, ok := chargeStatusMap[strings.ToLower(strings.TrimSpace(state.State))]; ok {
s := strings.ToLower(strings.TrimSpace(state.State))
if status, ok := states[s]; ok {
return status, nil
}
if status, ok := chargeStatusMap[s]; ok {
return status, nil
}
return api.StatusNone, fmt.Errorf("unknown charge status: %s", state)
return api.StatusNone, fmt.Errorf("unknown charge status '%s' for entity %s", state.State, entity)
}
// CallService calls a Home Assistant service

View file

@ -1,11 +1,13 @@
package homeassistant
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/stretchr/testify/assert"
@ -19,6 +21,78 @@ func newTestConnection(baseURL string) *Connection {
}
}
// newStateConnection returns a connection serving state for any entity
func newStateConnection(t *testing.T, state string) *Connection {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"entity_id":"sensor.foo","state":%q}`, state)
}))
t.Cleanup(srv.Close)
return newTestConnection(srv.URL)
}
func TestGetChargeStatus(t *testing.T) {
states, err := NewStatusMap("not_plugged", "Charging_Stopped, charging_error", "instant_charging")
require.NoError(t, err)
tests := []struct {
state string
want api.ChargeStatus
}{
{"A", api.StatusA},
{"connected", api.StatusB},
{" charging ", api.StatusC},
{"not_plugged", api.StatusA},
{"CHARGING_STOPPED", api.StatusB},
{"charging_error", api.StatusB},
{"instant_charging", api.StatusC},
{"paused", api.StatusB},
{"preparing", api.StatusNone}, // no longer built-in
}
for _, tc := range tests {
t.Run(tc.state, func(t *testing.T) {
status, err := newStateConnection(t, tc.state).GetChargeStatus("sensor.foo", states)
assert.Equal(t, tc.want, status)
if tc.want == api.StatusNone {
assert.ErrorContains(t, err, "unknown charge status '"+tc.state+"' for entity sensor.foo")
} else {
assert.NoError(t, err)
}
})
}
}
func TestNewStatusMapDuplicate(t *testing.T) {
_, err := NewStatusMap("foo", "foo", "")
assert.Error(t, err)
}
// TestStatusMapExtendsBuiltin verifies that configured states extend the
// built-in mapping and only override the states they explicitly redefine.
func TestStatusMapExtendsBuiltin(t *testing.T) {
states, err := NewStatusMap("", "charging", "instant_charging")
require.NoError(t, err)
tests := []struct {
state string
want api.ChargeStatus
}{
{"charging", api.StatusB}, // redefined
{"paused", api.StatusB}, // built-in, untouched
{"c", api.StatusC}, // built-in, untouched
{"instant_charging", api.StatusC}, // added
}
for _, tc := range tests {
t.Run(tc.state, func(t *testing.T) {
status, err := newStateConnection(t, tc.state).GetChargeStatus("sensor.foo", states)
require.NoError(t, err)
assert.Equal(t, tc.want, status)
})
}
}
// 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