Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion charger/homeassistant.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type HomeAssistant struct {
implement.Caps
conn *homeassistant.Connection
status string
states homeassistant.StatusMap
enabled string
enable string
maxcurrent string
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions templates/definition/charger/homeassistant.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -165,6 +192,9 @@ render: |
uri: {{ .uri }}
insecure: {{ .insecure }}
status: {{ .status }}
statusA: {{ .statusA }}
statusB: {{ .statusB }}
statusC: {{ .statusC }}
enabled: {{ .enabled }}
enable: {{ .enable }}
maxcurrent: {{ .setMaxCurrent }}
Expand Down
30 changes: 30 additions & 0 deletions templates/definition/vehicle/homeassistant.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 [%]
Expand Down Expand Up @@ -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 }}
Expand Down
81 changes: 49 additions & 32 deletions util/homeassistant/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
"complete": api.StatusB,
"charging_completed": api.StatusB,
"c": api.StatusC,
"charging": api.StatusC,
}

// 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,
// 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
func (c *Connection) GetChargeStatus(entity string) (api.ChargeStatus, error) {
// 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
Expand Down
74 changes: 74 additions & 0 deletions util/homeassistant/connection_test.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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)
Comment thread
andig marked this conversation as resolved.
}

// 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
Expand Down
10 changes: 9 additions & 1 deletion vehicle/homeassistant.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Loading