Add Home Assistant Vehicle (#22594)

This commit is contained in:
thecem 2025-08-01 21:33:32 +02:00 • committed by GitHub
parent 5d0aad8a91
commit 31d01bccd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 357 additions and 0 deletions

View file

@ -0,0 +1,118 @@
template: homeassistant
products:
- description:
de: Home Assistant Fahrzeug
en: Home Assistant Vehicle
group: generic
requirements:
description:
en: Requires a running Home Assistant instance with suitable vehicle entities and services.
de: Erfordert eine laufende Home Assistant Instanz mit passenden Fahrzeug-Entities und Services.
params:
- description:
de: Home Assistant Entity IDs
en: Home Assistant entity IDs
help:
de: Alle Werte sind Home Assistant Entity IDs (z.B. sensor.*, binary_sensor.*, number.*, script.*)
en: All values are Home Assistant entity IDs (e.g. sensor.*, binary_sensor.*, number.*, script.*)
- name: uri
description:
de: Home Assistant URI
en: Home Assistant URI
help:
de: z.B. http://192.168.1.10:8123
en: e.g. http://192.168.1.10:8123
example: "http://192.168.1.10:8123"
required: true
- name: token
description:
de: Home Assistant Long-Lived Access Token
en: Home Assistant Long-Lived Access Token
required: true
mask: true
- name: soc
description:
de: Ladezustand [%]
en: State of charge [%]
example: "sensor.vehicle_soc"
type: string
required: true
- name: range
description:
de: Restreichweite [km]
en: Remaining range [km]
example: "sensor.vehicle_range"
type: string
- name: status
description:
de: Ladestatus
en: Charging status
example: "sensor.vehicle_charging"
type: string
- name: limitSoc
description:
de: Ziel-Ladezustand [%]
en: Target state of charge [%]
example: "number.vehicle_target_state_of_charge"
type: string
- name: odometer
description:
de: Kilometerstand [km]
en: Odometer [km]
example: "sensor.vehicle_odometer"
type: string
- name: climater
description:
de: Klimatisierung aktiv
en: Climatisation active
example: "binary_sensor.vehicle_climater"
type: string
- name: maxCurrent
description:
de: Maximalstrom [A]
en: Max current [A]
example: "sensor.vehicle_max_current"
type: string
- name: getMaxCurrent
description:
de: Aktueller Maximalstrom [A]
en: Actual max current [A]
example: "sensor.vehicle_get_max_current"
type: string
- name: finishTime
description:
de: Ladeende (ISO8601 oder Unix)
en: Finish time (ISO8601 or Unix)
example: "sensor.vehicle_finish_time"
type: string
- name: start_charging
description:
de: Service zum Laden starten
en: Service to start charging
example: "script.vehicle_start_charge"
type: string
- name: stop_charging
description:
de: Service zum Laden stoppen
en: Service to stop charging
example: "script.vehicle_stop_charge"
type: string
- name: wakeup
description:
de: Service zum Aufwecken
en: Service to wake up vehicle
example: "script.vehicle_wakeup"
type: string
render: |
sensors:
soc: {{ .soc }}
{{ if .range }}range: {{ .range }}{{ end }}
{{ if .status }}status: {{ .status }}{{ end }}
{{ if .limitSoc }}limitSoc: {{ .limitSoc }}{{ end }}
{{ if .odometer }}odometer: {{ .odometer }}{{ end }}
{{ if .climater }}climater: {{ .climater }}{{ end }}
{{ if .finishTime }}finishTime: {{ .finishTime }}{{ end }}
{{ if or .start_charging .stop_charging .wakeup }}services:
{{ if .start_charging }}start_charging: {{ .start_charging }}{{ end }}
{{ if .stop_charging }}stop_charging: {{ .stop_charging }}{{ end }}
{{ if .wakeup }}wakeup: {{ .wakeup }}{{ end }}{{ end }}

239
vehicle/homeassistant.go Normal file
View file

@ -0,0 +1,239 @@
package vehicle
import (
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
)
type HomeAssistant struct {
*embed
*request.Helper
uri string
soc string
}
// Register on startup
func init() {
registry.Add("homeassistant", NewHomeAssistantVehicleFromConfig)
}
// Constructor from YAML config
func NewHomeAssistantVehicleFromConfig(other map[string]any) (api.Vehicle, error) {
var cc struct {
embed `mapstructure:",squash"`
URI string
Token string
Sensors struct {
Soc string // required
Range string // optional
Status string // optional
LimitSoc string // optional
Odometer string // optional
Climater string // optional
FinishTime string // optional
}
Services struct {
Start string `mapstructure:"start_charging"` // script.* optional
Stop string `mapstructure:"stop_charging"` // script.* optional
Wakeup string // script.* optional
}
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
switch {
case cc.URI == "":
return nil, errors.New("missing uri")
case cc.Token == "":
return nil, errors.New("missing token")
case cc.Sensors.Soc == "":
return nil, errors.New("missing soc sensor")
}
res := &HomeAssistant{
embed: &cc.embed,
Helper: request.NewHelper(util.NewLogger("ha-vehicle").Redact(cc.Token)),
uri: strings.TrimSuffix(cc.URI, "/"),
soc: cc.Sensors.Soc,
}
res.Client.Transport = &transport.Decorator{
Base: res.Client.Transport,
Decorator: transport.DecorateHeaders(map[string]string{
"Authorization": "Bearer " + cc.Token,
}),
}
// prepare optional feature functions with concise names
var (
limitSoc func() (int64, error)
status func() (api.ChargeStatus, error)
rng func() (int64, error)
odo func() (float64, error)
climater func() (bool, error)
finish func() (time.Time, error)
chargeEnable func(bool) error
wakeup func() error
)
if cc.Sensors.LimitSoc != "" {
limitSoc = func() (int64, error) { return res.getIntSensor(cc.Sensors.LimitSoc) }
}
if cc.Sensors.Status != "" {
status = func() (api.ChargeStatus, error) { return res.status(cc.Sensors.Status) }
}
if cc.Sensors.Range != "" {
rng = func() (int64, error) { return res.getIntSensor(cc.Sensors.Range) }
}
if cc.Sensors.Odometer != "" {
odo = func() (float64, error) { return res.getFloatSensor(cc.Sensors.Odometer) }
}
if cc.Sensors.Climater != "" {
climater = func() (bool, error) { return res.getBoolSensor(cc.Sensors.Climater) }
}
if cc.Sensors.FinishTime != "" {
finish = func() (time.Time, error) { return res.getTimeSensor(cc.Sensors.FinishTime) }
}
if cc.Services.Start != "" && cc.Services.Stop != "" {
chargeEnable = func(enable bool) error {
if enable {
return res.callScript(cc.Services.Start)
}
return res.callScript(cc.Services.Stop)
}
}
if cc.Services.Wakeup != "" {
wakeup = func() error { return res.callScript(cc.Services.Wakeup) }
}
// decorate all features
return decorateVehicle(
res,
limitSoc,
status,
rng,
odo,
climater,
nil, // maxCurrent setter not implemented
nil, // getMaxCurrent getter not implemented
finish,
wakeup,
chargeEnable,
), nil
}
func (v *HomeAssistant) Soc() (float64, error) {
return v.getFloatSensor(v.soc)
}
// Calls /api/states/<entity> and returns .state
func (v *HomeAssistant) getState(entity string) (string, error) {
var res struct {
State string `json:"state"`
}
uri := fmt.Sprintf("%s/api/states/%s", v.uri, url.PathEscape(entity))
if err := v.GetJSON(uri, &res); err != nil {
return "", err
}
if res.State == "unknown" || res.State == "unavailable" {
return "", api.ErrNotAvailable
}
return res.State, nil
}
func (v *HomeAssistant) callScript(script string) error {
// All configured services are scripts, so always use script.turn_on
payload := fmt.Sprintf(`{"entity_id": "%s"}`, script)
uri := fmt.Sprintf("%s/api/services/script/turn_on", v.uri)
req, _ := request.New(http.MethodPost, uri, strings.NewReader(payload))
_, err := v.DoBody(req)
return err
}
// generic helpers for fetching and parsing sensor values
func (v *HomeAssistant) getFloatSensor(entity string) (float64, error) {
s, err := v.getState(entity)
if err != nil {
return 0, err
}
return strconv.ParseFloat(s, 64)
}
func (v *HomeAssistant) getIntSensor(entity string) (int64, error) {
s, err := v.getState(entity)
if err != nil {
return 0, err
}
return strconv.ParseInt(s, 10, 64)
}
func (v *HomeAssistant) getBoolSensor(entity string) (bool, error) {
s, err := v.getState(entity)
if err != nil {
return false, err
}
return slices.Contains([]string{"on", "true", "1", "active"}, strings.ToLower(s)), nil
}
func (v *HomeAssistant) getTimeSensor(entity string) (time.Time, error) {
s, err := v.getState(entity)
if err != nil {
return time.Time{}, err
}
if ts, err := strconv.ParseInt(s, 10, 64); err == nil {
return time.Unix(ts, 0), nil
}
return time.Parse(time.RFC3339, s)
}
// status returns evcc charge status (optional, private)
func (v *HomeAssistant) status(sensor string) (api.ChargeStatus, error) {
var haStatusMap = map[string]api.ChargeStatus{
"charging": api.StatusC,
"on": api.StatusC,
"true": api.StatusC,
"active": api.StatusC,
"connected": api.StatusB,
"ready": api.StatusB,
"plugged": api.StatusB,
"disconnected": api.StatusA,
"off": api.StatusA,
"none": api.StatusA,
"unavailable": api.StatusA,
"unknown": api.StatusA,
"notreadyforcharging": api.StatusA,
}
s, err := v.getState(sensor)
if err != nil {
return api.StatusNone, err
}
state := strings.ToLower(s)
if mapped, ok := haStatusMap[state]; ok {
return mapped, nil
}
return api.StatusNone, errors.New("invalid state: " + s)
}