diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go
index 81f230bcc..e45ea26b3 100644
--- a/api/globalconfig/types.go
+++ b/api/globalconfig/types.go
@@ -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
}
diff --git a/assets/css/app.css b/assets/css/app.css
index fa1d2ed02..510d802aa 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -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;
}
diff --git a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue
index 2f216e07d..b094d016b 100644
--- a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue
+++ b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue
@@ -52,6 +52,7 @@
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
+ :service-values="serviceValues[param.Name]"
/>
@@ -109,6 +110,7 @@
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
+ :service-values="serviceValues[param.Name]"
/>
@@ -119,6 +121,7 @@
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
+ :service-values="serviceValues[param.Name]"
/>
@@ -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,
+ 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);
+ },
},
});
diff --git a/assets/js/components/Config/DeviceModal/index.test.ts b/assets/js/components/Config/DeviceModal/index.test.ts
new file mode 100644
index 000000000..be912c419
--- /dev/null
+++ b/assets/js/components/Config/DeviceModal/index.test.ts
@@ -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)).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)).toBe(
+ "homes/{home}/sensors/{sensor}?token={token}"
+ );
+ });
+});
diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts
index bff814364..58d62b7b4 100644
--- a/assets/js/components/Config/DeviceModal/index.ts
+++ b/assets/js/components/Config/DeviceModal/index.ts
@@ -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;
+};
+
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): Record =>
+ Object.entries(values).reduce(
+ (acc, [key, val]) => {
+ if (val !== undefined && val !== null) acc[key] = String(val);
+ return acc;
+ },
+ {} as Record
+ );
+
+ return {
+ name: param.Name,
+ dependencies: extractPlaceholders(param.Service),
+ url: (values: Record) =>
+ replacePlaceholders(param.Service!, stringValues(values)),
+ } as ParamService;
+ })
+ .filter((endpoint): endpoint is ParamService => endpoint !== null);
+};
+
+export const fetchServiceValues = async (
+ templateParams: TemplateParam[],
+ values: DeviceValues
+): Promise> => {
+ const endpoints = createServiceEndpoints(templateParams);
+ const result: Record = {};
+
+ await Promise.all(
+ endpoints.map(async (endpoint) => {
+ const params: Record = {};
+ 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,
};
diff --git a/assets/js/components/Config/PropertyEntry.vue b/assets/js/components/Config/PropertyEntry.vue
index bda67ae43..b209bb7a4 100644
--- a/assets/js/components/Config/PropertyEntry.vue
+++ b/assets/js/components/Config/PropertyEntry.vue
@@ -17,6 +17,7 @@
:unit="Unit"
:required="Required"
:choice="Choice"
+ :service-values="serviceValues"
:label="label"
/>
@@ -42,6 +43,7 @@ export default {
Unit: String,
Mask: Boolean,
Choice: Array,
+ serviceValues: Array,
modelValue: [String, Number, Boolean, Object],
},
emits: ["update:modelValue"],
diff --git a/assets/js/components/Config/PropertyField.vue b/assets/js/components/Config/PropertyField.vue
index a26dfe3ca..69af59582 100644
--- a/assets/js/components/Config/PropertyField.vue
+++ b/assets/js/components/Config/PropertyField.vue
@@ -77,18 +77,27 @@
:required="required"
rows="4"
/>
-
+
+
+
+
+