diff --git a/charger/homeassistant.go b/charger/homeassistant.go index c61b359ff..f6f8f0bbc 100644 --- a/charger/homeassistant.go +++ b/charger/homeassistant.go @@ -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 diff --git a/templates/definition/charger/homeassistant.yaml b/templates/definition/charger/homeassistant.yaml index 79d1b0318..cfb4235f6 100644 --- a/templates/definition/charger/homeassistant.yaml +++ b/templates/definition/charger/homeassistant.yaml @@ -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 }} diff --git a/templates/definition/vehicle/homeassistant.yaml b/templates/definition/vehicle/homeassistant.yaml index 2e8f2000d..00be34be4 100644 --- a/templates/definition/vehicle/homeassistant.yaml +++ b/templates/definition/vehicle/homeassistant.yaml @@ -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 }} diff --git a/util/homeassistant/connection.go b/util/homeassistant/connection.go index 3b0973eb7..e35ee29b6 100644 --- a/util/homeassistant/connection.go +++ b/util/homeassistant/connection.go @@ -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 diff --git a/util/homeassistant/connection_test.go b/util/homeassistant/connection_test.go index 934687f33..21c29b1e8 100644 --- a/util/homeassistant/connection_test.go +++ b/util/homeassistant/connection_test.go @@ -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 diff --git a/vehicle/homeassistant.go b/vehicle/homeassistant.go index b2946e52f..17ac68807 100644 --- a/vehicle/homeassistant.go +++ b/vehicle/homeassistant.go @@ -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) {