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

@ -16,6 +16,7 @@ type HomeAssistant struct {
implement.Caps
conn *homeassistant.Connection
status string
states homeassistant.StatusMap
enabled string
enable string
maxcurrent string
@ -30,6 +31,9 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Charger, error) {
var cc struct {
homeassistant.Config `mapstructure:",squash"`
Status string // required - sensor for charge status
StatusA string // optional - custom states mapped to status A
StatusB string // optional - custom states mapped to status B
StatusC string // optional - custom states mapped to status C
Enabled string // required - sensor for enabled state
Enable string // required - switch/input_boolean for enable/disable
MaxCurrent string // required - number entity for setting max current
@ -57,6 +61,11 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Charger, error) {
return nil, errors.New("missing maxcurrent number entity")
}
states, err := homeassistant.NewStatusMap(cc.StatusA, cc.StatusB, cc.StatusC)
if err != nil {
return nil, err
}
log := util.NewLogger("ha-charger")
conn, err := cc.Config.NewConnection(log)
@ -68,6 +77,7 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Charger, error) {
Caps: implement.New(),
conn: conn,
status: cc.Status,
states: states,
enabled: cc.Enabled,
enable: cc.Enable,
maxcurrent: cc.MaxCurrent,
@ -116,7 +126,7 @@ var _ api.Charger = (*HomeAssistant)(nil)
// Status implements the api.ChargeState interface
func (c *HomeAssistant) Status() (api.ChargeStatus, error) {
return c.conn.GetChargeStatus(c.status)
return c.conn.GetChargeStatus(c.status, c.states)
}
// Enabled implements the api.Charger interface

View file

@ -34,6 +34,33 @@ params:
help:
en: Entity ID for charging status (A=ready, B=connected, C=charging)
de: Entitäts-ID für Ladestatus (A=bereit, B=verbunden, C=laden)
- name: statusA
description:
de: Zustände für Status A
en: States for status A
example: not_plugged, disconnected
advanced: true
help:
en: Comma-separated list of additional states meaning ready
de: Komma-getrennte Liste zusätzlicher Zustände für bereit
- name: statusB
description:
de: Zustände für Status B
en: States for status B
example: charging_stopped, charging_completed
advanced: true
help:
en: Comma-separated list of additional states meaning connected
de: Komma-getrennte Liste zusätzlicher Zustände für verbunden
- name: statusC
description:
de: Zustände für Status C
en: States for status C
example: instant_charging, fast_charging
advanced: true
help:
en: Comma-separated list of additional states meaning charging
de: Komma-getrennte Liste zusätzlicher Zustände für laden
- name: enabled
description:
de: Aktivierungsstatus-Sensor
@ -165,6 +192,9 @@ render: |
uri: {{ .uri }}
insecure: {{ .insecure }}
status: {{ .status }}
statusA: {{ .statusA }}
statusB: {{ .statusB }}
statusC: {{ .statusC }}
enabled: {{ .enabled }}
enable: {{ .enable }}
maxcurrent: {{ .setMaxCurrent }}

View file

@ -54,6 +54,33 @@ params:
help:
en: Entity ID for charging status (A=disconnected, B=connected, C=charging)
de: Entitäts-ID für Ladestatus (A=getrennt, B=verbunden, C=laden)
- name: statusA
description:
de: Zustände für Status A
en: States for status A
example: not_plugged, disconnected
advanced: true
help:
en: Comma-separated list of additional states meaning disconnected
de: Komma-getrennte Liste zusätzlicher Zustände für getrennt
- name: statusB
description:
de: Zustände für Status B
en: States for status B
example: charging_stopped, charging_completed
advanced: true
help:
en: Comma-separated list of additional states meaning connected
de: Komma-getrennte Liste zusätzlicher Zustände für verbunden
- name: statusC
description:
de: Zustände für Status C
en: States for status C
example: instant_charging, fast_charging
advanced: true
help:
en: Comma-separated list of additional states meaning charging
de: Komma-getrennte Liste zusätzlicher Zustände für laden
- name: limitSoc
description:
de: Ziel-Ladezustand [%]
@ -144,6 +171,9 @@ render: |
odometer: {{ .odometer }}
climater: {{ .climater }}
finishTime: {{ .finishTime }}
statusA: {{ .statusA }}
statusB: {{ .statusB }}
statusC: {{ .statusC }}
services:
start_charging: {{ .start_charging }}
stop_charging: {{ .stop_charging }}

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

View file

@ -37,6 +37,9 @@ func NewHomeAssistantVehicleFromConfig(other map[string]any) (api.Vehicle, error
Climater string // optional
FinishTime string // optional
}
StatusA string // optional - custom states mapped to status A
StatusB string // optional - custom states mapped to status B
StatusC string // optional - custom states mapped to status C
Services struct {
Start string `mapstructure:"start_charging"` // script.* or switch.* optional
Stop string `mapstructure:"stop_charging"` // script.* optional
@ -74,7 +77,12 @@ func NewHomeAssistantVehicleFromConfig(other map[string]any) (api.Vehicle, error
}))
}
if cc.Sensors.Status != "" {
implement.Has(res, implement.ChargeState(func() (api.ChargeStatus, error) { return conn.GetChargeStatus(cc.Sensors.Status) }))
states, err := homeassistant.NewStatusMap(cc.StatusA, cc.StatusB, cc.StatusC)
if err != nil {
return nil, err
}
implement.Has(res, implement.ChargeState(func() (api.ChargeStatus, error) { return conn.GetChargeStatus(cc.Sensors.Status, states) }))
}
if cc.Sensors.Range != "" {
implement.Has(res, implement.VehicleRange(func() (int64, error) {