Home Assistant: configurable charge status values (BC) (#32136)
This commit is contained in:
parent
36a789b5c0
commit
c8b27650ca
6 changed files with 204 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"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,
|
||||
"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,
|
||||
"b": api.StatusB,
|
||||
"connected": api.StatusB,
|
||||
"plugged": api.StatusB,
|
||||
"starting": api.StatusB,
|
||||
"stopped": api.StatusB,
|
||||
"paused": api.StatusB,
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue