From 864c2cf59929fe0f8a400e56719baa5779973c6d Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 31 Jan 2025 12:26:23 +0100 Subject: [PATCH] Config UI: add loadpoints (BC) (#18137) --- .github/workflows/default.yml | 10 +- api/api.go | 3 +- api/error.go | 2 +- api/globalconfig/types.go | 2 +- assets/css/app.css | 34 +- assets/js/components/ChargingPlan.vue | 1 + assets/js/components/Config/ChargerModal.vue | 428 +++++++++ assets/js/components/Config/ControlModal.vue | 3 - assets/js/components/Config/DeviceCard.vue | 1 + assets/js/components/Config/DeviceTags.vue | 3 + assets/js/components/Config/FormRow.vue | 6 +- assets/js/components/Config/GeneralConfig.vue | 15 +- assets/js/components/Config/JsonModal.vue | 3 - .../js/components/Config/LoadpointModal.vue | 844 ++++++++++++++++++ assets/js/components/Config/MeterModal.vue | 318 +++---- assets/js/components/Config/Modbus.vue | 4 + assets/js/components/Config/NetworkModal.vue | 2 + ...ddDeviceButton.vue => NewDeviceButton.vue} | 16 +- .../components/Config/PropertyCollapsible.vue | 1 + assets/js/components/Config/PropertyField.vue | 13 +- assets/js/components/Config/SponsorModal.vue | 32 +- .../Config/SponsorTokenRequired.vue | 21 + assets/js/components/Config/TestResult.vue | 2 +- assets/js/components/Config/VehicleModal.vue | 503 +++++------ assets/js/components/Config/YamlModal.vue | 4 - assets/js/components/GenericModal.vue | 48 +- assets/js/components/Loadpoint.vue | 2 +- .../js/components/LoadpointSettingsModal.vue | 5 +- assets/js/components/MaterialIcon/Add.vue | 14 + .../js/components/MaterialIcon/Loadpoint.vue | 17 + assets/js/components/Mode.vue | 5 + assets/js/components/OfflineIndicator.vue | 3 + assets/js/components/SelectGroup.vue | 6 + assets/js/components/Site.vue | 56 +- assets/js/components/WelcomeIcons.vue | 121 +++ assets/js/mixins/formatter.js | 6 +- assets/js/utils/deepClone.js | 3 + assets/js/views/Config.vue | 557 ++++++++---- charger/template_test.go | 2 +- cmd/demo.yaml | 7 +- cmd/migrate.go | 38 +- cmd/refs.go | 98 ++ cmd/setup.go | 85 +- cmd/setup_circuits_test.go | 23 +- cmd/setup_test.go | 6 +- core/keys/loadpoint.go | 16 +- core/loadpoint.go | 201 +++-- core/loadpoint/api.go | 39 +- core/loadpoint/config.go | 83 ++ core/loadpoint/mock.go | 174 +++- core/loadpoint/pollmode_enumer.go | 94 ++ core/loadpoint/types.go | 39 + core/loadpoint_api.go | 145 ++- core/loadpoint_phases.go | 5 +- core/loadpoint_phases_test.go | 5 +- core/loadpoint_test.go | 117 +-- core/loadpoint_vehicle.go | 8 +- core/loadpoint_vehicle_test.go | 5 +- core/prioritizer/prioritizer.go | 4 +- core/prioritizer/prioritizer_test.go | 4 +- core/settings.go | 107 --- core/settings/config.go | 111 +++ core/settings/database.go | 65 ++ core/settings/settings.go | 18 + core/site.go | 2 +- hems/semp/semp.go | 2 +- i18n/de.toml | 130 ++- i18n/en.toml | 113 ++- server/helper.go | 9 + server/http.go | 16 +- server/http_config_device_handler.go | 19 +- server/http_config_helper.go | 13 + server/http_config_loadpoint_handler.go | 251 ++++++ server/http_config_site_handler.go | 3 +- server/http_config_site_other_handler.go | 6 + server/influxdb.go | 2 +- server/mqtt.go | 1 + .../definition/charger/demo-charger.yaml | 16 +- tests/config-battery.spec.js | 3 + tests/config-empty.evcc.yaml | 24 - tests/config-grid.spec.js | 16 +- tests/config-loadpoint.spec.js | 277 ++++++ tests/config-onboarding.spec.js | 101 +++ tests/config-one-lp.evcc.yaml | 24 + tests/config-tariffs.spec.js | 8 +- tests/simulator/api.js | 25 + util/config/config.go | 24 + util/config/device.go | 23 +- util/config/instance.go | 15 +- util/templates/class.go | 1 + util/templates/class_enumer.go | 20 +- vehicle/embed.go | 2 + 92 files changed, 4643 insertions(+), 1116 deletions(-) create mode 100644 assets/js/components/Config/ChargerModal.vue create mode 100644 assets/js/components/Config/LoadpointModal.vue rename assets/js/components/Config/{AddDeviceButton.vue => NewDeviceButton.vue} (75%) create mode 100644 assets/js/components/Config/SponsorTokenRequired.vue create mode 100644 assets/js/components/MaterialIcon/Add.vue create mode 100644 assets/js/components/MaterialIcon/Loadpoint.vue create mode 100644 assets/js/components/WelcomeIcons.vue create mode 100644 assets/js/utils/deepClone.js create mode 100644 cmd/refs.go create mode 100644 core/loadpoint/config.go create mode 100644 core/loadpoint/pollmode_enumer.go create mode 100644 core/loadpoint/types.go delete mode 100644 core/settings.go create mode 100644 core/settings/config.go create mode 100644 core/settings/database.go create mode 100644 core/settings/settings.go create mode 100644 server/http_config_loadpoint_handler.go create mode 100644 tests/config-loadpoint.spec.js create mode 100644 tests/config-onboarding.spec.js create mode 100755 tests/config-one-lp.evcc.yaml diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 5ebdada5f..908d648c6 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -13,7 +13,7 @@ on: jobs: clean: name: Clean - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -208,14 +208,12 @@ jobs: - name: Build Go run: make build + - name: Install Playwright + run: npx playwright install --with-deps chromium + - name: Run tests run: npx playwright test - # - name: Run tests - # uses: docker://mcr.microsoft.com/playwright:v1.34.3-jammy - # with: - # args: npx playwright test - - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: diff --git a/api/api.go b/api/api.go index 9a01f7de1..eb86e40f4 100644 --- a/api/api.go +++ b/api/api.go @@ -120,7 +120,8 @@ type Authorizer interface { Authorize(key string) error } -// PhaseDescriber returns the number of available phases +// PhaseDescriber returns the number of physically connected phases +// Used for vehicles and to limit switch sockets to 1p only type PhaseDescriber interface { Phases() int } diff --git a/api/error.go b/api/error.go index 72b07b970..672dddd3f 100644 --- a/api/error.go +++ b/api/error.go @@ -9,7 +9,7 @@ var ErrNotAvailable = errors.New("not available") var ErrMustRetry = errors.New("must retry") // ErrSponsorRequired indicates that a sponsor token is required -var ErrSponsorRequired = errors.New("sponsorship required, see https://github.com/evcc-io/evcc#sponsorship") +var ErrSponsorRequired = errors.New("sponsorship required, see https://docs.evcc.io/docs/sponsorship") // ErrMissingCredentials indicates that user/password are missing var ErrMissingCredentials = errors.New("missing user/password credentials") diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go index 611852cef..4e2ce2c04 100644 --- a/api/globalconfig/types.go +++ b/api/globalconfig/types.go @@ -38,7 +38,7 @@ type All struct { Vehicles []config.Named Tariffs Tariffs Site map[string]interface{} - Loadpoints []map[string]interface{} + Loadpoints []config.Named Circuits []config.Named } diff --git a/assets/css/app.css b/assets/css/app.css index 1a83bf9de..3d1f6e986 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -25,8 +25,9 @@ --evcc-green: #baffcb; --evcc-dark-green: #0fde41ff; --evcc-darker-green: #0ba631ff; + --evcc-darker-green-rgb: 11, 166, 49; --evcc-darkest-green: #076f20ff; - --evcc-darkest-green-rgb: 11, 166, 49; + --evcc-darkest-green-rgb: 7, 111, 32; --evcc-yellow: #faf000; --evcc-dark-yellow: #bbb400; --evcc-orange: #ff9000; @@ -269,10 +270,16 @@ a:hover { --bs-btn-hover-border-color: var(--bs-gray-medium); } -.btn-link:hover { +.btn-link:hover, +.btn-link:focus { color: var(--bs-primary); } +.btn-link:focus { + outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color); + outline-width: var(--bs-focus-ring-width); +} + .dark .btn-outline-secondary { --bs-btn-color: var(--bs-gray-bright); --bs-btn-border-color: var(--bs-gray-bright); @@ -364,6 +371,16 @@ a:hover { transition-timing-function: linear; } +.modal.fade-left .modal-dialog { + transform: translate(-50px, 0); +} +.modal.fade-right .modal-dialog { + transform: translate(50px, 0); +} +.modal.show .modal-dialog { + transform: none; +} + .modal-header { padding: 0 0 1rem 0; border: none; @@ -517,8 +534,13 @@ input::-webkit-datetime-edit { .form-control { color: var(--evcc-default-text); } -.form-control:disabled { +.form-control:disabled, +.form-control:read-only, +.dark .form-control:disabled, +.dark .form-control:read-only { + background-color: var(--bs-secondary-bg); color: var(--bs-gray-dark); + opacity: 1; } .dark .form-control { @@ -633,6 +655,12 @@ html.app .modal-dialog { color: var(--evcc-darkest-green); } +.hyphenate { + overflow-wrap: break-word; + word-wrap: break-word; + hyphens: auto; +} + input::-webkit-date-and-time-value { text-align: left; } diff --git a/assets/js/components/ChargingPlan.vue b/assets/js/components/ChargingPlan.vue index d54a07968..b3324c2db 100644 --- a/assets/js/components/ChargingPlan.vue +++ b/assets/js/components/ChargingPlan.vue @@ -271,6 +271,7 @@ export default { this.modal.show(); }, updateTargetTimeLabel: function () { + if (!this.effectivePlanTime) return ""; const targetDate = new Date(this.effectivePlanTime); this.targetTimeLabel = this.fmtAbsoluteDate(targetDate); }, diff --git a/assets/js/components/Config/ChargerModal.vue b/assets/js/components/Config/ChargerModal.vue new file mode 100644 index 000000000..ed157780e --- /dev/null +++ b/assets/js/components/Config/ChargerModal.vue @@ -0,0 +1,428 @@ + + + + diff --git a/assets/js/components/Config/ControlModal.vue b/assets/js/components/Config/ControlModal.vue index 40d4c48ff..603e0d314 100644 --- a/assets/js/components/Config/ControlModal.vue +++ b/assets/js/components/Config/ControlModal.vue @@ -154,7 +154,4 @@ export default { margin-right: calc(var(--bs-gutter-x) * -0.5); padding-right: 0; } -.btn-cancel { - margin-left: -0.75rem; -} diff --git a/assets/js/components/Config/DeviceCard.vue b/assets/js/components/Config/DeviceCard.vue index 22f7bdb81..2f436c7f2 100644 --- a/assets/js/components/Config/DeviceCard.vue +++ b/assets/js/components/Config/DeviceCard.vue @@ -10,6 +10,7 @@ type="button" class="btn btn-sm btn-outline-secondary position-relative border-0 p-2" :title="$t('config.main.edit')" + tabindex="0" @click="$emit('edit')" > diff --git a/assets/js/components/Config/DeviceTags.vue b/assets/js/components/Config/DeviceTags.vue index ab9d5e80c..1ee6b3ed6 100644 --- a/assets/js/components/Config/DeviceTags.vue +++ b/assets/js/components/Config/DeviceTags.vue @@ -83,6 +83,9 @@ export default { case "currentRange": return `${this.fmtNumber(value[0], 1)} A / ${this.fmtNumber(value[1], 1)} A`; case "controllable": + case "phases1p3p": + case "singlePhase": + case "enabled": case "configured": return value ? this.$t("config.deviceValue.yes") diff --git a/assets/js/components/Config/FormRow.vue b/assets/js/components/Config/FormRow.vue index 265d453bc..ba68ef929 100644 --- a/assets/js/components/Config/FormRow.vue +++ b/assets/js/components/Config/FormRow.vue @@ -11,9 +11,11 @@
-
{{ $t("config.form.example") }}: {{ example }}
+
+ {{ $t("config.form.example") }}: {{ example }} +
- + {{ $t("config.general.docsLink") }} diff --git a/assets/js/components/Config/GeneralConfig.vue b/assets/js/components/Config/GeneralConfig.vue index 9be8d48d6..d69c48d0a 100644 --- a/assets/js/components/Config/GeneralConfig.vue +++ b/assets/js/components/Config/GeneralConfig.vue @@ -7,6 +7,7 @@ class="config-button btn btn-link" type="button" :title="$t('config.main.edit')" + tabindex="0" @click.prevent="openModal('titleModal')" > @@ -20,6 +21,7 @@ class="config-button btn btn-link" type="button" :title="$t('config.main.edit')" + tabindex="0" @click.prevent="openModal('passwordModal')" > @@ -34,6 +36,7 @@ class="config-button btn btn-link" type="button" :title="$t('config.main.edit')" + tabindex="0" @click.prevent="openModal('globalSettingsModal')" > @@ -48,6 +51,7 @@ class="config-button btn btn-link" type="button" :title="$t('config.main.edit')" + tabindex="0" @click.prevent="openModal('globalSettingsModal')" > @@ -57,7 +61,7 @@ {{ $t("config.sponsor.title") }} đź§Ş
{{ sponsorStatus.name || "---" }} @@ -66,6 +70,7 @@ class="config-button btn btn-link" type="button" :title="$t('config.main.edit')" + tabindex="0" @click.prevent="openModal('sponsorModal')" > @@ -78,6 +83,7 @@ class="config-button btn btn-link" type="button" :title="$t('config.main.edit')" + tabindex="0" @click.prevent="openModal('networkModal')" > @@ -90,6 +96,7 @@ class="config-button btn btn-link" type="button" :title="$t('config.main.edit')" + tabindex="0" @click.prevent="openModal('controlModal')" > @@ -136,10 +143,8 @@ export default { const sponsor = store.state?.sponsor || {}; const { name, expiresSoon } = sponsor; let cssClass = ""; - if (expiresSoon) { - cssClass = "text-warning"; - } else if (name) { - cssClass = "text-primary"; + if (name) { + cssClass = expiresSoon ? "text-warning" : "text-primary"; } return { name, expiresSoon, cssClass }; diff --git a/assets/js/components/Config/JsonModal.vue b/assets/js/components/Config/JsonModal.vue index fbc5c901d..beb34acbb 100644 --- a/assets/js/components/Config/JsonModal.vue +++ b/assets/js/components/Config/JsonModal.vue @@ -168,7 +168,4 @@ export default { margin-right: calc(var(--bs-gutter-x) * -0.5); padding-right: 0; } -.btn-cancel { - margin-left: -0.75rem; -} diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue new file mode 100644 index 000000000..f2b5f7c01 --- /dev/null +++ b/assets/js/components/Config/LoadpointModal.vue @@ -0,0 +1,844 @@ + + + + diff --git a/assets/js/components/Config/MeterModal.vue b/assets/js/components/Config/MeterModal.vue index a421d05a8..cf129fc3f 100644 --- a/assets/js/components/Config/MeterModal.vue +++ b/assets/js/components/Config/MeterModal.vue @@ -1,159 +1,137 @@ @@ -30,7 +34,8 @@ export default { padding: 1rem 1rem 0.5rem; transition: border-color var(--evcc-transition-fast) linear; } -.root:hover { +.root:hover, +.root:focus-within { border-color: var(--evcc-default-text); color: var(--evcc-default-text); } @@ -42,4 +47,7 @@ button { width: 100%; color: inherit; } +button:focus { + outline: none; +} diff --git a/assets/js/components/Config/PropertyCollapsible.vue b/assets/js/components/Config/PropertyCollapsible.vue index 218f382f1..f2250cb8f 100644 --- a/assets/js/components/Config/PropertyCollapsible.vue +++ b/assets/js/components/Config/PropertyCollapsible.vue @@ -4,6 +4,7 @@ class="btn btn-link btn-sm text-gray px-0 border-0 d-flex align-items-center mb-2" :class="open ? 'text-primary' : ''" type="button" + tabindex="0" @click="toggle" > {{ $t("config.general.hideAdvancedSettings") }} diff --git a/assets/js/components/Config/PropertyField.vue b/assets/js/components/Config/PropertyField.vue index ac3f15330..dbeddea78 100644 --- a/assets/js/components/Config/PropertyField.vue +++ b/assets/js/components/Config/PropertyField.vue @@ -147,15 +147,15 @@ export default { return null; }, unitValue() { + if (this.type === "Duration") { + return this.fmtDurationUnit(this.value, this.unit); + } if (this.unit) { return this.unit; } if (this.property === "capacity") { return "kWh"; } - if (this.type === "Duration") { - return this.fmtSecondUnit(this.value); - } return null; }, icons() { @@ -173,6 +173,9 @@ export default { select() { return this.choice.length > 0; }, + durationFactor() { + return this.unit === "minute" ? 60 : 1; + }, selectOptions() { // If the valid values are already in the correct format, return them if (typeof this.choice[0] === "object") { @@ -211,7 +214,7 @@ export default { } if (this.type === "Duration" && typeof this.modelValue === "number") { - return this.modelValue / NS_PER_SECOND; + return this.modelValue / this.durationFactor / NS_PER_SECOND; } return this.modelValue; @@ -228,7 +231,7 @@ export default { } if (this.type === "Duration" && typeof newValue === "number") { - newValue = newValue * NS_PER_SECOND; + newValue = newValue * this.durationFactor * NS_PER_SECOND; } this.$emit("update:modelValue", newValue); diff --git a/assets/js/components/Config/SponsorModal.vue b/assets/js/components/Config/SponsorModal.vue index b66d482e7..b93357543 100644 --- a/assets/js/components/Config/SponsorModal.vue +++ b/assets/js/components/Config/SponsorModal.vue @@ -18,14 +18,23 @@
- +
+ + + {{ $t("config.sponsor.trialToken") }} + +

diff --git a/assets/js/components/Config/SponsorTokenRequired.vue b/assets/js/components/Config/SponsorTokenRequired.vue new file mode 100644 index 000000000..fbd346655 --- /dev/null +++ b/assets/js/components/Config/SponsorTokenRequired.vue @@ -0,0 +1,21 @@ + + + diff --git a/assets/js/components/Config/TestResult.vue b/assets/js/components/Config/TestResult.vue index 5a6f1308e..d34a3b157 100644 --- a/assets/js/components/Config/TestResult.vue +++ b/assets/js/components/Config/TestResult.vue @@ -18,7 +18,7 @@ role="status" aria-hidden="true" > - + {{ $t("config.validation.validate") }}
diff --git a/assets/js/components/Config/VehicleModal.vue b/assets/js/components/Config/VehicleModal.vue index ec4d6947a..6e774283c 100644 --- a/assets/js/components/Config/VehicleModal.vue +++ b/assets/js/components/Config/VehicleModal.vue @@ -1,260 +1,235 @@ diff --git a/assets/js/components/MaterialIcon/Loadpoint.vue b/assets/js/components/MaterialIcon/Loadpoint.vue new file mode 100644 index 000000000..5defffd0c --- /dev/null +++ b/assets/js/components/MaterialIcon/Loadpoint.vue @@ -0,0 +1,17 @@ + + + diff --git a/assets/js/components/Mode.vue b/assets/js/components/Mode.vue index 0986782b4..49e9394c6 100644 --- a/assets/js/components/Mode.vue +++ b/assets/js/components/Mode.vue @@ -6,6 +6,7 @@ type="button" class="btn flex-grow-1 flex-shrink-1" :class="{ active: isActive(m) }" + tabindex="0" @click="setTargetMode(m)" > {{ label(m) }} @@ -73,6 +74,10 @@ export default { .btn:hover { color: var(--evcc-gray); } +.btn:focus { + outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color); + outline-width: var(--bs-focus-ring-width); +} .btn.active { color: var(--evcc-background); background: var(--evcc-default-text); diff --git a/assets/js/components/OfflineIndicator.vue b/assets/js/components/OfflineIndicator.vue index 96316047f..0d295275a 100644 --- a/assets/js/components/OfflineIndicator.vue +++ b/assets/js/components/OfflineIndicator.vue @@ -12,6 +12,7 @@ class="btn btn-secondary me-2 btn-sm d-flex align-items-center" type="button" disabled + tabindex="0" > @@ -62,6 +64,7 @@ type="button" class="btn-close mt-1" aria-label="Close" + tabindex="0" @click="dismissed = true" >
diff --git a/assets/js/components/SelectGroup.vue b/assets/js/components/SelectGroup.vue index a746dbb70..7af0c5631 100644 --- a/assets/js/components/SelectGroup.vue +++ b/assets/js/components/SelectGroup.vue @@ -8,6 +8,8 @@ class="btn btn-sm flex-grow-1 flex-shrink-1" :class="{ active: option.value === modelValue, 'btn--equal': equalWidth }" :disabled="option.disabled" + :data-testid="`${id}-${option.value}`" + tabindex="0" @click="$emit('update:modelValue', option.value)" > {{ option.name }} @@ -56,6 +58,10 @@ export default { .btn:hover { color: var(--evcc-gray); } +.btn:focus { + outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color); + outline-width: var(--bs-focus-ring-width); +} .btn.active { color: var(--evcc-background); background: var(--evcc-default-text); diff --git a/assets/js/components/Site.vue b/assets/js/components/Site.vue index a31777fb6..217ea270c 100644 --- a/assets/js/components/Site.vue +++ b/assets/js/components/Site.vue @@ -3,7 +3,9 @@

- {{ siteTitle || "evcc" }} + + {{ siteTitle || "evcc" }} +

- +

{{ $t("startupError.title") }}

+
+
+

+ {{ $t("main.welcome") }} +

+ + + {{ $t("main.startConfiguration") }} + +
+
diff --git a/assets/js/components/WelcomeIcons.vue b/assets/js/components/WelcomeIcons.vue new file mode 100644 index 000000000..0e561d966 --- /dev/null +++ b/assets/js/components/WelcomeIcons.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/assets/js/mixins/formatter.js b/assets/js/mixins/formatter.js index a86ff9cb8..1d487e077 100644 --- a/assets/js/mixins/formatter.js +++ b/assets/js/mixins/formatter.js @@ -253,13 +253,13 @@ export default { day: "numeric", }).format(date); }, - fmtSecondUnit: function (seconds) { + fmtDurationUnit: function (value, unit = "second") { return new Intl.NumberFormat(this.$i18n?.locale, { style: "unit", - unit: "second", + unit, unitDisplay: "long", }) - .formatToParts(seconds) + .formatToParts(value) .find((part) => part.type === "unit").value; }, fmtMoney: function (amout = 0, currency = "EUR", decimals = true, withSymbol = false) { diff --git a/assets/js/utils/deepClone.js b/assets/js/utils/deepClone.js new file mode 100644 index 000000000..acf99cbc1 --- /dev/null +++ b/assets/js/utils/deepClone.js @@ -0,0 +1,3 @@ +export default function (obj) { + return JSON.parse(JSON.stringify(obj)); +} diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index 0067c6505..cbc884433 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -18,7 +18,6 @@

Missing features

  • aux meters
  • -
  • loadpoints and chargers
  • custom/plugin meters and vehicles
  • migration for vehicles, chargers, meters, loadpoints
  • remove mixed mode (evcc.yaml + db) for meters and vehicles
  • @@ -36,31 +35,85 @@
    +

    {{ $t("config.section.loadpoints") }} đź§Ş

    +
      + + + + + + +
    + +

    {{ $t("config.section.vehicles") }} đź§Ş

    +
      + + + + + +
    +

    {{ $t("config.section.grid") }} đź§Ş

      + +

    {{ $t("config.section.meter") }} đź§Ş

      @@ -109,163 +168,116 @@ - -
    - -

    {{ $t("config.section.loadpoints") }} đź§Ş

    - -
      - - - - -
    -

    {{ $t("config.section.vehicles") }} đź§Ş

    -
    -
      - - - - - -
    +

    {{ $t("config.section.integrations") }} đź§Ş

    -

    {{ $t("config.section.integrations") }} đź§Ş

    +
      + + + + + + + + + + + + + + + + -
        - - - diff --git a/charger/template_test.go b/charger/template_test.go index 9b0e6c38a..884d0b006 100644 --- a/charger/template_test.go +++ b/charger/template_test.go @@ -29,7 +29,7 @@ var acceptable = []string{ "(Client.Timeout exceeded while awaiting headers)", "can only have either uri or device", // modbus "connection already registered with different protocol: localhost:502", // modbus - "sponsorship required, see https://github.com/evcc-io/evcc#sponsorship", + "sponsorship required, see https://docs.evcc.io/docs/sponsorship", "eebus not configured", "context deadline exceeded", "timeout", // ocpp diff --git a/cmd/demo.yaml b/cmd/demo.yaml index c54a5b7ee..586f69aa1 100644 --- a/cmd/demo.yaml +++ b/cmd/demo.yaml @@ -262,7 +262,6 @@ tariffs: feedin: type: fixed price: 0.08 # EUR/kWh - co2: - type: template - template: grünstromindex - zip: 12349 + #co2: + # type: grünstromindex + # zip: 12349 diff --git a/cmd/migrate.go b/cmd/migrate.go index d384e4bee..d4d71bc4b 100644 --- a/cmd/migrate.go +++ b/cmd/migrate.go @@ -2,7 +2,9 @@ package cmd import ( "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/server/db" "github.com/evcc-io/evcc/server/db/settings" + "github.com/evcc-io/evcc/util/config" "github.com/spf13/cobra" ) @@ -34,83 +36,95 @@ func runMigrate(cmd *cobra.Command, args []string) { // TODO remove yaml file if reset { - log.WARN.Println("resetting:") + log.INFO.Println("resetting:") } else { - log.WARN.Println("migrating:") + log.INFO.Println("migrating:") } - log.DEBUG.Println("- global settings") + log.INFO.Println("- global settings") if reset { settings.Delete(keys.Interval) settings.Delete(keys.SponsorToken) + settings.Delete(keys.Title) } else { settings.SetInt(keys.Interval, int64(conf.Interval)) settings.SetString(keys.SponsorToken, conf.SponsorToken) } - log.DEBUG.Println("- network") + log.INFO.Println("- network") if reset { settings.Delete(keys.Network) } else { _ = settings.SetJson(keys.Network, conf.Network) } - log.DEBUG.Println("- mqtt") + log.INFO.Println("- mqtt") if reset { settings.Delete(keys.Mqtt) } else { _ = settings.SetJson(keys.Mqtt, conf.Mqtt) } - log.DEBUG.Println("- influx") + log.INFO.Println("- influx") if reset { settings.Delete(keys.Influx) } else { _ = settings.SetJson(keys.Influx, conf.Influx) } - log.DEBUG.Println("- hems") + log.INFO.Println("- hems") if reset { settings.Delete(keys.Hems) } else if conf.HEMS.Type != "" { _ = settings.SetYaml(keys.Hems, conf.HEMS) } - log.DEBUG.Println("- eebus") + log.INFO.Println("- eebus") if reset { settings.Delete(keys.EEBus) } else if conf.EEBus.Configured() { _ = settings.SetYaml(keys.EEBus, conf.EEBus) } - log.DEBUG.Println("- modbusproxy") + log.INFO.Println("- modbusproxy") if reset { settings.Delete(keys.ModbusProxy) } else if len(conf.ModbusProxy) > 0 { _ = settings.SetYaml(keys.ModbusProxy, conf.ModbusProxy) } - log.DEBUG.Println("- messaging") + log.INFO.Println("- messaging") if reset { settings.Delete(keys.Messaging) } else if len(conf.Messaging.Services) > 0 { _ = settings.SetYaml(keys.Messaging, conf.Messaging) } - log.DEBUG.Println("- tariffs") + log.INFO.Println("- tariffs") if reset { settings.Delete(keys.Tariffs) } else if conf.Tariffs.Grid.Type != "" || conf.Tariffs.FeedIn.Type != "" || conf.Tariffs.Co2.Type != "" || conf.Tariffs.Planner.Type != "" { _ = settings.SetYaml(keys.Tariffs, conf.Tariffs) } - log.DEBUG.Println("- circuits") + log.INFO.Println("- circuits") if reset { settings.Delete(keys.Circuits) } else if len(conf.Circuits) > 0 { _ = settings.SetYaml(keys.Circuits, conf.Circuits) } + log.INFO.Println("- device configs") + if reset { + // site keys + settings.Delete(keys.GridMeter) + settings.Delete(keys.PvMeters) + settings.Delete(keys.AuxMeters) + settings.Delete(keys.BatteryMeters) + // clear config table + result := db.Instance.Delete(&config.Config{}, "true") + log.INFO.Printf(" %d entries deleted", result.RowsAffected) + } // wait for shutdown <-shutdownDoneC() } diff --git a/cmd/refs.go b/cmd/refs.go new file mode 100644 index 000000000..f7598815e --- /dev/null +++ b/cmd/refs.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "iter" + "slices" + "strings" + + "github.com/evcc-io/evcc/api/globalconfig" + "github.com/evcc-io/evcc/core" + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/server/db/settings" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/config" + "github.com/evcc-io/evcc/util/templates" +) + +var references struct { + meter, charger, vehicle, circuit []string +} + +func collectRefs(conf globalconfig.All) error { + // site + if err := collectSiteRefs(conf); err != nil { + return err + } + + // loadpoints + if err := collectLoadpointRefs(slices.Values(conf.Loadpoints)); err != nil { + return err + } + + // append devices from database + configurable, err := config.ConfigurationsByClass(templates.Loadpoint) + if err != nil { + return err + } + + return collectLoadpointRefs(func(yield func(config.Named) bool) { + for _, cc := range configurable { + if !yield(cc.Named()) { + return + } + } + }) +} + +func collectSiteRefs(conf globalconfig.All) error { + var refs struct { + Meters core.MetersConfig `mapstructure:"meters"` // Meter references + Other map[string]any `mapstructure:",remain"` + } + + if err := util.DecodeOther(conf.Site, &refs); err != nil { + return err + } + + references.meter = append(references.meter, refs.Meters.GridMeterRef) + references.meter = append(references.meter, refs.Meters.PVMetersRef...) + references.meter = append(references.meter, refs.Meters.BatteryMetersRef...) + references.meter = append(references.meter, refs.Meters.ExtMetersRef...) + references.meter = append(references.meter, refs.Meters.AuxMetersRef...) + + // append devices from settings + if v, err := settings.String(keys.GridMeter); err == nil && v != "" { + references.meter = append(references.meter, v) + } + + for _, key := range []string{keys.PvMeters, keys.BatteryMeters, keys.ExtMeters, keys.AuxMeters} { + if v, err := settings.String(key); err == nil && v != "" { + references.meter = append(references.meter, strings.Split(v, ",")...) + } + } + + return nil +} + +func collectLoadpointRefs(named iter.Seq[config.Named]) error { + for cc := range named { + var refs struct { + CircuitRef string `mapstructure:"circuit"` // Circuit reference + ChargerRef string `mapstructure:"charger"` // Charger reference + VehicleRef string `mapstructure:"vehicle"` // Vehicle reference + MeterRef string `mapstructure:"meter"` // Charge meter reference + Other map[string]any `mapstructure:",remain"` + } + + if err := util.DecodeOther(cc.Other, &refs); err != nil { + return err + } + + references.meter = append(references.meter, refs.MeterRef) + references.charger = append(references.charger, refs.ChargerRef) + references.vehicle = append(references.vehicle, refs.VehicleRef) + references.circuit = append(references.circuit, refs.CircuitRef) + } + + return nil +} diff --git a/cmd/setup.go b/cmd/setup.go index 5cbeae6bf..f7dd80aa7 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -22,6 +22,8 @@ import ( "github.com/evcc-io/evcc/core" "github.com/evcc-io/evcc/core/circuit" "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/core/loadpoint" + coresettings "github.com/evcc-io/evcc/core/settings" "github.com/evcc-io/evcc/hems" "github.com/evcc-io/evcc/meter" "github.com/evcc-io/evcc/plugin/golang" @@ -46,6 +48,7 @@ import ( "github.com/gorilla/handlers" "github.com/gorilla/mux" "github.com/libp2p/zeroconf/v2" + "github.com/samber/lo" "github.com/spf13/cast" "github.com/spf13/cobra" "golang.org/x/sync/errgroup" @@ -262,7 +265,7 @@ func configureMeters(static []config.Named, names ...string) error { return &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)} } - if err := config.Meters().Add(config.NewConfigurableDevice(conf, instance)); err != nil { + if err := config.Meters().Add(config.NewConfigurableDevice(&conf, instance)); err != nil { return &DeviceError{cc.Name, err} } @@ -326,7 +329,7 @@ func configureChargers(static []config.Named, names ...string) error { return fmt.Errorf("cannot create charger '%s': %w", cc.Name, err) } - if err := config.Chargers().Add(config.NewConfigurableDevice(conf, instance)); err != nil { + if err := config.Chargers().Add(config.NewConfigurableDevice(&conf, instance)); err != nil { return &DeviceError{cc.Name, err} } @@ -419,7 +422,7 @@ func configureVehicles(static []config.Named, names ...string) error { mu.Lock() defer mu.Unlock() - devs2 = append(devs2, config.NewConfigurableDevice(conf, instance)) + devs2 = append(devs2, config.NewConfigurableDevice(&conf, instance)) return nil }) @@ -796,11 +799,16 @@ func configureTariffs(conf globalconfig.Tariffs) (*tariff.Tariffs, error) { } func configureDevices(conf globalconfig.All) error { + // collect references for filtering used devices + if err := collectRefs(conf); err != nil { + return err + } + // TODO: add name/identifier to error for better highlighting in UI - if err := configureMeters(conf.Meters); err != nil { + if err := configureMeters(conf.Meters, references.meter...); err != nil { return &ClassError{ClassMeter, err} } - if err := configureChargers(conf.Chargers); err != nil { + if err := configureChargers(conf.Chargers, references.charger...); err != nil { return &ClassError{ClassCharger, err} } if err := configureVehicles(conf.Vehicles); err != nil { @@ -853,8 +861,7 @@ func configureSiteAndLoadpoints(conf *globalconfig.All) (*core.Site, error) { return nil, err } - loadpoints, err := configureLoadpoints(*conf) - if err != nil { + if err := configureLoadpoints(*conf); err != nil { return nil, fmt.Errorf("failed configuring loadpoints: %w", err) } @@ -863,6 +870,11 @@ func configureSiteAndLoadpoints(conf *globalconfig.All) (*core.Site, error) { return nil, &ClassError{ClassTariff, err} } + loadpoints := lo.Map(config.Loadpoints().Devices(), func(dev config.Device[loadpoint.API], _ int) *core.Loadpoint { + lp := dev.Instance() + return lp.(*core.Loadpoint) + }) + site, err := configureSite(conf.Site, loadpoints, tariffs) if err != nil { return nil, err @@ -924,26 +936,59 @@ func configureSite(conf map[string]interface{}, loadpoints []*core.Loadpoint, ta return site, nil } -func configureLoadpoints(conf globalconfig.All) ([]*core.Loadpoint, error) { - if len(conf.Loadpoints) == 0 { - return nil, errors.New("missing loadpoints") - } +func configureLoadpoints(conf globalconfig.All) error { + for id, cc := range conf.Loadpoints { + cc.Name = "lp-" + strconv.Itoa(id+1) - var loadpoints []*core.Loadpoint + log := util.NewLoggerWithLoadpoint(cc.Name, id+1) + settings := coresettings.NewDatabaseSettingsAdapter(fmt.Sprintf("lp%d.", id+1)) - for id, cfg := range conf.Loadpoints { - log := util.NewLoggerWithLoadpoint("lp-"+strconv.Itoa(id+1), id+1) - settings := &core.Settings{Key: "lp" + strconv.Itoa(id+1) + "."} - - lp, err := core.NewLoadpointFromConfig(log, settings, cfg) + instance, err := core.NewLoadpointFromConfig(log, settings, cc.Other) if err != nil { - return nil, fmt.Errorf("failed configuring loadpoint: %w", err) + return fmt.Errorf("failed configuring loadpoint: %w", err) } - loadpoints = append(loadpoints, lp) + if err := config.Loadpoints().Add(config.NewStaticDevice(cc, loadpoint.API(instance))); err != nil { + return &DeviceError{cc.Name, err} + } } - return loadpoints, nil + // append devices from database + configurable, err := config.ConfigurationsByClass(templates.Loadpoint) + if err != nil { + return err + } + + for _, conf := range configurable { + cc := conf.Named() + + id := len(config.Loadpoints().Devices()) + name := "lp-" + strconv.Itoa(id+1) + log := util.NewLoggerWithLoadpoint(name, id+1) + + settings := coresettings.NewConfigSettingsAdapter(log, &conf) + + dynamic, static, err := loadpoint.SplitConfig(cc.Other) + if err != nil { + return fmt.Errorf("failed configuring loadpoint: %w", err) + } + + instance, err := core.NewLoadpointFromConfig(log, settings, static) + if err != nil { + return fmt.Errorf("failed configuring loadpoint: %w", err) + } + + if err := dynamic.Apply(instance); err != nil { + return err + } + + dev := config.NewConfigurableDevice[loadpoint.API](&conf, instance) + if err := config.Loadpoints().Add(dev); err != nil { + return err + } + } + + return nil } // configureAuth handles routing for devices. For now only api.AuthProvider related routes diff --git a/cmd/setup_circuits_test.go b/cmd/setup_circuits_test.go index 80f65190a..5728ace64 100644 --- a/cmd/setup_circuits_test.go +++ b/cmd/setup_circuits_test.go @@ -6,6 +6,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api/globalconfig" + "github.com/evcc-io/evcc/core" "github.com/evcc-io/evcc/server/db" "github.com/evcc-io/evcc/util/config" "github.com/stretchr/testify/suite" @@ -59,9 +60,11 @@ loadpoints: Name: "test", }, api.Charger(nil)))) - lps, err := configureLoadpoints(conf) + err := configureLoadpoints(conf) suite.Require().NoError(err) - suite.Require().NotNil(lps[0].GetCircuit()) + + lps := config.Loadpoints().Devices() + suite.Require().NotNil(lps[0].Instance().GetCircuit()) } func (suite *circuitsTestSuite) TestCircuitMissingLoadpoint() { @@ -88,9 +91,15 @@ loadpoints: Name: "test", }, api.Charger(nil)))) - lps, err := configureLoadpoints(conf) + err := configureLoadpoints(conf) suite.Require().NoError(err) + lpsd := config.Loadpoints().Devices() + lps := make([]*core.Loadpoint, 0, len(lpsd)) + for _, lp := range lpsd { + lps = append(lps, lp.Instance().(*core.Loadpoint)) + } + // circuit without device err = validateCircuits(lps) suite.Require().Error(err) @@ -140,9 +149,15 @@ loadpoints: Name: "test", }, api.Charger(nil)))) - lps, err := configureLoadpoints(conf) + err := configureLoadpoints(conf) suite.Require().NoError(err) + lpsd := config.Loadpoints().Devices() + lps := make([]*core.Loadpoint, 0, len(lpsd)) + for _, lp := range lpsd { + lps = append(lps, lp.Instance().(*core.Loadpoint)) + } + // lp using root circuit is valid suite.Require().NoError(validateCircuits(lps)) } diff --git a/cmd/setup_test.go b/cmd/setup_test.go index f4dae37f5..1847a8dda 100644 --- a/cmd/setup_test.go +++ b/cmd/setup_test.go @@ -24,11 +24,11 @@ func TestYamlOff(t *testing.T) { } var lp core.Loadpoint - if err := util.DecodeOther(conf.Loadpoints[0], &lp); err != nil { + if err := util.DecodeOther(conf.Loadpoints[0].Other, &lp); err != nil { t.Error(err) } - if lp.Mode_ != api.ModeOff { - t.Errorf("expected `off`, got %s", lp.Mode_) + if lp.DefaultMode != api.ModeOff { + t.Errorf("expected `off`, got %s", lp.DefaultMode) } } diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go index 4308fddf3..543f914b9 100644 --- a/core/keys/loadpoint.go +++ b/core/keys/loadpoint.go @@ -4,27 +4,31 @@ const ( // loadpoint settings Title = "title" // loadpoint title Mode = "mode" // charge mode + DefaultMode = "defaultMode" // default charge mode Priority = "priority" // priority MinCurrent = "minCurrent" // min current MaxCurrent = "maxCurrent" // max current MinSoc = "minSoc" // min soc LimitSoc = "limitSoc" // limit soc LimitEnergy = "limitEnergy" // limit energy + Soc = "soc" + Thresholds = "thresholds" EnableThreshold = "enableThreshold" DisableThreshold = "disableThreshold" EnableDelay = "enableDelay" DisableDelay = "disableDelay" BatteryBoost = "batteryBoost" - PhasesConfigured = "phasesConfigured" // configured phases (1/3, 0 for auto on 1p3p chargers, nil for plain chargers) + Phases = "phases" // configured phases (1/3, 0 for auto on 1p3p chargers, nil for plain chargers) + PhasesConfigured = "phasesConfigured" // TODO mirrors "phases" for UI purposes PhasesEnabled = "phasesEnabled" // enabled phases (1/3) PhasesActive = "phasesActive" // active phases as used by vehicle (1/2/3) - ChargerIcon = "chargerIcon" // charger icon for ui - ChargerFeature = "chargerFeature" // charger feature - ChargerPhysicalPhases = "chargerPhysicalPhases" // charger phases - ChargerPhases1p3p = "chargerPhases1p3p" // phase switcher (1p3p chargers) - ChargerStatusReason = "chargerStatusReason" // either awaiting authorization or disconnect required + ChargerIcon = "chargerIcon" // charger icon for ui + ChargerFeature = "chargerFeature" // charger feature + ChargerSinglePhase = "chargerSinglePhase" // api.PhaseDescriber: charger physical phases, sockets only + ChargerPhases1p3p = "chargerPhases1p3p" // api.PhaseSwitcher: 1p3p chargers + ChargerStatusReason = "chargerStatusReason" // either awaiting authorization or disconnect required // loadpoint status Enabled = "enabled" // loadpoint enabled diff --git a/core/loadpoint.go b/core/loadpoint.go index e6cff3974..3f761e257 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -6,7 +6,6 @@ import ( "math" "reflect" "slices" - "strings" "sync" "testing" "time" @@ -20,6 +19,7 @@ import ( "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/settings" "github.com/evcc-io/evcc/core/soc" "github.com/evcc-io/evcc/core/vehicle" "github.com/evcc-io/evcc/core/wrapper" @@ -64,32 +64,8 @@ const ( // elapsed is the time an expired timer will be set to var elapsed = time.Unix(0, 1) -// PollConfig defines the vehicle polling mode and interval -type PollConfig struct { - Mode string `mapstructure:"mode"` // polling mode charging (default), connected, always - Interval time.Duration `mapstructure:"interval"` // interval when not charging -} - -// SocConfig defines soc settings, estimation and update behavior -type SocConfig struct { - Poll PollConfig `mapstructure:"poll"` - Estimate *bool `mapstructure:"estimate"` -} - // Poll modes -const ( - pollCharging = "charging" - pollConnected = "connected" - pollAlways = "always" - - pollInterval = 60 * time.Minute -) - -// ThresholdConfig defines enable/disable hysteresis parameters -type ThresholdConfig struct { - Delay time.Duration - Threshold float64 -} +const pollInterval = 60 * time.Minute // Task is the task type type Task = func() @@ -107,24 +83,27 @@ type Loadpoint struct { // exposed public configuration sync.RWMutex // guard status - vmu sync.RWMutex // guard vehicle - Mode_ api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect + vmu sync.RWMutex // guard vehicle - Title_ string `mapstructure:"title"` // UI title - Priority_ int `mapstructure:"priority"` // Priority - CircuitRef string `mapstructure:"circuit"` // Circuit reference - ChargerRef string `mapstructure:"charger"` // Charger reference - VehicleRef string `mapstructure:"vehicle"` // Vehicle reference - MeterRef string `mapstructure:"meter"` // Charge meter reference - Soc SocConfig - Enable, Disable ThresholdConfig + CircuitRef string `mapstructure:"circuit"` // Circuit reference + ChargerRef string `mapstructure:"charger"` // Charger reference + VehicleRef string `mapstructure:"vehicle"` // Vehicle reference + MeterRef string `mapstructure:"meter"` // Charge meter reference + + Soc loadpoint.SocConfig + Enable, Disable loadpoint.ThresholdConfig // TODO deprecated - GuardDuration_ time.Duration `mapstructure:"guardduration"` // charger enable/disable minimum holding time - ConfiguredPhases_ int `mapstructure:"phases"` - MinCurrent_ float64 `mapstructure:"minCurrent"` - MaxCurrent_ float64 `mapstructure:"maxCurrent"` + DefaultMode api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect + Title_ string `mapstructure:"title"` // UI title + Priority_ int `mapstructure:"priority"` // Priority + GuardDuration_ time.Duration `mapstructure:"guardduration"` // charger enable/disable minimum holding time + Phases_ int `mapstructure:"phases"` + MinCurrent_ float64 `mapstructure:"minCurrent"` + MaxCurrent_ float64 `mapstructure:"maxCurrent"` + title string // UI title + priority int // Priority minCurrent float64 // PV mode: start current Min+PV mode: min current maxCurrent float64 // Max allowed current. Physically ensured by the charger configuredPhases int // Charger configured phase mode 0/1/3 @@ -186,34 +165,31 @@ type Loadpoint struct { db *session.DB session *session.Session - settings *Settings + settings settings.Settings tasks *util.Queue[Task] // tasks to be executed } // NewLoadpointFromConfig creates a new loadpoint -func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[string]interface{}) (*Loadpoint, error) { +func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, other map[string]interface{}) (*Loadpoint, error) { lp := NewLoadpoint(log, settings) if err := util.DecodeOther(other, lp); err != nil { return nil, err } // set vehicle polling mode - switch lp.Soc.Poll.Mode = strings.ToLower(lp.Soc.Poll.Mode); lp.Soc.Poll.Mode { - case pollCharging: - case pollConnected, pollAlways: + switch lp.Soc.Poll.Mode { + case loadpoint.PollCharging: + case loadpoint.PollConnected, loadpoint.PollAlways: lp.log.WARN.Printf("poll mode '%s' may deplete your battery or lead to API misuse. USE AT YOUR OWN RISK.", lp.Soc.Poll) default: - if lp.Soc.Poll.Mode != "" { - lp.log.WARN.Printf("invalid poll mode: %s", lp.Soc.Poll.Mode) - } - lp.Soc.Poll.Mode = pollCharging + lp.Soc.Poll.Mode = loadpoint.PollCharging } if lp.CircuitRef != "" { dev, err := config.Circuits().ByName(lp.CircuitRef) if err != nil { - return nil, err + return nil, fmt.Errorf("circuit: %w", err) } lp.circuit = dev.Instance() } @@ -221,7 +197,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri if lp.MeterRef != "" { dev, err := config.Meters().ByName(lp.MeterRef) if err != nil { - return nil, err + return nil, fmt.Errorf("meter: %w", err) } lp.chargeMeter = dev.Instance() } @@ -230,7 +206,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri if lp.VehicleRef != "" { dev, err := config.Vehicles().ByName(lp.VehicleRef) if err != nil { - return nil, err + return nil, fmt.Errorf("vehicle: %w", err) } lp.defaultVehicle = dev.Instance() } @@ -240,7 +216,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri } dev, err := config.Chargers().ByName(lp.ChargerRef) if err != nil { - return nil, err + return nil, fmt.Errorf("charger: %w", err) } lp.charger = dev.Instance() lp.configureChargerType(lp.charger) @@ -252,24 +228,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri } // TODO deprecated - if lp.MinCurrent_ > 0 { - lp.log.WARN.Println("deprecated: mincurrent setting is ignored, please remove") - if _, err := lp.settings.Float(keys.MinCurrent); err != nil { - lp.settings.SetFloat(keys.MinCurrent, lp.MinCurrent_) - } - } - if lp.MaxCurrent_ > 0 { - lp.log.WARN.Println("deprecated: maxcurrent setting is ignored, please remove") - if _, err := lp.settings.Float(keys.MaxCurrent); err != nil { - lp.settings.SetFloat(keys.MaxCurrent, lp.MaxCurrent_) - } - } - if lp.ConfiguredPhases_ > 0 { - lp.log.WARN.Println("deprecated: phases setting is ignored, please remove") - if _, err := lp.settings.Int(keys.PhasesConfigured); err != nil { - lp.settings.SetInt(keys.PhasesConfigured, int64(lp.ConfiguredPhases_)) - } - } + lp.migrateSettings() // validate thresholds if lp.Enable.Threshold > lp.Disable.Threshold { @@ -279,7 +238,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri } // choose sane default if mode is not set - if lp.mode = lp.Mode_; lp.mode == "" { + if lp.mode = lp.DefaultMode; lp.mode == "" { lp.mode = api.ModeOff } @@ -287,7 +246,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri } // NewLoadpoint creates a Loadpoint with sane defaults -func NewLoadpoint(log *util.Logger, settings *Settings) *Loadpoint { +func NewLoadpoint(log *util.Logger, settings settings.Settings) *Loadpoint { clock := clock.New() bus := evbus.New() @@ -300,31 +259,84 @@ func NewLoadpoint(log *util.Logger, settings *Settings) *Loadpoint { status: api.StatusNone, minCurrent: 6, // A maxCurrent: 16, // A - Soc: SocConfig{ - Poll: PollConfig{ + Soc: loadpoint.SocConfig{ + Poll: loadpoint.PollConfig{ Interval: pollInterval, - Mode: pollCharging, + Mode: loadpoint.PollCharging, }, }, - Enable: ThresholdConfig{Delay: time.Minute, Threshold: 0}, // t, W - Disable: ThresholdConfig{Delay: 3 * time.Minute, Threshold: 0}, // t, W - progress: NewProgress(0, 10), // soc progress indicator - coordinator: coordinator.NewDummy(), // dummy vehicle coordinator - tasks: util.NewQueue[Task](), // task queue + Enable: loadpoint.ThresholdConfig{Delay: time.Minute, Threshold: 0}, // t, W + Disable: loadpoint.ThresholdConfig{Delay: 3 * time.Minute, Threshold: 0}, // t, W + progress: NewProgress(0, 10), // soc progress indicator + coordinator: coordinator.NewDummy(), // dummy vehicle coordinator + tasks: util.NewQueue[Task](), // task queue } return lp } +// migrateSettings migrates loadpoint settings +func (lp *Loadpoint) migrateSettings() { + // One-time migrations MUST be mirrored in restoreSettings + if lp.DefaultMode != "" { + lp.log.WARN.Println("deprecated: mode setting is ignored, please remove") + if _, err := lp.settings.String(keys.Mode); err != nil { + lp.settings.SetString(keys.Mode, string(lp.DefaultMode)) + } + } + if lp.Title_ != "" { + lp.log.WARN.Println("deprecated: title setting is ignored, please remove") + if _, err := lp.settings.String(keys.Title); err != nil { + lp.settings.SetString(keys.Title, lp.Title_) + } + } + if lp.Priority_ > 0 { + lp.log.WARN.Println("deprecated: priority setting is ignored, please remove") + if _, err := lp.settings.String(keys.Priority); err != nil { + lp.settings.SetInt(keys.Priority, int64(lp.Priority_)) + } + } + if lp.MinCurrent_ > 0 { + lp.log.WARN.Println("deprecated: mincurrent setting is ignored, please remove") + if _, err := lp.settings.Float(keys.MinCurrent); err != nil { + lp.settings.SetFloat(keys.MinCurrent, lp.MinCurrent_) + } + } + if lp.MaxCurrent_ > 0 { + lp.log.WARN.Println("deprecated: maxcurrent setting is ignored, please remove") + if _, err := lp.settings.Float(keys.MaxCurrent); err != nil { + lp.settings.SetFloat(keys.MaxCurrent, lp.MaxCurrent_) + } + } + if lp.Phases_ > 0 { + lp.log.WARN.Println("deprecated: phases setting is ignored, please remove") + if _, err := lp.settings.Int(keys.Phases); err != nil { + lp.settings.SetInt(keys.Phases, int64(lp.Phases_)) + } + } + if lp.Soc.Estimate != nil || lp.Soc.Poll.Mode != loadpoint.PollCharging || lp.Soc.Poll.Interval != 0 { + lp.log.WARN.Println("deprecated: soc setting is ignored, please remove") + if _, err := lp.settings.String(keys.Soc); err != nil { + lp.settings.SetJson(keys.Soc, lp.Soc) + } + } +} + // restoreSettings restores loadpoint settings func (lp *Loadpoint) restoreSettings() { if testing.Testing() { return } + if v, err := lp.settings.String(keys.Title); err == nil && v != "" { + lp.setTitle(v) + } if v, err := lp.settings.String(keys.Mode); err == nil && v != "" { lp.setMode(api.ChargeMode(v)) } - if v, err := lp.settings.Int(keys.PhasesConfigured); err == nil && (v > 0 || lp.hasPhaseSwitching()) { + if v, err := lp.settings.Int(keys.Priority); err == nil && v > 0 { + lp.setPriority(int(v)) + } + if v, err := lp.settings.Int(keys.Phases); err == nil && (v > 0 || lp.hasPhaseSwitching()) { lp.setConfiguredPhases(int(v)) lp.phases = lp.configuredPhases } @@ -344,6 +356,16 @@ func (lp *Loadpoint) restoreSettings() { lp.SetSmartCostLimit(&v) } + var thresholds loadpoint.ThresholdsConfig + if err := lp.settings.Json(keys.Thresholds, &thresholds); err == nil { + lp.setThresholds(thresholds) + } + + var socConfig loadpoint.SocConfig + if err := lp.settings.Json(keys.Soc, &socConfig); err == nil { + lp.setSocConfig(socConfig) + } + t, err1 := lp.settings.Time(keys.PlanTime) v, err2 := lp.settings.Float(keys.PlanEnergy) if err1 == nil && err2 == nil { @@ -584,7 +606,7 @@ func (lp *Loadpoint) evChargeCurrentWrappedMeterHandler(current float64) { // defaultMode executes the action func (lp *Loadpoint) defaultMode() { lp.RLock() - mode := lp.Mode_ + mode := lp.DefaultMode lp.RUnlock() if mode != "" && mode != lp.GetMode() { @@ -610,7 +632,7 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even lp.restoreSettings() // publish initial values - lp.publish(keys.Title, lp.Title()) + lp.publish(keys.Title, lp.GetTitle()) lp.publish(keys.Mode, lp.GetMode()) lp.publish(keys.Priority, lp.GetPriority()) lp.publish(keys.MinCurrent, lp.GetMinCurrent()) @@ -627,15 +649,17 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even } lp.phases = phases lp.configuredPhases = phases - lp.publish(keys.ChargerPhysicalPhases, phases) + lp.publish(keys.Phases, phases) } else { - lp.publish(keys.ChargerPhysicalPhases, nil) + lp.publish(keys.Phases, nil) } lp.publish(keys.PhasesConfigured, lp.configuredPhases) lp.publish(keys.ChargerPhases1p3p, lp.hasPhaseSwitching()) + lp.publish(keys.ChargerSinglePhase, lp.getChargerPhysicalPhases() == 1) lp.publish(keys.PhasesEnabled, lp.phases) lp.publish(keys.PhasesActive, lp.ActivePhases()) + lp.publish(keys.SmartCostLimit, lp.smartCostLimit) lp.publishTimer(phaseTimer, 0, timerInactive) lp.publishTimer(pvTimer, 0, timerInactive) @@ -685,6 +709,11 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even lp.log.ERROR.Printf("charger enabled: %v", err) } + // set vehicle polling mode + if lp.Soc.Poll.Mode != loadpoint.PollCharging { + lp.log.WARN.Printf("poll mode '%s' may deplete your battery or lead to API misuse. USE AT YOUR OWN RISK.", lp.Soc.Poll) + } + // allow charger to access loadpoint if ctrl, ok := lp.charger.(loadpoint.Controller); ok { ctrl.LoadpointControl(lp) diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index e98511f1a..be1357286 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -22,12 +22,29 @@ type API interface { // GetStatus returns the charging status GetStatus() api.ChargeStatus + // + // references + // + + // GetCharger returns the loadpoint charger + GetChargerName() string + // GetMeter returns the loadpoint meter + GetMeterName() string + // GetCircuitName returns the loadpoint circuit name + GetCircuitName() string + // GetCircuit returns the loadpoint circuit + GetCircuit() api.Circuit + // GetDefaultVehicle returns the loadpoint default vehicle + GetDefaultVehicle() string + // // settings // - // Title returns the defined loadpoint title - Title() string + // GetTitle returns the loadpoint title + GetTitle() string + // SetTitle sets the loadpoint title + SetTitle(string) // GetPriority returns the priority GetPriority() int // SetPriority sets the priority @@ -41,10 +58,14 @@ type API interface { // SetMaxCurrent sets the max charging current SetMaxCurrent(float64) error - // GetMode returns the charge mode + // GetMode returns the current charge mode GetMode() api.ChargeMode // SetMode sets the charge mode SetMode(api.ChargeMode) + // GetDefaultMode returns the default charge mode (for reset) + GetDefaultMode() api.ChargeMode + // SetDefaultMode sets the default charge mode (for reset) + SetDefaultMode(api.ChargeMode) // GetPhases returns the enabled phases GetPhases() int // SetPhases sets the enabled phases @@ -95,6 +116,15 @@ type API interface { // GetPlan creates a charging plan GetPlan(targetTime time.Time, requiredDuration time.Duration) (api.Rates, error) + // GetSocConfig returns the soc poll settings + GetSocConfig() SocConfig + // SetSocConfig sets the soc poll settings + SetSocConfig(soc SocConfig) + + // GetThresholds returns the PV mode threshold settings + GetThresholds() ThresholdsConfig + // SetThresholds sets the PV mode threshold settings + SetThresholds(thresholds ThresholdsConfig) // GetEnableThreshold gets the loadpoint enable threshold GetEnableThreshold() float64 // SetEnableThreshold sets loadpoint enable threshold @@ -164,7 +194,4 @@ type API interface { SetVehicle(vehicle api.Vehicle) // StartVehicleDetection allows triggering vehicle detection for debugging purposes StartVehicleDetection() - - // GetCircuit gets the assigned circuit - GetCircuit() api.Circuit } diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go new file mode 100644 index 000000000..6834a222b --- /dev/null +++ b/core/loadpoint/config.go @@ -0,0 +1,83 @@ +package loadpoint + +import ( + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" +) + +type StaticConfig struct { + // static config + Charger string `json:"charger,omitempty"` + Meter string `json:"meter,omitempty"` + Circuit string `json:"circuit,omitempty"` + Vehicle string `json:"vehicle,omitempty"` +} + +type DynamicConfig struct { + // dynamic config + Title string `json:"title"` + DefaultMode string `json:"defaultMode"` + Priority int `json:"priority"` + Phases int `json:"phases"` + MinCurrent float64 `json:"minCurrent"` + MaxCurrent float64 `json:"maxCurrent"` + SmartCostLimit *float64 `json:"smartCostLimit"` + PlanEnergy float64 `json:"planEnergy"` + PlanTime time.Time `json:"planTime"` + LimitEnergy float64 `json:"limitEnergy"` + LimitSoc int `json:"limitSoc"` + + Thresholds ThresholdsConfig `json:"thresholds"` + Soc SocConfig `json:"soc"` +} + +func SplitConfig(payload map[string]any) (DynamicConfig, map[string]any, error) { + // split static and dynamic config via mapstructure + var cc struct { + DynamicConfig `mapstructure:",squash"` + Other map[string]any `mapstructure:",remain"` + } + + if err := util.DecodeOther(payload, &cc); err != nil { + return DynamicConfig{}, nil, err + } + + // TODO: proper handling of id/name + delete(cc.Other, "id") + delete(cc.Other, "name") + + return cc.DynamicConfig, cc.Other, nil +} + +func (payload DynamicConfig) Apply(lp API) error { + lp.SetTitle(payload.Title) + lp.SetPriority(payload.Priority) + lp.SetSmartCostLimit(payload.SmartCostLimit) + lp.SetThresholds(payload.Thresholds) + lp.SetPlanEnergy(payload.PlanTime, payload.PlanEnergy) + lp.SetLimitEnergy(payload.LimitEnergy) + lp.SetLimitSoc(payload.LimitSoc) + + // TODO mode warning + lp.SetSocConfig(payload.Soc) + + mode, err := api.ChargeModeString(payload.DefaultMode) + if err == nil { + lp.SetDefaultMode(mode) + } + + if err == nil { + err = lp.SetPhases(payload.Phases) + } + + if err == nil && payload.MinCurrent != 0 { + err = lp.SetMinCurrent(payload.MinCurrent) + } + if err == nil && payload.MaxCurrent != 0 { + err = lp.SetMaxCurrent(payload.MaxCurrent) + } + + return err +} diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index c50aade36..7664457d9 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -167,6 +167,20 @@ func (mr *MockAPIMockRecorder) GetChargePowerFlexibility() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChargePowerFlexibility", reflect.TypeOf((*MockAPI)(nil).GetChargePowerFlexibility)) } +// GetChargerName mocks base method. +func (m *MockAPI) GetChargerName() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChargerName") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetChargerName indicates an expected call of GetChargerName. +func (mr *MockAPIMockRecorder) GetChargerName() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChargerName", reflect.TypeOf((*MockAPI)(nil).GetChargerName)) +} + // GetCircuit mocks base method. func (m *MockAPI) GetCircuit() api.Circuit { m.ctrl.T.Helper() @@ -181,6 +195,48 @@ func (mr *MockAPIMockRecorder) GetCircuit() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCircuit", reflect.TypeOf((*MockAPI)(nil).GetCircuit)) } +// GetCircuitName mocks base method. +func (m *MockAPI) GetCircuitName() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCircuitName") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetCircuitName indicates an expected call of GetCircuitName. +func (mr *MockAPIMockRecorder) GetCircuitName() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCircuitName", reflect.TypeOf((*MockAPI)(nil).GetCircuitName)) +} + +// GetDefaultMode mocks base method. +func (m *MockAPI) GetDefaultMode() api.ChargeMode { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDefaultMode") + ret0, _ := ret[0].(api.ChargeMode) + return ret0 +} + +// GetDefaultMode indicates an expected call of GetDefaultMode. +func (mr *MockAPIMockRecorder) GetDefaultMode() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultMode", reflect.TypeOf((*MockAPI)(nil).GetDefaultMode)) +} + +// GetDefaultVehicle mocks base method. +func (m *MockAPI) GetDefaultVehicle() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDefaultVehicle") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetDefaultVehicle indicates an expected call of GetDefaultVehicle. +func (mr *MockAPIMockRecorder) GetDefaultVehicle() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultVehicle", reflect.TypeOf((*MockAPI)(nil).GetDefaultVehicle)) +} + // GetDisableDelay mocks base method. func (m *MockAPI) GetDisableDelay() time.Duration { m.ctrl.T.Helper() @@ -293,6 +349,20 @@ func (mr *MockAPIMockRecorder) GetMaxPhaseCurrent() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxPhaseCurrent", reflect.TypeOf((*MockAPI)(nil).GetMaxPhaseCurrent)) } +// GetMeterName mocks base method. +func (m *MockAPI) GetMeterName() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMeterName") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetMeterName indicates an expected call of GetMeterName. +func (mr *MockAPIMockRecorder) GetMeterName() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMeterName", reflect.TypeOf((*MockAPI)(nil).GetMeterName)) +} + // GetMinCurrent mocks base method. func (m *MockAPI) GetMinCurrent() float64 { m.ctrl.T.Helper() @@ -450,6 +520,20 @@ func (mr *MockAPIMockRecorder) GetSmartCostLimit() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSmartCostLimit", reflect.TypeOf((*MockAPI)(nil).GetSmartCostLimit)) } +// GetSocConfig mocks base method. +func (m *MockAPI) GetSocConfig() SocConfig { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSocConfig") + ret0, _ := ret[0].(SocConfig) + return ret0 +} + +// GetSocConfig indicates an expected call of GetSocConfig. +func (mr *MockAPIMockRecorder) GetSocConfig() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSocConfig", reflect.TypeOf((*MockAPI)(nil).GetSocConfig)) +} + // GetStatus mocks base method. func (m *MockAPI) GetStatus() api.ChargeStatus { m.ctrl.T.Helper() @@ -464,6 +548,34 @@ func (mr *MockAPIMockRecorder) GetStatus() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStatus", reflect.TypeOf((*MockAPI)(nil).GetStatus)) } +// GetThresholds mocks base method. +func (m *MockAPI) GetThresholds() ThresholdsConfig { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetThresholds") + ret0, _ := ret[0].(ThresholdsConfig) + return ret0 +} + +// GetThresholds indicates an expected call of GetThresholds. +func (mr *MockAPIMockRecorder) GetThresholds() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetThresholds", reflect.TypeOf((*MockAPI)(nil).GetThresholds)) +} + +// GetTitle mocks base method. +func (m *MockAPI) GetTitle() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTitle") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetTitle indicates an expected call of GetTitle. +func (mr *MockAPIMockRecorder) GetTitle() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTitle", reflect.TypeOf((*MockAPI)(nil).GetTitle)) +} + // GetVehicle mocks base method. func (m *MockAPI) GetVehicle() api.Vehicle { m.ctrl.T.Helper() @@ -544,6 +656,18 @@ func (mr *MockAPIMockRecorder) SetBatteryBoost(enable any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBatteryBoost", reflect.TypeOf((*MockAPI)(nil).SetBatteryBoost), enable) } +// SetDefaultMode mocks base method. +func (m *MockAPI) SetDefaultMode(arg0 api.ChargeMode) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetDefaultMode", arg0) +} + +// SetDefaultMode indicates an expected call of SetDefaultMode. +func (mr *MockAPIMockRecorder) SetDefaultMode(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetDefaultMode", reflect.TypeOf((*MockAPI)(nil).SetDefaultMode), arg0) +} + // SetDisableDelay mocks base method. func (m *MockAPI) SetDisableDelay(delay time.Duration) { m.ctrl.T.Helper() @@ -708,6 +832,42 @@ func (mr *MockAPIMockRecorder) SetSmartCostLimit(limit any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetSmartCostLimit", reflect.TypeOf((*MockAPI)(nil).SetSmartCostLimit), limit) } +// SetSocConfig mocks base method. +func (m *MockAPI) SetSocConfig(soc SocConfig) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetSocConfig", soc) +} + +// SetSocConfig indicates an expected call of SetSocConfig. +func (mr *MockAPIMockRecorder) SetSocConfig(soc any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetSocConfig", reflect.TypeOf((*MockAPI)(nil).SetSocConfig), soc) +} + +// SetThresholds mocks base method. +func (m *MockAPI) SetThresholds(thresholds ThresholdsConfig) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetThresholds", thresholds) +} + +// SetThresholds indicates an expected call of SetThresholds. +func (mr *MockAPIMockRecorder) SetThresholds(thresholds any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetThresholds", reflect.TypeOf((*MockAPI)(nil).SetThresholds), thresholds) +} + +// SetTitle mocks base method. +func (m *MockAPI) SetTitle(arg0 string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetTitle", arg0) +} + +// SetTitle indicates an expected call of SetTitle. +func (mr *MockAPIMockRecorder) SetTitle(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTitle", reflect.TypeOf((*MockAPI)(nil).SetTitle), arg0) +} + // SetVehicle mocks base method. func (m *MockAPI) SetVehicle(vehicle api.Vehicle) { m.ctrl.T.Helper() @@ -745,17 +905,3 @@ func (mr *MockAPIMockRecorder) StartVehicleDetection() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartVehicleDetection", reflect.TypeOf((*MockAPI)(nil).StartVehicleDetection)) } - -// Title mocks base method. -func (m *MockAPI) Title() string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Title") - ret0, _ := ret[0].(string) - return ret0 -} - -// Title indicates an expected call of Title. -func (mr *MockAPIMockRecorder) Title() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Title", reflect.TypeOf((*MockAPI)(nil).Title)) -} diff --git a/core/loadpoint/pollmode_enumer.go b/core/loadpoint/pollmode_enumer.go new file mode 100644 index 000000000..6c3b30867 --- /dev/null +++ b/core/loadpoint/pollmode_enumer.go @@ -0,0 +1,94 @@ +// Code generated by "enumer -type PollMode -trimprefix Poll -transform=lower -text"; DO NOT EDIT. + +package loadpoint + +import ( + "fmt" + "strings" +) + +const _PollModeName = "chargingconnectedalways" + +var _PollModeIndex = [...]uint8{0, 8, 17, 23} + +const _PollModeLowerName = "chargingconnectedalways" + +func (i PollMode) String() string { + if i < 0 || i >= PollMode(len(_PollModeIndex)-1) { + return fmt.Sprintf("PollMode(%d)", i) + } + return _PollModeName[_PollModeIndex[i]:_PollModeIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _PollModeNoOp() { + var x [1]struct{} + _ = x[PollCharging-(0)] + _ = x[PollConnected-(1)] + _ = x[PollAlways-(2)] +} + +var _PollModeValues = []PollMode{PollCharging, PollConnected, PollAlways} + +var _PollModeNameToValueMap = map[string]PollMode{ + _PollModeName[0:8]: PollCharging, + _PollModeLowerName[0:8]: PollCharging, + _PollModeName[8:17]: PollConnected, + _PollModeLowerName[8:17]: PollConnected, + _PollModeName[17:23]: PollAlways, + _PollModeLowerName[17:23]: PollAlways, +} + +var _PollModeNames = []string{ + _PollModeName[0:8], + _PollModeName[8:17], + _PollModeName[17:23], +} + +// PollModeString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func PollModeString(s string) (PollMode, error) { + if val, ok := _PollModeNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _PollModeNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to PollMode values", s) +} + +// PollModeValues returns all values of the enum +func PollModeValues() []PollMode { + return _PollModeValues +} + +// PollModeStrings returns a slice of all String values of the enum +func PollModeStrings() []string { + strs := make([]string, len(_PollModeNames)) + copy(strs, _PollModeNames) + return strs +} + +// IsAPollMode returns "true" if the value is listed in the enum definition. "false" otherwise +func (i PollMode) IsAPollMode() bool { + for _, v := range _PollModeValues { + if i == v { + return true + } + } + return false +} + +// MarshalText implements the encoding.TextMarshaler interface for PollMode +func (i PollMode) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface for PollMode +func (i *PollMode) UnmarshalText(text []byte) error { + var err error + *i, err = PollModeString(string(text)) + return err +} diff --git a/core/loadpoint/types.go b/core/loadpoint/types.go new file mode 100644 index 000000000..2e7b32b30 --- /dev/null +++ b/core/loadpoint/types.go @@ -0,0 +1,39 @@ +package loadpoint + +import ( + "time" +) + +// ThresholdsConfig defines pv mode hysteresis parameters +type ThresholdsConfig struct { + Enable ThresholdConfig `json:"enable"` + Disable ThresholdConfig `json:"disable"` +} + +// ThresholdConfig defines enable/disable hysteresis parameters +type ThresholdConfig struct { + Delay time.Duration `json:"delay"` + Threshold float64 `json:"threshold"` +} + +// SocConfig defines soc settings, estimation and update behavior +type SocConfig struct { + Poll PollConfig `json:"poll"` + Estimate *bool `json:"estimate"` +} + +// PollConfig defines the vehicle polling mode and interval +type PollConfig struct { + Mode PollMode `json:"mode"` // polling mode charging (default), connected, always + Interval time.Duration `json:"interval"` // interval when not charging +} + +//go:generate enumer -type PollMode -trimprefix Poll -transform=lower -text +type PollMode int + +// Poll modes +const ( + PollCharging PollMode = iota + PollConnected + PollAlways +) diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index a37429df9..f9158bbbf 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -13,11 +13,50 @@ import ( var _ loadpoint.API = (*Loadpoint)(nil) -// Title returns the human-readable loadpoint title -func (lp *Loadpoint) Title() string { +// GetCharger returns the loadpoint charger +func (lp *Loadpoint) GetChargerName() string { + return lp.ChargerRef +} + +// GetMeter returns the loadpoint meter +func (lp *Loadpoint) GetMeterName() string { + return lp.MeterRef +} + +// GetCircuitName returns the loadpoint circuit +func (lp *Loadpoint) GetCircuitName() string { + return lp.CircuitRef +} + +// GetDefaultVehicle returns the loadpoint default vehicle +func (lp *Loadpoint) GetDefaultVehicle() string { + return lp.VehicleRef +} + +// GetTitle returns the loadpoint title +func (lp *Loadpoint) GetTitle() string { lp.RLock() defer lp.RUnlock() - return lp.Title_ + return lp.title +} + +// SetTitle sets the loadpoint title +func (lp *Loadpoint) SetTitle(title string) { + lp.Lock() + defer lp.Unlock() + + lp.log.DEBUG.Println("set title:", title) + + if title != lp.title { + lp.setTitle(title) + } +} + +// setTitle sets the loadpoint title (no mutex) +func (lp *Loadpoint) setTitle(title string) { + lp.title = title + lp.publish(keys.Title, lp.title) + lp.settings.SetString(keys.Title, lp.title) } // GetStatus returns the charging status @@ -74,6 +113,26 @@ func (lp *Loadpoint) SetMode(mode api.ChargeMode) { } } +// GetDefaultMode returns the default charge mode +func (lp *Loadpoint) GetDefaultMode() api.ChargeMode { + lp.RLock() + defer lp.RUnlock() + return lp.DefaultMode +} + +// SetDefaultMode sets the default charge mode +func (lp *Loadpoint) SetDefaultMode(mode api.ChargeMode) { + lp.Lock() + defer lp.Unlock() + + lp.log.DEBUG.Println("set default mode:", mode) + + if lp.DefaultMode != mode { + lp.DefaultMode = mode + lp.settings.SetString(keys.DefaultMode, string(mode)) + } +} + // getChargedEnergy returns session charge energy in Wh func (lp *Loadpoint) getChargedEnergy() float64 { lp.RLock() @@ -85,7 +144,14 @@ func (lp *Loadpoint) getChargedEnergy() float64 { func (lp *Loadpoint) GetPriority() int { lp.RLock() defer lp.RUnlock() - return lp.Priority_ + return lp.priority +} + +// setPriority sets the loadpoint priority (no mutex) +func (lp *Loadpoint) setPriority(prio int) { + lp.priority = prio + lp.publish(keys.Priority, lp.priority) + lp.settings.SetInt(keys.Priority, int64(lp.priority)) } // SetPriority sets the loadpoint priority @@ -96,8 +162,7 @@ func (lp *Loadpoint) SetPriority(prio int) { lp.log.DEBUG.Println("set priority:", prio) if lp.Priority_ != prio { - lp.Priority_ = prio - lp.publish(keys.Priority, prio) + lp.setPriority(prio) } } @@ -112,7 +177,7 @@ func (lp *Loadpoint) GetPhases() int { func (lp *Loadpoint) SetPhases(phases int) error { // limit auto mode (phases=0) to scalable charger if !lp.hasPhaseSwitching() && phases == 0 { - return fmt.Errorf("invalid number of phases: %d", phases) + return fmt.Errorf("charger does not support phase switching") } if phases != 0 && phases != 1 && phases != 3 { @@ -239,6 +304,60 @@ func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error { return nil } +// GetSoc returns the PV mode threshold settings +func (lp *Loadpoint) GetSocConfig() loadpoint.SocConfig { + lp.RLock() + defer lp.RUnlock() + return lp.Soc +} + +func (lp *Loadpoint) setSocConfig(soc loadpoint.SocConfig) { + lp.Soc = soc + lp.settings.SetJson(keys.Soc, soc) + lp.requestUpdate() +} + +// SetSoc sets the PV mode threshold settings +func (lp *Loadpoint) SetSocConfig(soc loadpoint.SocConfig) { + lp.Lock() + defer lp.Unlock() + + lp.log.DEBUG.Printf("set soc config: %+v", soc) + + // apply immediately + lp.setSocConfig(soc) +} + +// GetThresholds returns the PV mode threshold settings +func (lp *Loadpoint) GetThresholds() loadpoint.ThresholdsConfig { + lp.RLock() + defer lp.RUnlock() + return loadpoint.ThresholdsConfig{ + Enable: lp.Enable, + Disable: lp.Disable, + } +} + +func (lp *Loadpoint) setThresholds(thresholds loadpoint.ThresholdsConfig) { + lp.Enable = thresholds.Enable + lp.Disable = thresholds.Disable + lp.publish(keys.EnableThreshold, lp.Enable.Threshold) + lp.publish(keys.DisableThreshold, lp.Disable.Threshold) + lp.settings.SetJson(keys.Thresholds, thresholds) + lp.requestUpdate() +} + +// SetThresholds sets the PV mode threshold settings +func (lp *Loadpoint) SetThresholds(thresholds loadpoint.ThresholdsConfig) { + lp.Lock() + defer lp.Unlock() + + lp.log.DEBUG.Printf("set thresholds: %+v", thresholds) + + // apply immediately + lp.setThresholds(thresholds) +} + // GetEnableThreshold gets the loadpoint enable threshold func (lp *Loadpoint) GetEnableThreshold() float64 { lp.RLock() @@ -255,7 +374,11 @@ func (lp *Loadpoint) SetEnableThreshold(threshold float64) { if lp.Enable.Threshold != threshold { lp.Enable.Threshold = threshold - lp.publish(keys.EnableThreshold, threshold) + // TODO reduce APIs + lp.setThresholds(loadpoint.ThresholdsConfig{ + Enable: lp.Enable, + Disable: lp.Disable, + }) } } @@ -275,7 +398,11 @@ func (lp *Loadpoint) SetDisableThreshold(threshold float64) { if lp.Disable.Threshold != threshold { lp.Disable.Threshold = threshold - lp.publish(keys.DisableThreshold, threshold) + // TODO reduce APIs + lp.setThresholds(loadpoint.ThresholdsConfig{ + Enable: lp.Enable, + Disable: lp.Disable, + }) } } diff --git a/core/loadpoint_phases.go b/core/loadpoint_phases.go index fc8d3f465..ead795812 100644 --- a/core/loadpoint_phases.go +++ b/core/loadpoint_phases.go @@ -8,8 +8,9 @@ import ( // setConfiguredPhases sets the default phase configuration func (lp *Loadpoint) setConfiguredPhases(phases int) { lp.configuredPhases = phases - lp.publish(keys.PhasesConfigured, lp.configuredPhases) - lp.settings.SetInt(keys.PhasesConfigured, int64(lp.configuredPhases)) + lp.publish(keys.Phases, lp.configuredPhases) + lp.publish(keys.PhasesConfigured, lp.configuredPhases) // TODO remove + lp.settings.SetInt(keys.Phases, int64(lp.configuredPhases)) } // setPhases sets the number of enabled phases without modifying the charger diff --git a/core/loadpoint_phases_test.go b/core/loadpoint_phases_test.go index 2faca534a..4dbb6b02f 100644 --- a/core/loadpoint_phases_test.go +++ b/core/loadpoint_phases_test.go @@ -8,6 +8,7 @@ import ( evbus "github.com/asaskevich/EventBus" "github.com/benbjohnson/clock" "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/util" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -405,10 +406,10 @@ func TestPvScalePhasesTimer(t *testing.T) { phases: tc.phases, measuredPhases: tc.measuredPhases, status: api.StatusC, - Enable: ThresholdConfig{ + Enable: loadpoint.ThresholdConfig{ Delay: dt, }, - Disable: ThresholdConfig{ + Disable: loadpoint.ThresholdConfig{ Delay: dt, }, } diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index c9d2d7594..76b26ca5a 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -7,6 +7,8 @@ import ( evbus "github.com/asaskevich/EventBus" "github.com/benbjohnson/clock" "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/loadpoint" + "github.com/evcc-io/evcc/core/settings" "github.com/evcc-io/evcc/core/soc" "github.com/evcc-io/evcc/push" "github.com/evcc-io/evcc/util" @@ -151,14 +153,14 @@ func TestUpdatePowerZero(t *testing.T) { for _, tc := range tc { t.Log(tc) - clck := clock.NewMock() + clock := clock.NewMock() ctrl := gomock.NewController(t) charger := api.NewMockCharger(ctrl) lp := &Loadpoint{ log: util.NewLogger("foo"), bus: evbus.New(), - clock: clck, + clock: clock, charger: charger, chargeMeter: &Null{}, // silence nil panics chargeRater: &Null{}, // silence nil panics @@ -302,24 +304,24 @@ func TestPVHysteresis(t *testing.T) { for _, tc := range tc { t.Log(tc) - clck := clock.NewMock() + clock := clock.NewMock() ctrl := gomock.NewController(t) charger := api.NewMockCharger(ctrl) Voltage = 100 lp := &Loadpoint{ log: util.NewLogger("foo"), - clock: clck, + clock: clock, charger: charger, minCurrent: minA, maxCurrent: maxA, phases: phases, measuredPhases: phases, - Enable: ThresholdConfig{ + Enable: loadpoint.ThresholdConfig{ Threshold: tc.enable, Delay: dt, }, - Disable: ThresholdConfig{ + Disable: loadpoint.ThresholdConfig{ Threshold: tc.disable, Delay: dt, }, @@ -328,10 +330,10 @@ func TestPVHysteresis(t *testing.T) { // charging, otherwise PV mode logic is short-circuited lp.status = status - start := clck.Now() + start := clock.Now() for step, se := range tc.series { - clck.Set(start.Add(se.delay)) + clock.Set(start.Add(se.delay)) // maxCurrent will read actual current and enabled state in PV mode // charger.EXPECT().Enabled().Return(tc.enabled, nil) @@ -352,13 +354,13 @@ func TestPVHysteresis(t *testing.T) { func TestPVHysteresisForStatusOtherThanC(t *testing.T) { const phases = 3 - clck := clock.NewMock() + clock := clock.NewMock() ctrl := gomock.NewController(t) Voltage = 100 lp := &Loadpoint{ log: util.NewLogger("foo"), - clock: clck, + clock: clock, minCurrent: minA, maxCurrent: maxA, phases: phases, @@ -407,9 +409,9 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) { socEstimator: socEstimator, // instead of vehicle: vehicle, mode: api.ModeNow, limitSoc: 90, // session limit - Soc: SocConfig{ - Poll: PollConfig{ - Mode: pollConnected, // allow polling when connected + Soc: loadpoint.SocConfig{ + Poll: loadpoint.PollConfig{ + Mode: loadpoint.PollConnected, // allow polling when connected Interval: pollInterval, }, }, @@ -473,6 +475,7 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) { log: util.NewLogger("foo"), bus: evbus.New(), clock: clock, + settings: settings.NewDatabaseSettingsAdapter("foo"), charger: charger, chargeMeter: &Null{}, // silence nil panics chargeRater: &Null{}, // silence nil panics @@ -481,7 +484,7 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) { minCurrent: minA, maxCurrent: maxA, status: api.StatusC, - Mode_: api.ModeOff, // default mode + DefaultMode: api.ModeOff, // default mode } attachListeners(t, lp) @@ -616,59 +619,59 @@ func TestSocPoll(t *testing.T) { lp := &Loadpoint{ clock: clock, log: util.NewLogger("foo"), - Soc: SocConfig{ - Poll: PollConfig{ + Soc: loadpoint.SocConfig{ + Poll: loadpoint.PollConfig{ Interval: time.Hour, }, }, } tc := []struct { - mode string + mode loadpoint.PollMode status api.ChargeStatus dt time.Duration res bool }{ // pollCharging - {pollCharging, api.StatusA, -1, false}, - {pollCharging, api.StatusA, 0, false}, - {pollCharging, api.StatusA, tRefresh, false}, - {pollCharging, api.StatusB, -1, true}, // poll once when car connected - {pollCharging, api.StatusB, 0, false}, - {pollCharging, api.StatusB, tRefresh, false}, - {pollCharging, api.StatusC, -1, true}, - {pollCharging, api.StatusC, 0, true}, - {pollCharging, api.StatusC, tNoRefresh, true}, // cached by vehicle - {pollCharging, api.StatusB, -1, true}, // fetch if car stopped charging - {pollCharging, api.StatusB, 0, false}, // no more polling - {pollCharging, api.StatusB, tRefresh, false}, // no more polling + {loadpoint.PollCharging, api.StatusA, -1, false}, + {loadpoint.PollCharging, api.StatusA, 0, false}, + {loadpoint.PollCharging, api.StatusA, tRefresh, false}, + {loadpoint.PollCharging, api.StatusB, -1, true}, // poll once when car connected + {loadpoint.PollCharging, api.StatusB, 0, false}, + {loadpoint.PollCharging, api.StatusB, tRefresh, false}, + {loadpoint.PollCharging, api.StatusC, -1, true}, + {loadpoint.PollCharging, api.StatusC, 0, true}, + {loadpoint.PollCharging, api.StatusC, tNoRefresh, true}, // cached by vehicle + {loadpoint.PollCharging, api.StatusB, -1, true}, // fetch if car stopped charging + {loadpoint.PollCharging, api.StatusB, 0, false}, // no more polling + {loadpoint.PollCharging, api.StatusB, tRefresh, false}, // no more polling // pollConnected - {pollConnected, api.StatusA, -1, false}, - {pollConnected, api.StatusA, 0, false}, - {pollConnected, api.StatusA, tRefresh, false}, - {pollConnected, api.StatusB, -1, true}, - {pollConnected, api.StatusB, 0, false}, - {pollConnected, api.StatusB, tNoRefresh, false}, - {pollConnected, api.StatusB, tRefresh, true}, - {pollConnected, api.StatusC, -1, true}, - {pollConnected, api.StatusC, 0, true}, - {pollConnected, api.StatusC, tNoRefresh, true}, // cached by vehicle - {pollConnected, api.StatusC, tRefresh, true}, + {loadpoint.PollConnected, api.StatusA, -1, false}, + {loadpoint.PollConnected, api.StatusA, 0, false}, + {loadpoint.PollConnected, api.StatusA, tRefresh, false}, + {loadpoint.PollConnected, api.StatusB, -1, true}, + {loadpoint.PollConnected, api.StatusB, 0, false}, + {loadpoint.PollConnected, api.StatusB, tNoRefresh, false}, + {loadpoint.PollConnected, api.StatusB, tRefresh, true}, + {loadpoint.PollConnected, api.StatusC, -1, true}, + {loadpoint.PollConnected, api.StatusC, 0, true}, + {loadpoint.PollConnected, api.StatusC, tNoRefresh, true}, // cached by vehicle + {loadpoint.PollConnected, api.StatusC, tRefresh, true}, // pollAlways - {pollAlways, api.StatusA, -1, true}, - {pollAlways, api.StatusA, 0, false}, - {pollAlways, api.StatusA, tNoRefresh, false}, - {pollAlways, api.StatusA, tRefresh, true}, - {pollAlways, api.StatusB, -1, true}, - {pollAlways, api.StatusB, 0, false}, - {pollAlways, api.StatusB, tNoRefresh, false}, - {pollAlways, api.StatusB, tRefresh, true}, - {pollAlways, api.StatusC, -1, true}, - {pollAlways, api.StatusC, 0, true}, - {pollAlways, api.StatusC, tNoRefresh, true}, // cached by vehicle - {pollAlways, api.StatusC, tRefresh, true}, + {loadpoint.PollAlways, api.StatusA, -1, true}, + {loadpoint.PollAlways, api.StatusA, 0, false}, + {loadpoint.PollAlways, api.StatusA, tNoRefresh, false}, + {loadpoint.PollAlways, api.StatusA, tRefresh, true}, + {loadpoint.PollAlways, api.StatusB, -1, true}, + {loadpoint.PollAlways, api.StatusB, 0, false}, + {loadpoint.PollAlways, api.StatusB, tNoRefresh, false}, + {loadpoint.PollAlways, api.StatusB, tRefresh, true}, + {loadpoint.PollAlways, api.StatusC, -1, true}, + {loadpoint.PollAlways, api.StatusC, 0, true}, + {loadpoint.PollAlways, api.StatusC, tNoRefresh, true}, // cached by vehicle + {loadpoint.PollAlways, api.StatusC, tRefresh, true}, } for _, tc := range tc { @@ -723,7 +726,7 @@ func TestPVHysteresisAfterPhaseSwitch(t *testing.T) { for _, tc := range tc { t.Log(tc) - clck := clock.NewMock() + clock := clock.NewMock() ctrl := gomock.NewController(t) switcher := api.NewMockPhaseSwitcher(ctrl) @@ -739,21 +742,21 @@ func TestPVHysteresisAfterPhaseSwitch(t *testing.T) { Voltage = 100 lp := &Loadpoint{ log: util.NewLogger("foo"), - clock: clck, + clock: clock, charger: charger, minCurrent: minA, maxCurrent: maxA, - Disable: ThresholdConfig{ + Disable: loadpoint.ThresholdConfig{ Delay: dt, }, status: api.StatusC, enabled: true, } - start := clck.Now() + start := clock.Now() for step, se := range tc.series { - clck.Set(start.Add(se.delay)) + clock.Set(start.Add(se.delay)) assert.Equal(t, se.current, lp.pvMaxCurrent(api.ModePV, se.site, 0, false, false), step) } diff --git a/core/loadpoint_vehicle.go b/core/loadpoint_vehicle.go index e7022d472..d4c2df0dd 100644 --- a/core/loadpoint_vehicle.go +++ b/core/loadpoint_vehicle.go @@ -314,9 +314,9 @@ func (lp *Loadpoint) vehicleOdometer() { // vehicleClimatePollAllowed determines if polling depending on mode and connection status func (lp *Loadpoint) vehicleClimatePollAllowed() bool { switch { - case lp.Soc.Poll.Mode == pollCharging && lp.charging(): + case lp.Soc.Poll.Mode == loadpoint.PollCharging && lp.charging(): return true - case (lp.Soc.Poll.Mode == pollConnected || lp.Soc.Poll.Mode == pollAlways) && lp.connected(): + case (lp.Soc.Poll.Mode == loadpoint.PollConnected || lp.Soc.Poll.Mode == loadpoint.PollAlways) && lp.connected(): return true default: return false @@ -337,8 +337,8 @@ func (lp *Loadpoint) vehicleSocPollAllowed() bool { remaining := lp.Soc.Poll.Interval - lp.clock.Since(lp.socUpdated) - honourUpdateInterval := lp.Soc.Poll.Mode == pollAlways || - lp.connected() && lp.Soc.Poll.Mode == pollConnected + honourUpdateInterval := lp.Soc.Poll.Mode == loadpoint.PollAlways || + lp.connected() && lp.Soc.Poll.Mode == loadpoint.PollConnected if honourUpdateInterval { if remaining > 0 { diff --git a/core/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go index ebe3bbb2b..4c68145e3 100644 --- a/core/loadpoint_vehicle_test.go +++ b/core/loadpoint_vehicle_test.go @@ -9,6 +9,7 @@ import ( "github.com/benbjohnson/clock" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/coordinator" + "github.com/evcc-io/evcc/core/settings" "github.com/evcc-io/evcc/core/soc" "github.com/evcc-io/evcc/util" "github.com/stretchr/testify/assert" @@ -171,8 +172,8 @@ func TestDefaultVehicle(t *testing.T) { vehicle.EXPECT().Phases().AnyTimes() vehicle.EXPECT().OnIdentified().AnyTimes() - lp := NewLoadpoint(util.NewLogger("foo"), nil) - lp.Mode_ = api.ModeOff // ondisconnect + lp := NewLoadpoint(util.NewLogger("foo"), settings.NewDatabaseSettingsAdapter("foo")) + lp.DefaultMode = api.ModeOff // ondisconnect lp.defaultVehicle = dflt // populate channels diff --git a/core/prioritizer/prioritizer.go b/core/prioritizer/prioritizer.go index 39c90b8bf..ff47cd5d9 100644 --- a/core/prioritizer/prioritizer.go +++ b/core/prioritizer/prioritizer.go @@ -40,12 +40,12 @@ func (p *Prioritizer) GetChargePowerFlexibility(lp loadpoint.API) float64 { for lp, power := range p.demand { if lp.EffectivePriority() < prio && power > 0 { reduceBy += power - msg += fmt.Sprintf("%.0fW from %s at prio %d, ", power, lp.Title(), lp.EffectivePriority()) + msg += fmt.Sprintf("%.0fW from %s at prio %d, ", power, lp.GetTitle(), lp.EffectivePriority()) } } if p.log != nil && reduceBy > 0 { - p.log.DEBUG.Printf("lp %s at prio %d gets additional %stotal %.0fW\n", lp.Title(), lp.EffectivePriority(), msg, reduceBy) + p.log.DEBUG.Printf("lp %s at prio %d gets additional %stotal %.0fW\n", lp.GetTitle(), lp.EffectivePriority(), msg, reduceBy) } return reduceBy diff --git a/core/prioritizer/prioritizer_test.go b/core/prioritizer/prioritizer_test.go index 870b3aba4..bd36e49de 100644 --- a/core/prioritizer/prioritizer_test.go +++ b/core/prioritizer/prioritizer_test.go @@ -14,12 +14,12 @@ func TestPrioritzer(t *testing.T) { p := New(nil) lo := loadpoint.NewMockAPI(ctrl) - lo.EXPECT().Title().AnyTimes() + lo.EXPECT().GetTitle().AnyTimes() lo.EXPECT().GetPriority().Return(0).AnyTimes() // prio 0 lo.EXPECT().EffectivePriority().Return(0).AnyTimes() // prio 0 hi := loadpoint.NewMockAPI(ctrl) - hi.EXPECT().Title().AnyTimes() + hi.EXPECT().GetTitle().AnyTimes() hi.EXPECT().GetPriority().Return(1).AnyTimes() // prio 1 hi.EXPECT().EffectivePriority().Return(1).AnyTimes() // prio 1 diff --git a/core/settings.go b/core/settings.go deleted file mode 100644 index a13e50433..000000000 --- a/core/settings.go +++ /dev/null @@ -1,107 +0,0 @@ -package core - -import ( - "time" - - "github.com/evcc-io/evcc/server/db/settings" -) - -// type Settings interface { -// SetString(val string) -// SetInt(val int64) -// SetFloat(val float64) -// SetTime(val time.Time) -// SetJson(val any) error -// SetBool(val bool) -// String(string, error) -// Int(int64, error) -// Float(float64, error) -// Time(time.Time, error) -// Bool(bool, error) -// Json(res any) error -// } - -type Settings struct { - Key string -} - -func (s *Settings) SetString(key string, val string) { - if s == nil { - return - } - settings.SetString(s.Key+key, val) -} - -func (s *Settings) SetInt(key string, val int64) { - if s == nil { - return - } - settings.SetInt(s.Key+key, val) -} - -func (s *Settings) SetFloat(key string, val float64) { - if s == nil { - return - } - settings.SetFloat(s.Key+key, val) -} - -func (s *Settings) SetTime(key string, val time.Time) { - if s == nil { - return - } - settings.SetTime(s.Key+key, val) -} - -func (s *Settings) SetBool(key string, val bool) { - if s == nil { - return - } - settings.SetBool(s.Key+key, val) -} - -// func (s *Settings) SetJson(key string, val any) error { -// if s == nil { -// return nil -// } -// return settings.SetJson(s.Key+key, val) -// } - -func (s *Settings) String(key string) (string, error) { - if s == nil { - return "", nil - } - return settings.String(s.Key + key) -} - -func (s *Settings) Int(key string) (int64, error) { - if s == nil { - return 0, nil - } - return settings.Int(s.Key + key) -} - -func (s *Settings) Float(key string) (float64, error) { - if s == nil { - return 0, nil - } - return settings.Float(s.Key + key) -} - -func (s *Settings) Time(key string) (time.Time, error) { - if s == nil { - return time.Time{}, nil - } - return settings.Time(s.Key + key) -} - -func (s *Settings) Bool(key string) (bool, error) { - if s == nil { - return false, nil - } - return settings.Bool(s.Key + key) -} - -// func (s *Settings) Json(key string, res any) error { -// return settings.Json(s.Key+key, &res) -// } diff --git a/core/settings/config.go b/core/settings/config.go new file mode 100644 index 000000000..9b7bd5ae0 --- /dev/null +++ b/core/settings/config.go @@ -0,0 +1,111 @@ +package settings + +import ( + "encoding/json" + "errors" + "time" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/config" + "github.com/spf13/cast" +) + +var _ Settings = (*ConfigSettings)(nil) + +type ConfigSettings struct { + log *util.Logger + conf *config.Config +} + +func NewConfigSettingsAdapter(log *util.Logger, conf *config.Config) *ConfigSettings { + return &ConfigSettings{log, conf} +} + +func (s *ConfigSettings) get(key string) (any, error) { + val := s.conf.Named().Other[key] + if val == nil { + return nil, errors.New("not found") + } + return val, nil +} + +func (s *ConfigSettings) set(key string, val any) { + data := s.conf.Named().Other + data[key] = val + if err := s.conf.PartialUpdate(data); err != nil { + s.log.ERROR.Println(err) + } +} + +func (s *ConfigSettings) SetString(key string, val string) { + s.set(key, val) +} + +func (s *ConfigSettings) SetInt(key string, val int64) { + s.set(key, val) +} + +func (s *ConfigSettings) SetFloat(key string, val float64) { + s.set(key, val) +} + +func (s *ConfigSettings) SetTime(key string, val time.Time) { + s.set(key, val) +} + +func (s *ConfigSettings) SetBool(key string, val bool) { + s.set(key, val) +} + +func (s *ConfigSettings) SetJson(key string, val any) error { + s.set(key, val) + return nil +} + +func (s *ConfigSettings) String(key string) (string, error) { + val, err := s.get(key) + if err != nil { + return "", err + } + return cast.ToStringE(val) +} + +func (s *ConfigSettings) Int(key string) (int64, error) { + val, err := s.get(key) + if err != nil { + return 0, err + } + return cast.ToInt64E(val) +} + +func (s *ConfigSettings) Float(key string) (float64, error) { + val, err := s.get(key) + if err != nil { + return 0, err + } + return cast.ToFloat64E(val) +} + +func (s *ConfigSettings) Time(key string) (time.Time, error) { + val, err := s.get(key) + if err != nil { + return time.Time{}, err + } + return cast.ToTimeE(val) +} + +func (s *ConfigSettings) Bool(key string) (bool, error) { + val, err := s.get(key) + if err != nil { + return false, err + } + return cast.ToBoolE(val) +} + +func (s *ConfigSettings) Json(key string, res any) error { + str, err := s.String(key) + if str == "" || err != nil { + return err + } + return json.Unmarshal([]byte(str), &res) +} diff --git a/core/settings/database.go b/core/settings/database.go new file mode 100644 index 000000000..22331c815 --- /dev/null +++ b/core/settings/database.go @@ -0,0 +1,65 @@ +package settings + +import ( + "time" + + db "github.com/evcc-io/evcc/server/db/settings" +) + +var _ Settings = (*dbSettings)(nil) + +type dbSettings struct { + Key string +} + +func NewDatabaseSettingsAdapter(key string) Settings { + return &dbSettings{key} +} + +func (s *dbSettings) SetString(key string, val string) { + db.SetString(s.Key+key, val) +} + +func (s *dbSettings) SetInt(key string, val int64) { + db.SetInt(s.Key+key, val) +} + +func (s *dbSettings) SetFloat(key string, val float64) { + db.SetFloat(s.Key+key, val) +} + +func (s *dbSettings) SetTime(key string, val time.Time) { + db.SetTime(s.Key+key, val) +} + +func (s *dbSettings) SetBool(key string, val bool) { + db.SetBool(s.Key+key, val) +} + +func (s *dbSettings) SetJson(key string, val any) error { + return db.SetJson(s.Key+key, val) +} + +func (s *dbSettings) String(key string) (string, error) { + return db.String(s.Key + key) +} + +func (s *dbSettings) Int(key string) (int64, error) { + return db.Int(s.Key + key) +} + +func (s *dbSettings) Float(key string) (float64, error) { + return db.Float(s.Key + key) +} + +func (s *dbSettings) Time(key string) (time.Time, error) { + return db.Time(s.Key + key) +} + +func (s *dbSettings) Bool(key string) (bool, error) { + return db.Bool(s.Key + key) +} + +func (s *dbSettings) Json(key string, res any) error { + return db.Json(s.Key+key, &res) +} diff --git a/core/settings/settings.go b/core/settings/settings.go new file mode 100644 index 000000000..a7701e650 --- /dev/null +++ b/core/settings/settings.go @@ -0,0 +1,18 @@ +package settings + +import "time" + +type Settings interface { + SetString(key string, val string) + SetInt(key string, val int64) + SetFloat(key string, val float64) + SetTime(key string, val time.Time) + SetJson(key string, val any) error + SetBool(key string, val bool) + String(key string) (string, error) + Int(key string) (int64, error) + Float(key string) (float64, error) + Time(key string) (time.Time, error) + Bool(key string) (bool, error) + Json(key string, res any) error +} diff --git a/core/site.go b/core/site.go index 3ed034f1f..0fa446da0 100644 --- a/core/site.go +++ b/core/site.go @@ -162,7 +162,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif if db.Instance != nil { var err error - if lp.db, err = session.NewStore(lp.Title(), db.Instance); err != nil { + if lp.db, err = session.NewStore(lp.GetTitle(), db.Instance); err != nil { return err } // Fix any dangling history diff --git a/hems/semp/semp.go b/hems/semp/semp.go index 5445a6059..fdd30d2a1 100644 --- a/hems/semp/semp.go +++ b/hems/semp/semp.go @@ -375,7 +375,7 @@ func (s *SEMP) deviceInfo(id int, lp loadpoint.API) DeviceInfo { res := DeviceInfo{ Identification: Identification{ DeviceID: s.deviceID(id), - DeviceName: lp.Title(), + DeviceName: lp.GetTitle(), DeviceType: sempCharger, DeviceSerial: s.serialNumber(id), DeviceVendor: "github.com/evcc-io/evcc", diff --git a/i18n/de.toml b/i18n/de.toml index 7b6147645..1de152487 100644 --- a/i18n/de.toml +++ b/i18n/de.toml @@ -27,6 +27,20 @@ never = "nur mit genug PV-Überschuss." titleAdd = "Batterie hinzufügen" titleEdit = "Batterie bearbeiten" +[config.charge] +titleAdd = "Energiezähler hinzufügen" +titleEdit = "Energiezähler bearbeiten" + +[config.charger] +chargers = "Wallboxen" +generic = "Generische Integrationen" +ocppHelp = "Kopiere diese Adresse in die Konfiguration der Wallbox." +ocppLabel = "OCPP-Server URL" +switchsockets = "Schaltbare Steckdosen" +template = "Hersteller" +titleAdd = "Wallbox hinzufügen" +titleEdit = "Wallbox bearbeiten" + [config.circuits] description = "Stellt sicher, dass die Summe aller Ladepunkte, die an einen Stromkreis angeschlossen sind, die konfigurierten Leistungs- und Stromgrenzen nicht überschreitet. Stromkreise können verschachtelt werden, um eine Hierarchie aufzubauen." title = "Lastmanagement" @@ -48,8 +62,6 @@ chargeStatus = "Status" chargeStatusA = "nicht verbunden" chargeStatusB = "verbunden" chargeStatusC = "lädt" -chargeStatusE = "keine Leistung" -chargeStatusF = "Fehler" chargedEnergy = "Geladen" co2 = "Netz-CO₂" configured = "Konfiguriert" @@ -68,9 +80,11 @@ org = "Organisation" phaseCurrents = "Strom L1, L2, L3" phasePowers = "Leistung L1, L2, L3" phaseVoltages = "Spannung L1, L2, L3" +phases1p3p = "Phasenumschaltung" power = "Leistung" powerRange = "Leistung" range = "Reichweite" +singlePhase = "Einphasig" soc = "SoC" socLimit = "SoC Begrenzung" temp = "Temperatur" @@ -78,6 +92,11 @@ topic = "Thema" url = "URL" yes = "Ja" +[config.deviceValueChargeStatus] +A = "A (nicht verbunden)" +B = "B (verbunden)" +C = "C (lädt)" + [config.eebus] description = "Grundkonfiguration für die Kommunikation mit anderen EEBus-Geräten." title = "EEBus" @@ -88,18 +107,20 @@ optional = "optional" [config.general] cancel = "Abbrechen" +delete = "Löschen" docsLink = "Siehe Dokumentation." experimental = "Experimentell" -hideAdvancedSettings = "Erweiterte einstellungen ausblenden" +hideAdvancedSettings = "Erweiterte Einstellungen ausblenden" off = "aus" on = "an" password = "Passwort" readFromFile = "Aus Datei lesen" remove = "Entfernen" save = "Speichern" -showAdvancedSettings = "Erweiterte einstellungen anzeigen" +showAdvancedSettings = "Erweiterte Einstellungen anzeigen" telemetry = "Telemetrie" title = "Titel" +validateSave = "Überprüfen & speichern" [config.grid] title = "Netzzähler" @@ -126,9 +147,84 @@ title = "InfluxDB" v1Support = "Unterstützung für InfluxDB 1.x?" v2Support = "Zurück zu InfluxDB 2.x" +[config.loadpoint] +addCharger = "Wallbox hinzufügen" +addMeter = "Zusätzlichen Zähler hinzufügen" +cancel = "Abbrechen" +chargerLabel = "Wallbox" +chargerPower11kw = "11 kW" +chargerPower11kwHelp = "Verwendet einen Strombereich von 6 bis 16 A." +chargerPower22kw = "22 kW" +chargerPower22kwHelp = "Verwendet einen Strombereich von 6 bis 32 A." +chargerPowerCustom = "andere" +chargerPowerCustomHelp = "Definiere einen eigenen Strombereich." +chargerTypeLabel = "Ladeleistung" +chargingTitle = "Laden" +circuitLabel = "Stromkreis" +circuitUnassigned = "nicht zugewiesen" +defaultModeHelp = "Lademodus beim Anschließen des Fahrzeugs." +defaultModeHelpKeep = "Der letzte ausgewählte Modus wird beibehalten." +defaultModeLabel = "Standard-Modus" +delete = "Löschen" +electricalSubtitle = "ggf. Elektriker fragen" +electricalTitle = "Elektrisch" +energyMeterHelp = "Zusätzlicher Zähler, wenn die Wallbox keinen integrierten hat." +energyMeterLabel = "Energiezähler" +estimateLabel = "Ladestand zwischen API-Updates interpolieren" +maxCurrentHelp = "Muss größer als der minimale Strom sein." +maxCurrentLabel = "Maximaler Strom" +minCurrentHelp = "Gehe nur unter 6 A, wenn du weißt, was du tust." +minCurrentLabel = "Minimaler Strom" +noVehicles = "Keine Fahrzeuge konfiguriert." +phases1p = "1-phasig" +phases3p = "3-phasig" +phasesAutomatic = "Automatische Phasen" +phasesAutomaticHelp = "Deine Wallbox unterstützt automatisches Umschalten zwischen 1- und 3-phasigem Laden. In der Hauptansicht kannst du das Phasenverhalten während des Ladens anpassen." +phasesHelp = "Anzahl der an die Wallbox angeschlossenen Phasen." +phasesLabel = "Phasen" +pollIntervalHelp = "Zeit zwischen Fahrzeug-API-Updates. Kurze Intervalle können die Fahrzeugbatterie belasten." +pollIntervalLabel = "Update-Intervall" +pollModeAlways = "immer" +pollModeAlwaysHelp = "Statusabfragen immer in regelmäßigen Abständen durchführen." +pollModeCharging = "nur beim Laden" +pollModeChargingHelp = "Fahrzeugstatus nur während des Ladens abfragen." +pollModeConnected = "wenn verbunden" +pollModeConnectedHelp = "Fahrzeugstatus in regelmäßigen Abständen abfragen, wenn verbunden." +pollModeLabel = "Update-Verhalten" +priorityHelp = "Ladepunkte mit höherer Priorität haben Vorrang beim PV-Überschuss." +priorityLabel = "Priorität" +save = "Speichern" +showAllSettings = "Alle Einstellungen anzeigen" +solarBehaviorCustomHelp = "Definiere eigene Ein- und Ausschaltschwellen und Verzögerungen." +solarBehaviorDefaultHelp = "Nur mit PV-Überschuss laden. Start nach {enableDelay} Überschuss. Stop wenn für {disableDelay} nicht genug Überschuss vorhanden ist." +solarBehaviorLabel = "PV-Verhalten" +solarModeCustom = "eigenes" +solarModeMaximum = "nur Sonne" +thresholdDisableDelayLabel = "Ausschaltverzögerung" +thresholdDisableHelpInvalid = "Bitte verwende einen positiven Wert." +thresholdDisableHelpPositive = "Laden stoppen, wenn mehr als {power} für {delay} aus dem Netz bezogen wird." +thresholdDisableHelpZero = "Stoppen, wenn minimale Ladeleistung für {delay} nicht erreicht werden kann." +thresholdDisableLabel = "Netzleistung ausschalten" +thresholdEnableDelayLabel = "Einschaltverzögerung" +thresholdEnableHelpInvalid = "Bitte verwende einen negativen Wert." +thresholdEnableHelpNegative = "Laden starten, wenn {surplus} Überschuss für {delay} verfügbar ist." +thresholdEnableHelpZero = "Starten, wenn minimale Ladeleistung für {delay} als Überschuss verfügbar ist." +thresholdEnableLabel = "Netzleistung einschalten" +titleAdd = "Ladepunkt hinzufügen" +titleEdit = "Ladepunkt bearbeiten" +titleExample = "Garage, Carport, etc." +titleLabel = "Titel" +vehicleAutoDetection = "automatische Erkennung" +vehicleHelpAutoDetection = "Wählt automatisch das plausibelste Fahrzeug aus. Manuelle Übersteuerung möglich." +vehicleHelpDefault = "Nimmt immer an, dass dieses Fahrzeug hier lädt. Auto-Erkennung deaktiviert. Manuelle Übersteuerung möglich." +vehicleLabel = "Standard-Fahrzeug" +vehiclesTitle = "Fahrzeuge" + [config.main] +addGrid = "Netzzähler hinzufügen" addLoadpoint = "Ladepunkt hinzufügen" addPvBattery = "PV oder Speicher hinzufügen" +addTariffs = "Tarife hinzufügen" addVehicle = "Fahrzeug hinzufügen" configured = "konfiguriert" edit = "bearbeiten" @@ -195,6 +291,11 @@ little = "little-endian" http = "HTTP (unverschlüsselt)" https = "HTTPS (verschlüsselt)" +[config.options.status] +A = "A (nicht verbunden)" +B = "B (verbunden)" +C = "C (lädt)" + [config.pv] titleAdd = "Zähler (PV) hinzufügen" titleEdit = "Zähler (PV) bearbeiten" @@ -209,13 +310,16 @@ system = "System" vehicles = "Fahrzeuge" [config.sponsor] -addToken = "Sponsortoken eingeben" -changeToken = "Sponsortoken ändern" +addToken = "Token eingeben" +changeToken = "Token ändern" description = "Das Sponsoring-Modell hilft uns, das Projekt zu pflegen und nachhaltig neue und spannende Funktionen zu entwickeln. Als Sponsor erhältst du Zugriff auf alle Wallbox-Implementierungen." descriptionToken = "Du erhältst das Token von {url}. Wir bieten auch ein Testtoken für Tests an." error = "Das Sponsortoken ist ungültig." labelToken = "Sponsortoken" title = "Sponsorship" +tokenRequired = "Du musst ein Sponsortoken konfigurieren, bevor du dieses Gerät anlegen kannst." +tokenRequiredLearnMore = "Mehr erfahren." +trialToken = "Testtoken" [config.system] logs = "Logs" @@ -293,7 +397,7 @@ thisYear = "dieses Jahr" total = "gesamt" [footer.sponsor] -becomeSponsor = "Werden Sie Sponsor" +becomeSponsor = "Werde Sponsor" becomeSponsorExtended = "Unterstütze uns direkt. Es gibt auch Sticker." confetti = "Lust auf Konfetti?" confettiPromise = "Es gibt auch Sticker und digitales Konfetti" @@ -381,7 +485,9 @@ reset = "Passwort zurücksetzen?" title = "Authentifizierung" [main] +startConfiguration = "Konfiguration beginnen" vehicles = "Parkplatz" +welcome = "Herzlich willkommen!" [main.chargingPlan] active = "Aktiv" @@ -629,16 +735,6 @@ csvTotal = "Gesamte CSV herunterladen" date = "Anfang" downloadCsv = "Als CSV herunterladen" energy = "Geladen" -energyGroupedTitle = "Energiemenge {energy}" -energyGroupedTitleByGroup = "Energiemenge {byGroup}" -energySubTitle = "{energy} gesamt" -energyTitle = "{percent} Sonnenenergie" -groupedCo2TitleByGroup = "CO₂-Menge {byGroup}" -groupedPriceTitleByGroup = "Kosten {byGroup}" -historyCo2SubTitle = "{value} gesamt" -historyCo2Title = "⌀ {value} CO₂-Emissionen" -historyPriceSubTitle = "{value} gesamt" -historyPriceTitle = "⌀ {value} Ladepreis" loadpoint = "Ladepunkt" noData = "Noch keine Ladevorgänge in diesem Monat." overview = "Übersicht" diff --git a/i18n/en.toml b/i18n/en.toml index 26ea338b1..ab62c2e74 100644 --- a/i18n/en.toml +++ b/i18n/en.toml @@ -27,6 +27,20 @@ never = "only with enough surplus." titleAdd = "Add Battery" titleEdit = "Edit Battery" +[config.charge] +titleAdd = "Add Charge Meter" +titleEdit = "Edit Charge Meter" + +[config.charger] +chargers = "EV chargers" +generic = "Generic integrations" +ocppHelp = "Copy this address into your chargers configuration." +ocppLabel = "OCPP-Server URL" +switchsockets = "Switchable sockets" +template = "Manufacturer" +titleAdd = "Add Charger" +titleEdit = "Edit Charger" + [config.circuits] description = "Ensures, that the sum of all loadpoints connected to a circuit does not exceed the configured power and current limits. Circuits can be nested to build a hierarchy." title = "Load Management" @@ -62,15 +76,18 @@ energy = "Energy" feedinPrice = "Feed-in price" gridPrice = "Grid price" hemsType = "System" +identifier = "RFID-Identifier" no = "no" odometer = "Odometer" org = "Organization" phaseCurrents = "Current L1, L2, L3" phasePowers = "Power L1, L2, L3" phaseVoltages = "Voltage L1, L2, L3" +phases1p3p = "Phase switch" power = "Power" powerRange = "Power" range = "Range" +singlePhase = "Single phase" soc = "SoC" socLimit = "Limit" temp = "Temperature" @@ -78,6 +95,11 @@ topic = "Topic" url = "URL" yes = "yes" +[config.deviceValueChargeStatus] +A = "A (not connected)" +B = "B (connected)" +C = "C (charging)" + [config.eebus] description = "Configuration that enables evcc to communicate with other EEBus devices." title = "EEBus" @@ -88,6 +110,7 @@ optional = "optional" [config.general] cancel = "Cancel" +delete = "Delete" docsLink = "See documentation." experimental = "Experimental" hideAdvancedSettings = "Hide advanced settings" @@ -100,6 +123,7 @@ save = "Save" showAdvancedSettings = "Show advanced settings" telemetry = "Telemetry" title = "Title" +validateSave = "Validate & save" [config.grid] title = "Grid meter" @@ -126,9 +150,84 @@ title = "InfluxDB" v1Support = "Need support for InfluxDB 1.x?" v2Support = "Back to InfluxDB 2.x" +[config.loadpoint] +addCharger = "Add charger" +addMeter = "Add dedicated charger meter" +cancel = "Cancel" +chargerLabel = "Charger" +chargerPower11kw = "11 kW" +chargerPower11kwHelp = "Will use a current range of 6 to 16 A." +chargerPower22kw = "22 kW" +chargerPower22kwHelp = "Will use a current range of 6 to 32 A." +chargerPowerCustom = "other" +chargerPowerCustomHelp = "Define a custom current range." +chargerTypeLabel = "Charger type" +chargingTitle = "Charging" +circuitLabel = "Circuit" +circuitUnassigned = "unassigned" +defaultModeHelp = "Charging mode when connecting the vehicle." +defaultModeHelpKeep = "Keeps the last selected charging mode." +defaultModeLabel = "Default mode" +delete = "Delete" +electricalSubtitle = "When in doubt, ask your electrician." +electricalTitle = "Electrical" +energyMeterHelp = "Additional meter if the charger doesn't have an integrated one." +energyMeterLabel = "Energy meter" +estimateLabel = "Interpolate charge level between API updates" +maxCurrentHelp = "Must be greater than minimum current." +maxCurrentLabel = "Maximum current" +minCurrentHelp = "Only go below 6 A if you know what you're doing." +minCurrentLabel = "Minimum current" +noVehicles = "No vehicles are configured." +phases1p = "1-phase" +phases3p = "3-phase" +phasesAutomatic = "Automatic phases" +phasesAutomaticHelp = "Your charger supports automatic switching between 1- and 3-phase charging. In the main screen you can adjust phase behaviour while charging." +phasesHelp = "Number of phases connected to the charger." +phasesLabel = "Phases" +pollIntervalHelp = "Time between vehicle API updates. Short intervals may drain the vehicle battery." +pollIntervalLabel = "Update interval" +pollModeAlways = "always" +pollModeAlwaysHelp = "Always request status updates in regular intervals." +pollModeCharging = "charging" +pollModeChargingHelp = "Only request vehicle status updates when charging." +pollModeConnected = "connected" +pollModeConnectedHelp = "Update vehicle status in regular intervals when connected." +pollModeLabel = "Update behaviour" +priorityHelp = "Higher priority charge points get preferred access to solar surplus." +priorityLabel = "Priority" +save = "Save" +showAllSettings = "Show all settings" +solarBehaviorCustomHelp = "Define your own enable and disable thresholds and delays." +solarBehaviorDefaultHelp = "Only charge with solar surplus. Start after {enableDelay} of surplus. Stop when there is not enough surplus for {disableDelay}." +solarBehaviorLabel = "Solar behaviour" +solarModeCustom = "custom" +solarModeMaximum = "maximum solar" +thresholdDisableDelayLabel = "Disable delay" +thresholdDisableHelpInvalid = "Please use a positive value." +thresholdDisableHelpPositive = "Stop charging, when more than {power} is used from the grid for {delay}." +thresholdDisableHelpZero = "Stop when minimum charge power can't be satisfied for {delay}." +thresholdDisableLabel = "Disable grid power" +thresholdEnableDelayLabel = "Enable delay" +thresholdEnableHelpInvalid = "Please use a negative value." +thresholdEnableHelpNegative = "Start charging, when {surplus} surplus is available for {delay}." +thresholdEnableHelpZero = "Start when minimum charge power surplus is available for {delay}." +thresholdEnableLabel = "Enable grid power" +titleAdd = "Add Charge Point" +titleEdit = "Edit Charge Point" +titleExample = "Garage, Carport, etc." +titleLabel = "Title" +vehicleAutoDetection = "auto detection" +vehicleHelpAutoDetection = "Automatically selects the most plausible vehicle. Manual override is possible." +vehicleHelpDefault = "Always assume this vehicle is charging here. Auto-detection disabled. Manual override is possible." +vehicleLabel = "Default vehicle" +vehiclesTitle = "Vehicles" + [config.main] +addGrid = "Add grid meter" addLoadpoint = "Add charge point" addPvBattery = "Add solar or battery" +addTariffs = "Add tariffs" addVehicle = "Add vehicle" configured = "configured" edit = "edit" @@ -194,6 +293,11 @@ little = "little-endian" http = "HTTP (unencrypted)" https = "HTTPS (encrypted)" +[config.options.status] +A = "A (not connected)" +B = "B (connected)" +C = "C (charging)" + [config.pv] titleAdd = "Add Solar Meter" titleEdit = "Edit Solar Meter" @@ -208,13 +312,16 @@ system = "System" vehicles = "Vehicles" [config.sponsor] -addToken = "Enter sponsor token" -changeToken = "Change sponsor token" +addToken = "Enter token" +changeToken = "Change token" description = "The sponsoring model helps us to maintain the project and sustainably build new and exciting features. As a sponsor you get access to all charger implementations." descriptionToken = "You get the token from {url}. We also offer a trial token for testing." error = "The sponsor token is not valid." labelToken = "Sponsor token" title = "Sponsorship" +tokenRequired = "You must configure a sponsor token before you can create this device." +tokenRequiredLearnMore = "Learn more." +trialToken = "Trial token" [config.system] logs = "Logs" @@ -380,7 +487,9 @@ reset = "Reset password?" title = "Authentication" [main] +startConfiguration = "Let's start configuration" vehicles = "Parking" +welcome = "Hello aboard!" [main.chargingPlan] active = "Active" diff --git a/server/helper.go b/server/helper.go index 43cd12060..ed6c84428 100644 --- a/server/helper.go +++ b/server/helper.go @@ -1,7 +1,9 @@ package server import ( + "encoding/json" "fmt" + "io" "math" "reflect" "slices" @@ -26,6 +28,13 @@ func parseFloat(payload string) (float64, error) { return f, err } +// jsonDecoder returns a json decoder with disallowed unknown fields +func jsonDecoder(r io.Reader) *json.Decoder { + dec := json.NewDecoder(r) + dec.DisallowUnknownFields() + return dec +} + // omitEmpty returns true if struct field is omitempty func omitEmpty(f reflect.StructField) bool { tag := f.Tag.Get("json") diff --git a/server/http.go b/server/http.go index a6765cb68..4a87b384e 100644 --- a/server/http.go +++ b/server/http.go @@ -156,6 +156,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param) } // loadpoint api + // TODO any loadpoint for id, lp := range site.Loadpoints() { api := api.PathPrefix(fmt.Sprintf("/loadpoints/%d", id+1)).Subrouter() @@ -236,7 +237,7 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P routes := map[string]route{ "templates": {"GET", "/templates/{class:[a-z]+}", templatesHandler}, "products": {"GET", "/products/{class:[a-z]+}", productsHandler}, - "devices": {"GET", "/devices/{class:[a-z]+}", devicesHandler}, + "devices": {"GET", "/devices/{class:[a-z]+}", devicesConfigHandler}, "device": {"GET", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceConfigHandler}, "devicestatus": {"GET", "/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler}, "dirty": {"GET", "/dirty", getHandler(ConfigDirty)}, @@ -247,7 +248,7 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P "testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler}, "interval": {"POST", "/interval/{value:[0-9.]+}", settingsSetDurationHandler(keys.Interval)}, "updatesponsortoken": {"POST", "/sponsortoken", updateSponsortokenHandler}, - "deletesponsortoken": {"DELETE", "/sponsortoken", settingsDeleteHandler(keys.SponsorToken)}, + "deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler}, } // yaml handlers @@ -279,6 +280,17 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P for _, r := range routes { api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc) } + + // loadpoints + for _, r := range map[string]route{ + "loadpoints": {"GET", "/loadpoints", loadpointsConfigHandler()}, + "loadpoint": {"GET", "/loadpoints/{id:[0-9.]+}", loadpointConfigHandler()}, + "updateloadpoint": {"PUT", "/loadpoints/{id:[0-9.]+}", updateLoadpointHandler()}, + "deleteloadpoint": {"DELETE", "/loadpoints/{id:[0-9.]+}", deleteLoadpointHandler()}, + "newloadpoint": {"POST", "/loadpoints", newLoadpointHandler()}, + } { + api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc) + } } { // api/system diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go index 91871d4b4..39474ce86 100644 --- a/server/http_config_device_handler.go +++ b/server/http_config_device_handler.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "strconv" @@ -33,11 +34,17 @@ func devicesConfig[T any](class templates.Class, h config.Handler[T]) ([]map[str return res, nil } -// devicesHandler returns a device configurations by class -func devicesHandler(w http.ResponseWriter, r *http.Request) { +// devicesConfigHandler returns a device configurations by class +func devicesConfigHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) class, err := templates.ClassString(vars["class"]) + + // TODO exclude loadpoints here + if err == nil && class == templates.Loadpoint { + err = fmt.Errorf("invalid class %s", class) + } + if err != nil { jsonError(w, http.StatusBadRequest, err) return @@ -142,6 +149,8 @@ func deviceConfigHandler(w http.ResponseWriter, r *http.Request) { return } + // TODO return application/yaml content type if type != template + jsonResult(w, res) } @@ -202,7 +211,7 @@ func newDevice[T any](class templates.Class, req map[string]any, newFromConf new return nil, err } - return &conf, h.Add(config.NewConfigurableDevice[T](conf, instance)) + return &conf, h.Add(config.NewConfigurableDevice[T](&conf, instance)) } // newDeviceHandler creates a new device by class @@ -215,6 +224,8 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) { return } + // TODO add application/yaml content type, reject type==template + var req map[string]any if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, http.StatusBadRequest, err) @@ -288,6 +299,8 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) { return } + // TODO add application/yaml content type, reject type==template + var req map[string]any if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, http.StatusBadRequest, err) diff --git a/server/http_config_helper.go b/server/http_config_helper.go index 1d16f2f28..138c712d9 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -188,6 +188,14 @@ func testInstance(instance any) map[string]testResult { res["chargedEnergy"] = makeResult(val, err) } + if _, ok := instance.(api.PhaseSwitcher); ok { + res["phases1p3p"] = makeResult(true, nil) + } + + if cc, ok := instance.(api.PhaseDescriber); ok { + res["singlePhase"] = makeResult(cc.Phases() == 1, nil) + } + if dev, ok := instance.(api.VehicleRange); ok { val, err := dev.Range() res["range"] = makeResult(val, err) @@ -198,5 +206,10 @@ func testInstance(instance any) map[string]testResult { res["socLimit"] = makeResult(val, err) } + if dev, ok := instance.(api.Identifier); ok { + val, err := dev.Identify() + res["identifier"] = makeResult(val, err) + } + return res } diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go new file mode 100644 index 000000000..4896b0f1c --- /dev/null +++ b/server/http_config_loadpoint_handler.go @@ -0,0 +1,251 @@ +package server + +import ( + "errors" + "io" + "net/http" + "strconv" + + "github.com/evcc-io/evcc/core" + "github.com/evcc-io/evcc/core/loadpoint" + coresettings "github.com/evcc-io/evcc/core/settings" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/config" + "github.com/evcc-io/evcc/util/templates" + "github.com/gorilla/mux" + "github.com/samber/lo" +) + +func getLoadpointStaticConfig(lp loadpoint.API) loadpoint.StaticConfig { + return loadpoint.StaticConfig{ + Charger: lp.GetChargerName(), + Meter: lp.GetMeterName(), + Circuit: lp.GetCircuitName(), + Vehicle: lp.GetDefaultVehicle(), + } +} + +func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig { + planTime, planEnergy := lp.GetPlanEnergy() + return loadpoint.DynamicConfig{ + Title: lp.GetTitle(), + DefaultMode: string(lp.GetDefaultMode()), + Priority: lp.GetPriority(), + Phases: lp.GetPhases(), + MinCurrent: lp.GetMinCurrent(), + MaxCurrent: lp.GetMaxCurrent(), + SmartCostLimit: lp.GetSmartCostLimit(), + Thresholds: lp.GetThresholds(), + Soc: lp.GetSocConfig(), + PlanEnergy: planEnergy, + PlanTime: planTime, + LimitEnergy: lp.GetLimitEnergy(), + LimitSoc: lp.GetLimitSoc(), + } +} + +type loadpointFullConfig struct { + ID int `json:"id,omitempty"` // db row id + Name string `json:"name"` // either slice index (yaml) or db: + + // static config + loadpoint.StaticConfig + loadpoint.DynamicConfig +} + +func loadpointSplitConfig(r io.Reader) (loadpoint.DynamicConfig, map[string]any, error) { + var payload map[string]any + + if err := jsonDecoder(r).Decode(&payload); err != nil { + return loadpoint.DynamicConfig{}, nil, err + } + + return loadpoint.SplitConfig(payload) +} + +// loadpointConfig returns a single loadpoint's configuration +func loadpointConfig(dev config.Device[loadpoint.API]) loadpointFullConfig { + lp := dev.Instance() + + var id int + if configurable, ok := dev.(config.ConfigurableDevice[loadpoint.API]); ok { + id = configurable.ID() + } + + res := loadpointFullConfig{ + ID: id, + Name: dev.Config().Name, + + StaticConfig: getLoadpointStaticConfig(lp), + DynamicConfig: getLoadpointDynamicConfig(lp), + } + + return res +} + +// loadpointsConfigHandler returns a device configurations by class +func loadpointsConfigHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + res := lo.Map(config.Loadpoints().Devices(), func(dev config.Device[loadpoint.API], _ int) loadpointFullConfig { + return loadpointConfig(dev) + }) + + jsonResult(w, res) + } +} + +// loadpointConfigHandler returns a device configurations by class +func loadpointConfigHandler() http.HandlerFunc { + h := config.Loadpoints() + + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + id, err := strconv.Atoi(vars["id"]) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + dev, err := h.ByName(config.NameForID(id)) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + res := loadpointConfig(dev) + + jsonResult(w, res) + } +} + +// newLoadpointHandler creates a new loadpoint +func newLoadpointHandler() http.HandlerFunc { + h := config.Loadpoints() + + // TODO revert charger, meter etc + + return func(w http.ResponseWriter, r *http.Request) { + dynamic, static, err := loadpointSplitConfig(r.Body) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + id := len(h.Devices()) + name := "lp-" + strconv.Itoa(id+1) + log := util.NewLoggerWithLoadpoint(name, id+1) + + conf, err := config.AddConfig(templates.Loadpoint, "", static) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + settings := coresettings.NewConfigSettingsAdapter(log, &conf) + instance, err := core.NewLoadpointFromConfig(log, settings, static) + if err != nil { + conf.Delete() + jsonError(w, http.StatusBadRequest, err) + return + } + + dev := config.NewConfigurableDevice[loadpoint.API](&conf, instance) + if err := dynamic.Apply(instance); err != nil { + conf.Delete() + jsonError(w, http.StatusBadRequest, err) + return + } + + if err := h.Add(dev); err != nil { + conf.Delete() + jsonError(w, http.StatusBadRequest, err) + return + } + + setConfigDirty() + + w.WriteHeader(http.StatusOK) + } +} + +// updateLoadpointHandler returns a device configurations by class +func updateLoadpointHandler() http.HandlerFunc { + h := config.Loadpoints() + + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + id, err := strconv.Atoi(vars["id"]) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + dev, err := h.ByName(config.NameForID(id)) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + configurable, ok := dev.(config.ConfigurableDevice[loadpoint.API]) + if !ok { + jsonError(w, http.StatusBadRequest, errors.New("not configurable")) + return + } + + instance := dev.Instance() + + dynamic, static, err := loadpointSplitConfig(r.Body) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + // static + if err := configurable.PartialUpdate(static, instance); err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + // dynamic + if err := dynamic.Apply(instance); err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + setConfigDirty() + + w.WriteHeader(http.StatusOK) + } +} + +// deleteLoadpointHandler deletes a loadpoint +func deleteLoadpointHandler() http.HandlerFunc { + h := config.Loadpoints() + + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + id, err := strconv.Atoi(vars["id"]) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + setConfigDirty() + + if err := deleteDevice(id, h); err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + res := struct { + ID int `json:"id"` + }{ + ID: id, + } + + jsonResult(w, res) + } +} diff --git a/server/http_config_site_handler.go b/server/http_config_site_handler.go index eb3b0a220..eb460f77b 100644 --- a/server/http_config_site_handler.go +++ b/server/http_config_site_handler.go @@ -1,7 +1,6 @@ package server import ( - "encoding/json" "net/http" "github.com/evcc-io/evcc/core/site" @@ -47,7 +46,7 @@ func updateSiteHandler(site site.API) http.HandlerFunc { Battery *[]string } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + if err := jsonDecoder(r.Body).Decode(&payload); err != nil { jsonError(w, http.StatusBadRequest, err) return } diff --git a/server/http_config_site_other_handler.go b/server/http_config_site_other_handler.go index 6ee5a7b43..cd8ab90e4 100644 --- a/server/http_config_site_other_handler.go +++ b/server/http_config_site_other_handler.go @@ -32,3 +32,9 @@ func updateSponsortokenHandler(w http.ResponseWriter, r *http.Request) { jsonResult(w, sponsor.Status()) } + +func deleteSponsorTokenHandler(w http.ResponseWriter, r *http.Request) { + settings.SetString(keys.SponsorToken, "") + setConfigDirty() + jsonResult(w, true) +} diff --git a/server/influxdb.go b/server/influxdb.go index 0e331eccf..fb395161f 100644 --- a/server/influxdb.go +++ b/server/influxdb.go @@ -165,7 +165,7 @@ func (m *Influx) Run(site site.API, in <-chan util.Param) { if param.Loadpoint != nil { lp := site.Loadpoints()[*param.Loadpoint] - tags["loadpoint"] = lp.Title() + tags["loadpoint"] = lp.GetTitle() if v := lp.GetVehicle(); v != nil { tags["vehicle"] = v.Title() } diff --git a/server/mqtt.go b/server/mqtt.go index fae7a9715..3e0fd7354 100644 --- a/server/mqtt.go +++ b/server/mqtt.go @@ -203,6 +203,7 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint. {"mode", setterFunc(api.ChargeModeString, pass(lp.SetMode))}, {"phases", intSetter(lp.SetPhases)}, {"limitSoc", intSetter(pass(lp.SetLimitSoc))}, + {"priority", intSetter(pass(lp.SetPriority))}, {"minCurrent", floatSetter(lp.SetMinCurrent)}, {"maxCurrent", floatSetter(lp.SetMaxCurrent)}, {"limitEnergy", floatSetter(pass(lp.SetLimitEnergy))}, diff --git a/templates/definition/charger/demo-charger.yaml b/templates/definition/charger/demo-charger.yaml index 9ced91830..03da11b27 100644 --- a/templates/definition/charger/demo-charger.yaml +++ b/templates/definition/charger/demo-charger.yaml @@ -14,7 +14,7 @@ params: de: Ladezustand en: Charge status type: choice - choice: [A, B, C, D, E, F] + choice: [A, B, C] default: A - name: power description: @@ -36,6 +36,13 @@ params: example: 16 type: int advanced: true + - name: phases1p3p + description: + de: Phasenumschaltung + en: Phase switching + type: bool + default: false + advanced: true render: | type: custom @@ -55,3 +62,10 @@ render: | power: source: const value: {{ .power }} + {{ if eq .phases1p3p "true" }} + phases1p3p: + source: js + script: | + 3 + tos: true + {{ end }} diff --git a/tests/config-battery.spec.js b/tests/config-battery.spec.js index 9cb90ee6f..99d442d7e 100644 --- a/tests/config-battery.spec.js +++ b/tests/config-battery.spec.js @@ -41,13 +41,16 @@ test.describe("battery meter", async () => { await expect(meterModal.getByTestId("device-tag-soc")).toContainText("75.0%"); await expect(meterModal.getByTestId("device-tag-power")).toContainText("-2.5 kW"); await meterModal.getByRole("button", { name: "Save" }).click(); + await expect(meterModal).not.toBeVisible(); await expect(page.getByTestId("battery")).toBeVisible(1); await expect(page.getByTestId("battery")).toContainText("openems"); // edit #1 await page.getByTestId("battery").getByRole("button", { name: "edit" }).click(); + await expect(meterModal).toBeVisible(); await meterModal.getByLabel("Battery capacity in kWh").fill("20"); await meterModal.getByRole("button", { name: "Validate & save" }).click(); + await expect(meterModal).not.toBeVisible(); const battery = page.getByTestId("battery"); await expect(battery).toBeVisible(1); diff --git a/tests/config-empty.evcc.yaml b/tests/config-empty.evcc.yaml index c7b0b6519..e69de29bb 100755 --- a/tests/config-empty.evcc.yaml +++ b/tests/config-empty.evcc.yaml @@ -1,24 +0,0 @@ -site: - title: Hello World - -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: diff --git a/tests/config-grid.spec.js b/tests/config-grid.spec.js index d10fc352b..6b420796b 100644 --- a/tests/config-grid.spec.js +++ b/tests/config-grid.spec.js @@ -3,13 +3,13 @@ import { start, stop, restart, baseUrl } from "./evcc"; import { startSimulator, stopSimulator, simulatorUrl, simulatorHost } from "./simulator"; import { enableExperimental } from "./utils"; -const CONFIG_EMPTY = "config-empty.evcc.yaml"; +const CONFIG_ONE_LP = "config-one-lp.evcc.yaml"; test.use({ baseURL: baseUrl() }); test.beforeAll(async () => { await startSimulator(); - await start(CONFIG_EMPTY); + await start(CONFIG_ONE_LP); }); test.afterAll(async () => { await stop(); @@ -34,11 +34,11 @@ test.describe("grid meter", async () => { await page.goto("/#/config"); await enableExperimental(page); - await expect(page.getByTestId("grid")).toHaveCount(1); - await expect(page.getByTestId("grid").getByTestId("device-tag-configured")).toContainText("no"); + await expect(page.getByTestId("grid")).toHaveCount(0); + await expect(page.getByTestId("add-grid")).toBeVisible(); // create #1 - await page.getByTestId("grid").getByRole("button", { name: "edit" }).click(); + await page.getByTestId("add-grid").click(); const meterModal = page.getByTestId("meter-modal"); await meterModal.getByLabel("Manufacturer").selectOption("OpenEMS"); @@ -50,7 +50,7 @@ test.describe("grid meter", async () => { await expect(meterModal).not.toBeVisible(); // restart - await restart(CONFIG_EMPTY); + await restart(CONFIG_ONE_LP); await expect(page.getByTestId("grid").getByTestId("device-tag-power")).toContainText("5.0 kW"); // check in main ui @@ -64,7 +64,7 @@ test.describe("grid meter", async () => { await meterModal.getByRole("button", { name: "Delete" }).click(); await expect(meterModal).not.toBeVisible(); - await expect(page.getByTestId("grid")).toHaveCount(1); - await expect(page.getByTestId("grid").getByTestId("device-tag-configured")).toContainText("no"); + await expect(page.getByTestId("grid")).not.toBeVisible(); + await expect(page.getByTestId("add-grid")).toBeVisible(); }); }); diff --git a/tests/config-loadpoint.spec.js b/tests/config-loadpoint.spec.js new file mode 100644 index 000000000..cb8285e35 --- /dev/null +++ b/tests/config-loadpoint.spec.js @@ -0,0 +1,277 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; + +const CONFIG_EMPTY = "config-empty.evcc.yaml"; +const CONFIG_ONE_LP = "config-one-lp.evcc.yaml"; + +test.use({ baseURL: baseUrl() }); + +test.afterEach(async () => { + await stop(); +}); + +async function enableExperimental(page) { + await page + .getByTestId("generalconfig-experimental") + .getByRole("button", { name: "edit" }) + .click(); + await page.getByLabel("Experimental 🧪").click(); + await page.getByRole("button", { name: "Close" }).click(); +} + +async function addDemoCharger(page) { + const lpModal = page.getByTestId("loadpoint-modal"); + await lpModal.getByRole("button", { name: "Add charger" }).click(); + + const modal = page.getByTestId("charger-modal"); + await modal.getByLabel("Manufacturer").selectOption("Demo charger"); + await modal.getByRole("button", { name: "Save" }).click(); + await expect(modal).not.toBeVisible(); +} + +async function addVehicle(page, title) { + await page.getByRole("button", { name: "Add vehicle" }).click(); + const modal = page.getByTestId("vehicle-modal"); + await modal.getByLabel("Manufacturer").selectOption("Generic vehicle"); + await modal.getByLabel("Title").fill(title); + await modal.getByRole("button", { name: "Validate & save" }).click(); + await expect(modal).not.toBeVisible(); +} + +async function newLoadpoint(page, title) { + const lpModal = page.getByTestId("loadpoint-modal"); + await page.getByRole("button", { name: "Add charge point" }).click(); + await expect(lpModal).toBeVisible(); + await lpModal.getByLabel("Title").fill(title); +} + +test.describe("loadpoint", async () => { + test("create, update and delete", async ({ page }) => { + await start(CONFIG_EMPTY); + + await page.goto("/#/config"); + await enableExperimental(page); + + const lpModal = page.getByTestId("loadpoint-modal"); + const chargerModal = page.getByTestId("charger-modal"); + + await expect(page.getByTestId("loadpoint")).toHaveCount(0); + + // new loadpoint + await newLoadpoint(page, "Solar Carport"); + await lpModal.getByRole("button", { name: "Add charger" }).click(); + + // add charger + await chargerModal.getByLabel("Manufacturer").selectOption("Demo charger"); + await chargerModal.getByLabel("Charge status").selectOption("C"); + await chargerModal.getByLabel("Power").fill("11000"); + await chargerModal.getByRole("button", { name: "Save" }).click(); + await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport"); + + // create loadpoint + await lpModal.getByRole("button", { name: "Save" }).click(); + await expect(page.getByTestId("loadpoint")).toHaveCount(1); + await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport"); + await expect(page.getByTestId("loadpoint")).toContainText("charging"); + await expect(page.getByTestId("loadpoint")).toContainText("11.0 kW"); + + // restart button appears + const restartButton = await page + .getByTestId("bottom-banner") + .getByRole("button", { name: "Restart" }); + await expect(restartButton).toBeVisible(); + + // restart + await restart(CONFIG_EMPTY); + await page.reload(); + await expect(page.getByTestId("loadpoint")).toHaveCount(1); + await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport"); + + // update loadpoint title + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await lpModal.getByLabel("Title").fill("Solar Carport 2"); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2"); + + // restart + await restart(CONFIG_EMPTY); + await page.reload(); + await expect(page.getByTestId("loadpoint")).toHaveCount(1); + await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2"); + + // update loadpoint power + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await lpModal.getByTestId("chargerPower-22kw").click(); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expect(lpModal).not.toBeVisible(); + + // restart + await restart(CONFIG_EMPTY); + await page.reload(); + await expect(page.getByTestId("loadpoint")).toHaveCount(1); + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await expect(lpModal.getByTestId("chargerPower-22kw")).toHaveClass(/active/); + await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport 2"); + await lpModal.getByRole("button", { name: "Close" }).click(); + + // delete loadpoint + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await lpModal.getByRole("button", { name: "Delete" }).click(); + await expect(page.getByTestId("loadpoint")).toHaveCount(0); + + // restart + await restart(CONFIG_EMPTY); + await page.reload(); + await expect(page.getByTestId("loadpoint")).toHaveCount(0); + }); + + test("priority", async ({ page }) => { + await start(CONFIG_ONE_LP); + await page.goto("/#/config"); + await enableExperimental(page); + + const lpModal = page.getByTestId("loadpoint-modal"); + + // loadpoint from yaml + await expect(page.getByTestId("loadpoint")).toHaveCount(1); + await expect(page.getByTestId("loadpoint")).toContainText("Carport"); + + // add two more loadpoints + for (const title of ["Garage", "Garden"]) { + await newLoadpoint(page, title); + await addDemoCharger(page); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expect(lpModal).not.toBeVisible(); + } + + // three loadpoints + await expect(page.getByTestId("loadpoint")).toHaveCount(3); + await expect(page.getByTestId("loadpoint").nth(0)).toContainText("Carport"); + await expect(page.getByTestId("loadpoint").nth(1)).toContainText("Garage"); + await expect(page.getByTestId("loadpoint").nth(2)).toContainText("Garden"); + + // second loadpoint > priority 2 + await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); + await lpModal.getByTestId("loadpointParamPriority-2").click(); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expect(lpModal).not.toBeVisible(); + + // third loadpoint > priority 1 + await page.getByTestId("loadpoint").nth(2).getByRole("button", { name: "edit" }).click(); + await lpModal.getByTestId("loadpointParamPriority-1").click(); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expect(lpModal).not.toBeVisible(); + // restart + await restart(CONFIG_ONE_LP); + await page.reload(); + + // check priorities + await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); + await expect(lpModal.getByTestId("loadpointParamPriority-2")).toHaveClass(/active/); + await lpModal.getByRole("button", { name: "Close" }).click(); + + await page.getByTestId("loadpoint").nth(2).getByRole("button", { name: "edit" }).click(); + await expect(lpModal.getByTestId("loadpointParamPriority-1")).toHaveClass(/active/); + await lpModal.getByRole("button", { name: "Close" }).click(); + }); + + test("vehicle", async ({ page }) => { + await start(CONFIG_EMPTY); + await page.goto("/#/config"); + await enableExperimental(page); + + const LP_1 = "Carport"; + const LP_2 = "Garage"; + const VEHICLE_1 = "Green Car"; + const VEHICLE_2 = "Yellow Van"; + + const lpModal = page.getByTestId("loadpoint-modal"); + + // add loadpoint > no vehicle option + await newLoadpoint(page, LP_1); + await addDemoCharger(page); + await expect(lpModal.getByLabel("Default vehicle")).not.toBeVisible(); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expect(lpModal).not.toBeVisible(); + + // edit loadpoint + await page.getByTestId("loadpoint").nth(0).getByRole("button", { name: "edit" }).click(); + await expect(lpModal).toContainText("No vehicles are configured."); + await lpModal.getByRole("button", { name: "Close" }).click(); + + // add vehicles + for (const title of [VEHICLE_1, VEHICLE_2]) { + await addVehicle(page, title); + } + + // set vehicle as default for loadpoint 1 + await page.getByTestId("loadpoint").nth(0).getByRole("button", { name: "edit" }).click(); + await expect(lpModal).toBeVisible(); + await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_1); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expect(lpModal).not.toBeVisible(); + + // add second loadpoint + await newLoadpoint(page, LP_2); + await addDemoCharger(page); + await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_2); + await lpModal.getByRole("button", { name: "Save" }).click(); + + // restart + await restart(CONFIG_EMPTY); + await page.reload(); + + // check loadpoint default vehicles + for (const [index, vehicle] of [VEHICLE_1, VEHICLE_2].entries()) { + await page.getByTestId("loadpoint").nth(index).getByRole("button", { name: "edit" }).click(); + await expect(lpModal.locator("#loadpointParamVehicle option:checked")).toHaveText(vehicle); + await lpModal.getByRole("button", { name: "Close" }).click(); + await expect(lpModal).not.toBeVisible(); + } + }); + + test("keep mode", async ({ page }) => { + await start(CONFIG_EMPTY); + await page.goto("/#/config"); + await enableExperimental(page); + + // add grid meter + await page.getByRole("button", { name: "Add grid meter" }).click(); + const meterModal = page.getByTestId("meter-modal"); + await meterModal.getByLabel("Manufacturer").selectOption("Demo meter"); + await meterModal.getByLabel("Power").fill("-1000"); + await meterModal.getByRole("button", { name: "Save" }).click(); + await expect(meterModal).not.toBeVisible(); + + // add a loadpoint with dummy charger, + const lpModal = page.getByTestId("loadpoint-modal"); + await newLoadpoint(page, "Carport"); + await addDemoCharger(page); + await lpModal.getByLabel("Default mode").selectOption("---"); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expect(lpModal).not.toBeVisible(); + await restart(CONFIG_EMPTY); + + // change on main ui + await page.goto("/"); + await expect(page.getByRole("button", { name: "Off" })).toHaveClass(/active/); + await page.getByRole("button", { name: "Solar", exact: true }).click(); + await restart(CONFIG_EMPTY); + await page.reload(); + await expect(page.getByRole("button", { name: "Solar", exact: true })).toHaveClass(/active/); + + // change default mode in config to fast + await page.goto("/#/config"); + // open first loadpoint + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await expect(lpModal).toBeVisible(); + await lpModal.getByLabel("Default mode").selectOption("Fast"); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expect(lpModal).not.toBeVisible(); + await restart(CONFIG_EMPTY); + + // check loadpoint mode + await page.goto("/"); + await expect(page.getByRole("button", { name: "Fast" })).toHaveClass(/active/); + }); +}); diff --git a/tests/config-onboarding.spec.js b/tests/config-onboarding.spec.js new file mode 100644 index 000000000..15d10c1de --- /dev/null +++ b/tests/config-onboarding.spec.js @@ -0,0 +1,101 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; +import { enableExperimental } from "./utils"; + +const CONFIG = "config-empty.evcc.yaml"; + +test.use({ baseURL: baseUrl() }); + +test.beforeAll(async () => { + await start(CONFIG, null, ""); +}); +test.afterAll(async () => { + await stop(); +}); + +const PASSWORD = "secret"; + +test.describe("onboarding", async () => { + test("password, loadpoint, grid, pv", async ({ page }) => { + await page.goto("/"); + + // set admin password + const admin = page.getByTestId("password-modal"); + await expect(admin).toBeVisible(); + await admin.getByLabel("New password").fill(PASSWORD); + await admin.getByLabel("Repeat password").fill(PASSWORD); + await admin.getByRole("button", { name: "Create Password" }).click(); + await expect(admin).not.toBeVisible(); + + // onboarding + await expect(page.locator("body")).toContainText("Hello aboard!"); + await page.getByRole("link", { name: "Let's start configuration" }).click(); + + // login + const login = page.getByTestId("login-modal"); + await expect(login).toBeVisible(); + await login.getByLabel("Password").fill(PASSWORD); + await login.getByRole("button", { name: "Login" }).click(); + await expect(login).not.toBeVisible(); + + // config page + await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible(); + await enableExperimental(page); + + // create loadpoint with charger + await page.getByTestId("add-loadpoint").click(); + const lpModal = page.getByTestId("loadpoint-modal"); + await lpModal.getByLabel("Title").fill("Solar Carport"); + await lpModal.getByRole("button", { name: "Add charger" }).click(); + + const chargerModal = page.getByTestId("charger-modal"); + await chargerModal.getByLabel("Manufacturer").selectOption("Demo charger"); + await chargerModal.getByLabel("Charge status").selectOption("C"); + await chargerModal.getByLabel("Power").fill("3000"); + await chargerModal.getByRole("button", { name: "Save" }).click(); + await expect(chargerModal).not.toBeVisible(); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expect(lpModal).not.toBeVisible(); + + // create grid meter + await page.getByRole("button", { name: "Add grid meter" }).click(); + const gridModal = page.getByTestId("meter-modal"); + await expect(gridModal).toBeVisible(); + await gridModal.getByLabel("Manufacturer").selectOption("Demo meter"); + await gridModal.getByLabel("Power").fill("-2000"); + await gridModal.getByRole("button", { name: "Save" }).click(); + await expect(gridModal).not.toBeVisible(); + + // create pv meter + await page.getByRole("button", { name: "Add solar or battery" }).click(); + const pvModal = page.getByTestId("meter-modal"); + await expect(gridModal).toBeVisible(); + await pvModal.getByRole("button", { name: "Add solar meter" }).click(); + await pvModal.getByLabel("Manufacturer").selectOption("Demo meter"); + await pvModal.getByLabel("Power").fill("5000"); + await pvModal.getByRole("button", { name: "Save" }).click(); + await expect(pvModal).not.toBeVisible(); + + // restart + const restartButton = await page + .getByTestId("bottom-banner") + .getByRole("button", { name: "Restart" }); + await expect(restartButton).toBeVisible(); + await restart(CONFIG); + await expect(restartButton).not.toBeVisible(); + + // navigate to main screen + await page.getByTestId("home-link").click(); + + // verify configuration + await page.getByTestId("visualization").click(); + await expect(page.getByTestId("energyflow-entry-production")).toContainText("5.0 kW"); + await expect(page.getByTestId("energyflow-entry-loadpoints")).toContainText("3.0 kW"); + await expect(page.getByTestId("energyflow-entry-gridexport")).toContainText("2.0 kW"); + + const loadpoint = page.getByTestId("loadpoint"); + await expect(loadpoint.getByRole("heading", { name: "Solar Carport" })).toBeVisible(); + await expect(loadpoint).toContainText("3.0 kW"); + await expect(loadpoint).toContainText("Charging…"); + }); +}); diff --git a/tests/config-one-lp.evcc.yaml b/tests/config-one-lp.evcc.yaml new file mode 100755 index 000000000..c7b0b6519 --- /dev/null +++ b/tests/config-one-lp.evcc.yaml @@ -0,0 +1,24 @@ +site: + title: Hello World + +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: diff --git a/tests/config-tariffs.spec.js b/tests/config-tariffs.spec.js index 9d900497b..263319686 100644 --- a/tests/config-tariffs.spec.js +++ b/tests/config-tariffs.spec.js @@ -24,17 +24,15 @@ test.describe("tariffs", async () => { await start(CONFIG_GRID_ONLY); await goToConfig(page); - await expect(page.getByTestId("tariffs")).toBeVisible(); - await expect(page.getByTestId("tariffs")).toContainText( - ["Tariffs", "Currency", "EUR"].join("") - ); + await expect(page.getByTestId("tariffs")).not.toBeVisible(); + await expect(page.getByTestId("add-tariffs")).toBeVisible(); }); test("tariffs via ui", async ({ page }) => { await start(CONFIG_GRID_ONLY); await goToConfig(page); - await page.getByTestId("tariffs").getByRole("button", { name: "edit" }).click(); + await page.getByTestId("add-tariffs").click(); const modal = await page.getByTestId("tariffs-modal"); await expect(modal).toBeVisible(); await page.waitForLoadState("networkidle"); diff --git a/tests/simulator/api.js b/tests/simulator/api.js index 77ad47701..11e8b328d 100644 --- a/tests/simulator/api.js +++ b/tests/simulator/api.js @@ -42,6 +42,30 @@ const openemsMiddleware = (req, res, next) => { } }; +const teslaloggerMiddleware = (req, res, next) => { + if (req.method === "GET" && req.originalUrl.startsWith("/currentjson/")) { + const id = parseInt(req.originalUrl.split("/")[2]); + const vehicle = state.vehicles[id - 1]; + if (!vehicle) { + res.statusCode = 404; + res.end(JSON.stringify({ error: "Vehicle not found" })); + return; + } + const data = { + battery_level: vehicle.soc, + battery_range_km: vehicle.range, + plugged_in: true, + charging: false, + odometer: 10000, + is_preconditioning: false, + charge_current_request: 10, + }; + res.end(JSON.stringify(data)); + } else { + next(); + } +}; + export default () => ({ name: "api", enforce: "pre", @@ -51,6 +75,7 @@ export default () => ({ server.middlewares.use(bodyParser.json()); server.middlewares.use(stateApiMiddleware); server.middlewares.use(openemsMiddleware); + server.middlewares.use(teslaloggerMiddleware); }; }, }); diff --git a/util/config/config.go b/util/config/config.go index ad472f8bc..870ee153e 100644 --- a/util/config/config.go +++ b/util/config/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" + "dario.cat/mergo" "github.com/evcc-io/evcc/util/templates" "gorm.io/gorm" ) @@ -74,6 +75,29 @@ func (d *Config) Update(conf map[string]any) error { }) } +// PartialUpdate partially updates a config's details to the database +func (d *Config) PartialUpdate(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 + } + + actual := d.detailsAsMap() + if err := mergo.Merge(&actual, conf, mergo.WithOverride); err != nil { + return err + } + + val, err := detailsFromMap(actual) + if err != nil { + return err + } + d.Value = val + + return tx.Save(&d).Error + }) +} + // Delete deletes a config from the database func (d *Config) Delete() error { return db.Delete(Config{ID: d.ID}).Error diff --git a/util/config/device.go b/util/config/device.go index a8a18f38a..0c1ec1f14 100644 --- a/util/config/device.go +++ b/util/config/device.go @@ -7,16 +7,19 @@ type Device[T any] interface { type ConfigurableDevice[T any] interface { Device[T] ID() int + // Assign(T) + // Update1(map[string]any) error Update(map[string]any, T) error + PartialUpdate(map[string]any, T) error Delete() error } type configurableDevice[T any] struct { - config Config + config *Config instance T } -func NewConfigurableDevice[T any](config Config, instance T) ConfigurableDevice[T] { +func NewConfigurableDevice[T any](config *Config, instance T) ConfigurableDevice[T] { return &configurableDevice[T]{ config: config, instance: instance, @@ -35,6 +38,14 @@ func (d *configurableDevice[T]) ID() int { return d.config.ID } +// func (d *configurableDevice[T]) Assign(instance T) { +// d.instance = instance +// } + +// func (d *configurableDevice[T]) Update(config map[string]any) error { +// return d.config.Update(config) +// } + func (d *configurableDevice[T]) Update(config map[string]any, instance T) error { if err := d.config.Update(config); err != nil { return err @@ -43,6 +54,14 @@ func (d *configurableDevice[T]) Update(config map[string]any, instance T) error return nil } +func (d *configurableDevice[T]) PartialUpdate(config map[string]any, instance T) error { + if err := d.config.PartialUpdate(config); err != nil { + return err + } + d.instance = instance + return nil +} + func (d *configurableDevice[T]) Delete() error { return d.config.Delete() } diff --git a/util/config/instance.go b/util/config/instance.go index d6fbee1b4..a68b0a2b9 100644 --- a/util/config/instance.go +++ b/util/config/instance.go @@ -3,15 +3,17 @@ package config import ( evbus "github.com/asaskevich/EventBus" "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/loadpoint" ) var bus = evbus.New() var instance struct { - meters *handler[api.Meter] - chargers *handler[api.Charger] - vehicles *handler[api.Vehicle] - circuits *handler[api.Circuit] + meters *handler[api.Meter] + chargers *handler[api.Charger] + vehicles *handler[api.Vehicle] + circuits *handler[api.Circuit] + loadpoints *handler[loadpoint.API] } func init() { @@ -23,6 +25,7 @@ func Reset() { instance.chargers = &handler[api.Charger]{topic: "charger"} instance.vehicles = &handler[api.Vehicle]{topic: "vehicle"} instance.circuits = &handler[api.Circuit]{topic: "circuit"} + instance.loadpoints = &handler[loadpoint.API]{topic: "loadpoint"} } type Handler[T any] interface { @@ -49,6 +52,10 @@ func Circuits() Handler[api.Circuit] { return instance.circuits } +func Loadpoints() Handler[loadpoint.API] { + return instance.loadpoints +} + // Instances returns the instances of the given devices func Instances[T any](devices []Device[T]) []T { res := make([]T, 0, len(devices)) diff --git a/util/templates/class.go b/util/templates/class.go index 42ea0c109..14a3a65db 100644 --- a/util/templates/class.go +++ b/util/templates/class.go @@ -9,5 +9,6 @@ const ( Meter Vehicle Tariff + Loadpoint Circuit ) diff --git a/util/templates/class_enumer.go b/util/templates/class_enumer.go index 48acc8c8e..bcbf9a0b3 100644 --- a/util/templates/class_enumer.go +++ b/util/templates/class_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _ClassName = "chargermetervehicletariffcircuit" +const _ClassName = "chargermetervehicletariffloadpointcircuit" -var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 32} +var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 34, 41} -const _ClassLowerName = "chargermetervehicletariffcircuit" +const _ClassLowerName = "chargermetervehicletariffloadpointcircuit" func (i Class) String() string { i -= 1 @@ -29,10 +29,11 @@ func _ClassNoOp() { _ = x[Meter-(2)] _ = x[Vehicle-(3)] _ = x[Tariff-(4)] - _ = x[Circuit-(5)] + _ = x[Loadpoint-(5)] + _ = x[Circuit-(6)] } -var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Circuit} +var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Loadpoint, Circuit} var _ClassNameToValueMap = map[string]Class{ _ClassName[0:7]: Charger, @@ -43,8 +44,10 @@ var _ClassNameToValueMap = map[string]Class{ _ClassLowerName[12:19]: Vehicle, _ClassName[19:25]: Tariff, _ClassLowerName[19:25]: Tariff, - _ClassName[25:32]: Circuit, - _ClassLowerName[25:32]: Circuit, + _ClassName[25:34]: Loadpoint, + _ClassLowerName[25:34]: Loadpoint, + _ClassName[34:41]: Circuit, + _ClassLowerName[34:41]: Circuit, } var _ClassNames = []string{ @@ -52,7 +55,8 @@ var _ClassNames = []string{ _ClassName[7:12], _ClassName[12:19], _ClassName[19:25], - _ClassName[25:32], + _ClassName[25:34], + _ClassName[34:41], } // ClassString retrieves an enum value from the enum constants string name. diff --git a/vehicle/embed.go b/vehicle/embed.go index b9416cb7c..e4259490f 100644 --- a/vehicle/embed.go +++ b/vehicle/embed.go @@ -40,6 +40,8 @@ func (v *embed) Capacity() float64 { return v.Capacity_ } +var _ api.PhaseDescriber = (*embed)(nil) + // Phases returns the phases used by the vehicle func (v *embed) Phases() int { return v.Phases_