From 31d01bccd366a9d0a6e875c3afbe548f3000c8a3 Mon Sep 17 00:00:00 2001 From: thecem <46648579+thecem@users.noreply.github.com> Date: Fri, 1 Aug 2025 21:33:32 +0200 Subject: [PATCH] Add Home Assistant Vehicle (#22594) --- .../definition/vehicle/homeassistant.yaml | 118 +++++++++ vehicle/homeassistant.go | 239 ++++++++++++++++++ 2 files changed, 357 insertions(+) create mode 100644 templates/definition/vehicle/homeassistant.yaml create mode 100644 vehicle/homeassistant.go diff --git a/templates/definition/vehicle/homeassistant.yaml b/templates/definition/vehicle/homeassistant.yaml new file mode 100644 index 000000000..ea380458c --- /dev/null +++ b/templates/definition/vehicle/homeassistant.yaml @@ -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 }} diff --git a/vehicle/homeassistant.go b/vehicle/homeassistant.go new file mode 100644 index 000000000..889fa97f1 --- /dev/null +++ b/vehicle/homeassistant.go @@ -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/ 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) +}