Add Home Assistant Vehicle (#22594)
This commit is contained in:
parent
5d0aad8a91
commit
31d01bccd3
2 changed files with 357 additions and 0 deletions
118
templates/definition/vehicle/homeassistant.yaml
Normal file
118
templates/definition/vehicle/homeassistant.yaml
Normal 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
239
vehicle/homeassistant.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue