Add support for loading and storing device configuration from/to database (#6199)
This commit is contained in:
parent
8c68689c1d
commit
0854849243
86 changed files with 3259 additions and 2692 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
31
assets/js/components/Config/FormRow.vue
Normal file
31
assets/js/components/Config/FormRow.vue
Normal 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>
|
||||
126
assets/js/components/Config/PropertyField.vue
Normal file
126
assets/js/components/Config/PropertyField.vue
Normal 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>
|
||||
398
assets/js/components/Config/VehicleModal.vue
Normal file
398
assets/js/components/Config/VehicleModal.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
274
assets/js/views/Config.vue
Normal 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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
352
cmd/config.go
352
cmd/config.go
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
66
cmd/device.go
Normal 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()
|
||||
}
|
||||
18
cmd/dump.go
18
cmd/dump.go
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
cmd/flags.go
17
cmd/flags.go
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
cmd/meter.go
26
cmd/meter.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
351
cmd/setup.go
351
cmd/setup.go
|
|
@ -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, cp, loadpoints, vehicles, tariffs)
|
||||
return configureSite(conf.Site, loadpoints, config.Instances(config.Vehicles().Devices()), 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
"grid": conf.Tariffs.Grid,
|
||||
"feedin": conf.Tariffs.FeedIn,
|
||||
"co2": conf.Tariffs.Co2,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package db
|
||||
package session
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package db
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
27
core/site.go
27
core/site.go
|
|
@ -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
2
go.mod
|
|
@ -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
|
||||
|
|
|
|||
39
i18n/de.toml
39
i18n/de.toml
|
|
@ -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"
|
||||
|
|
|
|||
38
i18n/en.toml
38
i18n/en.toml
|
|
@ -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
2475
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
res, err := templates.ByName(class, name)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonError(w, http.StatusBadRequest, errors.New("template not found"))
|
||||
jsonResult(w, res)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
delete(req, "type")
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
|
|
|||
73
server/http_config_helper.go
Normal file
73
server/http_config_helper.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
9
server/product.go
Normal 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
|
||||
|
|
@ -4,8 +4,6 @@ products:
|
|||
params:
|
||||
- preset: vehicle-base
|
||||
- preset: vehicle-identify
|
||||
- name: capacity
|
||||
default: 27.4
|
||||
render: |
|
||||
type: dacia
|
||||
{{ include "vehicle-base" . }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ products:
|
|||
params:
|
||||
- preset: vehicle-base
|
||||
- preset: vehicle-identify
|
||||
- name: capacity
|
||||
default: 17.6
|
||||
render: |
|
||||
type: smart
|
||||
{{ include "vehicle-base" . }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
34
tests/config-empty.evcc.yaml
Executable 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:
|
||||
41
tests/config-with-vehicle.evcc.yaml
Executable file
41
tests/config-with-vehicle.evcc.yaml
Executable 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
122
tests/config.spec.js
Normal 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
153
util/config/config.go
Normal 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
72
util/config/device.go
Normal 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
69
util/config/handler.go
Normal 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
43
util/config/instance.go
Normal 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
12
util/config/types.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -97,7 +97,6 @@ func loadTemplates(class Class) {
|
|||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue