Add support for loading and storing device configuration from/to database (#6199)

This commit is contained in:
andig 2023-08-17 19:59:31 +02:00 • committed by GitHub
parent 8c68689c1d
commit 0854849243
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
86 changed files with 3259 additions and 2692 deletions

View file

@ -408,6 +408,19 @@ input::-webkit-datetime-edit {
--bs-tooltip-color: var(--bs-gray-deep);
}
.card {
--bs-card-bg: var(--evcc-background);
}
.form-control {
background-color: var(--evcc-box);
color: var(--evcc-default-text);
}
input[type="time"]::-webkit-calendar-picker-indicator {
display: none;
}
.table {
--bs-table-bg: transparent;
}

View file

@ -3,6 +3,7 @@ import smoothscroll from "smoothscroll-polyfill";
import "../css/app.css";
import { createApp, h } from "vue";
import { createMetaManager, plugin as metaPlugin } from "vue-meta";
import Vue3linkify from "vue-3-linkify";
import App from "./views/App.vue";
import setupRouter from "./router";
import setupI18n from "./i18n";
@ -68,6 +69,7 @@ app.use(setupRouter(i18n));
app.use(createMetaManager());
app.use(metaPlugin);
app.use(featureflags);
app.use(Vue3linkify);
window.app = app.mount("#app");
watchThemeChanges();

View file

@ -0,0 +1,31 @@
<template>
<div class="mb-3">
<label :for="id">
<div class="form-label">
{{ label }}
<small v-if="optional">{{ $t("config.form.optional") }}</small>
</div>
</label>
<div :class="smallValue ? 'w-50' : 'w-100'">
<slot />
</div>
<div class="form-text">
<div v-if="example">{{ $t("config.form.example") }}: {{ example }}</div>
<div v-if="help" v-linkify:options="{ target: '_blank' }">{{ help }}</div>
</div>
</div>
</template>
<script>
export default {
name: "FormRow",
props: {
id: String,
label: String,
help: String,
optional: Boolean,
smallValue: Boolean,
example: String,
},
};
</script>

View file

@ -0,0 +1,126 @@
<template>
<div v-if="unit" class="input-group w-100">
<input
:id="id"
v-model="value"
:type="type"
:placeholder="placeholder"
:required="required"
aria-label="unit"
:aria-describedby="id + '_unit'"
class="form-control"
/>
<span :id="id + '_unit'" class="input-group-text">{{ unit }}</span>
</div>
<div v-else-if="icons" :id="id" class="d-flex flex-wrap">
<div
v-for="{ key } in selectOptions"
v-show="key === value || selectMode"
:key="key"
class="me-2 mb-2"
>
<input
:id="`icon_${key}`"
v-model="value"
type="radio"
class="btn-check"
:name="property"
autocomplete="off"
:required="required"
:value="key"
@click="toggleSelectMode"
/>
<label class="btn btn-outline-secondary" :for="`icon_${key}`" :aria-label="key">
<VehicleIcon :name="key" />
</label>
</div>
</div>
<select v-else-if="select" :id="id" v-model="value" class="form-select">
<option v-if="!required" value="">---</option>
<option v-for="{ key, name } in selectOptions" :key="key" :value="key">
{{ name }}
</option>
</select>
<textarea
v-else-if="textarea"
:id="id"
v-model="value"
class="form-control"
:type="type"
:placeholder="placeholder"
:required="required"
rows="4"
/>
<input
v-else
:id="id"
v-model="value"
class="form-control"
:type="type"
:placeholder="placeholder"
:required="required"
/>
</template>
<script>
import VehicleIcon from "../VehicleIcon";
export default {
name: "PropertyField",
components: { VehicleIcon },
props: {
id: String,
property: String,
masked: Boolean,
placeholder: String,
required: Boolean,
validValues: { type: Array, default: () => [] },
modelValue: [String, Number, Boolean, Object],
},
emits: ["update:modelValue"],
data: () => {
return { selectMode: false };
},
computed: {
type() {
return this.masked ? "password" : "text";
},
unit() {
if (this.property === "capacity") {
return "kWh";
}
return null;
},
icons() {
return this.property === "icon";
},
textarea() {
return ["accessToken", "refreshToken"].includes(this.property);
},
select() {
return this.validValues.length > 0;
},
selectOptions() {
return this.validValues.map((value) => ({
key: value,
name: this.$t(`config.options.${this.property}.${value}`),
}));
},
value: {
get() {
return this.modelValue;
},
set(value) {
this.$emit("update:modelValue", value);
},
},
},
methods: {
toggleSelectMode() {
this.$nextTick(() => {
this.selectMode = !this.selectMode;
});
},
},
};
</script>

View file

@ -0,0 +1,398 @@
<template>
<Teleport to="body">
<div
id="vehicleModal"
ref="modal"
class="modal fade text-dark"
data-bs-backdrop="true"
tabindex="-1"
role="dialog"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ $t(`config.vehicle.${isNew ? "titleAdd" : "titleEdit"}`) }}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<form ref="form" class="container mx-0 px-0">
<FormRow id="vehicleTemplate" :label="$t('config.vehicle.template')">
<select
id="vehicleTemplate"
v-model="templateName"
:disabled="!isNew"
class="form-select w-100"
@change="templateChanged"
>
<option value="offline">
{{ $t("config.vehicle.offline") }}
</option>
<option disabled>----------</option>
<optgroup :label="$t('config.vehicle.online')">
<option
v-for="option in templateOptions.online"
:key="option.name"
:value="option.template"
>
{{ option.name }}
</option>
</optgroup>
<optgroup :label="$t('config.vehicle.scooter')">
<option
v-for="option in templateOptions.scooter"
:key="option.name"
:value="option.template"
>
{{ option.name }}
</option>
</optgroup>
<optgroup :label="$t('config.vehicle.generic')">
<option
v-for="option in templateOptions.generic"
:key="option.name"
:value="option.template"
>
{{ option.name }}
</option>
</optgroup>
</select>
</FormRow>
<FormRow
v-for="param in templateParams"
:id="`vehicleParam${param.Name}`"
:key="param.Name"
:optional="!param.Required"
:label="param.Description || `[${param.Name}]`"
:help="param.Description === param.Help ? undefined : param.Help"
:small-value="['capacity'].includes(param.Name)"
:example="param.Example"
>
<PropertyField
:id="`vehicleParam${param.Name}`"
v-model="values[param.Name]"
:masked="param.Mask"
:property="param.Name"
class="me-2"
:required="param.Required"
:validValues="param.ValidValues"
/>
</FormRow>
<div
v-if="templateName"
class="alert my-4"
:class="{
'alert-secondary': testUnknown || testRunning,
'alert-success': testSuccess,
'alert-danger': testFailed,
}"
role="alert"
>
<div class="d-flex justify-content-between align-items-center">
<div>
{{ $t("config.validation.label") }}:
<span v-if="testUnknown">{{
$t("config.validation.unknown")
}}</span>
<span v-if="testRunning">{{
$t("config.validation.running")
}}</span>
<strong v-if="testSuccess">{{
$t("config.validation.success")
}}</strong>
<strong v-if="testFailed">{{
$t("config.validation.failed")
}}</strong>
</div>
<a href="#" class="alert-link" @click.prevent="test">
{{ $t("config.validation.validate") }}
</a>
</div>
<hr v-if="testResult" />
<div v-if="testResult">
{{ testResult }}
</div>
</div>
<div v-if="templateName" class="my-4">
<button
type="submit"
class="btn btn-primary me-3"
:disabled="testRunning"
@click.prevent="isNew ? create() : update()"
>
{{
testUnknown
? $t("config.vehicle.validateSave")
: $t("config.vehicle.save")
}}
</button>
<button
type="button"
class="btn btn-link text-muted"
data-bs-dismiss="modal"
>
{{ $t("config.vehicle.cancel") }}
</button>
</div>
<div v-if="isDeletable" class="text-center mt-4">
<button
type="button"
class="btn btn-link text-danger"
@click.prevent="remove"
>
{{ $t("config.vehicle.delete") }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script>
import FormRow from "./FormRow.vue";
import PropertyField from "./PropertyField.vue";
import api from "../../api";
const initialValues = { type: "template", icon: "car" };
const TEST_UNKNOWN = "unknown";
const TEST_SUCCESS = "success";
const TEST_FAILED = "failed";
const TEST_RUNNING = "running";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export default {
name: "VehicleModal",
components: { FormRow, PropertyField },
props: {
id: Number,
},
emits: ["vehicle-changed"],
data() {
return {
isModalVisible: false,
templates: [],
products: [],
templateName: null,
template: null,
values: { ...initialValues },
testResult: "",
testState: TEST_UNKNOWN,
};
},
computed: {
testRunning() {
return this.testState === TEST_RUNNING;
},
testSuccess() {
return this.testState === TEST_SUCCESS;
},
testFailed() {
return this.testState === TEST_FAILED;
},
testUnknown() {
return this.testState === TEST_UNKNOWN;
},
templateOptions() {
return {
online: this.products.filter((p) => !p.group && p.template !== "offline"),
generic: this.products.filter((p) => p.group === "generic"),
scooter: this.products.filter((p) => p.group === "scooter"),
};
},
templateParams() {
const params = this.template?.Params || [];
const filteredParams = params.filter((p) => !p.Advanced || p.Name === "icon");
const adjustedParams = filteredParams.map((p) => {
if (p.Name === "title" || p.Name === "icon") {
p.Required = true;
}
return p;
});
return adjustedParams;
},
apiData() {
return {
template: this.templateName,
...this.values,
};
},
isNew() {
return this.id === undefined;
},
isDeletable() {
return !this.isNew;
},
},
watch: {
isModalVisible(visible) {
if (visible) {
this.reset();
this.templateName = "offline";
this.resetTest();
this.loadProducts();
if (this.id !== undefined) {
this.loadConfiguration();
}
}
},
templateName() {
this.loadTemplate();
},
values: {
handler() {
this.resetTest();
},
deep: true,
},
},
mounted() {
this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal.addEventListener("hide.bs.modal", this.modalInvisible);
},
unmounted() {
this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal?.removeEventListener("hide.bs.modal", this.modalInvisible);
},
methods: {
reset() {
this.values = { ...initialValues };
this.resetTest();
},
resetTest() {
this.testState = TEST_UNKNOWN;
this.testResult = null;
},
async loadConfiguration() {
try {
const vehicle = (await api.get(`config/devices/vehicle/${this.id}`)).data.result;
this.values = vehicle.config;
this.applyDefaultsFromTemplate();
this.templateName = this.values.template;
} catch (e) {
console.error(e);
}
},
async loadProducts() {
try {
this.products = (await api.get("config/products/vehicle")).data.result;
} catch (e) {
console.error(e);
}
},
async loadTemplate() {
try {
const opts = {
params: {
lang: this.$i18n.locale,
name: this.templateName,
},
};
this.template = (await api.get("config/templates/vehicle", opts)).data.result;
this.applyDefaultsFromTemplate();
} catch (e) {
console.error(e);
}
},
templateChanged() {
this.reset();
},
applyDefaultsFromTemplate() {
const params = this.template?.Params || [];
params
.filter((p) => p.Default && !this.values[p.Name])
.forEach((p) => {
this.values[p.Name] = p.Default;
});
},
async test() {
if (!this.$refs.form.reportValidity()) return false;
this.testState = TEST_RUNNING;
try {
let url = "config/test/vehicle";
if (!this.isNew) {
url += `/${this.id}`;
}
await api.post(url, this.apiData);
this.testState = TEST_SUCCESS;
this.testResult = null;
return true;
} catch (e) {
console.error(e);
this.testState = TEST_FAILED;
this.testResult = e.response?.data?.error || e.message;
}
return false;
},
async create() {
if (this.testUnknown) {
const success = await this.test();
if (!success) return;
await sleep(250);
}
try {
await api.post("config/devices/vehicle", this.apiData);
this.$emit("vehicle-changed");
this.modalInvisible();
} catch (e) {
console.error(e);
alert("create failed");
}
},
async update() {
if (this.testUnknown) {
const success = await this.test();
if (!success) return;
await sleep(250);
}
try {
await api.put(`config/devices/vehicle/${this.id}`, this.apiData);
this.$emit("vehicle-changed");
this.modalInvisible();
} catch (e) {
console.error(e);
alert("update failed");
}
},
async remove() {
try {
await api.delete(`config/devices/vehicle/${this.id}`);
this.$emit("vehicle-changed");
this.modalInvisible();
} catch (e) {
console.error(e);
alert("delete failed");
}
},
modalVisible() {
this.isModalVisible = true;
},
modalInvisible() {
this.isModalVisible = false;
},
},
};
</script>
<style scoped>
.container {
margin-left: calc(var(--bs-gutter-x) * -0.5);
margin-right: calc(var(--bs-gutter-x) * -0.5);
padding-right: 0;
}
</style>

View file

@ -1,9 +1,15 @@
<template>
<div class="mb-4 row">
<label :for="id" class="col-sm-4 col-form-label pt-0 pt-sm-1">
{{ label }}
<label
:for="id"
:class="`col-sm-${leftWidth} col-form-label d-sm-flex align-items-sm-baseline`"
>
<div class="w-100">
<span class="label">{{ label }}</span>
<small v-if="optional" class="d-sm-block ms-2 ms-sm-0">optional</small>
</div>
</label>
<div class="col-sm-8">
<div :class="`col-sm-${rightWidth}`">
<slot />
</div>
</div>
@ -15,6 +21,23 @@ export default {
props: {
id: String,
label: String,
optional: Boolean,
smallValue: Boolean,
},
computed: {
leftWidth() {
return this.smallValue ? "6" : "4";
},
rightWidth() {
return this.smallValue ? "6" : "8";
},
},
};
</script>
<style scoped>
.label {
-webkit-hyphens: auto;
hyphens: auto;
max-width: 100%;
}
</style>

View file

@ -23,7 +23,6 @@
:tariffCo2="tariffCo2"
:currency="currency"
/>
<VehcileSettingsModal />
<Footer v-bind="footer"></Footer>
</div>
</div>
@ -33,7 +32,6 @@
import "@h2d2/shopicons/es/regular/arrowup";
import TopNavigation from "./TopNavigation.vue";
import Notifications from "./Notifications.vue";
import VehcileSettingsModal from "./VehicleSettingsModal.vue";
import Energyflow from "./Energyflow/Energyflow.vue";
import Loadpoints from "./Loadpoints.vue";
import Footer from "./Footer.vue";
@ -48,7 +46,6 @@ export default {
Footer,
Notifications,
TopNavigation,
VehcileSettingsModal,
},
mixins: [formatter, collector],
props: {

View file

@ -26,7 +26,6 @@
{{ $t("header.sessions") }}
</router-link>
</li>
<li>
<button type="button" class="dropdown-item" @click="openSettingsModal">
<span
@ -36,6 +35,9 @@
{{ $t("header.settings") }}
</button>
</li>
<li v-if="$hiddenFeatures()">
<router-link class="dropdown-item" to="/config"> Configuration 🧪 </router-link>
</li>
<template v-if="providerLogins.length > 0">
<li><hr class="dropdown-divider" /></li>
<li>

View file

@ -47,7 +47,7 @@ export default {
return [...new Set(this.names || [this.name])];
},
count: function () {
return this.names.length;
return this.names?.length || 0;
},
single: function () {
return this.uniqueNames.length == 1;

View file

@ -23,17 +23,6 @@
{{ $t("main.vehicle.unknown") }}
</button>
</li>
<div v-if="$hiddenFeatures()">
<div class="dropdown-divider"></div>
<li>
<h6 class="dropdown-header">{{ $t("main.vehicle.moreActions") }}</h6>
</li>
<li>
<button type="button" class="dropdown-item" @click="addVehicle">
{{ $t("main.vehicle.addVehicle") }} 🧪
</button>
</li>
</div>
</ul>
</div>
</template>
@ -41,7 +30,6 @@
<script>
import "@h2d2/shopicons/es/filled/options";
import Dropdown from "bootstrap/js/dist/dropdown";
import Modal from "bootstrap/js/dist/modal";
export default {
name: "VehicleOptions",
@ -50,7 +38,7 @@ export default {
vehicles: Array,
isUnknown: Boolean,
},
emits: ["change-vehicle", "remove-vehicle", "add-vehicle"],
emits: ["change-vehicle", "remove-vehicle"],
computed: {
dropdownId() {
return `vehicleOptionsDropdown${this.id}`;
@ -69,14 +57,6 @@ export default {
removeVehicle() {
this.$emit("remove-vehicle");
},
addVehicle() {
this.$emit("remove-vehicle");
const modal = Modal.getOrCreateInstance(
document.getElementById("vehicleSettingsModal")
);
modal.show();
},
},
};
</script>

View file

@ -1,193 +0,0 @@
<template>
<Teleport to="body">
<div
id="vehicleSettingsModal"
ref="modal"
class="modal fade text-dark"
data-bs-backdrop="true"
tabindex="-1"
role="dialog"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add New Vehicle 🧪</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<div class="container mx-0 px-0">
<FormRow id="vehicleTemplate" :label="$t('vehicleSettings.template')">
<select
id="vehicleTemplate"
v-model="template"
class="form-select form-select-sm w-100"
>
<option
v-for="option in vehicleOptions"
:key="option.name"
:value="option.value"
>
{{ option.name }}
</option>
</select>
</FormRow>
<FormRow
v-for="param in templateParams"
:id="`vehicleParam${param.Name}`"
:key="param.Name"
:label="param.Description.EN || `[${param.Name}]`"
>
<input
:id="`vehicleParam${param.Name}`"
v-model="values[param.Name]"
:type="param.Mask ? 'password' : 'text'"
class="w-100 me-2"
:placeholder="param.Example"
:required="param.Required"
/>
</FormRow>
<div class="buttons d-flex justify-content-between mb-4">
<button
type="button"
class="btn btn-outline-secondary"
data-bs-dismiss="modal"
>
{{ $t("vehicleSettings.cancel") }}
</button>
<button type="submit" class="btn btn-primary" @click="test">
{{ $t("vehicleSettings.test") }}
</button>
</div>
<div class="card result">
<div class="card-body">
<pre><code>{{ configYaml }}</code></pre>
<code
v-if="testResult"
:class="testSuccess ? 'text-success' : 'text-danger'"
>
<hr />
{{ testResult }}
</code>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script>
import FormRow from "./FormRow.vue";
import api from "../api";
import YAML from "json-to-pretty-yaml";
export default {
name: "VehicleSettingsModal",
components: { FormRow },
data() {
return {
isModalVisible: false,
templates: [],
template: null,
values: {},
testResult: "",
testSuccess: false,
};
},
computed: {
vehicleOptions() {
const result = [];
this.templates.forEach((t) => {
t.Products.forEach((p) => {
const value = t.Template;
let name = this.productName(p);
result.push({ name, value });
});
});
result.sort((a, b) => a.name.localeCompare(b.name));
return result;
},
templateParams() {
const params = this.templates.find((t) => t.Template === this.template)?.Params || [];
return params.filter((p) => !p.Advanced);
},
configYaml() {
return YAML.stringify([
{
name: "my_vehicle",
...this.apiData,
},
]);
},
apiData() {
return {
template: this.template,
...this.values,
};
},
},
watch: {
isModalVisible(visible) {
if (visible) {
this.loadTemplates();
}
},
template() {
this.values = {};
},
},
mounted() {
this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal.addEventListener("hidden.bs.modal", this.modalInvisible);
},
unmounted() {
this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal?.removeEventListener("hidden.bs.modal", this.modalInvisible);
},
methods: {
productName({ Brand, Description }) {
const brand = Brand || "";
const description = Description.Generic || Description.EN || "";
return `${brand} ${description}`.trim();
},
async loadTemplates() {
try {
this.templates = (await api.get("config/templates/vehicle")).data.result;
} catch (e) {
console.error(e);
}
},
async test() {
try {
this.testResult = (await api.post("config/test/vehicle", this.apiData)).data.result;
this.testSuccess = true;
} catch (e) {
console.error(e);
this.testSuccess = false;
this.testResult = e.response?.data?.error || e.message;
}
},
modalVisible() {
this.isModalVisible = true;
},
modalInvisible() {
this.isModalVisible = false;
},
},
};
</script>
<style scoped>
.container {
margin-left: calc(var(--bs-gutter-x) * -0.5);
margin-right: calc(var(--bs-gutter-x) * -0.5);
padding-right: 0;
}
</style>

View file

@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from "vue-router";
import Main from "./views/Main.vue";
import ChargingSessions from "./views/ChargingSessions.vue";
import Config from "./views/Config.vue";
import { ensureCurrentLocaleMessages } from "./i18n";
export default function setupRouter(i18n) {
@ -9,6 +10,7 @@ export default function setupRouter(i18n) {
history: createWebHashHistory(),
routes: [
{ path: "/", component: Main, props: true },
{ path: "/config", component: Config, props: true },
{
path: "/sessions",
component: ChargingSessions,

View file

@ -651,15 +651,6 @@ export default {
position: relative;
top: -3px;
}
.custom-select {
left: 0;
top: 0;
bottom: 0;
right: 0;
position: absolute;
opacity: 0;
-webkit-appearance: menulist-button;
}
.table {
border-collapse: separate;
border-spacing: 0;

274
assets/js/views/Config.vue Normal file
View file

@ -0,0 +1,274 @@
<template>
<div class="container px-4">
<header class="d-flex justify-content-between align-items-center py-3 mb-4">
<h1 class="mb-1 pt-1 d-flex text-nowrap">
<router-link class="dropdown-item mx-2 me-2" to="/">
<shopicon-bold-arrowback size="s" class="back"></shopicon-bold-arrowback>
</router-link>
Configuration 🧪
</h1>
<TopNavigation />
</header>
<div class="alert alert-danger mb-5" role="alert">
<strong>Highly experimental!</strong> Only play around with this settings if you know
what your doing. Otherwise you might have to reset or manually repair you database.
</div>
<!--
<h2 class="d-flex align-items-center text-evcc">
<shopicon-regular-powersupply size="m" class="me-2"></shopicon-regular-powersupply>
Grid
</h2>
<div>
<ul class="p-0">
<li class="d-flex align-items-center">
Grid meter <small class="mx-2">E3/DC</small>
<button class="btn btn-sm btn-link text-gray" @click="todo">edit</button>
</li>
<li class="d-flex align-items-center">
Grid use <small class="ms-2">32ct/kWh fixed</small>
<button class="btn btn-sm btn-link text-gray" @click="todo">edit</button>
</li>
<li class="d-flex align-items-center">
Grid feed-in <small class="ms-2">8ct/kWh fixed</small>
<button class="btn btn-sm btn-link text-gray" @click="todo">edit</button>
</li>
<li class="d-flex align-items-center">
Grid CO₂ <small class="ms-2">none</small>
<button class="btn btn-sm btn-link text-gray" @click="todo">configure</button>
</li>
</ul>
</div>
<hr />
<h2 class="d-flex align-items-center text-evcc">
<shopicon-regular-sun size="m" class="me-2"></shopicon-regular-sun>
Photovoltaics
</h2>
<div>
<ul class="p-0">
<li v-for="(meter, index) in pvs" :key="index" class="d-flex align-items-center">
PV {{ index + 1 }}
<shopicon-regular-warning
v-if="index === 3"
size="s"
class="text-warning ms-2"
></shopicon-regular-warning>
<button class="btn btn-sm btn-link text-gray" @click="todo">edit</button>
</li>
<li class="d-flex align-items-center">
<button class="btn btn-sm btn-link text-gray px-0" @click="todo">
+ add PV
</button>
</li>
</ul>
</div>
<hr />
<h2 class="d-flex align-items-center text-evcc">
<shopicon-regular-batterythreequarters
size="m"
class="me-2"
></shopicon-regular-batterythreequarters>
Battery storage
</h2>
<div>
<ul class="p-0">
<li
v-for="(meter, index) in batteries"
:key="index"
class="d-flex align-items-center"
>
Battery {{ index + 1 }}
<button class="btn btn-sm btn-link text-gray" @click="todo">edit</button>
</li>
<li class="d-flex align-items-center">
<button class="btn btn-sm btn-link text-gray px-0" @click="todo">
+ add battery
</button>
</li>
</ul>
</div>
<hr />
<h2 class="d-flex align-items-center text-evcc">
<shopicon-regular-lightning size="m" class="me-2"></shopicon-regular-lightning>
Loadpoints
</h2>
<div>Bar</div>
<hr />
-->
<h2 class="my-4">My Vehicles</h2>
<div>
<ul class="p-0 config-list">
<li
v-for="(vehicle, index) in vehicles"
:key="index"
class="config-entry"
data-testid="vehicle"
>
<div class="d-flex align-items-center mb-2">
<VehicleIcon :name="vehicle.config?.icon" class="me-2" />
<span class="flex-grow-1 text-nowrap text-truncate">{{
vehicle.config?.title || vehicle.name
}}</span>
<button
v-if="vehicle.id"
class="btn btn-sm btn-link text-gray"
@click="editVehicle(vehicle.id)"
>
{{ $t("config.main.edit") }}
</button>
<span v-else class="text-gray opacity-50 px-2 py-1" disabled>yaml</span>
</div>
<div v-if="false" class="d-flex mb-2" @click="todo">
<!-- example for later -->
<span
v-if="index > 1 && index < 6"
title="Charge"
class="badge text-bg-secondary me-1 mb-1"
>33%</span
>
<span
v-if="index > 2"
title="Capacity"
class="badge text-bg-secondary me-1 mb-1"
>42 kWh</span
>
<span
v-if="index < 4"
title="Power"
class="badge text-bg-secondary me-1 mb-1"
>+4,3 kW</span
>
<span title="Odometer" class="badge text-bg-secondary me-1 mb-1"
>23.123 km</span
>
<span
v-if="index == 2"
title="Climate"
class="badge text-bg-secondary me-1 mb-1"
>climate</span
>
</div>
</li>
</ul>
<button class="btn btn-outline-secondary" @click="addVehicle">
{{ $t("config.main.addVehicle") }}
</button>
<VehicleModal :id="vehicleId" @vehicle-changed="vehicleChanged" />
</div>
<hr class="my-5" />
<!--
<h2 class="d-flex align-items-center text-evcc">
<shopicon-regular-megaphone size="m" class="me-2"></shopicon-regular-megaphone>
Notifications
</h2>
<div>Bar</div>
<hr />
-->
</div>
</template>
<script>
import TopNavigation from "../components/TopNavigation.vue";
import "@h2d2/shopicons/es/bold/arrowback";
import "@h2d2/shopicons/es/regular/powersupply";
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/warning";
import "@h2d2/shopicons/es/regular/car3";
import "@h2d2/shopicons/es/regular/megaphone";
import "@h2d2/shopicons/es/regular/lightning";
import "@h2d2/shopicons/es/regular/batterythreequarters";
import "@h2d2/shopicons/es/regular/home";
import Modal from "bootstrap/js/dist/modal";
import api from "../api";
import VehicleIcon from "../components/VehicleIcon";
import VehicleModal from "../components/Config/VehicleModal.vue";
export default {
name: "Config",
components: { TopNavigation, VehicleIcon, VehicleModal },
data() {
return {
vehicles: [],
loadpoints: [],
pvs: [],
batteries: [],
grid: null,
vehicleId: undefined,
};
},
mounted() {
this.loadVehicles();
this.loadLoadpoints();
this.loadMeters();
},
methods: {
async loadVehicles() {
const response = await api.get("/config/devices/vehicle");
this.vehicles = response.data?.result;
},
async loadLoadpoints() {
// TODO: add GET loadpoints API
const response = await api.get("/config/devices/charger");
this.loadpoints = response.data?.result;
},
async loadMeters() {
// TODO: split this into separate endpoints for pv, battery, grid
const response = await api.get("/config/devices/meter");
const meters = response.data?.result || [];
this.pvs = meters;
this.batteries = meters;
this.grid = meters[0];
},
vehicleModal() {
return Modal.getOrCreateInstance(document.getElementById("vehicleModal"));
},
editVehicle(id) {
this.vehicleId = id;
this.vehicleModal().show();
},
addVehicle() {
this.vehicleId = undefined;
this.vehicleModal().show();
},
vehicleChanged() {
this.vehicleId = undefined;
this.vehicleModal().hide();
this.loadVehicles();
},
todo() {
alert("not implemented yet");
},
},
};
</script>
<style scoped>
.back {
width: 22px;
height: 22px;
position: relative;
top: -2px;
}
.container {
max-width: 700px;
}
.config-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
grid-gap: 1rem;
}
.config-entry {
border-radius: 1rem;
color: var(--evcc-default-text);
background: var(--evcc-box);
padding: 1rem 1rem 0.5rem;
display: block;
list-style-type: none;
}
</style>

View file

@ -1,11 +1,11 @@
package cmd
import (
"fmt"
"strconv"
"strings"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util/config"
"github.com/spf13/cobra"
)
@ -13,6 +13,7 @@ import (
var chargerCmd = &cobra.Command{
Use: "charger [name]",
Short: "Query configured chargers",
Args: cobra.MaximumNArgs(1),
Run: runCharger,
}
@ -20,7 +21,6 @@ const noCurrent = -1
func init() {
rootCmd.AddCommand(chargerCmd)
chargerCmd.PersistentFlags().StringP(flagName, "n", "", fmt.Sprintf(flagNameDescription, "charger"))
chargerCmd.Flags().IntP(flagCurrent, "i", noCurrent, flagCurrentDescription)
//lint:ignore SA1019 as Title is safe on ascii
chargerCmd.Flags().BoolP(flagEnable, "e", false, strings.Title(flagEnable))
@ -44,24 +44,14 @@ func runCharger(cmd *cobra.Command, args []string) {
}
// select single charger
if err := selectByName(cmd, &conf.Chargers); err != nil {
if err := selectByName(args, &conf.Chargers); err != nil {
log.FATAL.Fatal(err)
}
if err := cp.configureChargers(conf); err != nil {
if err := configureChargers(conf.Chargers); err != nil {
log.FATAL.Fatal(err)
}
chargers := cp.chargers
if len(args) == 1 {
name := args[0]
charger, err := cp.Charger(name)
if err != nil {
log.FATAL.Fatal(err)
}
chargers = map[string]api.Charger{name: charger}
}
current := int64(noCurrent)
if flag := cmd.Flags().Lookup(flagCurrent); flag.Changed {
var err error
@ -80,8 +70,10 @@ func runCharger(cmd *cobra.Command, args []string) {
}
}
chargers := config.Chargers().Devices()
var flagUsed bool
for _, v := range chargers {
for _, v := range config.Instances(chargers) {
if current != noCurrent {
flagUsed = true
@ -135,8 +127,10 @@ func runCharger(cmd *cobra.Command, args []string) {
d := dumper{len: len(chargers)}
flag := cmd.Flags().Lookup(flagDiagnose).Changed
for name, v := range chargers {
d.DumpWithHeader(name, v)
for _, dev := range chargers {
v := dev.Instance()
d.DumpWithHeader(dev.Config().Name, v)
if flag {
d.DumpDiagnosis(v)
}

View file

@ -7,15 +7,15 @@ import (
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util/config"
"github.com/spf13/cobra"
)
// cmdEcho.AddCommand
// chargerRampCmd represents the charger command
var chargerRampCmd = &cobra.Command{
Use: "ramp [name]",
Short: "Ramp current from 6..16A in configurable steps",
Args: cobra.MaximumNArgs(1),
Run: runChargerRamp,
}
@ -76,24 +76,14 @@ func runChargerRamp(cmd *cobra.Command, args []string) {
}
// select single charger
if err := selectByName(cmd, &conf.Chargers); err != nil {
if err := selectByName(args, &conf.Chargers); err != nil {
log.FATAL.Fatal(err)
}
if err := cp.configureChargers(conf); err != nil {
if err := configureChargers(conf.Chargers); err != nil {
log.FATAL.Fatal(err)
}
chargers := cp.chargers
if len(args) == 1 {
name := args[0]
charger, err := cp.Charger(name)
if err != nil {
log.FATAL.Fatal(err)
}
chargers = map[string]api.Charger{name: charger}
}
digits, err := strconv.Atoi(cmd.Flags().Lookup(flagDigits).Value.String())
if err != nil {
log.ERROR.Fatalln(err)
@ -104,11 +94,13 @@ func runChargerRamp(cmd *cobra.Command, args []string) {
log.ERROR.Fatalln(err)
}
for _, c := range chargers {
if _, ok := c.(api.ChargerEx); digits > 0 && !ok {
chargers := config.Chargers().Devices()
for _, v := range config.Instances(chargers) {
if _, ok := v.(api.ChargerEx); digits > 0 && !ok {
log.ERROR.Fatalln("charger does not support mA control")
}
ramp(c, digits, delay)
ramp(v, digits, delay)
}
// wait for shutdown

View file

@ -1,352 +0,0 @@
package cmd
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/dustin/go-humanize"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/charger"
"github.com/evcc-io/evcc/meter"
"github.com/evcc-io/evcc/provider/mqtt"
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/server"
"github.com/evcc-io/evcc/server/oauth2redirect"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/modbus"
"github.com/evcc-io/evcc/vehicle"
"github.com/evcc-io/evcc/vehicle/wrapper"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"golang.org/x/exp/maps"
"golang.org/x/sync/errgroup"
)
var conf = config{
Interval: 10 * time.Second,
Log: "info",
Network: networkConfig{
Schema: "http",
Host: "evcc.local",
Port: 7070,
},
Mqtt: mqttConfig{
Topic: "evcc",
},
Database: dbConfig{
Type: "sqlite",
Dsn: "~/.evcc/evcc.db",
},
}
type config struct {
URI interface{} // TODO deprecated
Network networkConfig
Log string
SponsorToken string
Plant string // telemetry plant id
Telemetry bool
Metrics bool
Profile bool
Levels map[string]string
Interval time.Duration
Database dbConfig
Mqtt mqttConfig
ModbusProxy []proxyConfig
Javascript []javascriptConfig
Go []goConfig
Influx server.InfluxConfig
EEBus map[string]interface{}
HEMS typedConfig
Messaging messagingConfig
Meters []qualifiedConfig
Chargers []qualifiedConfig
Vehicles []qualifiedConfig
Tariffs tariffConfig
Site map[string]interface{}
Loadpoints []map[string]interface{}
}
type mqttConfig struct {
mqtt.Config `mapstructure:",squash"`
Topic string
}
type javascriptConfig struct {
VM string
Script string
}
type goConfig struct {
VM string
Script string
}
type proxyConfig struct {
Port int
ReadOnly bool
modbus.Settings `mapstructure:",squash"`
}
type dbConfig struct {
Type string
Dsn string
}
type qualifiedConfig struct {
Name, Type string
Other map[string]interface{} `mapstructure:",remain"`
}
type typedConfig struct {
Type string
Other map[string]interface{} `mapstructure:",remain"`
}
type messagingConfig struct {
Events map[string]push.EventTemplateConfig
Services []typedConfig
}
type tariffConfig struct {
Currency string
Grid typedConfig
FeedIn typedConfig
Co2 typedConfig
Planner typedConfig
}
type networkConfig struct {
Schema string
Host string
Port int
}
func (c networkConfig) HostPort() string {
if c.Schema == "http" && c.Port == 80 || c.Schema == "https" && c.Port == 443 {
return c.Host
}
return net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
}
func (c networkConfig) URI() string {
return fmt.Sprintf("%s://%s", c.Schema, c.HostPort())
}
// ConfigProvider provides configuration items
type ConfigProvider struct {
meters map[string]api.Meter
chargers map[string]api.Charger
vehicles map[string]api.Vehicle
visited map[string]bool
auth *util.AuthCollection
}
func (cp *ConfigProvider) TrackVisitors() {
cp.visited = make(map[string]bool)
}
// Meter provides meters by name
func (cp *ConfigProvider) Meter(name string) (api.Meter, error) {
if meter, ok := cp.meters[name]; ok {
// track duplicate usage https://github.com/evcc-io/evcc/issues/1744
if cp.visited != nil {
if _, ok := cp.visited[name]; ok {
log.FATAL.Fatalf("duplicate meter usage: %s", name)
}
cp.visited[name] = true
}
return meter, nil
}
return nil, fmt.Errorf("meter does not exist: %s", name)
}
// Charger provides chargers by name
func (cp *ConfigProvider) Charger(name string) (api.Charger, error) {
if charger, ok := cp.chargers[name]; ok {
return charger, nil
}
return nil, fmt.Errorf("charger does not exist: %s", name)
}
// Vehicle provides vehicles by name
func (cp *ConfigProvider) Vehicle(name string) (api.Vehicle, error) {
if vehicle, ok := cp.vehicles[name]; ok {
return vehicle, nil
}
return nil, fmt.Errorf("vehicle does not exist: %s", name)
}
func (cp *ConfigProvider) configure(conf config) error {
err := cp.configureMeters(conf)
if err == nil {
err = cp.configureChargers(conf)
}
if err == nil {
err = cp.configureVehicles(conf)
}
return err
}
func (cp *ConfigProvider) configureMeters(conf config) error {
cp.meters = make(map[string]api.Meter)
for id, cc := range conf.Meters {
if cc.Name == "" {
return fmt.Errorf("cannot create %s meter: missing name", humanize.Ordinal(id+1))
}
m, err := meter.NewFromConfig(cc.Type, cc.Other)
if err != nil {
err = fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)
return err
}
if _, exists := cp.meters[cc.Name]; exists {
return fmt.Errorf("duplicate meter name: %s already defined and must be unique", cc.Name)
}
cp.meters[cc.Name] = m
}
return nil
}
func (cp *ConfigProvider) configureChargers(conf config) error {
var mu sync.Mutex
g, _ := errgroup.WithContext(context.Background())
cp.chargers = make(map[string]api.Charger)
for id, cc := range conf.Chargers {
if cc.Name == "" {
return fmt.Errorf("cannot create %s charger: missing name", humanize.Ordinal(id+1))
}
cc := cc
g.Go(func() error {
c, err := charger.NewFromConfig(cc.Type, cc.Other)
if err != nil {
return fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)
}
mu.Lock()
defer mu.Unlock()
if _, exists := cp.chargers[cc.Name]; exists {
return fmt.Errorf("duplicate charger name: %s already defined and must be unique", cc.Name)
}
cp.chargers[cc.Name] = c
return nil
})
}
return g.Wait()
}
func (cp *ConfigProvider) configureVehicles(conf config) error {
var mu sync.Mutex
g, _ := errgroup.WithContext(context.Background())
cp.vehicles = make(map[string]api.Vehicle)
for id, cc := range conf.Vehicles {
if cc.Name == "" {
return fmt.Errorf("cannot create %s vehicle: missing name", humanize.Ordinal(id+1))
}
cc := cc
g.Go(func() error {
v, err := vehicle.NewFromConfig(cc.Type, cc.Other)
if err != nil {
var ce *util.ConfigError
if errors.As(err, &ce) {
return fmt.Errorf("cannot create vehicle '%s': %w", cc.Name, err)
}
// wrap non-config vehicle errors to prevent fatals
log.ERROR.Printf("creating vehicle %s failed: %v", cc.Name, err)
v = wrapper.New(cc.Name, cc.Other, err)
}
// ensure vehicle config has title
if v.Title() == "" {
//lint:ignore SA1019 as Title is safe on ascii
v.SetTitle(strings.Title(cc.Name))
}
mu.Lock()
defer mu.Unlock()
if _, exists := cp.vehicles[cc.Name]; exists {
return fmt.Errorf("duplicate vehicle name: %s already defined and must be unique", cc.Name)
}
cp.vehicles[cc.Name] = v
return nil
})
}
return g.Wait()
}
// webControl handles routing for devices. For now only api.AuthProvider related routes
func (cp *ConfigProvider) webControl(conf networkConfig, router *mux.Router, paramC chan<- util.Param) {
auth := router.PathPrefix("/oauth").Subrouter()
auth.Use(handlers.CompressHandler)
auth.Use(handlers.CORS(
handlers.AllowedHeaders([]string{"Content-Type"}),
))
// wire the handler
oauth2redirect.SetupRouter(auth)
// initialize
cp.auth = util.NewAuthCollection(paramC)
baseURI := conf.URI()
baseAuthURI := fmt.Sprintf("%s/oauth", baseURI)
// stable map iteration
keys := maps.Keys(cp.vehicles)
sort.Strings(keys)
var id int
for _, k := range keys {
v := cp.vehicles[k]
if provider, ok := v.(api.AuthProvider); ok {
id += 1
basePath := fmt.Sprintf("vehicles/%d", id)
callbackURI := fmt.Sprintf("%s/%s/callback", baseAuthURI, basePath)
// register vehicle
ap := cp.auth.Register(fmt.Sprintf("oauth/%s", basePath), v.Title())
provider.SetCallbackParams(baseURI, callbackURI, ap.Handler())
auth.
Methods(http.MethodPost).
Path(fmt.Sprintf("/%s/login", basePath)).
HandlerFunc(provider.LoginHandler())
auth.
Methods(http.MethodPost).
Path(fmt.Sprintf("/%s/logout", basePath)).
HandlerFunc(provider.LogoutHandler())
log.INFO.Printf("ensure the oauth client redirect/callback is configured for %s: %s", v.Title(), callbackURI)
}
}
cp.auth.Publish()
}

View file

@ -28,7 +28,7 @@ type loadpoint struct {
ResetOnDisconnect string
}
type config struct {
type globalConfig struct {
Meters []device
Chargers []device
Vehicles []device
@ -48,7 +48,7 @@ type config struct {
}
type Configure struct {
config config
config globalConfig
}
// AddDevice adds a device reference of a specific category to the configuration

View file

@ -470,7 +470,7 @@ func (c *CmdConfigure) processInputConfig(param templates.Param) string {
help: help,
valueType: param.Type,
validValues: param.ValidValues,
mask: param.IsMask(),
mask: param.IsMasked(),
required: param.IsRequired(),
})

View file

@ -201,7 +201,7 @@ func (c *CmdConfigure) flowNewConfigFile() {
filename := DefaultConfigFilename
for {
file, err := os.OpenFile(filename, os.O_WRONLY, 0666)
file, err := os.OpenFile(filename, os.O_WRONLY, 0o666)
if errors.Is(err, os.ErrNotExist) {
break
}

View file

@ -1,6 +1,8 @@
package configure
import "github.com/evcc-io/evcc/util/templates"
import (
"github.com/evcc-io/evcc/util/templates"
)
const (
DefaultConfigFilename string = "evcc.yaml"

View file

@ -11,7 +11,7 @@ import (
//go:embed demo.yaml
var demoYaml string
func demoConfig(conf *config) error {
func demoConfig(conf *globalConfig) error {
viper.SetConfigType("yaml")
if err := viper.ReadConfig(strings.NewReader(demoYaml)); err != nil {
return fmt.Errorf("failed decoding demo config: %w", err)

66
cmd/device.go Normal file
View file

@ -0,0 +1,66 @@
package cmd
import (
"fmt"
"strings"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/templates"
"github.com/spf13/cobra"
"golang.org/x/exp/slices"
)
// deviceCmd represents the device debug command
var deviceCmd = &cobra.Command{
Use: "device",
Short: "Query database-configured devices (debug only)",
Run: runDevice,
}
func init() {
rootCmd.AddCommand(deviceCmd)
}
func runDevice(cmd *cobra.Command, args []string) {
// load config
if err := loadConfigFile(&conf); err != nil {
log.FATAL.Fatal(err)
}
// setup environment
if err := configureEnvironment(cmd, conf); err != nil {
log.FATAL.Fatal(err)
}
for _, class := range []templates.Class{templates.Meter, templates.Charger, templates.Vehicle} {
devs, err := config.ConfigurationsByClass(class)
if err != nil {
log.FATAL.Fatal(err)
}
if len(devs) == 0 {
continue
}
fmt.Println(class)
fmt.Println(strings.Repeat("-", len(class.String())))
for _, d := range devs {
fmt.Printf("%d. %s\n", d.ID, d.Type)
details := d.Details
slices.SortFunc(details, func(i, j config.ConfigDetail) bool {
return i.Key < j.Key
})
for _, d := range details {
fmt.Printf("%s: %s\n", d.Key, d.Value)
}
fmt.Println()
}
}
// wait for shutdown
<-shutdownDoneC()
}

View file

@ -11,6 +11,7 @@ import (
"github.com/Masterminds/sprig/v3"
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/server"
"github.com/evcc-io/evcc/util/config"
"github.com/spf13/cobra"
)
@ -34,11 +35,12 @@ func init() {
dumpConfig = dumpCmd.Flags().Bool("cfg", false, "Dump config file")
}
func handle(device any, err error) any {
func handle[T any](name string, h config.Handler[T]) config.Device[T] {
dev, err := h.ByName(name)
if err != nil {
log.FATAL.Fatal(err)
}
return device
return dev
}
func runDump(cmd *cobra.Command, args []string) {
@ -94,24 +96,24 @@ func runDump(cmd *cobra.Command, args []string) {
fmt.Println("")
if name := site.Meters.GridMeterRef; name != "" {
d.DumpWithHeader(fmt.Sprintf("grid: %s", name), handle(cp.Meter(name)))
d.DumpWithHeader(fmt.Sprintf("grid: %s", name), handle(name, config.Meters()))
}
for id, name := range append(site.Meters.PVMetersRef, site.Meters.PVMetersRef_...) {
if name != "" {
d.DumpWithHeader(fmt.Sprintf("pv %d: %s", id+1, name), handle(cp.Meter(name)))
d.DumpWithHeader(fmt.Sprintf("pv %d: %s", id+1, name), handle(name, config.Meters()))
}
}
for id, name := range append(site.Meters.BatteryMetersRef, site.Meters.BatteryMetersRef_...) {
if name != "" {
d.DumpWithHeader(fmt.Sprintf("battery %d: %s", id+1, name), handle(cp.Meter(name)))
d.DumpWithHeader(fmt.Sprintf("battery %d: %s", id+1, name), handle(name, config.Meters()))
}
}
for id, name := range site.Meters.AuxMetersRef {
if name != "" {
d.DumpWithHeader(fmt.Sprintf("aux %d: %s", id+1, name), handle(cp.Meter(name)))
d.DumpWithHeader(fmt.Sprintf("aux %d: %s", id+1, name), handle(name, config.Meters()))
}
}
@ -126,11 +128,11 @@ func runDump(cmd *cobra.Command, args []string) {
fmt.Println("")
if name := lp.MeterRef; name != "" {
d.DumpWithHeader(fmt.Sprintf("charge: %s", name), handle(cp.Meter(name)))
d.DumpWithHeader(fmt.Sprintf("charge: %s", name), handle(name, config.Meters()))
}
if name := lp.ChargerRef; name != "" {
d.DumpWithHeader(fmt.Sprintf("charger: %s", name), handle(cp.Charger(name)))
d.DumpWithHeader(fmt.Sprintf("charger: %s", name), handle(name, config.Chargers()))
}
}
}

View file

@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"github.com/evcc-io/evcc/util/config"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
@ -11,9 +12,6 @@ const (
flagHeaders = "log-headers"
flagHeadersDescription = "Log headers"
flagName = "name"
flagNameDescription = "Select %s by name"
flagCurrent = "current"
flagCurrentDescription = "Set maximum current"
@ -57,17 +55,16 @@ func bindP(cmd *cobra.Command, key string, flagName ...string) {
}
}
func selectByName(cmd *cobra.Command, conf *[]qualifiedConfig) error {
flag := cmd.Flags().Lookup(flagName)
if !flag.Changed {
func selectByName(args []string, conf *[]config.Named) error {
if len(args) != 1 {
return nil
}
name := flag.Value.String()
name := args[0]
for _, cfg := range *conf {
if cfg.Name == name {
*conf = []qualifiedConfig{cfg}
for _, c := range *conf {
if c.Name == name {
*conf = []config.Named{c}
return nil
}
}

View file

@ -1,9 +1,7 @@
package cmd
import (
"fmt"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util/config"
"github.com/spf13/cobra"
)
@ -11,12 +9,12 @@ import (
var meterCmd = &cobra.Command{
Use: "meter [name]",
Short: "Query configured meters",
Args: cobra.MaximumNArgs(1),
Run: runMeter,
}
func init() {
rootCmd.AddCommand(meterCmd)
meterCmd.PersistentFlags().StringP(flagName, "n", "", fmt.Sprintf(flagNameDescription, "meter"))
}
func runMeter(cmd *cobra.Command, args []string) {
@ -31,27 +29,21 @@ func runMeter(cmd *cobra.Command, args []string) {
}
// select single meter
if err := selectByName(cmd, &conf.Meters); err != nil {
if err := selectByName(args, &conf.Meters); err != nil {
log.FATAL.Fatal(err)
}
if err := cp.configureMeters(conf); err != nil {
if err := configureMeters(conf.Meters); err != nil {
log.FATAL.Fatal(err)
}
meters := cp.meters
if len(args) == 1 {
name := args[0]
meter, err := cp.Meter(name)
if err != nil {
log.FATAL.Fatal(err)
}
meters = map[string]api.Meter{name: meter}
}
meters := config.Meters().Devices()
d := dumper{len: len(meters)}
for name, v := range meters {
d.DumpWithHeader(name, v)
for _, dev := range meters {
v := dev.Instance()
d.DumpWithHeader(dev.Config().Name, v)
}
// wait for shutdown

View file

@ -18,6 +18,7 @@ import (
"github.com/evcc-io/evcc/server/modbus"
"github.com/evcc-io/evcc/server/updater"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/pipe"
"github.com/evcc-io/evcc/util/sponsor"
"github.com/evcc-io/evcc/util/telemetry"
@ -187,7 +188,6 @@ func runRoot(cmd *cobra.Command, args []string) {
// setup site and loadpoints
var site *core.Site
if err == nil {
cp.TrackVisitors() // track duplicate usage
site, err = configureSiteAndLoadpoints(conf)
}
@ -276,7 +276,7 @@ func runRoot(cmd *cobra.Command, args []string) {
}
// allow web access for vehicles
cp.webControl(conf.Network, httpd.Router(), valueChan)
configureAuth(conf.Network, config.Instances(config.Vehicles().Devices()), httpd.Router(), valueChan)
go func() {
site.Run(stopC, conf.Interval)

View file

@ -1,19 +1,24 @@
package cmd
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/charger"
"github.com/evcc-io/evcc/charger/eebus"
"github.com/evcc-io/evcc/cmd/shutdown"
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/hems"
"github.com/evcc-io/evcc/meter"
"github.com/evcc-io/evcc/provider/golang"
"github.com/evcc-io/evcc/provider/javascript"
"github.com/evcc-io/evcc/provider/mqtt"
@ -21,24 +26,130 @@ import (
"github.com/evcc-io/evcc/server"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/server/oauth2redirect"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/locale"
"github.com/evcc-io/evcc/util/machine"
"github.com/evcc-io/evcc/util/modbus"
"github.com/evcc-io/evcc/util/pipe"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/sponsor"
"github.com/evcc-io/evcc/util/templates"
"github.com/evcc-io/evcc/vehicle"
"github.com/evcc-io/evcc/vehicle/wrapper"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/libp2p/zeroconf/v2"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"golang.org/x/sync/errgroup"
"golang.org/x/text/currency"
)
var cp = new(ConfigProvider)
var conf = globalConfig{
Interval: 10 * time.Second,
Log: "info",
Network: networkConfig{
Schema: "http",
Host: "evcc.local",
Port: 7070,
},
Mqtt: mqttConfig{
Topic: "evcc",
},
Database: dbConfig{
Type: "sqlite",
Dsn: "~/.evcc/evcc.db",
},
}
func loadConfigFile(conf *config) error {
type globalConfig struct {
URI interface{} // TODO deprecated
Network networkConfig
Log string
SponsorToken string
Plant string // telemetry plant id
Telemetry bool
Metrics bool
Profile bool
Levels map[string]string
Interval time.Duration
Database dbConfig
Mqtt mqttConfig
ModbusProxy []proxyConfig
Javascript []javascriptConfig
Go []goConfig
Influx server.InfluxConfig
EEBus map[string]interface{}
HEMS config.Typed
Messaging messagingConfig
Meters []config.Named
Chargers []config.Named
Vehicles []config.Named
Tariffs tariffConfig
Site map[string]interface{}
Loadpoints []map[string]interface{}
}
type mqttConfig struct {
mqtt.Config `mapstructure:",squash"`
Topic string
}
type javascriptConfig struct {
VM string
Script string
}
type goConfig struct {
VM string
Script string
}
type proxyConfig struct {
Port int
ReadOnly bool
modbus.Settings `mapstructure:",squash"`
}
type dbConfig struct {
Type string
Dsn string
}
type messagingConfig struct {
Events map[string]push.EventTemplateConfig
Services []config.Typed
}
type tariffConfig struct {
Currency string
Grid config.Typed
FeedIn config.Typed
Co2 config.Typed
Planner config.Typed
}
type networkConfig struct {
Schema string
Host string
Port int
}
func (c networkConfig) HostPort() string {
if c.Schema == "http" && c.Port == 80 || c.Schema == "https" && c.Port == 443 {
return c.Host
}
return net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
}
func (c networkConfig) URI() string {
return fmt.Sprintf("%s://%s", c.Schema, c.HostPort())
}
func loadConfigFile(conf *globalConfig) error {
err := viper.ReadInConfig()
if cfgFile = viper.ConfigFileUsed(); cfgFile == "" {
@ -61,7 +172,149 @@ func loadConfigFile(conf *config) error {
return err
}
func configureEnvironment(cmd *cobra.Command, conf config) (err error) {
func configureMeters(static []config.Named) error {
for i, cc := range static {
if cc.Name == "" {
return fmt.Errorf("cannot create meter %d: missing name", i+1)
}
instance, err := meter.NewFromConfig(cc.Type, cc.Other)
if err != nil {
return fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)
}
if err := config.Meters().Add(config.NewStaticDevice(cc, instance)); err != nil {
return err
}
}
// append devices from database
configurable, err := config.ConfigurationsByClass(templates.Meter)
if err != nil {
return err
}
for _, conf := range configurable {
cc := conf.Named()
instance, err := meter.NewFromConfig(cc.Type, cc.Other)
if err != nil {
return fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)
}
if err := config.Meters().Add(config.NewConfigurableDevice(conf, instance)); err != nil {
return err
}
}
return nil
}
func configureChargers(static []config.Named) error {
g, _ := errgroup.WithContext(context.Background())
for i, cc := range static {
if cc.Name == "" {
return fmt.Errorf("cannot create charger %d: missing name", i+1)
}
cc := cc
g.Go(func() error {
instance, err := charger.NewFromConfig(cc.Type, cc.Other)
if err != nil {
return fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)
}
return config.Chargers().Add(config.NewStaticDevice(cc, instance))
})
}
// append devices from database
configurable, err := config.ConfigurationsByClass(templates.Charger)
if err != nil {
return err
}
for _, conf := range configurable {
conf := conf
g.Go(func() error {
cc := conf.Named()
instance, err := charger.NewFromConfig(cc.Type, cc.Other)
if err != nil {
return fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)
}
return config.Chargers().Add(config.NewConfigurableDevice(conf, instance))
})
}
return g.Wait()
}
func vehicleInstance(cc config.Named) (api.Vehicle, error) {
instance, err := vehicle.NewFromConfig(cc.Type, cc.Other)
if err != nil {
var ce *util.ConfigError
if errors.As(err, &ce) {
return nil, fmt.Errorf("cannot create vehicle '%s': %w", cc.Name, err)
}
// wrap non-config vehicle errors to prevent fatals
log.ERROR.Printf("creating vehicle %s failed: %v", cc.Name, err)
instance = wrapper.New(cc.Name, cc.Other, err)
}
// ensure vehicle config has title
if instance.Title() == "" {
//lint:ignore SA1019 as Title is safe on ascii
instance.SetTitle(strings.Title(cc.Name))
}
return instance, nil
}
func configureVehicles(static []config.Named) error {
g, _ := errgroup.WithContext(context.Background())
for i, cc := range static {
if cc.Name == "" {
return fmt.Errorf("cannot create vehicle %d: missing name", i+1)
}
cc := cc
g.Go(func() error {
instance, err := vehicleInstance(cc)
if err != nil {
return fmt.Errorf("cannot create vehicle '%s': %w", cc.Name, err)
}
return config.Vehicles().Add(config.NewStaticDevice(cc, instance))
})
}
// append devices from database
configurable, err := config.ConfigurationsByClass(templates.Vehicle)
if err != nil {
return err
}
for _, conf := range configurable {
conf := conf
g.Go(func() error {
cc := conf.Named()
instance, err := vehicleInstance(cc)
if err != nil {
return fmt.Errorf("cannot create vehicle '%s': %w", cc.Name, err)
}
return config.Vehicles().Add(config.NewConfigurableDevice(conf, instance))
})
}
return g.Wait()
}
func configureEnvironment(cmd *cobra.Command, conf globalConfig) (err error) {
// full http request log
if cmd.Flags().Lookup(flagHeaders).Changed {
request.LogHeaders = true
@ -107,6 +360,11 @@ func configureEnvironment(cmd *cobra.Command, conf config) (err error) {
err = configureEEBus(conf.EEBus)
}
// setup config database
if err == nil {
err = config.Init(db.Instance)
}
return
}
@ -200,7 +458,7 @@ func configureGo(conf []goConfig) error {
}
// setup HEMS
func configureHEMS(conf typedConfig, site *core.Site, httpd *server.HTTPd) error {
func configureHEMS(conf config.Typed, site *core.Site, httpd *server.HTTPd) error {
hems, err := hems.NewFromConfig(conf.Type, conf.Other, site, httpd)
if err != nil {
return fmt.Errorf("failed configuring hems: %w", err)
@ -308,12 +566,22 @@ func configureTariffs(conf tariffConfig) (tariff.Tariffs, error) {
return *tariffs, nil
}
func configureSiteAndLoadpoints(conf config) (*core.Site, error) {
if err := cp.configure(conf); err != nil {
func configureDevices(conf globalConfig) error {
if err := configureMeters(conf.Meters); err != nil {
return err
}
if err := configureChargers(conf.Chargers); err != nil {
return err
}
return configureVehicles(conf.Vehicles)
}
func configureSiteAndLoadpoints(conf globalConfig) (*core.Site, error) {
if err := configureDevices(conf); err != nil {
return nil, err
}
loadpoints, err := configureLoadpoints(conf, cp)
loadpoints, err := configureLoadpoints(conf)
if err != nil {
return nil, fmt.Errorf("failed configuring loadpoints: %w", err)
}
@ -323,20 +591,11 @@ func configureSiteAndLoadpoints(conf config) (*core.Site, error) {
return nil, err
}
// list of vehicles ordered by name
keys := maps.Keys(cp.vehicles)
slices.Sort(keys)
vehicles := make([]api.Vehicle, 0, len(cp.vehicles))
for _, k := range keys {
vehicles = append(vehicles, cp.vehicles[k])
return configureSite(conf.Site, loadpoints, config.Instances(config.Vehicles().Devices()), tariffs)
}
return configureSite(conf.Site, cp, loadpoints, vehicles, tariffs)
}
func configureSite(conf map[string]interface{}, cp *ConfigProvider, loadpoints []*core.Loadpoint, vehicles []api.Vehicle, tariffs tariff.Tariffs) (*core.Site, error) {
site, err := core.NewSiteFromConfig(log, cp, conf, loadpoints, vehicles, tariffs)
func configureSite(conf map[string]interface{}, loadpoints []*core.Loadpoint, vehicles []api.Vehicle, tariffs tariff.Tariffs) (*core.Site, error) {
site, err := core.NewSiteFromConfig(log, conf, loadpoints, vehicles, tariffs)
if err != nil {
return nil, fmt.Errorf("failed configuring site: %w", err)
}
@ -344,7 +603,7 @@ func configureSite(conf map[string]interface{}, cp *ConfigProvider, loadpoints [
return site, nil
}
func configureLoadpoints(conf config, cp *ConfigProvider) (loadpoints []*core.Loadpoint, err error) {
func configureLoadpoints(conf globalConfig) (loadpoints []*core.Loadpoint, err error) {
lpInterfaces, ok := viper.AllSettings()["loadpoints"].([]interface{})
if !ok || len(lpInterfaces) == 0 {
return nil, errors.New("missing loadpoints")
@ -357,7 +616,7 @@ func configureLoadpoints(conf config, cp *ConfigProvider) (loadpoints []*core.Lo
}
log := util.NewLogger("lp-" + strconv.Itoa(id+1))
lp, err := core.NewLoadpointFromConfig(log, cp, lpc)
lp, err := core.NewLoadpointFromConfig(log, lpc)
if err != nil {
return nil, fmt.Errorf("failed configuring loadpoint: %w", err)
}
@ -367,3 +626,49 @@ func configureLoadpoints(conf config, cp *ConfigProvider) (loadpoints []*core.Lo
return loadpoints, nil
}
// configureAuth handles routing for devices. For now only api.AuthProvider related routes
func configureAuth(conf networkConfig, vehicles []api.Vehicle, router *mux.Router, paramC chan<- util.Param) {
auth := router.PathPrefix("/oauth").Subrouter()
auth.Use(handlers.CompressHandler)
auth.Use(handlers.CORS(
handlers.AllowedHeaders([]string{"Content-Type"}),
))
// wire the handler
oauth2redirect.SetupRouter(auth)
// initialize
authCollection := util.NewAuthCollection(paramC)
baseURI := conf.URI()
baseAuthURI := fmt.Sprintf("%s/oauth", baseURI)
var id int
for _, v := range vehicles {
if provider, ok := v.(api.AuthProvider); ok {
id += 1
basePath := fmt.Sprintf("vehicles/%d", id)
callbackURI := fmt.Sprintf("%s/%s/callback", baseAuthURI, basePath)
// register vehicle
ap := authCollection.Register(fmt.Sprintf("oauth/%s", basePath), v.Title())
provider.SetCallbackParams(baseURI, callbackURI, ap.Handler())
auth.
Methods(http.MethodPost).
Path(fmt.Sprintf("/%s/login", basePath)).
HandlerFunc(provider.LoginHandler())
auth.
Methods(http.MethodPost).
Path(fmt.Sprintf("/%s/logout", basePath)).
HandlerFunc(provider.LogoutHandler())
log.INFO.Printf("ensure the oauth client redirect/callback is configured for %s: %s", v.Title(), callbackURI)
}
}
authCollection.Publish()
}

View file

@ -16,7 +16,7 @@ loadpoints:
`
func TestYamlOff(t *testing.T) {
var conf config
var conf globalConfig
viper.SetConfigType("yaml")
if err := viper.ReadConfig(strings.NewReader(sample)); err != nil {
t.Error(err)

View file

@ -6,6 +6,7 @@ import (
"text/tabwriter"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util/config"
"github.com/spf13/cobra"
)
@ -13,12 +14,12 @@ import (
var tariffCmd = &cobra.Command{
Use: "tariff [name]",
Short: "Query configured tariff",
Args: cobra.MaximumNArgs(1),
Run: runTariff,
}
func init() {
rootCmd.AddCommand(tariffCmd)
tariffCmd.PersistentFlags().StringP(flagName, "n", "", fmt.Sprintf(flagNameDescription, "vehicle"))
}
func runTariff(cmd *cobra.Command, args []string) {
@ -32,12 +33,16 @@ func runTariff(cmd *cobra.Command, args []string) {
fatal(err)
}
name := cmd.Flags().Lookup(flagName).Value.String()
var name string
if len(args) == 1 {
name = args[0]
}
for key, cc := range map[string]typedConfig{
for key, cc := range map[string]config.Typed{
"grid": conf.Tariffs.Grid,
"feedin": conf.Tariffs.FeedIn,
"co2": conf.Tariffs.Co2,
"planner": conf.Tariffs.Planner,
} {
if cc.Type == "" || (name != "" && key != name) {
continue

View file

@ -4,6 +4,7 @@ import (
"fmt"
"strings"
"github.com/evcc-io/evcc/util/config"
"github.com/samber/lo"
"github.com/spf13/cobra"
"golang.org/x/exp/slices"
@ -27,11 +28,11 @@ func runToken(cmd *cobra.Command, args []string) {
log.FATAL.Fatal(err)
}
var vehicleConf qualifiedConfig
var vehicleConf config.Named
if len(conf.Vehicles) == 1 {
vehicleConf = conf.Vehicles[0]
} else if len(args) == 1 {
idx := slices.IndexFunc(conf.Vehicles, func(v qualifiedConfig) bool {
idx := slices.IndexFunc(conf.Vehicles, func(v config.Named) bool {
return strings.EqualFold(v.Name, args[0])
})
@ -41,7 +42,7 @@ func runToken(cmd *cobra.Command, args []string) {
}
if vehicleConf.Name == "" {
vehicles := lo.Map(conf.Vehicles, func(v qualifiedConfig, _ int) string {
vehicles := lo.Map(conf.Vehicles, func(v config.Named, _ int) string {
return v.Name
})
log.FATAL.Fatalf("vehicle not found, have %v", vehicles)

View file

@ -11,6 +11,7 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/vehicle"
"github.com/evcc-io/evcc/vehicle/tronity"
"github.com/samber/lo"
@ -100,7 +101,7 @@ func tronityAuthorize(addr string, oc *oauth2.Config) (*oauth2.Token, error) {
}
}
func tronityToken(conf config, vehicleConf qualifiedConfig) (*oauth2.Token, error) {
func tronityToken(conf globalConfig, vehicleConf config.Named) (*oauth2.Token, error) {
var cc struct {
Credentials vehicle.ClientCredentials
RedirectURI string

View file

@ -1,24 +1,23 @@
package cmd
import (
"fmt"
"strings"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util/config"
"github.com/spf13/cobra"
"golang.org/x/exp/maps"
)
// vehicleCmd represents the vehicle command
var vehicleCmd = &cobra.Command{
Use: "vehicle [name]",
Short: "Query configured vehicles",
Args: cobra.MaximumNArgs(1),
Run: runVehicle,
}
func init() {
rootCmd.AddCommand(vehicleCmd)
vehicleCmd.PersistentFlags().StringP(flagName, "n", "", fmt.Sprintf(flagNameDescription, "vehicle"))
vehicleCmd.Flags().BoolP(flagStart, "a", false, flagStartDescription)
vehicleCmd.Flags().BoolP(flagStop, "o", false, flagStopDescription)
vehicleCmd.Flags().BoolP(flagWakeup, "w", false, flagWakeupDescription)
@ -38,34 +37,18 @@ func runVehicle(cmd *cobra.Command, args []string) {
}
// select single vehicle
if err := selectByName(cmd, &conf.Vehicles); err != nil {
if err := selectByName(args, &conf.Vehicles); err != nil {
fatal(err)
}
if err := cp.configureVehicles(conf); err != nil {
if err := configureVehicles(conf.Vehicles); err != nil {
fatal(err)
}
vehicles := cp.vehicles
if len(args) == 1 {
name := args[0]
vehicle, err := cp.Vehicle(name)
if err != nil {
log.FATAL.Fatal(err)
}
vehicles = map[string]api.Vehicle{name: vehicle}
}
// check single vehicle for error
if len(vehicles) == 1 {
if err, ok := maps.Values(vehicles)[0].(error); ok {
fatal(err)
}
}
vehicles := config.Vehicles().Devices()
var flagUsed bool
for _, v := range vehicles {
for _, v := range config.Instances(vehicles) {
if cmd.Flags().Lookup(flagWakeup).Changed {
flagUsed = true
@ -107,8 +90,10 @@ func runVehicle(cmd *cobra.Command, args []string) {
d := dumper{len: len(vehicles)}
flag := cmd.Flags().Lookup(flagDiagnose).Changed
for name, v := range vehicles {
d.DumpWithHeader(name, v)
for _, dev := range vehicles {
v := dev.Instance()
d.DumpWithHeader(dev.Config().Name, v)
if flag {
d.DumpDiagnosis(v)
}

View file

@ -1,10 +0,0 @@
package core
import "github.com/evcc-io/evcc/api"
// configProvider gives access to configuration repository
type configProvider interface {
Meter(string) (api.Meter, error)
Charger(string) (api.Charger, error)
Vehicle(string) (api.Vehicle, error)
}

View file

@ -11,14 +11,15 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/coordinator"
"github.com/evcc-io/evcc/core/db"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/planner"
"github.com/evcc-io/evcc/core/session"
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/core/wrapper"
"github.com/evcc-io/evcc/provider"
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
evbus "github.com/asaskevich/EventBus"
"github.com/avast/retry-go/v4"
@ -173,14 +174,14 @@ type Loadpoint struct {
progress *Progress // Step-wise progress indicator
// session log
db db.Database
session *db.Session
db session.Database
session *session.Session
tasks *util.Queue[Task] // tasks to be executed
}
// NewLoadpointFromConfig creates a new loadpoint
func NewLoadpointFromConfig(log *util.Logger, cp configProvider, other map[string]interface{}) (*Loadpoint, error) {
func NewLoadpointFromConfig(log *util.Logger, other map[string]interface{}) (*Loadpoint, error) {
lp := NewLoadpoint(log)
if err := util.DecodeOther(other, lp); err != nil {
return nil, err
@ -218,18 +219,20 @@ func NewLoadpointFromConfig(log *util.Logger, cp configProvider, other map[strin
lp.collectDefaults()
if lp.MeterRef != "" {
var err error
if lp.chargeMeter, err = cp.Meter(lp.MeterRef); err != nil {
dev, err := config.Meters().ByName(lp.MeterRef)
if err != nil {
return nil, err
}
lp.chargeMeter = dev.Instance()
}
// default vehicle
if lp.VehicleRef != "" {
var err error
if lp.defaultVehicle, err = cp.Vehicle(lp.VehicleRef); err != nil {
dev, err := config.Vehicles().ByName(lp.VehicleRef)
if err != nil {
return nil, err
}
lp.defaultVehicle = dev.Instance()
}
// TODO deprecated
@ -240,10 +243,11 @@ func NewLoadpointFromConfig(log *util.Logger, cp configProvider, other map[strin
if lp.ChargerRef == "" {
return nil, errors.New("missing charger")
}
var err error
if lp.charger, err = cp.Charger(lp.ChargerRef); err != nil {
dev, err := config.Chargers().ByName(lp.ChargerRef)
if err != nil {
return nil, err
}
lp.charger = dev.Instance()
lp.configureChargerType(lp.charger)
// setup fixed phases:
@ -404,7 +408,7 @@ func (lp *Loadpoint) evChargeStartHandler() {
lp.socUpdated = time.Time{}
// set created when first charging session segment starts
lp.updateSession(func(session *db.Session) {
lp.updateSession(func(session *session.Session) {
if session.Created.IsZero() {
session.Created = lp.clock.Now()
}

View file

@ -2,7 +2,7 @@ package core
import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/db"
"github.com/evcc-io/evcc/core/session"
)
func (lp *Loadpoint) chargeMeterTotal() float64 {
@ -30,7 +30,7 @@ func (lp *Loadpoint) createSession() {
return
}
lp.session = lp.db.Session(lp.chargeMeterTotal())
lp.session = lp.db.New(lp.chargeMeterTotal())
if vehicle := lp.GetVehicle(); vehicle != nil {
lp.session.Vehicle = vehicle.Title()
@ -78,7 +78,7 @@ func (lp *Loadpoint) stopSession() {
lp.db.Persist(s)
}
type sessionOption func(*db.Session)
type sessionOption func(*session.Session)
// updateSession updates any parameter of a charging session and persists the session.
func (lp *Loadpoint) updateSession(opts ...sessionOption) {

View file

@ -6,7 +6,7 @@ import (
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
coredb "github.com/evcc-io/evcc/core/db"
"github.com/evcc-io/evcc/core/session"
"github.com/evcc-io/evcc/mock"
serverdb "github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/util"
@ -19,7 +19,7 @@ func TestSession(t *testing.T) {
serverdb.Instance, err = serverdb.New("sqlite", ":memory:")
assert.NoError(t, err)
db, err := coredb.New("foo")
db, err := session.NewStore("foo", serverdb.Instance)
assert.NoError(t, err)
clock := clock.NewMock()
@ -51,7 +51,7 @@ func TestSession(t *testing.T) {
assert.NotNil(t, lp.session)
// start charging
lp.updateSession(func(session *coredb.Session) {
lp.updateSession(func(session *session.Session) {
if session.Created.IsZero() {
session.Created = lp.clock.Now()
}

View file

@ -8,7 +8,7 @@ import (
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/db"
"github.com/evcc-io/evcc/core/session"
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/provider"
"github.com/evcc-io/evcc/server/db/settings"
@ -170,7 +170,7 @@ func (lp *Loadpoint) setActiveVehicle(vehicle api.Vehicle) {
lp.publish(phasesActive, lp.activePhases())
lp.unpublishVehicle()
lp.updateSession(func(session *db.Session) {
lp.updateSession(func(session *session.Session) {
var title string
if vehicle != nil {
title = vehicle.Title()
@ -347,7 +347,7 @@ func (lp *Loadpoint) vehicleOdometer() {
lp.publish(vehicleOdometer, odo)
// update session once odometer is read
lp.updateSession(func(session *db.Session) {
lp.updateSession(func(session *session.Session) {
session.Odometer = &odo
})
} else if !errors.Is(err, api.ErrNotAvailable) {

View file

@ -1,7 +1,6 @@
package db
package session
import (
serverdb "github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/util"
"gorm.io/gorm"
)
@ -14,22 +13,13 @@ type DB struct {
}
type Database interface {
Session(startEnergy float64) *Session
New(startEnergy float64) *Session
Persist(session interface{})
}
// New creates a database storage driver
func New(name string) (*DB, error) {
db := serverdb.Instance
// TODO deprecate
var err error
if table := "transactions"; db.Migrator().HasTable(table) {
err = db.Migrator().RenameTable(table, new(Session))
}
if err == nil {
err = db.AutoMigrate(new(Session))
}
// NewStore creates a session store
func NewStore(name string, db *gorm.DB) (*DB, error) {
err := db.AutoMigrate(new(Session))
sessiondb := &DB{
log: util.NewLogger("db"),
@ -40,8 +30,8 @@ func New(name string) (*DB, error) {
return sessiondb, err
}
// Session creates a charging session
func (s *DB) Session(meter float64) *Session {
// New creates a charging session
func (s *DB) New(meter float64) *Session {
t := Session{
Loadpoint: s.name,
}

View file

@ -1,4 +1,4 @@
package db
package session
import (
"testing"

View file

@ -1,4 +1,4 @@
package db
package session
import (
"context"

View file

@ -11,15 +11,16 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/cmd/shutdown"
"github.com/evcc-io/evcc/core/coordinator"
"github.com/evcc-io/evcc/core/db"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/planner"
"github.com/evcc-io/evcc/core/prioritizer"
"github.com/evcc-io/evcc/core/session"
"github.com/evcc-io/evcc/push"
serverdb "github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/telemetry"
)
@ -100,7 +101,6 @@ type MetersConfig struct {
// NewSiteFromConfig creates a new site
func NewSiteFromConfig(
log *util.Logger,
cp configProvider,
other map[string]interface{},
loadpoints []*Loadpoint,
vehicles []api.Vehicle,
@ -134,9 +134,9 @@ func NewSiteFromConfig(
lp.coordinator = coordinator.NewAdapter(lp, site.coordinator)
lp.planner = planner.New(lp.log, tariff)
if serverdb.Instance != nil {
if db.Instance != nil {
var err error
if lp.db, err = db.New(lp.Title()); err != nil {
if lp.db, err = session.NewStore(lp.Title(), db.Instance); err != nil {
return nil, err
}
@ -147,19 +147,20 @@ func NewSiteFromConfig(
// grid meter
if site.Meters.GridMeterRef != "" {
var err error
if site.gridMeter, err = cp.Meter(site.Meters.GridMeterRef); err != nil {
dev, err := config.Meters().ByName(site.Meters.GridMeterRef)
if err != nil {
return nil, err
}
site.gridMeter = dev.Instance()
}
// multiple pv
for _, ref := range append(site.Meters.PVMetersRef, site.Meters.PVMetersRef_...) {
pv, err := cp.Meter(ref)
dev, err := config.Meters().ByName(ref)
if err != nil {
return nil, err
}
site.pvMeters = append(site.pvMeters, pv)
site.pvMeters = append(site.pvMeters, dev.Instance())
}
// TODO deprecated
@ -169,11 +170,11 @@ func NewSiteFromConfig(
// multiple batteries
for _, ref := range append(site.Meters.BatteryMetersRef, site.Meters.BatteryMetersRef_...) {
battery, err := cp.Meter(ref)
dev, err := config.Meters().ByName(ref)
if err != nil {
return nil, err
}
site.batteryMeters = append(site.batteryMeters, battery)
site.batteryMeters = append(site.batteryMeters, dev.Instance())
}
// TODO deprecated
@ -187,11 +188,11 @@ func NewSiteFromConfig(
// auxiliary meters
for _, ref := range site.Meters.AuxMetersRef {
meter, err := cp.Meter(ref)
dev, err := config.Meters().ByName(ref)
if err != nil {
return nil, err
}
site.auxMeters = append(site.auxMeters, meter)
site.auxMeters = append(site.auxMeters, dev.Instance())
}
// configure meter from references

2
go.mod
View file

@ -25,7 +25,6 @@ require (
github.com/deepmap/oapi-codegen v1.13.0
github.com/denisbrodbeck/machineid v1.0.1
github.com/dmarkham/enumer v1.5.8
github.com/dustin/go-humanize v1.0.1
github.com/dylanmei/iso8601 v0.1.0
github.com/eclipse/paho.mqtt.golang v1.4.2
github.com/enbility/cemd v0.2.2
@ -117,6 +116,7 @@ require (
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/chzyer/readline v1.5.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect

View file

@ -18,6 +18,40 @@ low = "wenn etwas voll"
medium = "wenn halbvoll"
never = "nur mit genug PV-Überschuss"
[config]
[config.form]
example = "Beispiel"
optional = "optional"
[config.main]
addVehicle = "Fahrzeug hinzufügen"
edit = "bearbeiten"
title = "Konfiguration"
vehicles = "Meine Fahrzeuge"
[config.validation]
failed = "fehlgeschlagen"
label = "Status"
running = "prüfe…"
save = "Speichern"
success = "erfolgreich"
unknown = "unbekannt"
validate = "prüfen"
validateSave = "Prüfen & speichern"
[config.vehicle]
cancel = "Abbrechen"
delete = "Fahrzeug löschen"
generic = "Weitere Integrationen"
offline = "Generisches Fahrzeug"
online = "Fahrzeuge mit Schnittstelle"
scooter = "Scooter"
template = "Hersteller"
titleAdd = "Fahrzeug hinzufügen"
titleEdit = "Fahrzeug bearbeiten"
update = "Prüfen & speichern"
[footer]
[footer.community]
@ -362,8 +396,3 @@ lineError = "In {0} wurde ein Fehler gefunden."
lineErrorLink = "Zeile {0}"
restartButton = "Neu starten"
title = "Fehler beim Starten"
[vehicleSettings]
cancel = "Abbrechen"
template = "Hersteller"
test = "Test"

View file

@ -18,6 +18,39 @@ low = "when somewhat full"
medium = "when half full"
never = "only with enough surplus"
[config]
[config.form]
example = "Example"
optional = "optional"
[config.main]
addVehicle = "Add vehicle"
edit = "edit"
title = "Configuration"
vehicles = "My Vehicles"
[config.validation]
failed = "failed"
label = "Status"
running = "validating…"
success = "successfull"
unknown = "unknown"
validate = "validate"
[config.vehicle]
cancel = "Cancel"
delete = "Delete vehicle"
generic = "Other integrations"
offline = "Generic vehicle"
online = "Vehicles with online API"
save = "Save"
scooter = "Scooter"
template = "Manufacturer"
titleAdd = "Add Vehicle"
titleEdit = "Edit Vehicle"
validateSave = "Validate & save"
[footer]
[footer.community]
@ -362,8 +395,3 @@ lineError = "Error in {0}."
lineErrorLink = "line {0}"
restartButton = "Restart"
title = "Startup Error"
[vehicleSettings]
cancel = "Cancel"
template = "Manufacturer"
test = "Test"

2475
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -36,7 +36,6 @@
"eslint-plugin-vue": "^9.3.0",
"happy-dom": "^10.0.3",
"histoire": "^0.16.1",
"json-to-pretty-yaml": "^1.2.2",
"markty-toml": "^0.1.1",
"prettier": "^3.0.0",
"prettier-plugin-toml": "^0.3.1",
@ -46,6 +45,7 @@
"vite-plugin-toml": "^0.5.0",
"vitest": "^0.33.0",
"vue": "^3.2.29",
"vue-3-linkify": "^1.1.0",
"vue-eslint-parser": "^9.1.0",
"vue-hot-reload-api": "^2.3.4",
"vue-i18n": "^9.2.2",

View file

@ -99,7 +99,13 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
"state": {[]string{"GET"}, "/state", stateHandler(cache)},
"config": {[]string{"GET"}, "/config/templates/{class:[a-z]+}", templatesHandler},
"products": {[]string{"GET"}, "/config/products/{class:[a-z]+}", productsHandler},
"test": {[]string{"POST", "OPTIONS"}, "/config/test/{class:[a-z]+}", testHandler},
"device": {[]string{"GET"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceHandler},
"devices": {[]string{"GET"}, "/config/devices/{class:[a-z]+}", devicesHandler},
"newdevice": {[]string{"POST", "OPTIONS"}, "/config/devices/{class:[a-z]+}", newDeviceHandler},
"updatedevice": {[]string{"PUT", "OPTIONS"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
"deletedevice": {[]string{"DELETE", "OPTIONS"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler},
"testconfig": {[]string{"POST", "OPTIONS"}, "/config/test/{class:[a-z]+}", testHandler},
"testdevice": {[]string{"POST", "OPTIONS"}, "/config/test/{class:[a-z]+}/{id:[0-9.]+}", testHandler},
"buffersoc": {[]string{"POST", "OPTIONS"}, "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
"bufferstartsoc": {[]string{"POST", "OPTIONS"}, "/bufferstartsoc/{value:[0-9.]+}", floatHandler(site.SetBufferStartSoc, site.GetBufferStartSoc)},
"prioritysoc": {[]string{"POST", "OPTIONS"}, "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},

View file

@ -4,17 +4,27 @@ import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/charger"
"github.com/evcc-io/evcc/meter"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/templates"
"github.com/evcc-io/evcc/vehicle"
"github.com/gorilla/mux"
"golang.org/x/exp/slices"
)
const (
// typeTemplate is the updatable configuration type
typeTemplate = "template"
// masked indicates a masked config parameter value
masked = "***"
)
// templatesHandler returns the list of templates by class
func templatesHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@ -25,65 +35,21 @@ func templatesHandler(w http.ResponseWriter, r *http.Request) {
return
}
res := templates.ByClass(class)
lang := r.URL.Query().Get("lang")
templates.EncoderLanguage(lang)
if name := r.URL.Query().Get("name"); name != "" {
for _, t := range res {
if t.TemplateDefinition.Template == name {
jsonResult(w, t)
return
}
}
jsonError(w, http.StatusBadRequest, errors.New("template not found"))
res, err := templates.ByName(class, name)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
jsonResult(w, res)
return
}
type product struct {
Name string `json:"name"`
Template string `json:"template"`
}
type products []product
func (p products) MarshalJSON() (out []byte, err error) {
if p == nil {
return []byte(`null`), nil
}
if len(p) == 0 {
return []byte(`{}`), nil
}
out = append(out, '{')
for _, e := range p {
key, err := json.Marshal(e.Name)
if err != nil {
return nil, err
}
val, err := json.Marshal(e.Template)
if err != nil {
return nil, err
}
out = append(out, key...)
out = append(out, ':')
out = append(out, val...)
out = append(out, ',')
}
// replace last ',' with '}'
if len(out) > 1 {
out[len(out)-1] = '}'
} else {
out = append(out, '}')
}
return out, nil
jsonResult(w, templates.ByClass(class))
}
// productsHandler returns the list of products by class
@ -105,6 +71,7 @@ func productsHandler(w http.ResponseWriter, r *http.Request) {
res = append(res, product{
Name: p.Title(lang),
Template: t.TemplateDefinition.Template,
Group: t.Group,
})
}
}
@ -116,8 +83,141 @@ func productsHandler(w http.ResponseWriter, r *http.Request) {
jsonResult(w, res)
}
// testHandler tests a configuration by class
func testHandler(w http.ResponseWriter, r *http.Request) {
func deviceConfigMap[T any](class templates.Class, dev config.Device[T]) (map[string]any, error) {
conf := dev.Config()
dc := map[string]any{
"name": conf.Name,
"type": conf.Type,
}
if configurable, ok := dev.(config.ConfigurableDevice[T]); ok {
// from database
params, err := sanitizeMasked(class, conf.Other)
if err != nil {
return nil, err
}
dc["id"] = configurable.ID()
dc["config"] = params
} else if title := conf.Other["title"]; title != nil {
// from yaml- add title only
if s, ok := title.(string); ok {
dc["config"] = map[string]any{"title": s}
}
}
return dc, nil
}
func deviceConfig[T any](class templates.Class, id int, h config.Handler[T]) (map[string]any, error) {
dev, err := h.ByName(config.NameForID(id))
if err != nil {
return nil, err
}
return deviceConfigMap(class, dev)
}
// deviceHandler returns a device configuration by class
func deviceHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
class, err := templates.ClassString(vars["class"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
id, err := strconv.Atoi(vars["id"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
var res map[string]any
switch class {
case templates.Meter:
res, err = deviceConfig(class, id, config.Meters())
case templates.Charger:
res, err = deviceConfig(class, id, config.Chargers())
case templates.Vehicle:
res, err = deviceConfig(class, id, config.Vehicles())
}
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
jsonResult(w, res)
}
func devicesConfig[T any](class templates.Class, h config.Handler[T]) ([]map[string]any, error) {
var res []map[string]any
for _, dev := range h.Devices() {
dc, err := deviceConfigMap(class, dev)
if err != nil {
return nil, err
}
res = append(res, dc)
}
return res, nil
}
// devicesHandler returns a device configurations by class
func devicesHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
class, err := templates.ClassString(vars["class"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
var res []map[string]any
switch class {
case templates.Meter:
res, err = devicesConfig(class, config.Meters())
case templates.Charger:
res, err = devicesConfig(class, config.Chargers())
case templates.Vehicle:
res, err = devicesConfig(class, config.Vehicles())
}
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
jsonResult(w, res)
}
func newDevice[T any](class templates.Class, req map[string]any, newFromConf func(string, map[string]any) (T, error), h config.Handler[T]) (*config.Config, error) {
instance, err := newFromConf(typeTemplate, req)
if err != nil {
return nil, err
}
conf, err := config.AddConfig(class, typeTemplate, req)
if err != nil {
return nil, err
}
return &conf, h.Add(config.NewConfigurableDevice[T](conf, instance))
}
// newDeviceHandler creates a new device by class
func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
class, err := templates.ClassString(vars["class"])
@ -131,50 +231,206 @@ func testHandler(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, err)
return
}
// var req struct {
// Type string
// Other map[string]any `mapstructure:",remain"`
// }
// if err := mapstructure.Decode(plain, &req); err != nil {
// jsonError(w, http.StatusBadRequest, err)
// return
// }
// tmpl, err := templates.ByName(class, req.Name)
// if err != nil {
// jsonError(w, http.StatusBadRequest, err)
// return
// }
// b, _, err := tmpl.RenderResult(templates.TemplateRenderModeInstance, req.Other)
// if err != nil {
// jsonError(w, http.StatusBadRequest, err)
// return
// }
// var instance any
// if err := yaml.Unmarshal(b, &instance); err != nil {
// jsonError(w, http.StatusBadRequest, err)
// return
// }
typ := "template"
if req["type"] != nil {
typ = req["type"].(string)
delete(req, "type")
}
var dev any
var conf *config.Config
switch class {
case templates.Charger:
dev, err = charger.NewFromConfig(typ, req)
conf, err = newDevice(class, req, charger.NewFromConfig, config.Chargers())
case templates.Meter:
dev, err = meter.NewFromConfig(typ, req)
conf, err = newDevice(class, req, meter.NewFromConfig, config.Meters())
case templates.Vehicle:
dev, err = vehicle.NewFromConfig(typ, req)
conf, err = newDevice(class, req, vehicle.NewFromConfig, config.Vehicles())
}
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
res := struct {
ID int `json:"id"`
}{
ID: conf.ID,
}
jsonResult(w, res)
}
func updateDevice[T any](id int, class templates.Class, conf map[string]any, newFromConf func(string, map[string]any) (T, error), h config.Handler[T]) error {
dev, instance, err := deviceInstanceFromMergedConfig(id, class, conf, newFromConf, h)
if err != nil {
return err
}
configurable, ok := dev.(config.ConfigurableDevice[T])
if !ok {
return errors.New("not configurable")
}
return configurable.Update(conf, instance)
}
// updateDeviceHandler updates database device's configuration by class
func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
class, err := templates.ClassString(vars["class"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
id, err := strconv.Atoi(vars["id"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
delete(req, "type")
switch class {
case templates.Charger:
err = updateDevice(id, class, req, charger.NewFromConfig, config.Chargers())
case templates.Meter:
err = updateDevice(id, class, req, meter.NewFromConfig, config.Meters())
case templates.Vehicle:
err = updateDevice(id, class, req, vehicle.NewFromConfig, config.Vehicles())
}
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
res := struct {
ID int `json:"id"`
}{
ID: id,
}
jsonResult(w, res)
}
func deleteDevice[T any](id int, h config.Handler[T]) error {
name := config.NameForID(id)
dev, err := h.ByName(name)
if err != nil {
return err
}
configurable, ok := dev.(config.ConfigurableDevice[T])
if !ok {
return errors.New("not configurable")
}
if err := configurable.Delete(); err != nil {
return err
}
return h.Delete(name)
}
// deleteDeviceHandler deletes a device from database by class
func deleteDeviceHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
class, err := templates.ClassString(vars["class"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
id, err := strconv.Atoi(vars["id"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
switch class {
case templates.Charger:
err = deleteDevice(id, config.Chargers())
case templates.Meter:
err = deleteDevice(id, config.Meters())
case templates.Vehicle:
err = deleteDevice(id, config.Vehicles())
}
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
res := struct {
ID int `json:"id"`
}{
ID: id,
}
jsonResult(w, res)
}
func testDevice[T any](id int, class templates.Class, conf map[string]any, newFromConf func(string, map[string]any) (T, error), h config.Handler[T]) (T, error) {
if id == 0 {
return newFromConf(typeTemplate, conf)
}
_, instance, err := deviceInstanceFromMergedConfig(id, class, conf, newFromConf, h)
return instance, err
}
// testHandler tests a configuration by class
func testHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
class, err := templates.ClassString(vars["class"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
var id int
if vars["id"] != "" {
// test existing device with updated config
id, err = strconv.Atoi(vars["id"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
}
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
delete(req, "type")
var instance any
switch class {
case templates.Charger:
instance, err = testDevice(id, class, req, charger.NewFromConfig, config.Chargers())
case templates.Meter:
instance, err = testDevice(id, class, req, meter.NewFromConfig, config.Meters())
case templates.Vehicle:
instance, err = testDevice(id, class, req, vehicle.NewFromConfig, config.Vehicles())
}
if err != nil {
@ -189,17 +445,17 @@ func testHandler(w http.ResponseWriter, r *http.Request) {
res := make(map[string]result)
if dev, ok := dev.(api.Meter); ok {
if dev, ok := instance.(api.Meter); ok {
val, err := dev.CurrentPower()
res["CurrentPower"] = result{val, err}
}
if dev, ok := dev.(api.MeterEnergy); ok {
if dev, ok := instance.(api.MeterEnergy); ok {
val, err := dev.TotalEnergy()
res["TotalEnergy"] = result{val, err}
}
if dev, ok := dev.(api.Battery); ok {
if dev, ok := instance.(api.Battery); ok {
val, err := dev.Soc()
res["Soc"] = result{val, err}
}

View file

@ -0,0 +1,73 @@
package server
import (
"errors"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/templates"
)
func templateForConfig(class templates.Class, conf map[string]any) (templates.Template, error) {
typ, ok := conf[typeTemplate].(string)
if !ok {
return templates.Template{}, errors.New("config template not found")
}
return templates.ByName(class, typ)
}
func sanitizeMasked(class templates.Class, conf map[string]any) (map[string]any, error) {
tmpl, err := templateForConfig(class, conf)
if err != nil {
return nil, err
}
res := make(map[string]any, len(conf))
for k, v := range conf {
if i, p := tmpl.ParamByName(k); i >= 0 && p.IsMasked() {
v = masked
}
res[k] = v
}
return res, nil
}
func mergeMasked(class templates.Class, conf, old map[string]any) (map[string]any, error) {
tmpl, err := templateForConfig(class, conf)
if err != nil {
return nil, err
}
res := make(map[string]any, len(conf))
for k, v := range conf {
if i, p := tmpl.ParamByName(k); i >= 0 && p.IsMasked() && v == masked {
v = old[k]
}
res[k] = v
}
return res, nil
}
func deviceInstanceFromMergedConfig[T any](id int, class templates.Class, conf map[string]any, newFromConf func(string, map[string]any) (T, error), h config.Handler[T]) (config.Device[T], T, error) {
var zero T
dev, err := h.ByName(config.NameForID(id))
if err != nil {
return nil, zero, err
}
merged, err := mergeMasked(class, conf, dev.Config().Other)
if err != nil {
return nil, zero, err
}
instance, err := newFromConf(typeTemplate, merged)
return dev, instance, err
}

View file

@ -10,8 +10,8 @@ import (
"strings"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/db"
dbserver "github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/core/session"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/util/locale"
"github.com/gorilla/mux"
"golang.org/x/text/language"
@ -30,13 +30,13 @@ func csvResult(ctx context.Context, w http.ResponseWriter, res any, filename str
// sessionHandler returns the list of charging sessions
func sessionHandler(w http.ResponseWriter, r *http.Request) {
if dbserver.Instance == nil {
if db.Instance == nil {
jsonError(w, http.StatusBadRequest, errors.New("database offline"))
return
}
var (
res db.Sessions
res session.Sessions
cond []string
args []any
)
@ -59,7 +59,7 @@ func sessionHandler(w http.ResponseWriter, r *http.Request) {
// TODO support other databases than Sqlite
query := strings.Join(append([]string{"charged_kwh>=0.05"}, cond...), " AND ")
if txn := dbserver.Instance.Where(query, args...).Order("created DESC").Find(&res); txn.Error != nil {
if txn := db.Instance.Where(query, args...).Order("created DESC").Find(&res); txn.Error != nil {
jsonError(w, http.StatusInternalServerError, txn.Error)
return
}
@ -92,17 +92,17 @@ func sessionHandler(w http.ResponseWriter, r *http.Request) {
// deleteSessionHandler removes session in sessions table with given id
func deleteSessionHandler(w http.ResponseWriter, r *http.Request) {
if dbserver.Instance == nil {
if db.Instance == nil {
jsonError(w, http.StatusBadRequest, errors.New("database offline"))
return
}
var res db.Sessions
var res session.Sessions
vars := mux.Vars(r)
id := vars["id"]
if txn := dbserver.Instance.Table("sessions").Delete(&res, id); txn.Error != nil {
if txn := db.Instance.Table("sessions").Delete(&res, id); txn.Error != nil {
jsonError(w, http.StatusBadRequest, txn.Error)
return
}
@ -112,7 +112,7 @@ func deleteSessionHandler(w http.ResponseWriter, r *http.Request) {
// updateSessionHandler updates the data of an existing session
func updateSessionHandler(w http.ResponseWriter, r *http.Request) {
if dbserver.Instance == nil {
if db.Instance == nil {
jsonError(w, http.StatusBadRequest, errors.New("database offline"))
return
}
@ -126,7 +126,7 @@ func updateSessionHandler(w http.ResponseWriter, r *http.Request) {
return
}
if txn := dbserver.Instance.Table("sessions").Where("id = ?", id).Updates(&session); txn.Error != nil {
if txn := db.Instance.Table("sessions").Where("id = ?", id).Updates(&session); txn.Error != nil {
jsonError(w, http.StatusBadRequest, txn.Error)
return
}

9
server/product.go Normal file
View file

@ -0,0 +1,9 @@
package server
type product struct {
Name string `json:"name"`
Template string `json:"template"`
Group string `json:"group,omitempty"`
}
type products []product

View file

@ -4,8 +4,6 @@ products:
params:
- preset: vehicle-base
- preset: vehicle-identify
- name: capacity
default: 27.4
render: |
type: dacia
{{ include "vehicle-base" . }}

View file

@ -5,6 +5,9 @@ products:
group: generic
params:
- name: title
- name: icon
default: car
advanced: true
- name: akey
required: true
- name: token
@ -13,9 +16,6 @@ params:
type: float
- name: phases
advanced: true
- name: icon
default: car
advanced: true
- preset: vehicle-identify
render: |
type: custom

View file

@ -8,6 +8,9 @@ requirements:
generic: Remote Control of PSA car https://github.com/flobz/psa_car_controller
params:
- name: title
- name: icon
default: car
advanced: true
- name: url
required: true
example: http://192.0.2.2
@ -23,9 +26,6 @@ params:
- name: capacity
- name: phases
advanced: true
- name: icon
default: car
advanced: true
- preset: vehicle-identify
render: |
type: custom

View file

@ -10,12 +10,12 @@ requirements:
en: Only supported if the vehicle can provided the state of charge (Soc) to the connected wallbox.
params:
- name: title
- name: capacity
- name: phases
advanced: true
- name: icon
default: car
advanced: true
- name: capacity
- name: phases
advanced: true
- preset: vehicle-identify
render: |
type: custom

View file

@ -9,6 +9,9 @@ requirements:
de: Voraussetzung ist ein konfigurierter MQTT Broker und eine mazda2mqtt Installation https://github.com/C64Axel/mazda2mqtt.
params:
- name: title
- name: icon
default: car
advanced: true
- name: vin
required: true
help:
@ -17,9 +20,6 @@ params:
- name: capacity
- name: phases
advanced: true
- name: icon
default: car
advanced: true
- name: timeout
default: 720h
advanced: true

View file

@ -6,6 +6,8 @@ products:
group: scooter
params:
- name: title
- name: icon
default: scooter
- name: user
required: true
- name: password
@ -18,8 +20,6 @@ params:
required: true
- name: capacity
default: 4
- name: icon
default: scooter
- preset: vehicle-identify
render: |
type: niu

View file

@ -9,6 +9,9 @@ requirements:
en: Support for all vehicles via ODB2 adapter in the vehicle. More info at [Open Vehicle Monitoring System](http://api.openvehicles.com/).
params:
- name: title
- name: icon
default: car
advanced: true
- name: user
required: true
- name: password
@ -20,9 +23,6 @@ params:
default: 12
- name: phases
advanced: true
- name: icon
default: car
advanced: true
- preset: vehicle-identify
render: |
type: ovms

View file

@ -6,8 +6,6 @@ products:
params:
- preset: vehicle-base
- preset: vehicle-identify
- name: capacity
default: 17.6
render: |
type: smart
{{ include "vehicle-base" . }}

View file

@ -19,13 +19,18 @@ requirements:
- [Tesla Auth (macOS, Linux)](https://github.com/adriankumpf/tesla_auth)
params:
- name: title
- name: icon
default: car
advanced: true
- name: accessToken
required: true
mask: true
help:
en: "See https://docs.evcc.io/docs/devices/vehicles#tesla"
de: "Siehe https://docs.evcc.io/docs/devices/vehicles#tesla"
- name: refreshToken
required: true
mask: true
help:
en: "See https://docs.evcc.io/docs/devices/vehicles#tesla"
de: "Siehe https://docs.evcc.io/docs/devices/vehicles#tesla"
@ -34,9 +39,6 @@ params:
- name: capacity
- name: phases
advanced: true
- name: icon
default: car
advanced: true
- preset: vehicle-identify
render: |
type: tesla

View file

@ -9,6 +9,9 @@ requirements:
de: Open Source Tesla Datenlogger https://github.com/adriankumpf/teslamate. Voraussetzung ist konfigurierter MQTT Broker.
params:
- name: title
- name: icon
default: car
advanced: true
- name: id
description:
de: Fahrzeug-ID
@ -17,9 +20,6 @@ params:
- name: capacity
- name: phases
advanced: true
- name: icon
default: car
advanced: true
- preset: vehicle-identify
render: |
type: custom

View file

@ -7,6 +7,9 @@ requirements:
evcc: ["sponsorship"]
params:
- name: title
- name: icon
default: car
advanced: true
- name: clientid
description:
generic: Tronity API Client ID
@ -27,9 +30,6 @@ params:
default: 10
- name: phases
advanced: true
- name: icon
default: car
advanced: true
- preset: vehicle-identify
render: |
type: tronity

View file

@ -8,7 +8,7 @@ render:
user: # Benutzerkonto (bspw. E-Mail Adresse, User Id, etc.)
password: # Passwort des Benutzerkontos (bei führenden Nullen bitte in einfache Hochkommata setzen)
vin: W... # Erforderlich, wenn mehrere Fahrzeuge des Herstellers vorhanden sind (Optional)
capacity: 27.4 # Akkukapazität in kWh (Optional)
capacity: 50 # Akkukapazität in kWh (Optional)
advanced: |
type: template
template: dacia
@ -16,7 +16,7 @@ render:
user: # Benutzerkonto (bspw. E-Mail Adresse, User Id, etc.)
password: # Passwort des Benutzerkontos (bei führenden Nullen bitte in einfache Hochkommata setzen)
vin: W... # Erforderlich, wenn mehrere Fahrzeuge des Herstellers vorhanden sind (Optional)
capacity: 27.4 # Akkukapazität in kWh (Optional)
capacity: 50 # Akkukapazität in kWh (Optional)
phases: 3 # Die maximale Anzahl der Phasen welche genutzt werden können (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
cache: 15m # Zeitintervall nach dem Daten erneut vom Fahrzeug abgefragt werden (Optional)

View file

@ -13,11 +13,11 @@ render:
type: template
template: evnotify
title: # Wird in der Benutzeroberfläche angezeigt (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
akey:
token:
capacity: 50 # Akkukapazität in kWh (Optional)
phases: 3 # Die maximale Anzahl der Phasen welche genutzt werden können (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
mode: # Möglich sind Off, Now, MinPV und PV, oder leer wenn keiner definiert werden soll (Optional)
minSoc: 25 # Ladung mit maximaler Geschwindigkeit bis zu dem angegeben Ladestand unabhängig PV-Erzeugung, wenn der Lademodus nicht auf 'Aus' steht (Optional)
targetSoc: 80 # Bis zu welchem Ladestand (Soc) soll das Fahrzeug geladen werden (Optional)

View file

@ -15,11 +15,11 @@ render:
type: template
template: flobz
title: # Wird in der Benutzeroberfläche angezeigt (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
url: http://192.0.2.2
vin: W... # Erforderlich
capacity: 50 # Akkukapazität in kWh (Optional)
phases: 3 # Die maximale Anzahl der Phasen welche genutzt werden können (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
mode: # Möglich sind Off, Now, MinPV und PV, oder leer wenn keiner definiert werden soll (Optional)
minSoc: 25 # Ladung mit maximaler Geschwindigkeit bis zu dem angegeben Ladestand unabhängig PV-Erzeugung, wenn der Lademodus nicht auf 'Aus' steht (Optional)
targetSoc: 80 # Bis zu welchem Ladestand (Soc) soll das Fahrzeug geladen werden (Optional)

View file

@ -14,9 +14,9 @@ render:
type: template
template: iso15118
title: # Wird in der Benutzeroberfläche angezeigt (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
capacity: 50 # Akkukapazität in kWh (Optional)
phases: 3 # Die maximale Anzahl der Phasen welche genutzt werden können (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
mode: # Möglich sind Off, Now, MinPV und PV, oder leer wenn keiner definiert werden soll (Optional)
minSoc: 25 # Ladung mit maximaler Geschwindigkeit bis zu dem angegeben Ladestand unabhängig PV-Erzeugung, wenn der Lademodus nicht auf 'Aus' steht (Optional)
targetSoc: 80 # Bis zu welchem Ladestand (Soc) soll das Fahrzeug geladen werden (Optional)

View file

@ -14,10 +14,10 @@ render:
type: template
template: mazda2mqtt
title: # Wird in der Benutzeroberfläche angezeigt (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
vin: W... # Erforderlich
capacity: 50 # Akkukapazität in kWh (Optional)
phases: 3 # Die maximale Anzahl der Phasen welche genutzt werden können (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
timeout: 720h # Optional
mode: # Möglich sind Off, Now, MinPV und PV, oder leer wenn keiner definiert werden soll (Optional)
minSoc: 25 # Ladung mit maximaler Geschwindigkeit bis zu dem angegeben Ladestand unabhängig PV-Erzeugung, wenn der Lademodus nicht auf 'Aus' steht (Optional)

View file

@ -7,20 +7,20 @@ render:
type: template
template: niu-e-scooter
title: # Wird in der Benutzeroberfläche angezeigt (Optional)
icon: scooter # Icon in der Benutzeroberfläche (Optional)
user: # Benutzerkonto (bspw. E-Mail Adresse, User Id, etc.)
password: # Passwort des Benutzerkontos (bei führenden Nullen bitte in einfache Hochkommata setzen)
serial:
capacity: 4 # Akkukapazität in kWh (Optional)
icon: scooter # Icon in der Benutzeroberfläche (Optional)
advanced: |
type: template
template: niu-e-scooter
title: # Wird in der Benutzeroberfläche angezeigt (Optional)
icon: scooter # Icon in der Benutzeroberfläche (Optional)
user: # Benutzerkonto (bspw. E-Mail Adresse, User Id, etc.)
password: # Passwort des Benutzerkontos (bei führenden Nullen bitte in einfache Hochkommata setzen)
serial:
capacity: 4 # Akkukapazität in kWh (Optional)
icon: scooter # Icon in der Benutzeroberfläche (Optional)
mode: # Möglich sind Off, Now, MinPV und PV, oder leer wenn keiner definiert werden soll (Optional)
minSoc: 25 # Ladung mit maximaler Geschwindigkeit bis zu dem angegeben Ladestand unabhängig PV-Erzeugung, wenn der Lademodus nicht auf 'Aus' steht (Optional)
targetSoc: 80 # Bis zu welchem Ladestand (Soc) soll das Fahrzeug geladen werden (Optional)

View file

@ -16,12 +16,12 @@ render:
type: template
template: ovms
title: # Wird in der Benutzeroberfläche angezeigt (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
user: # Benutzerkonto (bspw. E-Mail Adresse, User Id, etc.)
password: # Passwort des Benutzerkontos (bei führenden Nullen bitte in einfache Hochkommata setzen)
vehicleid:
capacity: 12 # Akkukapazität in kWh (Optional)
phases: 3 # Die maximale Anzahl der Phasen welche genutzt werden können (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
mode: # Möglich sind Off, Now, MinPV und PV, oder leer wenn keiner definiert werden soll (Optional)
minSoc: 25 # Ladung mit maximaler Geschwindigkeit bis zu dem angegeben Ladestand unabhängig PV-Erzeugung, wenn der Lademodus nicht auf 'Aus' steht (Optional)
targetSoc: 80 # Bis zu welchem Ladestand (Soc) soll das Fahrzeug geladen werden (Optional)

View file

@ -9,7 +9,7 @@ render:
user: # Benutzerkonto (bspw. E-Mail Adresse, User Id, etc.)
password: # Passwort des Benutzerkontos (bei führenden Nullen bitte in einfache Hochkommata setzen)
vin: W... # Erforderlich, wenn mehrere Fahrzeuge des Herstellers vorhanden sind (Optional)
capacity: 17.6 # Akkukapazität in kWh (Optional)
capacity: 50 # Akkukapazität in kWh (Optional)
advanced: |
type: template
template: smart
@ -17,7 +17,7 @@ render:
user: # Benutzerkonto (bspw. E-Mail Adresse, User Id, etc.)
password: # Passwort des Benutzerkontos (bei führenden Nullen bitte in einfache Hochkommata setzen)
vin: W... # Erforderlich, wenn mehrere Fahrzeuge des Herstellers vorhanden sind (Optional)
capacity: 17.6 # Akkukapazität in kWh (Optional)
capacity: 50 # Akkukapazität in kWh (Optional)
phases: 3 # Die maximale Anzahl der Phasen welche genutzt werden können (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
cache: 15m # Zeitintervall nach dem Daten erneut vom Fahrzeug abgefragt werden (Optional)

View file

@ -21,12 +21,12 @@ render:
type: template
template: tesla
title: # Wird in der Benutzeroberfläche angezeigt (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
accessToken: # Siehe https://docs.evcc.io/docs/devices/vehicles#tesla
refreshToken: # Siehe https://docs.evcc.io/docs/devices/vehicles#tesla
vin: W... # Erforderlich, wenn mehrere Fahrzeuge des Herstellers vorhanden sind (Optional)
capacity: 50 # Akkukapazität in kWh (Optional)
phases: 3 # Die maximale Anzahl der Phasen welche genutzt werden können (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
mode: # Möglich sind Off, Now, MinPV und PV, oder leer wenn keiner definiert werden soll (Optional)
minSoc: 25 # Ladung mit maximaler Geschwindigkeit bis zu dem angegeben Ladestand unabhängig PV-Erzeugung, wenn der Lademodus nicht auf 'Aus' steht (Optional)
targetSoc: 80 # Bis zu welchem Ladestand (Soc) soll das Fahrzeug geladen werden (Optional)

View file

@ -14,10 +14,10 @@ render:
type: template
template: teslamate
title: # Wird in der Benutzeroberfläche angezeigt (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
id: 1 # Optional
capacity: 50 # Akkukapazität in kWh (Optional)
phases: 3 # Die maximale Anzahl der Phasen welche genutzt werden können (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
mode: # Möglich sind Off, Now, MinPV und PV, oder leer wenn keiner definiert werden soll (Optional)
minSoc: 25 # Ladung mit maximaler Geschwindigkeit bis zu dem angegeben Ladestand unabhängig PV-Erzeugung, wenn der Lademodus nicht auf 'Aus' steht (Optional)
targetSoc: 80 # Bis zu welchem Ladestand (Soc) soll das Fahrzeug geladen werden (Optional)

View file

@ -15,12 +15,12 @@ render:
type: template
template: tronity
title: # Wird in der Benutzeroberfläche angezeigt (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
clientid: # Einrichtung unter https://app.tronity.tech
clientsecret: # Einrichtung unter https://app.tronity.tech
vin: W... # Erforderlich, wenn mehrere Fahrzeuge des Herstellers vorhanden sind (Optional)
capacity: 10 # Akkukapazität in kWh (Optional)
phases: 3 # Die maximale Anzahl der Phasen welche genutzt werden können (Optional)
icon: car # Icon in der Benutzeroberfläche (Optional)
mode: # Möglich sind Off, Now, MinPV und PV, oder leer wenn keiner definiert werden soll (Optional)
minSoc: 25 # Ladung mit maximaler Geschwindigkeit bis zu dem angegeben Ladestand unabhängig PV-Erzeugung, wenn der Lademodus nicht auf 'Aus' steht (Optional)
targetSoc: 80 # Bis zu welchem Ladestand (Soc) soll das Fahrzeug geladen werden (Optional)

34
tests/config-empty.evcc.yaml Executable file
View file

@ -0,0 +1,34 @@
site:
title: Hello World
meters:
grid: grid
meters:
- name: grid
type: custom
power:
source: js
script: |
1000
loadpoints:
- title: Carport
charger: charger
chargers:
- name: charger
type: custom
enable:
source: js
script:
enabled:
source: js
script: |
false
status:
source: js
script: |
"B"
maxcurrent:
source: js
script:

View file

@ -0,0 +1,41 @@
site:
title: Hello World
meters:
grid: grid
meters:
- name: grid
type: custom
power:
source: js
script: |
1000
loadpoints:
- title: Carport
charger: charger
vehicles:
- name: my_bike
type: template
template: offline
title: YAML Bike
icon: bike
chargers:
- name: charger
type: custom
enable:
source: js
script:
enabled:
source: js
script: |
false
status:
source: js
script: |
"B"
maxcurrent:
source: js
script:

122
tests/config.spec.js Normal file
View file

@ -0,0 +1,122 @@
const { test, expect } = require("@playwright/test");
const { start, stop, restart, cleanRestart } = require("./evcc");
const CONFIG_EMPTY = "config-empty.evcc.yaml";
const CONFIG_WITH_VEHICLE = "config-with-vehicle.evcc.yaml";
test.beforeAll(async () => {
await start(CONFIG_EMPTY);
});
test.afterAll(async () => {
await stop();
});
test.describe("basics", async () => {
test("navigation to config", async ({ page }) => {
await page.goto("/");
await page.getByTestId("topnavigation-button").click();
await page.getByRole("button", { name: "Settings" }).click();
await page.getByLabel("Experimental 🧪").click();
await page.getByRole("button", { name: "Close" }).click();
await page.getByTestId("topnavigation-button").click();
await page.getByRole("link", { name: "Configuration" }).click();
await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
});
test("alert box should always be visible", async ({ page }) => {
await page.goto("/#/config");
await expect(page.getByRole("alert")).toBeVisible();
});
});
test.describe("vehicles", async () => {
test("create, edit and delete vehicles", async ({ page }) => {
await page.goto("/#/config");
await expect(page.getByTestId("vehicle")).toHaveCount(0);
// create #1
await page.getByRole("button", { name: "Add vehicle" }).click();
await page.getByLabel("Manufacturer").selectOption("Generic vehicle");
await page.getByLabel("Title").fill("Green Car");
await page.getByRole("button", { name: "Validate & save" }).click();
await expect(page.getByTestId("vehicle")).toHaveCount(1);
// create #2
await page.getByRole("button", { name: "Add vehicle" }).click();
await page.getByLabel("Manufacturer").selectOption("Generic vehicle");
await page.getByLabel("Title").fill("Yellow Van");
await page.getByRole("button", { name: "Validate & save" }).click();
await expect(page.getByTestId("vehicle")).toHaveCount(2);
await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/Green Car/);
await expect(page.getByTestId("vehicle").nth(1)).toHaveText(/Yellow Van/);
// edit #1
await page.getByTestId("vehicle").nth(0).getByRole("button", { name: "edit" }).click();
await expect(page.getByLabel("Title")).toHaveValue("Green Car");
await page.getByLabel("Title").fill("Fancy Car");
await page.getByRole("button", { name: "Validate & save" }).click();
await expect(page.getByTestId("vehicle")).toHaveCount(2);
await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/Fancy Car/);
// delete #1
await page.getByTestId("vehicle").nth(0).getByRole("button", { name: "edit" }).click();
await page.getByRole("button", { name: "Delete Vehicle" }).click();
await expect(page.getByTestId("vehicle")).toHaveCount(1);
await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/Yellow Van/);
// delete #2
await page.getByTestId("vehicle").nth(0).getByRole("button", { name: "edit" }).click();
await page.getByRole("button", { name: "Delete Vehicle" }).click();
await expect(page.getByTestId("vehicle")).toHaveCount(0);
});
test("config should survive restart", async ({ page }) => {
await page.goto("/#/config");
await expect(page.getByTestId("vehicle")).toHaveCount(0);
// create #1 & #2
await page.getByRole("button", { name: "Add vehicle" }).click();
await page.getByLabel("Manufacturer").selectOption("Generic vehicle");
await page.getByLabel("Title").fill("Green Car");
await page.getByRole("button", { name: "Validate & save" }).click();
await page.getByRole("button", { name: "Add vehicle" }).click();
await page.getByLabel("Manufacturer").selectOption("Generic vehicle");
await page.getByLabel("Title").fill("Yellow Van");
await page.getByLabel("van").check();
await page.getByRole("button", { name: "Validate & save" }).click();
await expect(page.getByTestId("vehicle")).toHaveCount(2);
// restart evcc
await restart(CONFIG_EMPTY);
await page.reload();
await expect(page.getByTestId("vehicle")).toHaveCount(2);
await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/Green Car/);
await expect(page.getByTestId("vehicle").nth(1)).toHaveText(/Yellow Van/);
});
test("mixed config (yaml + db)", async ({ page }) => {
await cleanRestart(CONFIG_WITH_VEHICLE);
await page.goto("/#/config");
await expect(page.getByTestId("vehicle")).toHaveCount(1);
// create #1
await page.getByRole("button", { name: "Add vehicle" }).click();
await page.getByLabel("Manufacturer").selectOption("Generic vehicle");
await page.getByLabel("Title").fill("Green Car");
await page.getByRole("button", { name: "Validate & save" }).click();
await expect(page.getByTestId("vehicle")).toHaveCount(2);
await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/YAML Bike/);
await expect(page.getByTestId("vehicle").nth(1)).toHaveText(/Green Car/);
});
});

153
util/config/config.go Normal file
View file

@ -0,0 +1,153 @@
package config
import (
"fmt"
"github.com/evcc-io/evcc/util/templates"
"gorm.io/gorm"
)
type Config struct {
ID int `gorm:"primarykey"`
Class templates.Class
Type string
Details []ConfigDetail `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
type ConfigDetail struct {
ConfigID int `gorm:"index:idx_unique"`
Key string `gorm:"index:idx_unique"`
Value string
}
// Named converts device details to named config
func (d *Config) Named() Named {
res := Named{
Name: NameForID(d.ID),
Type: d.Type,
Other: d.detailsAsMap(),
}
return res
}
// Typed converts device details to typed config
func (d *Config) Typed() Typed {
res := Typed{
Type: d.Type,
Other: d.detailsAsMap(),
}
return res
}
// detailsAsMap converts device details to map
func (d *Config) detailsAsMap() map[string]any {
res := make(map[string]any, len(d.Details))
for _, detail := range d.Details {
res[detail.Key] = detail.Value
}
return res
}
// detailsFromMap converts map to device details
func detailsFromMap(config map[string]any) []ConfigDetail {
res := make([]ConfigDetail, 0, len(config))
for k, v := range config {
res = append(res, ConfigDetail{Key: k, Value: fmt.Sprintf("%v", v)})
}
return res
}
// Update updates a config's details to the database
func (d *Config) Update(conf map[string]any) error {
return db.Transaction(func(tx *gorm.DB) error {
var config Config
if err := tx.Where(Config{Class: d.Class, ID: d.ID}).First(&config).Error; err != nil {
return err
}
if err := tx.Delete(new(ConfigDetail), ConfigDetail{ConfigID: d.ID}).Error; err != nil {
return err
}
d.Details = detailsFromMap(conf)
return tx.Save(&d.Details).Error
})
}
// Delete deletes a config from the database
func (d *Config) Delete() error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Delete(new(ConfigDetail), ConfigDetail{ConfigID: d.ID}).Error; err != nil {
return err
}
return tx.Delete(Config{ID: d.ID}).Error
})
}
var db *gorm.DB
func Init(instance *gorm.DB) error {
db = instance
for old, new := range map[string]string{
"devices": "configs",
"device_details": "config_details",
} {
if m := db.Migrator(); m.HasTable(old) {
if err := m.RenameTable(old, new); err != nil {
return err
}
}
}
return db.AutoMigrate(new(Config), new(ConfigDetail))
}
// NameForID returns a unique config name for the given id
func NameForID(id int) string {
return fmt.Sprintf("db:%d", id)
}
// ConfigurationsByClass returns devices by class from the database
func ConfigurationsByClass(class templates.Class) ([]Config, error) {
var devices []Config
tx := db.Where(&Config{Class: class}).Preload("Details").Order("id").Find(&devices)
// remove devices without details
for i := 0; i < len(devices); {
if len(devices[i].Details) > 0 {
i++
continue
}
// delete device
copy(devices[i:], devices[i+1:])
devices = devices[: len(devices)-1 : len(devices)-1]
}
return devices, tx.Error
}
// ConfigByID returns device by id from the database
func ConfigByID(id int) (Config, error) {
var config Config
tx := db.Where(&Config{ID: id}).Preload("Details").First(&config)
return config, tx.Error
}
// AddConfig adds a new config to the database
func AddConfig(class templates.Class, typ string, conf map[string]any) (Config, error) {
config := Config{
Class: class,
Type: typ,
Details: detailsFromMap(conf),
}
err := db.Transaction(func(tx *gorm.DB) error {
return tx.Create(&config).Error
})
return config, err
}

72
util/config/device.go Normal file
View file

@ -0,0 +1,72 @@
package config
type Device[T any] interface {
Config() Named
Instance() T
}
type ConfigurableDevice[T any] interface {
Device[T]
ID() int
Update(map[string]any, T) error
Delete() error
}
type configurableDevice[T any] struct {
config Config
instance T
}
func NewConfigurableDevice[T any](config Config, instance T) ConfigurableDevice[T] {
return &configurableDevice[T]{
config: config,
instance: instance,
}
}
func (d *configurableDevice[T]) Config() Named {
return d.config.Named()
}
func (d *configurableDevice[T]) Instance() T {
return d.instance
}
func (d *configurableDevice[T]) ID() int {
return d.config.ID
}
func (d *configurableDevice[T]) Update(config map[string]any, instance T) error {
if err := d.config.Update(config); err != nil {
return err
}
d.instance = instance
return nil
}
func (d *configurableDevice[T]) Delete() error {
return d.config.Delete()
}
type staticDevice[T any] struct {
config Named
instance T
}
func NewStaticDevice[T any](config Named, instance T) Device[T] {
return &staticDevice[T]{
config: config,
instance: instance,
}
}
func (d *staticDevice[T]) Configurable() bool {
return true
}
func (d *staticDevice[T]) Config() Named {
return d.config
}
func (d *staticDevice[T]) Instance() T {
return d.instance
}

69
util/config/handler.go Normal file
View file

@ -0,0 +1,69 @@
package config
import (
"errors"
"fmt"
"sync"
)
type handler[T any] struct {
mu sync.Mutex
devices []Device[T]
}
// Devices returns the handlers devices
func (cp *handler[T]) Devices() []Device[T] {
cp.mu.Lock()
defer cp.mu.Unlock()
return cp.devices
}
// Add adds device and config
func (cp *handler[T]) Add(dev Device[T]) error {
conf := dev.Config()
if conf.Name == "" {
return errors.New("missing name")
}
if _, err := cp.ByName(conf.Name); err == nil {
return fmt.Errorf("duplicate name: %s already defined and must be unique", conf.Name)
}
cp.mu.Lock()
defer cp.mu.Unlock()
cp.devices = append(cp.devices, dev)
return nil
}
// Delete deletes device
func (cp *handler[T]) Delete(name string) error {
cp.mu.Lock()
defer cp.mu.Unlock()
for i, dev := range cp.devices {
if name == dev.Config().Name {
cp.devices = append(cp.devices[:i], cp.devices[i+1:]...)
return nil
}
}
return fmt.Errorf("not found: %s", name)
}
// ByName provides device by name
func (cp *handler[T]) ByName(name string) (Device[T], error) {
cp.mu.Lock()
defer cp.mu.Unlock()
for _, dev := range cp.devices {
if name == dev.Config().Name {
return dev, nil
}
}
return nil, fmt.Errorf("not found: %s", name)
}

43
util/config/instance.go Normal file
View file

@ -0,0 +1,43 @@
package config
import (
"github.com/evcc-io/evcc/api"
)
var instance = struct {
meters *handler[api.Meter]
chargers *handler[api.Charger]
vehicles *handler[api.Vehicle]
}{
meters: new(handler[api.Meter]),
chargers: new(handler[api.Charger]),
vehicles: new(handler[api.Vehicle]),
}
type Handler[T any] interface {
Devices() []Device[T]
Add(dev Device[T]) error
Delete(name string) error
ByName(name string) (Device[T], error)
}
func Meters() Handler[api.Meter] {
return instance.meters
}
func Chargers() Handler[api.Charger] {
return instance.chargers
}
func Vehicles() Handler[api.Vehicle] {
return instance.vehicles
}
// Instances returns the instances of the given devices
func Instances[T any](devices []Device[T]) []T {
res := make([]T, 0, len(devices))
for _, dev := range devices {
res = append(res, dev.Instance())
}
return res
}

12
util/config/types.go Normal file
View file

@ -0,0 +1,12 @@
package config
type Typed struct {
Type string `json:"type"`
Other map[string]interface{} `mapstructure:",remain"`
}
type Named struct {
Name string `json:"name"`
Type string `json:"type"`
Other map[string]interface{} `mapstructure:",remain"`
}

View file

@ -2,14 +2,6 @@ package templates
type Class int
func (c *Class) UnmarshalText(text []byte) error {
class, err := ClassString(string(text))
if err == nil {
*c = class
}
return err
}
//go:generate enumer -type Class
const (
_ Class = iota

View file

@ -245,19 +245,18 @@ params:
en: "Icon as shown in user interface"
type: choice
validvalues:
- bike
- bus
- car
- heater
- heatercooler
- heatpump
- bus
- van
- tractor
- bike
- scooter
- moped
- motorcycle
- plug
- scooter
- tractor
- van
- generic
- waterheater
- heater
- cooler
- name: ski
required: true
description:

View file

@ -97,7 +97,6 @@ func loadTemplates(class Class) {
return nil
})
if err != nil {
panic(err)
}

View file

@ -226,7 +226,7 @@ func (p *Param) IsAdvanced() bool {
return p.Advanced != nil && *p.Advanced
}
func (p *Param) IsMask() bool {
func (p *Param) IsMasked() bool {
return p.Mask != nil && *p.Mask
}