HomeAssistant: add auto-discovery (#25141)
This commit is contained in:
parent
c33a6c20e0
commit
ef096a236f
31 changed files with 764 additions and 119 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
54
assets/js/components/Config/DeviceModal/index.test.ts
Normal file
54
assets/js/components/Config/DeviceModal/index.test.ts
Normal 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}"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -77,18 +77,27 @@
|
|||
:required="required"
|
||||
rows="4"
|
||||
/>
|
||||
<div v-else class="position-relative">
|
||||
<input
|
||||
v-else
|
||||
:id="id"
|
||||
v-model="value"
|
||||
class="form-control"
|
||||
:class="inputClasses"
|
||||
:list="datalistId"
|
||||
:class="`${datalistId && serviceValues.length > 0 ? 'form-select' : 'form-control'} ${inputClasses}`"
|
||||
:type="inputType"
|
||||
:step="step"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:autocomplete="masked ? 'off' : null"
|
||||
:autocomplete="masked || datalistId ? 'off' : null"
|
||||
/>
|
||||
<button v-if="showClearButton" type="button" class="form-control-clear" @click="value = ''">
|
||||
×
|
||||
</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() {
|
||||
|
|
|
|||
26
assets/js/utils/placeholder.test.ts
Normal file
26
assets/js/utils/placeholder.test.ts
Normal 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}");
|
||||
});
|
||||
});
|
||||
20
assets/js/utils/placeholder.ts
Normal file
20
assets/js/utils/placeholder.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
2
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
10
push/hub.go
10
push/hub.go
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)},
|
||||
|
|
|
|||
33
server/network/registry.go
Normal file
33
server/network/registry.go
Normal 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()
|
||||
}
|
||||
}
|
||||
36
server/service/registry.go
Normal file
36
server/service/registry.go
Normal 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
|
||||
}
|
||||
|
|
@ -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" . }}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
c.Client.Transport = &oauth2.Transport{
|
||||
Base: c.Client.Transport,
|
||||
Decorator: transport.DecorateHeaders(map[string]string{
|
||||
"Authorization": "Bearer " + token,
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
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 {
|
||||
|
|
|
|||
23
util/homeassistant/instance.go
Normal file
23
util/homeassistant/instance.go
Normal 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]
|
||||
}
|
||||
54
util/homeassistant/oauth2.go
Normal file
54
util/homeassistant/oauth2.go
Normal 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)
|
||||
}
|
||||
44
util/homeassistant/proxy.go
Normal file
44
util/homeassistant/proxy.go
Normal 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()
|
||||
}
|
||||
81
util/homeassistant/service.go
Normal file
81
util/homeassistant/service.go
Normal 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))
|
||||
}
|
||||
11
util/homeassistant/types.go
Normal file
11
util/homeassistant/types.go
Normal 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"`
|
||||
}
|
||||
73
util/homeassistant/zeroconf.go
Normal file
73
util/homeassistant/zeroconf.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -40,25 +41,22 @@ func NewHomeAssistantVehicleFromConfig(other map[string]any) (api.Vehicle, error
|
|||
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
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue