Skip to content

Commit ef096a2

Browse files
authored
HomeAssistant: add auto-discovery (#25141)
1 parent c33a6c2 commit ef096a2

File tree

31 files changed

+764
-119
lines changed

31 files changed

+764
-119
lines changed

api/globalconfig/types.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"github.com/evcc-io/evcc/api"
1212
"github.com/evcc-io/evcc/hems/shm"
1313
"github.com/evcc-io/evcc/plugin/mqtt"
14-
"github.com/evcc-io/evcc/push"
1514
"github.com/evcc-io/evcc/server/eebus"
1615
"github.com/evcc-io/evcc/util"
1716
"github.com/evcc-io/evcc/util/config"
@@ -137,10 +136,15 @@ type DB struct {
137136
}
138137

139138
type Messaging struct {
140-
Events map[string]push.EventTemplateConfig
139+
Events map[string]MessagingEventTemplate
141140
Services []config.Typed
142141
}
143142

143+
// MessagingEventTemplate is the push message configuration for an event
144+
type MessagingEventTemplate struct {
145+
Title, Msg string
146+
}
147+
144148
func (c Messaging) Configured() bool {
145149
return len(c.Services) > 0 || len(c.Events) > 0
146150
}

assets/css/app.css

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,36 @@ input[type="time"]::-webkit-calendar-picker-indicator {
584584
display: none;
585585
}
586586

587+
input[list]::-webkit-list-button {
588+
display: none !important;
589+
appearance: none !important;
590+
}
591+
592+
.form-control-clear {
593+
position: absolute;
594+
right: 0.75rem;
595+
top: 0;
596+
bottom: 0;
597+
justify-content: center;
598+
display: flex;
599+
align-items: center;
600+
justify-self: center;
601+
width: 1.1rem;
602+
font-size: 1.5rem;
603+
font-weight: normal;
604+
color: var(--evcc-default-text);
605+
border: none;
606+
background-color: transparent;
607+
padding: 0;
608+
margin: 0;
609+
cursor: pointer;
610+
}
611+
612+
/* hide dropdown arrow if clear is visible */
613+
.form-select.has-clear-button {
614+
background-image: none;
615+
}
616+
587617
.table {
588618
--bs-table-bg: transparent;
589619
}

assets/js/components/Config/DeviceModal/DeviceModalBase.vue

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
:key="param.Name"
5353
v-bind="param"
5454
v-model="values[param.Name]"
55+
:service-values="serviceValues[param.Name]"
5556
/>
5657
<p v-if="authError" class="text-danger">{{ authError }}</p>
5758
<div class="d-flex justify-content-end">
@@ -109,6 +110,7 @@
109110
:key="param.Name"
110111
v-bind="param"
111112
v-model="values[param.Name]"
113+
:service-values="serviceValues[param.Name]"
112114
/>
113115

114116
<PropertyCollapsible>
@@ -119,6 +121,7 @@
119121
:key="param.Name"
120122
v-bind="param"
121123
v-model="values[param.Name]"
124+
:service-values="serviceValues[param.Name]"
122125
/>
123126
</template>
124127
<template v-if="$slots['collapsible-more']" #more>
@@ -160,7 +163,7 @@ import { initialTestState, performTest } from "../utils/test";
160163
import sleep from "@/utils/sleep";
161164
import { extractDomain } from "@/utils/extractDomain";
162165
import { ConfigType } from "@/types/evcc";
163-
import type { DeviceType } from "@/types/evcc";
166+
import type { DeviceType, Timeout } from "@/types/evcc";
164167
import {
165168
handleError,
166169
type DeviceValues,
@@ -172,6 +175,7 @@ import {
172175
type ApiData,
173176
applyDefaultsFromTemplate,
174177
createDeviceUtils,
178+
fetchServiceValues,
175179
} from "./index";
176180
177181
const CUSTOM_FIELDS = ["modbus"];
@@ -248,6 +252,8 @@ export default defineComponent({
248252
loadingTemplate: false,
249253
values: { ...this.initialValues } as DeviceValues,
250254
test: initialTestState(),
255+
serviceValues: {} as Record<string, string[]>,
256+
serviceValuesTimer: null as Timeout | null,
251257
};
252258
},
253259
computed: {
@@ -423,6 +429,8 @@ export default defineComponent({
423429
if (!isYamlInput) {
424430
this.loadTemplate();
425431
}
432+
433+
this.updateServiceValues();
426434
},
427435
usage() {
428436
// Reload products when usage changes (e.g., meter type selection)
@@ -446,9 +454,22 @@ export default defineComponent({
446454
values: {
447455
handler() {
448456
this.test = initialTestState();
457+
this.updateServiceValues();
458+
},
459+
deep: true,
460+
},
461+
authValues: {
462+
handler() {
463+
if (this.authRequired) {
464+
this.resetAuthStatus();
465+
}
449466
},
450467
deep: true,
451468
},
469+
authRequired() {
470+
// update on auth state change
471+
this.updateServiceValues();
472+
},
452473
},
453474
methods: {
454475
reset() {
@@ -515,9 +536,12 @@ export default defineComponent({
515536
}
516537
this.loadingTemplate = false;
517538
},
518-
async checkAuthStatus() {
539+
resetAuthStatus() {
519540
this.authOk = false;
520541
this.authProviderUrl = null;
542+
},
543+
async checkAuthStatus() {
544+
this.resetAuthStatus();
521545
522546
// no auth required
523547
if (!this.template?.Auth) return;
@@ -532,8 +556,6 @@ export default defineComponent({
532556
// validate data
533557
if (this.authValuesMissing) return;
534558
535-
this.authOk = false;
536-
this.authProviderUrl = null;
537559
const { type } = this.template.Auth;
538560
const values = this.authValues;
539561
this.authLoading = true;
@@ -672,6 +694,14 @@ export default defineComponent({
672694
}
673695
return value === ConfigType.Custom;
674696
},
697+
async updateServiceValues() {
698+
if (this.serviceValuesTimer) {
699+
clearTimeout(this.serviceValuesTimer);
700+
}
701+
this.serviceValuesTimer = setTimeout(async () => {
702+
this.serviceValues = await fetchServiceValues(this.templateParams, this.values);
703+
}, 500);
704+
},
675705
},
676706
});
677707
</script>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it } from "vitest";
2+
import { createServiceEndpoints, type TemplateParam } from "./index";
3+
4+
const buildParam = (name: string, service?: string): TemplateParam => ({
5+
Name: name,
6+
Required: false,
7+
Advanced: false,
8+
Deprecated: false,
9+
Service: service,
10+
});
11+
12+
describe("createServiceEndpoints", () => {
13+
it("skips params without service", () => {
14+
const params = [buildParam("home", "homes"), buildParam("power", "homes/{home}/sensors")];
15+
const endpoints = createServiceEndpoints(params);
16+
expect(endpoints.map((endpoint) => endpoint.name)).toEqual(["home", "power"]);
17+
});
18+
19+
it("replaces single placeholder", () => {
20+
const params = [buildParam("home", "homes"), buildParam("power", "homes/{home}/sensors")];
21+
const endpoints = createServiceEndpoints(params);
22+
const homeEndpoint = endpoints.find(({ name }) => name === "home")!;
23+
const powerEndpoint = endpoints.find(({ name }) => name === "power")!;
24+
expect(homeEndpoint.dependencies).toEqual([]);
25+
expect(homeEndpoint.url({})).toBe("homes");
26+
expect(powerEndpoint.dependencies).toEqual(["home"]);
27+
expect(powerEndpoint.url({ home: "main" })).toBe("homes/main/sensors");
28+
expect(powerEndpoint.url({ home: "with space" })).toBe("homes/with%20space/sensors");
29+
expect(powerEndpoint.url({} as Record<string, string>)).toBe("homes/{home}/sensors");
30+
});
31+
32+
it("replaces multiple placeholders", () => {
33+
const params = [
34+
buildParam("home", "homes"),
35+
buildParam("sensor", "homes/{home}/sensors/{sensor}"),
36+
];
37+
const endpoints = createServiceEndpoints(params);
38+
const sensorEndpoint = endpoints.find(({ name }) => name === "sensor")!;
39+
expect(sensorEndpoint.dependencies).toEqual(["home", "sensor"]);
40+
expect(sensorEndpoint.url({ home: "hq", sensor: "battery" })).toBe("homes/hq/sensors/battery");
41+
});
42+
43+
it("encodes replacements", () => {
44+
const params = [buildParam("token", "homes/{home}/sensors/{sensor}?token={token}")];
45+
const endpoints = createServiceEndpoints(params);
46+
const tokenEndpoint = endpoints[0]!;
47+
expect(tokenEndpoint.url({ home: "hq", sensor: "bat/tery", token: "a+b c" })).toBe(
48+
"homes/hq/sensors/bat%2Ftery?token=a%2Bb%20c"
49+
);
50+
expect(tokenEndpoint.url({} as Record<string, string>)).toBe(
51+
"homes/{home}/sensors/{sensor}?token={token}"
52+
);
53+
});
54+
});

assets/js/components/Config/DeviceModal/index.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { DeviceType, MODBUS_COMSET, MeterTemplateUsage } from "@/types/evcc";
22
import { ConfigType } from "@/types/evcc";
33
import api, { baseApi } from "@/api";
4+
import { extractPlaceholders, replacePlaceholders } from "@/utils/placeholder";
45

56
export type Product = {
67
group: string;
@@ -28,9 +29,16 @@ export type TemplateParam = {
2829
Deprecated: boolean;
2930
Default?: string | number | boolean;
3031
Choice?: string[];
32+
Service?: string;
3133
Usages?: TemplateParamUsage[];
3234
};
3335

36+
export type ParamService = {
37+
name: string;
38+
dependencies: string[];
39+
url: (values: Record<string, any>) => string;
40+
};
41+
3442
export type ModbusCapability = "rs485" | "tcpip";
3543

3644
export type ModbusParam = TemplateParam & {
@@ -106,6 +114,68 @@ export function customChargerName(type: ConfigType, isHeating: boolean) {
106114
return `${prefix}${type}`;
107115
}
108116

117+
export async function loadServiceValues(path: string) {
118+
try {
119+
const response = await api.get(`/config/service/${path}`);
120+
return response.data as string[];
121+
} catch (e) {
122+
console.error(e);
123+
return [];
124+
}
125+
}
126+
127+
export const createServiceEndpoints = (params: TemplateParam[]): ParamService[] => {
128+
return params
129+
.map((param) => {
130+
if (!param.Service) {
131+
return null;
132+
}
133+
const stringValues = (values: Record<string, any>): Record<string, string> =>
134+
Object.entries(values).reduce(
135+
(acc, [key, val]) => {
136+
if (val !== undefined && val !== null) acc[key] = String(val);
137+
return acc;
138+
},
139+
{} as Record<string, string>
140+
);
141+
142+
return {
143+
name: param.Name,
144+
dependencies: extractPlaceholders(param.Service),
145+
url: (values: Record<string, any>) =>
146+
replacePlaceholders(param.Service!, stringValues(values)),
147+
} as ParamService;
148+
})
149+
.filter((endpoint): endpoint is ParamService => endpoint !== null);
150+
};
151+
152+
export const fetchServiceValues = async (
153+
templateParams: TemplateParam[],
154+
values: DeviceValues
155+
): Promise<Record<string, string[]>> => {
156+
const endpoints = createServiceEndpoints(templateParams);
157+
const result: Record<string, string[]> = {};
158+
159+
await Promise.all(
160+
endpoints.map(async (endpoint) => {
161+
const params: Record<string, any> = {};
162+
endpoint.dependencies.forEach((dependency) => {
163+
if (values[dependency]) {
164+
params[dependency] = values[dependency];
165+
}
166+
});
167+
if (Object.keys(params).length !== endpoint.dependencies.length) {
168+
// missing dependency values, skip
169+
return;
170+
}
171+
const url = endpoint.url(params);
172+
result[endpoint.name] = await loadServiceValues(url);
173+
})
174+
);
175+
176+
return result;
177+
};
178+
109179
export function createDeviceUtils(deviceType: DeviceType) {
110180
function test(id: number | undefined, data: any) {
111181
let url = `config/test/${deviceType}`;
@@ -201,6 +271,7 @@ export function createDeviceUtils(deviceType: DeviceType) {
201271
create,
202272
loadProducts,
203273
loadTemplate,
274+
loadServiceValues,
204275
checkAuth,
205276
getAuthProviderUrl,
206277
};

assets/js/components/Config/PropertyEntry.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
:unit="Unit"
1818
:required="Required"
1919
:choice="Choice"
20+
:service-values="serviceValues"
2021
:label="label"
2122
/>
2223
</FormRow>
@@ -42,6 +43,7 @@ export default {
4243
Unit: String,
4344
Mask: Boolean,
4445
Choice: Array,
46+
serviceValues: Array,
4547
modelValue: [String, Number, Boolean, Object],
4648
},
4749
emits: ["update:modelValue"],

0 commit comments

Comments
 (0)