import type { DeviceType, MODBUS_COMSET, MeterTemplateUsage } from "@/types/evcc"; import { ConfigType } from "@/types/evcc"; import api from "@/api"; import { extractPlaceholders, replacePlaceholders } from "@/utils/placeholder"; // config write needs the admin password (script plugin) export const ADMIN_PASSWORD_REQUIRED = 428; const allowAdminPasswordRequired = (status: number) => (status >= 200 && status < 300) || status === ADMIN_PASSWORD_REQUIRED; function adminPasswordHeader(adminPassword = ""): Record { return adminPassword ? { "X-Admin-Password": adminPassword } : {}; } export type Product = { group: string; name: string; template: string; }; export type Template = { Params: TemplateParam[]; Auth?: { type: string; params?: string[]; }; Requirements: { Description: string; }; }; export type TemplateParamUsage = "vehicle" | "battery" | "grid" | "pv" | "charger" | "aux" | "ext"; export type TemplateParam = { Name: string; Required: boolean; Advanced: boolean; Deprecated: boolean; Default?: string | number | boolean; Type?: string; Choice?: string[]; Service?: string; Usages?: TemplateParamUsage[]; }; export type ParamService = { name: string; service: string; url: (values: Record) => string; }; export type ModbusCapability = "rs485" | "tcpip"; export type ModbusParam = TemplateParam & { ID?: string; Comset?: MODBUS_COMSET; Baudrate?: number; Port?: number; }; export type DeviceValues = { type: ConfigType; icon?: string; deviceProduct?: string; yaml?: string; template: string | null; deviceTitle?: string; deviceIcon?: string; deviceDisable?: boolean; usage?: MeterTemplateUsage; heating?: boolean; integrateddevice?: boolean; stationid?: string; [key: string]: any; }; export type ApiData = { type?: ConfigType; icon?: string; usage?: MeterTemplateUsage; title?: string; priority?: number; identifiers?: string[]; [key: string]: any; }; export type AuthCheckResponse = { success: boolean; error?: string; authId?: string; }; export function handleError(e: any, msg: string) { console.error(e); let message = msg; const { error } = e.response.data || {}; if (error) message += `: ${error}`; alert(message); } export function applyDefaultsFromTemplate(template: Template | null, values: DeviceValues) { const params = template?.Params || []; params.forEach((p) => { if (p.Default && !values[p.Name]) { values[p.Name] = p.Default; } else if (p.Type === "Bool" && values[p.Name] === undefined) { values[p.Name] = false; // initialize } }); } export function customChargerName(type: ConfigType, isHeating: boolean) { if (!type) { return "config.general.customOption"; } const prefix = "config.charger.type."; const suffix = isHeating ? ".heating" : ".charging"; if (type === ConfigType.Custom) { return `${prefix}custom${suffix}`; } return `${prefix}${type}`; } // flattenDeviceConfig converts a GET /config/devices/:class/:id response // into the flat shape expected by POST/PUT/test (id and name are dropped). // // GET (config = device-specfic): // { // id: 26, // name: "db:26", // type: "template", // deviceTitle: "Espresso", // device__: "..", // config: { // template: "tasmota", // host: "192.168.1.2" // } // } // // PUT|POST|test (flat): // { // type: "template", // deviceTitle: "Espresso", // device__: "..", // template: "tasmota", // host: "192.168.1.2" // } // // TODO: align GET and PUT shapes — always nest device-specific values under // `config` and drop the artificial `device` prefix (deviceTitle → title, ...) // matching db structure. Once the API is symmetric this helper goes away. export function flattenDeviceConfig(dev: any): Record { const { id, name, config, ...rest } = dev; const flat = { ...config, ...rest }; // title/icon are extracted from yaml on GET for display only; writing them // back is rejected ("cannot mix yaml and other") if (flat.yaml) { delete flat.title; delete flat.icon; } return flat; } export async function loadServiceValues(path: string) { try { const response = await api.get(`/config/service/${path}`, { validateStatus: (status) => status >= 200 && status < 500, }); return (response.data as string[]) || []; } catch { return []; } } // Expand {modbus} to actual connection params based on values const expandModbus = (service: string, values: Record): string => { if (!service.includes("{modbus}")) return service; if (values["device"]) { return service.replace( "{modbus}", "device={device}&baudrate={baudrate}&comset={comset}&id={id}" ); } if (values["host"]) { return service.replace("{modbus}", "uri={host}:{port}&id={id}"); } return service; }; 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 && val !== "" && key !== "modbus") acc[key] = String(val); return acc; }, {} as Record ); return { name: param.Name, service: param.Service, url: (values: Record) => replacePlaceholders(expandModbus(param.Service!, values), 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 url = endpoint.url(values); if (extractPlaceholders(url).length > 0) { // missing values, not all placeholders are filled return; } const data = await loadServiceValues(url); if (data) { result[endpoint.name] = data; } }) ); return result; }; export function createDeviceUtils(deviceType: DeviceType) { function test(id: number | undefined, data: any, adminPassword = "") { let url = `config/test/${deviceType}`; if (id !== undefined) { url += `/merge/${id}`; } const opts = { headers: adminPasswordHeader(adminPassword), validateStatus: allowAdminPasswordRequired, }; return api.post(url, data, opts); } function update(id: number, data: any, force = false, adminPassword = "") { const opts = { headers: adminPasswordHeader(adminPassword), validateStatus: allowAdminPasswordRequired, params: { force }, }; return api.put(`config/devices/${deviceType}/${id}`, data, opts); } function remove(id: number) { return api.delete(`config/devices/${deviceType}/${id}`); } // disable flips the disable flag by re-PUTing the existing config. // force=true so a broken device can still be toggled. async function disable(id: number, disable: boolean) { const dev = (await api.get(`config/devices/${deviceType}/${id}`)).data; const body = { ...flattenDeviceConfig(dev), deviceDisable: disable }; return api.put(`config/devices/${deviceType}/${id}`, body, { params: { force: true } }); } async function load(id: number) { const response = await api.get(`config/devices/${deviceType}/${id}`); return response.data; } function create(data: any, force = false, adminPassword = "") { const opts = { headers: adminPasswordHeader(adminPassword), validateStatus: allowAdminPasswordRequired, params: { force }, }; return api.post(`config/devices/${deviceType}`, data, opts); } async function loadProducts(lang?: string, usage?: string) { const params: Record = { lang }; if (usage) { params["usage"] = usage; } const response = await api.get(`config/products/${deviceType}`, { params }); return response.data; } async function loadTemplate(templateName: string, lang?: string) { if (!templateName) return null; const opts = { params: { lang, name: templateName, }, }; const response = await api.get(`config/templates/${deviceType}`, opts); return response.data; } async function checkAuth( type: string, values: Record, id?: number ): Promise { const body = { type, ...values }; let url = `config/auth`; if (id !== undefined) { url += `/${deviceType}/merge/${id}`; } try { const { status, data = {} } = await api.post(url, body, { validateStatus: (status) => [204, 400].includes(status), }); // already set up if (status === 204) { return { success: true }; } // auth error, user has to perform login if (status === 400) { return { success: false, error: data?.error, authId: data?.loginRequired }; } } catch (error) { return { success: false, error: (error as any).message }; } return { success: false, error: "unexpected error" }; } return { test, update, remove, disable, load, create, loadProducts, loadTemplate, loadServiceValues, checkAuth, }; }