HomeAssistant: add auto-discovery (#25141)

This commit is contained in:
andig 2025-11-23 10:49:39 +01:00 • committed by GitHub
parent c33a6c20e0
commit ef096a236f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 764 additions and 119 deletions

View file

@ -11,7 +11,6 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/hems/shm"
"github.com/evcc-io/evcc/plugin/mqtt"
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/server/eebus"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
@ -137,10 +136,15 @@ type DB struct {
}
type Messaging struct {
Events map[string]push.EventTemplateConfig
Events map[string]MessagingEventTemplate
Services []config.Typed
}
// MessagingEventTemplate is the push message configuration for an event
type MessagingEventTemplate struct {
Title, Msg string
}
func (c Messaging) Configured() bool {
return len(c.Services) > 0 || len(c.Events) > 0
}

View file

@ -584,6 +584,36 @@ input[type="time"]::-webkit-calendar-picker-indicator {
display: none;
}
input[list]::-webkit-list-button {
display: none !important;
appearance: none !important;
}
.form-control-clear {
position: absolute;
right: 0.75rem;
top: 0;
bottom: 0;
justify-content: center;
display: flex;
align-items: center;
justify-self: center;
width: 1.1rem;
font-size: 1.5rem;
font-weight: normal;
color: var(--evcc-default-text);
border: none;
background-color: transparent;
padding: 0;
margin: 0;
cursor: pointer;
}
/* hide dropdown arrow if clear is visible */
.form-select.has-clear-button {
background-image: none;
}
.table {
--bs-table-bg: transparent;
}

View file

@ -52,6 +52,7 @@
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
:service-values="serviceValues[param.Name]"
/>
<p v-if="authError" class="text-danger">{{ authError }}</p>
<div class="d-flex justify-content-end">
@ -109,6 +110,7 @@
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
:service-values="serviceValues[param.Name]"
/>
<PropertyCollapsible>
@ -119,6 +121,7 @@
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
:service-values="serviceValues[param.Name]"
/>
</template>
<template v-if="$slots['collapsible-more']" #more>
@ -160,7 +163,7 @@ import { initialTestState, performTest } from "../utils/test";
import sleep from "@/utils/sleep";
import { extractDomain } from "@/utils/extractDomain";
import { ConfigType } from "@/types/evcc";
import type { DeviceType } from "@/types/evcc";
import type { DeviceType, Timeout } from "@/types/evcc";
import {
handleError,
type DeviceValues,
@ -172,6 +175,7 @@ import {
type ApiData,
applyDefaultsFromTemplate,
createDeviceUtils,
fetchServiceValues,
} from "./index";
const CUSTOM_FIELDS = ["modbus"];
@ -248,6 +252,8 @@ export default defineComponent({
loadingTemplate: false,
values: { ...this.initialValues } as DeviceValues,
test: initialTestState(),
serviceValues: {} as Record<string, string[]>,
serviceValuesTimer: null as Timeout | null,
};
},
computed: {
@ -423,6 +429,8 @@ export default defineComponent({
if (!isYamlInput) {
this.loadTemplate();
}
this.updateServiceValues();
},
usage() {
// Reload products when usage changes (e.g., meter type selection)
@ -446,9 +454,22 @@ export default defineComponent({
values: {
handler() {
this.test = initialTestState();
this.updateServiceValues();
},
deep: true,
},
authValues: {
handler() {
if (this.authRequired) {
this.resetAuthStatus();
}
},
deep: true,
},
authRequired() {
// update on auth state change
this.updateServiceValues();
},
},
methods: {
reset() {
@ -515,9 +536,12 @@ export default defineComponent({
}
this.loadingTemplate = false;
},
async checkAuthStatus() {
resetAuthStatus() {
this.authOk = false;
this.authProviderUrl = null;
},
async checkAuthStatus() {
this.resetAuthStatus();
// no auth required
if (!this.template?.Auth) return;
@ -532,8 +556,6 @@ export default defineComponent({
// validate data
if (this.authValuesMissing) return;
this.authOk = false;
this.authProviderUrl = null;
const { type } = this.template.Auth;
const values = this.authValues;
this.authLoading = true;
@ -672,6 +694,14 @@ export default defineComponent({
}
return value === ConfigType.Custom;
},
async updateServiceValues() {
if (this.serviceValuesTimer) {
clearTimeout(this.serviceValuesTimer);
}
this.serviceValuesTimer = setTimeout(async () => {
this.serviceValues = await fetchServiceValues(this.templateParams, this.values);
}, 500);
},
},
});
</script>

View file

@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { createServiceEndpoints, type TemplateParam } from "./index";
const buildParam = (name: string, service?: string): TemplateParam => ({
Name: name,
Required: false,
Advanced: false,
Deprecated: false,
Service: service,
});
describe("createServiceEndpoints", () => {
it("skips params without service", () => {
const params = [buildParam("home", "homes"), buildParam("power", "homes/{home}/sensors")];
const endpoints = createServiceEndpoints(params);
expect(endpoints.map((endpoint) => endpoint.name)).toEqual(["home", "power"]);
});
it("replaces single placeholder", () => {
const params = [buildParam("home", "homes"), buildParam("power", "homes/{home}/sensors")];
const endpoints = createServiceEndpoints(params);
const homeEndpoint = endpoints.find(({ name }) => name === "home")!;
const powerEndpoint = endpoints.find(({ name }) => name === "power")!;
expect(homeEndpoint.dependencies).toEqual([]);
expect(homeEndpoint.url({})).toBe("homes");
expect(powerEndpoint.dependencies).toEqual(["home"]);
expect(powerEndpoint.url({ home: "main" })).toBe("homes/main/sensors");
expect(powerEndpoint.url({ home: "with space" })).toBe("homes/with%20space/sensors");
expect(powerEndpoint.url({} as Record<string, string>)).toBe("homes/{home}/sensors");
});
it("replaces multiple placeholders", () => {
const params = [
buildParam("home", "homes"),
buildParam("sensor", "homes/{home}/sensors/{sensor}"),
];
const endpoints = createServiceEndpoints(params);
const sensorEndpoint = endpoints.find(({ name }) => name === "sensor")!;
expect(sensorEndpoint.dependencies).toEqual(["home", "sensor"]);
expect(sensorEndpoint.url({ home: "hq", sensor: "battery" })).toBe("homes/hq/sensors/battery");
});
it("encodes replacements", () => {
const params = [buildParam("token", "homes/{home}/sensors/{sensor}?token={token}")];
const endpoints = createServiceEndpoints(params);
const tokenEndpoint = endpoints[0]!;
expect(tokenEndpoint.url({ home: "hq", sensor: "bat/tery", token: "a+b c" })).toBe(
"homes/hq/sensors/bat%2Ftery?token=a%2Bb%20c"
);
expect(tokenEndpoint.url({} as Record<string, string>)).toBe(
"homes/{home}/sensors/{sensor}?token={token}"
);
});
});

View file

@ -1,6 +1,7 @@
import type { DeviceType, MODBUS_COMSET, MeterTemplateUsage } from "@/types/evcc";
import { ConfigType } from "@/types/evcc";
import api, { baseApi } from "@/api";
import { extractPlaceholders, replacePlaceholders } from "@/utils/placeholder";
export type Product = {
group: string;
@ -28,9 +29,16 @@ export type TemplateParam = {
Deprecated: boolean;
Default?: string | number | boolean;
Choice?: string[];
Service?: string;
Usages?: TemplateParamUsage[];
};
export type ParamService = {
name: string;
dependencies: string[];
url: (values: Record<string, any>) => string;
};
export type ModbusCapability = "rs485" | "tcpip";
export type ModbusParam = TemplateParam & {
@ -106,6 +114,68 @@ export function customChargerName(type: ConfigType, isHeating: boolean) {
return `${prefix}${type}`;
}
export async function loadServiceValues(path: string) {
try {
const response = await api.get(`/config/service/${path}`);
return response.data as string[];
} catch (e) {
console.error(e);
return [];
}
}
export const createServiceEndpoints = (params: TemplateParam[]): ParamService[] => {
return params
.map((param) => {
if (!param.Service) {
return null;
}
const stringValues = (values: Record<string, any>): Record<string, string> =>
Object.entries(values).reduce(
(acc, [key, val]) => {
if (val !== undefined && val !== null) acc[key] = String(val);
return acc;
},
{} as Record<string, string>
);
return {
name: param.Name,
dependencies: extractPlaceholders(param.Service),
url: (values: Record<string, any>) =>
replacePlaceholders(param.Service!, stringValues(values)),
} as ParamService;
})
.filter((endpoint): endpoint is ParamService => endpoint !== null);
};
export const fetchServiceValues = async (
templateParams: TemplateParam[],
values: DeviceValues
): Promise<Record<string, string[]>> => {
const endpoints = createServiceEndpoints(templateParams);
const result: Record<string, string[]> = {};
await Promise.all(
endpoints.map(async (endpoint) => {
const params: Record<string, any> = {};
endpoint.dependencies.forEach((dependency) => {
if (values[dependency]) {
params[dependency] = values[dependency];
}
});
if (Object.keys(params).length !== endpoint.dependencies.length) {
// missing dependency values, skip
return;
}
const url = endpoint.url(params);
result[endpoint.name] = await loadServiceValues(url);
})
);
return result;
};
export function createDeviceUtils(deviceType: DeviceType) {
function test(id: number | undefined, data: any) {
let url = `config/test/${deviceType}`;
@ -201,6 +271,7 @@ export function createDeviceUtils(deviceType: DeviceType) {
create,
loadProducts,
loadTemplate,
loadServiceValues,
checkAuth,
getAuthProviderUrl,
};

View file

@ -17,6 +17,7 @@
:unit="Unit"
:required="Required"
:choice="Choice"
:service-values="serviceValues"
:label="label"
/>
</FormRow>
@ -42,6 +43,7 @@ export default {
Unit: String,
Mask: Boolean,
Choice: Array,
serviceValues: Array,
modelValue: [String, Number, Boolean, Object],
},
emits: ["update:modelValue"],

View file

@ -77,18 +77,27 @@
:required="required"
rows="4"
/>
<input
v-else
:id="id"
v-model="value"
class="form-control"
:class="inputClasses"
:type="inputType"
:step="step"
:placeholder="placeholder"
:required="required"
:autocomplete="masked ? 'off' : null"
/>
<div v-else class="position-relative">
<input
:id="id"
v-model="value"
:list="datalistId"
:class="`${datalistId && serviceValues.length > 0 ? 'form-select' : 'form-control'} ${inputClasses}`"
:type="inputType"
:step="step"
:placeholder="placeholder"
:required="required"
:autocomplete="masked || datalistId ? 'off' : null"
/>
<button v-if="showClearButton" type="button" class="form-control-clear" @click="value = ''">
&times;
</button>
<datalist v-if="showDatalist" :id="datalistId">
<option v-for="v in serviceValues" :key="v" :value="v">
{{ v }}
</option>
</datalist>
</div>
</template>
<script>
@ -117,12 +126,28 @@ export default {
choice: { type: Array, default: () => [] },
modelValue: [String, Number, Boolean, Object],
label: String,
serviceValues: { type: Array, default: () => [] },
},
emits: ["update:modelValue"],
data: () => {
return { selectMode: false };
},
computed: {
datalistId() {
return this.serviceValues.length > 0 ? `${this.id}-datalist` : null;
},
showDatalist() {
if (!this.datalistId) return false;
const length = this.serviceValues.length;
// no values
if (length === 0) return false;
// value selected, dont offer single same option again
if (this.value && this.serviceValues.includes(this.value)) return false;
return true;
},
showClearButton() {
return this.datalistId && this.value;
},
inputType() {
if (this.masked) {
return "password";
@ -146,6 +171,9 @@ export default {
if (this.invalid) {
result += " is-invalid";
}
if (this.showClearButton) {
result += " has-clear-button";
}
return result;
},
endAlign() {

View file

@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { extractPlaceholders as extract, replacePlaceholders as replace } from "./placeholder";
describe("placeholder", () => {
it("extract", () => {
expect(extract("homes")).toEqual([]);
expect(extract("")).toEqual([]);
expect(extract("a/{home}/b")).toEqual(["home"]);
expect(extract("{a}/{b}")).toEqual(["a", "b"]);
expect(extract("{p1}/{p2}")).toEqual(["p1", "p2"]);
expect(extract("{a_b}/{c_d}")).toEqual(["a_b", "c_d"]);
expect(extract("{a}/{a}")).toEqual(["a", "a"]);
});
it("replace", () => {
expect(replace("homes", {})).toBe("homes");
expect(replace("a/{b}/c", { b: "x" })).toBe("a/x/c");
expect(replace("{a}/{b}", { a: "1", b: "2" })).toBe("1/2");
expect(replace("{a}", { a: "b c" })).toBe("b%20c");
expect(replace("{a}", { a: "b/c" })).toBe("b%2Fc");
expect(replace("{a}", { a: "b+c" })).toBe("b%2Bc");
expect(replace("{a}", {})).toBe("{a}");
expect(replace("{a}", { a: "" })).toBe("");
expect(replace("{a}/{b}", { a: "x" })).toBe("x/{b}");
});
});

View file

@ -0,0 +1,20 @@
/** Matches {placeholder} names (letters, numbers, underscores) */
const PLACEHOLDER_PATTERN = /\{(\w+)\}/g;
/** Extract {placeholder} names from template */
export function extractPlaceholders(template: string): string[] {
const matches = template.match(PLACEHOLDER_PATTERN);
if (!matches) return [];
return matches.map((match) => match.slice(1, -1));
}
/** Replace {placeholders} with URL-encoded values */
export function replacePlaceholders(template: string, values: Record<string, string>): string {
return template.replace(PLACEHOLDER_PATTERN, (match, key) => {
const value = values[key];
if (value === undefined) {
return match;
}
return encodeURIComponent(value);
});
}

View file

@ -20,23 +20,26 @@ func init() {
}
func NewHomeAssistantSwitchFromConfig(other map[string]any) (api.Charger, error) {
var cc struct {
cc := struct {
embed `mapstructure:",squash"`
URI string
Token string
_URI string `mapstructure:"uri"` // TODO deprecated
_Token string `mapstructure:"token"` // TODO deprecated
Home string
Enable string
Power string
StandbyPower float64
}{
Home: "Home",
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
return NewHomeAssistantSwitch(cc.embed, cc.URI, cc.Token, cc.Enable, cc.Power, cc.StandbyPower)
return NewHomeAssistantSwitch(cc.embed, cc.Home, cc.Enable, cc.Power, cc.StandbyPower)
}
func NewHomeAssistantSwitch(embed embed, uri, token, enable, power string, standbypower float64) (api.Charger, error) {
func NewHomeAssistantSwitch(embed embed, home, enable, power string, standbypower float64) (api.Charger, error) {
if enable == "" {
return nil, errors.New("missing enable switch entity")
}
@ -47,7 +50,7 @@ func NewHomeAssistantSwitch(embed embed, uri, token, enable, power string, stand
}
log := util.NewLogger("ha-switch")
conn, err := homeassistant.NewConnection(log, uri, token)
conn, err := homeassistant.NewConnection(log, home)
if err != nil {
return nil, err
}

View file

@ -28,8 +28,9 @@ func init() {
// NewHomeAssistantFromConfig creates a HomeAssistant charger from generic config
func NewHomeAssistantFromConfig(other map[string]any) (api.Charger, error) {
cc := struct {
URI string
Token string
_URI string `mapstructure:"uri"` // TODO deprecated
_Token string `mapstructure:"token"` // TODO deprecated
Home string
Status string // required - sensor for charge status
Enabled string // required - sensor for enabled state
Enable string // required - switch/input_boolean for enable/disable
@ -38,7 +39,9 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Charger, error) {
Energy string // optional - energy sensor
Currents []string // optional - current sensors for L1, L2, L3
Voltages []string // optional - voltage sensors for L1, L2, L3
}{}
}{
Home: "Home",
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
@ -55,7 +58,7 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Charger, error) {
}
log := util.NewLogger("ha-charger")
conn, err := homeassistant.NewConnection(log, cc.URI, cc.Token)
conn, err := homeassistant.NewConnection(log, cc.Home)
if err != nil {
return nil, err
}

View file

@ -18,6 +18,7 @@ import (
"github.com/evcc-io/evcc/server"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/mcp"
"github.com/evcc-io/evcc/server/network"
"github.com/evcc-io/evcc/server/updater"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/auth"
@ -157,6 +158,12 @@ func runRoot(cmd *cobra.Command, args []string) {
err = networkSettings(&conf.Network)
}
// configure plugin external url
if err == nil {
// network configuration complete, start dependent services like HomeAssistant discovery
network.Start(conf.Network)
}
// start broadcasting values
tee := new(util.Tee)
valueChan := make(chan util.Param, 64)

View file

@ -24,8 +24,9 @@ func init() {
// NewHomeAssistantFromConfig creates a HomeAssistant meter from generic config
func NewHomeAssistantFromConfig(other map[string]any) (api.Meter, error) {
cc := struct {
URI string
Token string
_URI string `mapstructure:"uri"` // TODO deprecated
_Token string `mapstructure:"token"` // TODO deprecated
Home string
Power string
Energy string
Currents []string
@ -40,6 +41,7 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Meter, error) {
batterySocLimits `mapstructure:",squash"`
batteryPowerLimits `mapstructure:",squash"`
}{
Home: "Home",
batterySocLimits: batterySocLimits{
MinSoc: 20,
MaxSoc: 95,
@ -55,7 +57,7 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Meter, error) {
}
log := util.NewLogger("ha-meter")
conn, err := homeassistant.NewConnection(log, cc.URI, cc.Token)
conn, err := homeassistant.NewConnection(log, cc.Home)
if err != nil {
return nil, err
}

2
package-lock.json generated
View file

@ -12,7 +12,7 @@
"@h2d2/shopicons": "^1.9.0",
"@popperjs/core": "^2.11.8",
"@unhead/vue": "^2.0.14",
"axios": "^1.12.0",
"axios": "^1.13.2",
"bootstrap": "^5.3.8",
"canvas-confetti": "^1.9.3",
"chart.js": "^4.5.0",

View file

@ -24,7 +24,7 @@
"@h2d2/shopicons": "^1.9.0",
"@popperjs/core": "^2.11.8",
"@unhead/vue": "^2.0.14",
"axios": "^1.12.0",
"axios": "^1.13.2",
"bootstrap": "^5.3.8",
"canvas-confetti": "^1.9.3",
"chart.js": "^4.5.0",

View file

@ -7,6 +7,7 @@ import (
"text/template"
"github.com/Masterminds/sprig/v3"
"github.com/evcc-io/evcc/api/globalconfig"
"github.com/evcc-io/evcc/core/vehicle"
"github.com/evcc-io/evcc/util"
)
@ -17,11 +18,6 @@ type Event struct {
Event string
}
// EventTemplateConfig is the push message configuration for an event
type EventTemplateConfig struct {
Title, Msg string
}
type Vehicles interface {
// ByName returns a single vehicle adapter by name
ByName(string) (vehicle.API, error)
@ -29,14 +25,14 @@ type Vehicles interface {
// Hub subscribes to event notifications and sends them to client devices
type Hub struct {
definitions map[string]EventTemplateConfig
definitions map[string]globalconfig.MessagingEventTemplate
sender []Messenger
cache *util.ParamCache
vehicles Vehicles
}
// NewHub creates push hub with definitions and receiver
func NewHub(cc map[string]EventTemplateConfig, vv Vehicles, cache *util.ParamCache) (*Hub, error) {
func NewHub(cc map[string]globalconfig.MessagingEventTemplate, vv Vehicles, cache *util.ParamCache) (*Hub, error) {
// instantiate all event templates
for k, v := range cc {
if _, err := template.New("out").Funcs(sprig.FuncMap()).Parse(v.Title); err != nil {

View file

@ -15,6 +15,7 @@ import (
"github.com/evcc-io/evcc/hems/shm"
"github.com/evcc-io/evcc/server/assets"
"github.com/evcc-io/evcc/server/eebus"
"github.com/evcc-io/evcc/server/service"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/auth"
"github.com/evcc-io/evcc/util/config"
@ -321,6 +322,9 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, valueChan chan<- util.Par
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
// services
api.PathPrefix("/service").Handler(http.StripPrefix("/api/config/service", service.Handler()))
// site
for _, r := range map[string]route{
"site": {"GET", "/site", siteHandler(site)},

View file

@ -0,0 +1,33 @@
package network
import (
"sync"
"github.com/evcc-io/evcc/api/globalconfig"
)
var (
mu sync.Mutex
registry []func()
)
var config globalconfig.Network
func Config() globalconfig.Network {
return config
}
func Register(fun func()) {
mu.Lock()
defer mu.Unlock()
registry = append(registry, fun)
}
func Start(conf globalconfig.Network) {
config = conf
for _, fun := range registry {
go fun()
}
}

View file

@ -0,0 +1,36 @@
package service
import (
"net/http"
"sync"
)
var (
mu sync.Mutex
registry = make(map[string]http.Handler)
)
func Register(name string, handler http.Handler) {
mu.Lock()
defer mu.Unlock()
if _, ok := registry[name]; ok {
panic("service " + name + " already registered")
}
registry[name] = handler
}
func Handler() http.Handler {
mux := http.NewServeMux()
for name, h := range registry {
// e.g. "/homes/foo"
prefix := "/" + name
// strip "/homes/foo" then hand off to h
mux.Handle(prefix+"/", http.StripPrefix(prefix, h))
}
return mux
}

View file

@ -7,35 +7,37 @@ products:
group: switchsockets
requirements:
evcc: ["skiptest"]
auth:
type: homeassistant
params: [home]
params:
- name: uri
required: true
example: http://homeassistant.local:8123
deprecated: true
- name: token
mask: true
required: true
deprecated: true
- name: home
description:
de: Long-Lived Access Token
en: Long-Lived Access Token
help:
de: aus Home Assistant Profil
en: from Home Assistant profile
de: Home Assistant Instanz
en: Home Assistant Instance
default: Home
service: homeassistant/homes
- name: switch
required: true
description:
de: Entity ID des schaltbaren Geräts
en: Entity ID of the switch device
service: homeassistant/homes/{home}/entities?domain=switch
example: switch.smartsocket
required: true
- name: power
description:
de: Entity ID für Leistungsmessung
en: Entity ID for power measurement
service: homeassistant/homes/{home}/entities?domain=sensor
example: sensor.smartsocket_power
- preset: switchsocket
render: |
type: homeassistant-switch
uri: {{ .uri }}
token: {{ .token }}
home: {{ .home }}
enable: {{ .switch }}
power: {{ .power }}
{{ include "switchsocket" . }}

View file

@ -7,26 +7,31 @@ products:
group: generic
requirements:
description:
en: Home Assistant instance with API access
de: Home Assistant Instanz mit API-Zugriff
en: Requires a running Home Assistant instance with suitable sensor entities. All values are Home Assistant entity IDs (e.g. sensor.*)
de: Erfordert eine laufende Home Assistant Instanz mit passenden Sensor-Entitäten. Alle Werte sind Home Assistant Entity IDs (z.B. sensor.*)
auth:
type: homeassistant
params: [home]
params:
- name: usage
choice: ["grid", "pv", "battery", "aux", "charge"]
- name: uri
required: true
example: "http://homeassistant.local:8123"
deprecated: true
- name: token
deprecated: true
- name: home
description:
de: Home Assistant Long-Lived Access Token
en: Home Assistant Long-Lived Access Token
example: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
de: Home Assistant Instanz
en: Home Assistant Instance
default: Home
service: homeassistant/homes
required: true
mask: true
- name: power
description:
de: Leistungsentität
en: Power Entity
required: true
service: homeassistant/homes/{home}/entities?domain=sensor
example: "sensor.house_power"
help:
en: Entity ID for instantaneous power measurement in watts. The entity must provide numeric values only (e.g., "1234", not "1234 W").
@ -35,6 +40,7 @@ params:
description:
de: Energieentität
en: Energy Entity
service: homeassistant/homes/{home}/entities?domain=sensor
example: "sensor.house_energy"
advanced: true
help:
@ -44,6 +50,7 @@ params:
description:
de: L1 Stromentität
en: L1 Current Entity
service: homeassistant/homes/{home}/entities?domain=sensor
example: "sensor.house_current_l1"
advanced: true
help:
@ -53,6 +60,7 @@ params:
description:
de: L2 Stromentität
en: L2 Current Entity
service: homeassistant/homes/{home}/entities?domain=sensor
example: "sensor.house_current_l2"
advanced: true
help:
@ -62,6 +70,7 @@ params:
description:
de: L3 Stromentität
en: L3 Current Entity
service: homeassistant/homes/{home}/entities?domain=sensor
example: "sensor.house_current_l3"
advanced: true
help:
@ -71,6 +80,7 @@ params:
description:
de: L1 Spannungsentität
en: L1 Voltage Entity
service: homeassistant/homes/{home}/entities?domain=sensor
example: "sensor.house_voltage_l1"
advanced: true
help:
@ -80,6 +90,7 @@ params:
description:
de: L2 Spannungsentität
en: L2 Voltage Entity
service: homeassistant/homes/{home}/entities?domain=sensor
example: "sensor.house_voltage_l2"
advanced: true
help:
@ -89,6 +100,7 @@ params:
description:
de: L3 Spannungsentität
en: L3 Voltage Entity
service: homeassistant/homes/{home}/entities?domain=sensor
example: "sensor.house_voltage_l3"
advanced: true
help:
@ -98,6 +110,7 @@ params:
description:
de: Batterieladestand
en: Battery State of Charge
service: homeassistant/homes/{home}/entities?domain=sensor
example: "sensor.battery_soc"
advanced: true
help:
@ -111,8 +124,7 @@ params:
- name: maxacpower
render: |
type: homeassistant
uri: {{ .uri }}
token: {{ .token }}
home: {{ .home }}
power: {{ .power }}
energy: {{ .energy }}
currents:

View file

@ -9,85 +9,89 @@ requirements:
description:
en: Requires a running Home Assistant instance with suitable vehicle entities and services. All values are Home Assistant entity IDs (e.g. sensor.*, binary_sensor.*, number.*, script.*)
de: Erfordert eine laufende Home Assistant Instanz mit passenden Fahrzeug-Entities und Services. Alle Werte sind Home Assistant Entity IDs (z.B. sensor.*, binary_sensor.*, number.*, script.*)
auth:
type: homeassistant
params: [home]
params:
- preset: vehicle-common
- name: uri
required: true
example: http://homeassistant.local:8123
deprecated: true
- name: token
deprecated: true
- name: home
description:
de: Home Assistant Long-Lived Access Token
en: Home Assistant Long-Lived Access Token
example: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
de: Home Assistant Instanz
en: Home Assistant Instance
default: Home
service: homeassistant/homes
required: true
mask: true
- name: soc
description:
de: Ladezustand [%]
en: State of charge [%]
service: homeassistant/homes/{home}/entities?domain=sensor
example: "sensor.vehicle_soc"
type: string
required: true
- name: range
description:
de: Restreichweite [km]
en: Remaining range [km]
service: homeassistant/homes/{home}/entities?domain=sensor
example: "sensor.vehicle_range"
type: string
- name: status
description:
de: Ladestatus
en: Charging status
service: homeassistant/homes/{home}/entities?domain=sensor
example: "sensor.vehicle_charging"
type: string
- name: limitSoc
description:
de: Ziel-Ladezustand [%]
en: Target state of charge [%]
service: homeassistant/homes/{home}/entities?domain=number,input_number
example: "number.vehicle_target_state_of_charge"
type: string
- name: odometer
description:
de: Kilometerstand [km]
en: Odometer [km]
service: homeassistant/homes/{home}/entities?domain=sensor
example: "sensor.vehicle_odometer"
type: string
- name: climater
description:
de: Klimatisierung aktiv
en: Climatisation active
service: homeassistant/homes/{home}/entities?domain=binary_sensor
example: "binary_sensor.vehicle_climater"
type: string
- name: finishTime
description:
de: Ladeende (ISO8601 oder Unix)
en: Finish time (ISO8601 or Unix)
service: homeassistant/homes/{home}/entities?domain=sensor
example: "sensor.vehicle_finish_time"
type: string
- name: start_charging
description:
de: Service zum Laden starten
en: Service to start charging
service: homeassistant/homes/{home}/entities?domain=script
example: "script.vehicle_start_charge"
type: string
- name: stop_charging
description:
de: Service zum Laden stoppen
en: Service to stop charging
service: homeassistant/homes/{home}/entities?domain=script
example: "script.vehicle_stop_charge"
type: string
- name: wakeup
description:
de: Service zum Aufwecken
en: Service to wake up vehicle
service: homeassistant/homes/{home}/entities?domain=script
example: "script.vehicle_wakeup"
type: string
- name: setMaxCurrent
description:
de: Ladestromstärke setzen [A]
en: Set charging current [A]
service: homeassistant/homes/{home}/entities?domain=number,input_number
example: "number.vehicle_charging_current"
type: string
- preset: vehicle-features
- name: streaming
default: true
@ -95,8 +99,7 @@ render: |
type: homeassistant
{{ include "vehicle-common" . }}
{{ include "vehicle-features" . }}
uri: {{ .uri }}
token: {{ .token }}
home: {{ .home }}
sensors:
soc: {{ .soc }}
range: {{ .range }}

View file

@ -11,52 +11,46 @@ import (
"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"
"github.com/samber/lo"
"golang.org/x/oauth2"
)
// Connection represents a Home Assistant API connection
type Connection struct {
*request.Helper
uri string
instance *proxyInstance
}
// NewConnection creates a new Home Assistant connection
func NewConnection(log *util.Logger, uri, token string) (*Connection, error) {
if uri == "" {
return nil, errors.New("missing uri")
}
if token == "" {
return nil, errors.New("missing token")
}
func NewConnection(log *util.Logger, home string) (*Connection, error) {
c := &Connection{
Helper: request.NewHelper(log.Redact(token)),
uri: util.DefaultScheme(strings.TrimSuffix(uri, "/"), "http"),
Helper: request.NewHelper(log),
instance: &proxyInstance{home: home},
}
// Set up authentication headers
c.Client.Transport = &transport.Decorator{
Base: c.Client.Transport,
Decorator: transport.DecorateHeaders(map[string]string{
"Authorization": "Bearer " + token,
"Content-Type": "application/json",
}),
c.Client.Transport = &oauth2.Transport{
Base: c.Client.Transport,
Source: c.instance,
}
return c, nil
}
// StateResponse represents a Home Assistant entity state
type StateResponse struct {
State string `json:"state"`
Attributes map[string]any `json:"attributes"`
// GetStates retrieves the list of entities
func (c *Connection) GetStates() ([]StateResponse, error) {
var res []StateResponse
uri := fmt.Sprintf("%s/api/states", c.instance.URI())
err := c.GetJSON(uri, &res)
return res, err
}
// GetState retrieves the state of an entity
func (c *Connection) GetState(entity string) (string, error) {
var res StateResponse
uri := fmt.Sprintf("%s/api/states/%s", c.uri, url.PathEscape(entity))
uri := fmt.Sprintf("%s/api/states/%s", c.instance.URI(), url.PathEscape(entity))
if err := c.GetJSON(uri, &res); err != nil {
return "", err
@ -169,7 +163,7 @@ func (c *Connection) GetChargeStatus(entity string) (api.ChargeStatus, error) {
// CallService calls a Home Assistant service
func (c *Connection) CallService(domain, service string, data map[string]any) error {
uri := fmt.Sprintf("%s/api/services/%s/%s", c.uri, domain, service)
uri := fmt.Sprintf("%s/api/services/%s/%s", c.instance.URI(), domain, service)
req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
if err != nil {

View file

@ -0,0 +1,23 @@
package homeassistant
import (
"sync"
"golang.org/x/oauth2"
)
type instance struct {
URI string
oauth2.TokenSource
}
var (
mu sync.Mutex
instances = make(map[string]*instance)
)
func instanceByName(name string) *instance {
mu.Lock()
defer mu.Unlock()
return instances[name]
}

View file

@ -0,0 +1,54 @@
package homeassistant
import (
"context"
"fmt"
"github.com/evcc-io/evcc/plugin/auth"
"github.com/evcc-io/evcc/server/network"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"golang.org/x/oauth2"
)
// https://developers.home-assistant.io/docs/auth_api
func init() {
auth.Register("homeassistant", NewHomeAssistantFromConfig)
}
func NewHomeAssistantFromConfig(other map[string]any) (oauth2.TokenSource, error) {
var cc struct {
Home string
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
inst := instanceByName(cc.Home)
if inst == nil {
return nil, fmt.Errorf("unknown instance: %s", cc.Home)
}
return NewHomeAssistant(cc.Home, inst.URI)
}
func NewHomeAssistant(name, uri string) (oauth2.TokenSource, error) {
extUrl := network.Config().ExternalURL()
redirectUri := extUrl + "/providerauth/callback"
log := util.NewLogger("homeassistant")
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewClient(log))
oc := oauth2.Config{
ClientID: extUrl,
RedirectURL: redirectUri,
Endpoint: oauth2.Endpoint{
AuthURL: uri + "/auth/authorize",
TokenURL: uri + "/auth/token",
},
}
return auth.NewOAuth(ctx, "HomeAssistant", name, &oc)
}

View file

@ -0,0 +1,44 @@
package homeassistant
import (
"fmt"
"sync"
"golang.org/x/oauth2"
)
type proxyInstance struct {
mu sync.Mutex
home string
instance *instance
}
func (inst *proxyInstance) URI() string {
inst.mu.Lock()
defer inst.mu.Unlock()
if inst.instance == nil {
inst.instance = instanceByName(inst.home)
if inst.instance == nil {
return ""
}
}
return inst.instance.URI
}
func (inst *proxyInstance) Token() (*oauth2.Token, error) {
inst.mu.Lock()
defer inst.mu.Unlock()
if inst.instance == nil {
inst.instance = instanceByName(inst.home)
if inst.instance == nil {
return nil, fmt.Errorf("unknown instance: %s", inst.home)
}
}
return inst.instance.Token()
}

View file

@ -0,0 +1,81 @@
package homeassistant
import (
"encoding/json"
"maps"
"net/http"
"slices"
"strings"
"github.com/evcc-io/evcc/server/service"
"github.com/evcc-io/evcc/util"
"github.com/samber/lo"
)
var log = util.NewLogger("homeassistant")
func init() {
mux := http.NewServeMux()
mux.HandleFunc("GET /homes", getHomes)
mux.HandleFunc("GET /homes/{home}/entities", getEntities)
service.Register("homeassistant", mux)
}
func getHomes(w http.ResponseWriter, req *http.Request) {
mu.Lock()
defer mu.Unlock()
jsonWrite(w, slices.Sorted(maps.Keys(instances)))
}
func getEntities(w http.ResponseWriter, req *http.Request) {
home := req.PathValue("home")
if instanceByName(home) == nil {
w.WriteHeader(http.StatusBadRequest)
return
}
conn, _ := NewConnection(log, home)
res, err := conn.GetStates()
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
var domains []string
if domain := req.URL.Query().Get("domain"); domain != "" {
domains = strings.Split(domain, ",")
}
// cache list of entities
w.Header().Set("Cache-control", "max-age=300")
jsonWrite(w, lo.Map(lo.Filter(res, func(e StateResponse, _ int) bool {
if len(domains) == 0 {
return true
}
for _, domain := range domains {
if strings.HasPrefix(e.EntityId, domain+".") {
return true
}
}
return false
}), func(e StateResponse, _ int) string {
return e.EntityId
}))
}
// jsonWrite writes a JSON response
func jsonWrite(w http.ResponseWriter, data any) {
json.NewEncoder(w).Encode(data)
}
// jsonError writes an error response
func jsonError(w http.ResponseWriter, status int, err error) {
w.WriteHeader(status)
jsonWrite(w, util.ErrorAsJson(err))
}

View file

@ -0,0 +1,11 @@
package homeassistant
type StateResponse struct {
EntityId string `json:"entity_id"`
State string `json:"state"`
Attributes struct {
UnitOfMeasurement string `json:"unit_of_measurement"`
DeviceClass string `json:"device_class"`
FriendlyName string `json:"friendly_name"`
} `json:"attributes"`
}

View file

@ -0,0 +1,73 @@
package homeassistant
import (
"context"
"fmt"
"strings"
"github.com/evcc-io/evcc/server/network"
"github.com/evcc-io/evcc/util"
"github.com/libp2p/zeroconf/v2"
)
func init() {
network.Register(scan)
}
func scan() {
ctx := context.Background()
log := util.NewLogger("homeassistant")
entries := make(chan *zeroconf.ServiceEntry, 1)
go func() {
for {
select {
case se := <-entries:
uri := fmt.Sprintf("http://%s:%d", se.HostName, se.Port)
OUTER:
for _, text := range se.Text {
for _, prefix := range []string{"external_url", "base_url", "internal_url"} {
if u, ok := strings.CutPrefix(text, prefix+"="); ok && u != "" {
uri = u
break OUTER
}
}
}
if err := authorize(se.Instance, uri); err != nil {
log.ERROR.Println(err)
}
case <-ctx.Done():
return
}
}
}()
if err := zeroconf.Browse(ctx, "_home-assistant._tcp.", "local.", entries); err != nil {
log.ERROR.Println("zeroconf: failed to browse:", err.Error())
}
}
func authorize(name, uri string) error {
mu.Lock()
defer mu.Unlock()
if _, ok := instances[name]; ok {
return nil
}
ts, err := NewHomeAssistant(name, uri)
if err != nil {
return err
}
instances[name] = &instance{
URI: uri,
TokenSource: ts,
}
return nil
}

View file

@ -199,6 +199,7 @@ type Param struct {
Usages []string `json:",omitempty"` // restrict param to these usage types, e.g. "battery" for home battery capacity
Type ParamType // string representation of the value type, "string" is default
Choice []string `json:",omitempty"` // defines a set of choices, e.g. "grid", "pv", "battery", "charge" for "usage"
Service string `json:",omitempty"` // defines a service to provide choices
AllInOne bool `json:"-"` // defines if the defined usages can all be present in a single device
// TODO move somewhere else should not be part of the param definition

View file

@ -23,10 +23,11 @@ func init() {
// Constructor from YAML config
func NewHomeAssistantVehicleFromConfig(other map[string]any) (api.Vehicle, error) {
var cc struct {
cc := struct {
embed `mapstructure:",squash"`
URI string
Token string
_URI string `mapstructure:"uri"` // TODO deprecated
_Token string `mapstructure:"token"` // TODO deprecated
Home string
Sensors struct {
Soc string // required
Range string // optional
@ -37,28 +38,25 @@ func NewHomeAssistantVehicleFromConfig(other map[string]any) (api.Vehicle, error
FinishTime string // optional
}
Services struct {
Start string `mapstructure:"start_charging"` // script.* optional
Stop string `mapstructure:"stop_charging"` // script.* optional
Wakeup string // script.* optional
SetMaxCurrent string `mapstructure:"setMaxCurrent"` // number.* or input_number.* optional
Start string `mapstructure:"start_charging"` // script.* optional
Stop string `mapstructure:"stop_charging"` // script.* optional
Wakeup string // script.* optional
SetMaxCurrent string // number.* or input_number.* optional
}
}{
Home: "Home",
}
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 == "":
if cc.Sensors.Soc == "" {
return nil, errors.New("missing soc sensor")
}
log := util.NewLogger("ha-vehicle")
conn, err := homeassistant.NewConnection(log, cc.URI, cc.Token)
conn, err := homeassistant.NewConnection(log, cc.Home)
if err != nil {
return nil, err
}