From 5f9047531dd06aae820fd9ca670f17f24dfb30b0 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 19 Jun 2026 17:50:22 +0200 Subject: [PATCH 001/733] Heating: add temperature range, add heating colors (#30995) --- assets/css/app.css | 11 + .../js/components/Config/LoadpointModal.vue | 47 ++++ .../Loadpoints/Loadpoint.stories.ts | 23 ++ assets/js/components/Loadpoints/Loadpoint.vue | 4 + .../js/components/Vehicles/LimitSocSelect.vue | 9 +- assets/js/components/Vehicles/Soc.stories.ts | 247 ++++++++++++++++++ assets/js/components/Vehicles/Soc.vue | 235 ++++++++++++++--- assets/js/components/Vehicles/Vehicle.vue | 4 + assets/js/types/evcc.ts | 7 + core/keys/loadpoint.go | 1 + core/loadpoint.go | 8 + core/loadpoint/api.go | 5 + core/loadpoint/config.go | 8 + core/loadpoint/config_test.go | 25 ++ core/loadpoint/mock.go | 26 ++ core/loadpoint_api.go | 23 ++ i18n/de.json | 5 + i18n/en.json | 5 + server/http_config_loadpoint_handler.go | 1 + 19 files changed, 663 insertions(+), 31 deletions(-) create mode 100644 assets/js/components/Vehicles/Soc.stories.ts create mode 100644 core/loadpoint/config_test.go diff --git a/assets/css/app.css b/assets/css/app.css index 22caccc07..51e5e0fab 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -54,6 +54,17 @@ --evcc-accent2: var(--evcc-darker-green); --evcc-accent3: var(--evcc-darkest-green); + /* heating temperature bar: thermometer scale (cold -> hot) */ + --evcc-temp-cold: #1b6fba; + --evcc-temp-warm: #ffa700; + --evcc-temp-hot: #e30022; + --evcc-heating-gradient: linear-gradient( + 90deg, + var(--evcc-temp-cold) 5%, + var(--evcc-temp-warm) 20%, + var(--evcc-temp-warm) 40%, + var(--evcc-temp-hot) 95% + ); --evcc-transition-slow: 1000ms; --evcc-transition-medium: 500ms; --evcc-transition-fast: 250ms; diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue index 1466a4d61..0d4e3cfd2 100644 --- a/assets/js/components/Config/LoadpointModal.vue +++ b/assets/js/components/Config/LoadpointModal.vue @@ -540,6 +540,49 @@

{{ $t("config.loadpoint.noVehicles") }}

+ +
+
{{ $t("config.loadpoint.temperatureRangeTitle") }}
+

+ {{ $t("config.loadpoint.temperatureRangeHelp") }} +

+
+ + + + + + + +
+
@@ -619,6 +662,10 @@ const defaultValues = { poll: { mode: "charging", interval: 60 * nsPerMin }, estimate: true, }, + ui: { + minTemp: 0, + maxTemp: 100, + }, vehicle: "", charger: "", circuit: "", diff --git a/assets/js/components/Loadpoints/Loadpoint.stories.ts b/assets/js/components/Loadpoints/Loadpoint.stories.ts index c8d23adef..92a62c124 100644 --- a/assets/js/components/Loadpoints/Loadpoint.stories.ts +++ b/assets/js/components/Loadpoints/Loadpoint.stories.ts @@ -14,6 +14,12 @@ export default { parameters: { layout: "centered", }, + argTypes: { + vehicleSoc: { control: { type: "range", min: 0, max: 100, step: 1 } }, + limitSoc: { control: { type: "range", min: 0, max: 100, step: 1 } }, + effectiveLimitSoc: { control: { type: "range", min: 0, max: 100, step: 1 } }, + vehicleLimitSoc: { control: { type: "range", min: 0, max: 100, step: 1 } }, + }, } as Meta; // Based on actual API state structure from demo.evcc.io @@ -170,3 +176,20 @@ ChargerIconNoVehicle.args = { vehicleName: "", vehicles: [], }; + +// integrated heater: temperature bar (30..70 range), device limit marker +export const Heating = Template.bind({}); +Heating.args = { + ...baseState, + title: "Warmwasser", + chargerIcon: "heater", + mode: CHARGE_MODE.NOW, + chargerFeatureHeating: true, + chargerFeatureIntegratedDevice: true, + vehicleName: "", + vehicles: [], + ui: { minTemp: 30, maxTemp: 70 }, + vehicleSoc: 48, + effectiveLimitSoc: 60, + vehicleLimitSoc: 65, +}; diff --git a/assets/js/components/Loadpoints/Loadpoint.vue b/assets/js/components/Loadpoints/Loadpoint.vue index 1e218b0d2..a5a24b0e6 100644 --- a/assets/js/components/Loadpoints/Loadpoint.vue +++ b/assets/js/components/Loadpoints/Loadpoint.vue @@ -120,6 +120,7 @@ import type { Forecast, SMART_COST_TYPE, BATTERY_MODE, + LoadpointUi, } from "@/types/evcc"; import type { PlanStrategy } from "@/components/ChargingPlans/types"; @@ -168,6 +169,9 @@ export default defineComponent({ chargerFeatureContinuous: Boolean, chargerIcon: String as PropType, + // heating display range (ui-only) + ui: Object as PropType, + // vehicle connected: Boolean, // charging: Boolean, diff --git a/assets/js/components/Vehicles/LimitSocSelect.vue b/assets/js/components/Vehicles/LimitSocSelect.vue index 42eaf038f..765d52f21 100644 --- a/assets/js/components/Vehicles/LimitSocSelect.vue +++ b/assets/js/components/Vehicles/LimitSocSelect.vue @@ -34,12 +34,19 @@ export default defineComponent({ limitSoc: { type: Number, default: 0 }, rangePerSoc: Number, heating: Boolean, + minTemp: { type: Number, default: 0 }, + maxTemp: { type: Number, default: 0 }, }, emits: ["limit-soc-updated"], computed: { + rangeActive() { + return this.heating && this.maxTemp > this.minTemp; + }, options() { const result = []; - for (let soc = 20; soc <= 100; soc += this.step) { + const start = this.rangeActive ? this.minTemp : 20; + const end = this.rangeActive ? this.maxTemp : 100; + for (let soc = start; soc <= end; soc += this.step) { const text = this.fmtSocOption(soc, this.rangePerSoc, distanceUnit(), this.heating); result.push({ soc, text }); } diff --git a/assets/js/components/Vehicles/Soc.stories.ts b/assets/js/components/Vehicles/Soc.stories.ts new file mode 100644 index 000000000..093b91597 --- /dev/null +++ b/assets/js/components/Vehicles/Soc.stories.ts @@ -0,0 +1,247 @@ +import Soc from "./Soc.vue"; +import type { Meta, StoryFn } from "@storybook/vue3"; + +// non-heating SoC bar (percentage scale). current soc steps up across the +// variants; the heating variants use the matching position on the 20..60 scale. +const baseState = { + connected: true, + socBasedCharging: true, + socBasedPlanning: true, + vehicleSoc: 40, + effectiveLimitSoc: 90, // manual limit + vehicleLimitSoc: 0, + enabled: false, + charging: false, + heating: false, +}; + +// heating bar: vehicleSoc is the current temperature, ui defines the scale +// (20..60), effectiveLimitSoc / vehicleLimitSoc are target temperatures. +// temp = 20 + soc * 0.4, so positions line up with the charging column. +const heatingBase = { + ...baseState, + heating: true, + ui: { minTemp: 20, maxTemp: 60 }, + vehicleSoc: 36, // ~40% + effectiveLimitSoc: 56, // manual limit, ~90% +}; + +export default { + title: "Vehicles/Soc", + component: Soc, + argTypes: { + connected: { control: "boolean" }, + enabled: { control: "boolean" }, + charging: { control: "boolean" }, + heating: { control: "boolean" }, + socBasedCharging: { control: "boolean" }, + socBasedPlanning: { control: "boolean" }, + vehicleSoc: { control: "number" }, + vehicleLimitSoc: { control: "number" }, + effectiveLimitSoc: { control: "number" }, + effectivePlanSoc: { control: "number" }, + minSoc: { control: "number" }, + minSocNotReached: { control: "boolean" }, + ui: { control: "object" }, + }, +} as Meta; + +// give the bar a fixed width and the card background (var(--evcc-box)) it lives on +// in the app, so the grey track (var(--evcc-background)) reads correctly +const cardStyle = + "width: 340px; padding: 1rem; background: var(--evcc-box); border-radius: 0.5rem;"; + +const Template: StoryFn = (args) => ({ + components: { Soc }, + setup() { + return { args, cardStyle }; + }, + template: '
', +}); + +// --- charging (percentage) variants, current soc increasing --- + +export const MinSocNotReached = Template.bind({}); +MinSocNotReached.args = { + ...baseState, + enabled: true, + charging: true, + vehicleSoc: 10, + minSoc: 20, + minSocNotReached: true, +}; + +// very low / low current soc, actively charging +export const ChargingVeryLow = Template.bind({}); +ChargingVeryLow.args = { ...baseState, enabled: true, charging: true, vehicleSoc: 5 }; + +export const ChargingLow = Template.bind({}); +ChargingLow.args = { ...baseState, enabled: true, charging: true, vehicleSoc: 20 }; + +export const Connected = Template.bind({}); +Connected.args = { ...baseState, vehicleSoc: 30 }; + +export const Ready = Template.bind({}); +Ready.args = { ...baseState, enabled: true, vehicleSoc: 45 }; + +export const Charging = Template.bind({}); +Charging.args = { ...baseState, enabled: true, charging: true, vehicleSoc: 60 }; + +// manual limit (slider) below the vehicle's own limit -> vehicle marker sits right of the thumb +export const VehicleLimitManualBelow = Template.bind({}); +VehicleLimitManualBelow.args = { + ...baseState, + enabled: true, + charging: true, + vehicleSoc: 65, + vehicleLimitSoc: 80, + effectiveLimitSoc: 70, +}; + +// manual limit above the vehicle's own limit -> fill clamps at the vehicle marker, thumb beyond +export const VehicleLimitManualAbove = Template.bind({}); +VehicleLimitManualAbove.args = { + ...baseState, + enabled: true, + charging: true, + vehicleSoc: 70, + vehicleLimitSoc: 80, + effectiveLimitSoc: 90, +}; + +// current soc nearly at the top of the scale, limit raised to make room +export const NearlyFull = Template.bind({}); +NearlyFull.args = { + ...baseState, + enabled: true, + charging: true, + vehicleSoc: 96, + effectiveLimitSoc: 100, +}; + +export const Disconnected = Template.bind({}); +Disconnected.args = { ...baseState, connected: false }; + +// no vehicle SoC: energy-based bar (chargedEnergy vs limitEnergy), no slider +export const NoSoc = Template.bind({}); +NoSoc.args = { + ...baseState, + socBasedCharging: false, + socBasedPlanning: false, + enabled: true, + charging: true, + chargedEnergy: 14000, +}; + +// --- heating (temperature range) variants, current temp increasing --- + +export const MinTempNotReached = Template.bind({}); +MinTempNotReached.args = { + ...heatingBase, + enabled: true, + charging: true, + vehicleSoc: 24, + minSoc: 28, + minSocNotReached: true, +}; + +// very low / low current temp, actively heating (temp = 20 + soc% * 0.4) +export const HeatingChargingVeryLow = Template.bind({}); +HeatingChargingVeryLow.args = { ...heatingBase, enabled: true, charging: true, vehicleSoc: 22 }; + +export const HeatingChargingLow = Template.bind({}); +HeatingChargingLow.args = { ...heatingBase, enabled: true, charging: true, vehicleSoc: 28 }; + +export const HeatingIdle = Template.bind({}); +HeatingIdle.args = { ...heatingBase, vehicleSoc: 32 }; + +export const HeatingReady = Template.bind({}); +HeatingReady.args = { ...heatingBase, enabled: true, vehicleSoc: 38 }; + +export const HeatingCharging = Template.bind({}); +HeatingCharging.args = { ...heatingBase, enabled: true, charging: true, vehicleSoc: 44 }; + +export const HeaterLimitManualBelow = Template.bind({}); +HeaterLimitManualBelow.args = { + ...heatingBase, + enabled: true, + charging: true, + vehicleSoc: 46, + vehicleLimitSoc: 52, + effectiveLimitSoc: 48, +}; + +export const HeaterLimitManualAbove = Template.bind({}); +HeaterLimitManualAbove.args = { + ...heatingBase, + enabled: true, + charging: true, + vehicleSoc: 48, + vehicleLimitSoc: 52, + effectiveLimitSoc: 56, +}; + +// current temp nearly at the top of the scale, limit raised to make room +export const HeatingNearlyFull = Template.bind({}); +HeatingNearlyFull.args = { + ...heatingBase, + enabled: true, + charging: true, + vehicleSoc: 58, + effectiveLimitSoc: 60, +}; + +export const HeatingDisconnected = Template.bind({}); +HeatingDisconnected.args = { ...heatingBase, connected: false }; + +// no temperature reading yet: warm bar, no slider +export const HeatingNoReading = Template.bind({}); +HeatingNoReading.args = { ...heatingBase, vehicleSoc: 0, enabled: true, charging: true }; + +// --- overview: charging (left) vs heating (right), each row the same situation --- + +const chargingVariants = [ + { name: "Disconnected", args: Disconnected.args }, + { name: "ChargingVeryLow", args: ChargingVeryLow.args }, + { name: "MinSocNotReached", args: MinSocNotReached.args }, + { name: "ChargingLow", args: ChargingLow.args }, + { name: "Connected", args: Connected.args }, + { name: "Ready", args: Ready.args }, + { name: "Charging", args: Charging.args }, + { name: "VehicleLimitManualBelow", args: VehicleLimitManualBelow.args }, + { name: "VehicleLimitManualAbove", args: VehicleLimitManualAbove.args }, + { name: "NearlyFull", args: NearlyFull.args }, + { name: "NoSoc", args: NoSoc.args }, +]; + +const heatingVariants = [ + { name: "HeatingDisconnected", args: HeatingDisconnected.args }, + { name: "HeatingChargingVeryLow", args: HeatingChargingVeryLow.args }, + { name: "MinTempNotReached", args: MinTempNotReached.args }, + { name: "HeatingChargingLow", args: HeatingChargingLow.args }, + { name: "HeatingIdle", args: HeatingIdle.args }, + { name: "HeatingReady", args: HeatingReady.args }, + { name: "HeatingCharging", args: HeatingCharging.args }, + { name: "HeaterLimitManualBelow", args: HeaterLimitManualBelow.args }, + { name: "HeaterLimitManualAbove", args: HeaterLimitManualAbove.args }, + { name: "HeatingNearlyFull", args: HeatingNearlyFull.args }, + { name: "HeatingNoReading", args: HeatingNoReading.args }, +]; + +export const Overview: StoryFn = () => ({ + components: { Soc }, + setup() { + return { columns: [chargingVariants, heatingVariants] }; + }, + template: ` +
+
+
+
{{ v.name }}
+ +
+
+
+ `, +}); +Overview.parameters = { controls: { disable: true } }; diff --git a/assets/js/components/Vehicles/Soc.vue b/assets/js/components/Vehicles/Soc.vue index 617828a95..bb2b8830b 100644 --- a/assets/js/components/Vehicles/Soc.vue +++ b/assets/js/components/Vehicles/Soc.vue @@ -1,24 +1,30 @@ diff --git a/assets/js/components/Config/MeterModal.vue b/assets/js/components/Config/MeterModal.vue index d42746572..1858d2b2f 100644 --- a/assets/js/components/Config/MeterModal.vue +++ b/assets/js/components/Config/MeterModal.vue @@ -85,6 +85,18 @@ /> + + @@ -105,7 +117,7 @@ import { } from "./DeviceModal"; import { customTemplateOption, type TemplateGroup } from "./DeviceModal/TemplateSelector.vue"; import defaultMeterYaml from "./defaultYaml/meter.yaml?raw"; -import { getModal, replaceModal } from "@/configModal"; +import { getModal, replaceModal, closeModal } from "@/configModal"; const initialValues = { type: ConfigType.Template, @@ -192,6 +204,9 @@ export default defineComponent({ isNew(): boolean { return this.id === undefined; }, + canConvertToConsumer(): boolean { + return !this.isNew && this.selectedType === "ext" && this.extMeterUsage === "charge"; + }, extMeterUsageOptions() { return ["charge", "aux", "grid", "pv", "battery"].map((key) => ({ name: this.$t(`config.meter.usage.${key}`), @@ -270,6 +285,11 @@ export default defineComponent({ const result = { action, name, type }; this.$emit("changed", result); }, + async convertToConsumer() { + if (!window.confirm(this.$t("config.meter.convertToConsumer.confirm"))) return; + this.$emit("changed", { action: "converted", type: "ext", id: this.id }); + await closeModal(); + }, handleClose() { this.extMeterUsage = "charge"; this.$emit("close"); diff --git a/assets/js/configModal.ts b/assets/js/configModal.ts index 68f1256a6..098aed074 100644 --- a/assets/js/configModal.ts +++ b/assets/js/configModal.ts @@ -11,7 +11,7 @@ export interface ModalEntry { } export interface ModalResult { - action: "added" | "updated" | "removed" | "cancelled"; + action: "added" | "updated" | "removed" | "converted" | "cancelled"; name?: string; id?: number; type?: string; diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index 9a75c563a..31e3f1467 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -1097,6 +1097,17 @@ export default defineComponent({ } } + // Converted: move ext meter to consumers (history is reconciled on restart) + if (result.action === "converted") { + const name = this.meters.find((m) => m.id === result.id)?.name; + if (name) { + const ext = (this.site.ext || []).filter((n) => n !== name); + const consumers = [...(this.site.consumers || []), name]; + await api.put("/config/site", { ext, consumers }); + await this.loadSite(); + } + } + // Removed: reload site config if (result.action === "removed") { await this.loadSite(); diff --git a/core/metrics/collector.go b/core/metrics/collector.go index 970612a95..69e2a15b7 100644 --- a/core/metrics/collector.go +++ b/core/metrics/collector.go @@ -41,6 +41,14 @@ func NewCollector(group, name, title string, opt ...func(*Accumulator)) (*Collec // createEntity ensures the entity row exists and refreshes its title. func createEntity(group, name, title string) (entity, error) { + // keep history when a meter is regrouped "meter" -> "consumer" (aux, ext convert) + if group == Consumer { + var prev entity + if db.Instance.Where(`"group" = ? AND name = ?`, Meter, name).Limit(1).Find(&prev).RowsAffected > 0 { + db.Instance.Model(&prev).UpdateColumn("group", Consumer) + } + } + e := entity{Group: group, Name: name} if err := db.Instance.Where(&e).Attrs(entity{Title: title}).FirstOrCreate(&e).Error; err != nil { diff --git a/core/metrics/collector_test.go b/core/metrics/collector_test.go index c4a4588d5..c85927d9d 100644 --- a/core/metrics/collector_test.go +++ b/core/metrics/collector_test.go @@ -309,3 +309,56 @@ func TestCreateEntityRefreshesTitle(t *testing.T) { require.NoError(t, db.Instance.Model(new(entity)).Where("\"group\" = ? AND name = ?", "grid", "grid").Count(&count).Error) require.EqualValues(t, 1, count, "must not duplicate existing rows") } + +// a meter regrouped to consumer keeps its id and history via in-place relabel +func TestCreateEntityReconcilesExtToConsumer(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + // ext meter with a persisted history slot + ext, err := createEntity(Meter, "db:5", "Fridge") + require.NoError(t, err) + require.NoError(t, persist(ext, time.Unix(15*60, 0), 0.3, 0)) + + // reconfigured as consumer: same row relabeled, history intact + con, err := createEntity(Consumer, "db:5", "Fridge") + require.NoError(t, err) + require.Equal(t, ext.Id, con.Id, "must reuse the existing row") + require.Equal(t, Consumer, con.Group) + + var stored entity + require.NoError(t, db.Instance.First(&stored, ext.Id).Error) + require.Equal(t, Consumer, stored.Group, "group must be persisted") + + // no duplicate entity for the name + var entities int64 + require.NoError(t, db.Instance.Model(new(entity)).Where("name = ?", "db:5").Count(&entities).Error) + require.EqualValues(t, 1, entities) + + // history row still attached to the (now consumer) entity + var meters int64 + require.NoError(t, db.Instance.Model(new(meter)).Where("meter = ?", ext.Id).Count(&meters).Error) + require.EqualValues(t, 1, meters) +} + +// an existing consumer row blocks the relabel, leaving the meter row untouched +func TestCreateEntityReconcileGuard(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + meterRow, err := createEntity(Meter, "db:7", "") + require.NoError(t, err) + + // pre-existing consumer row blocks the in-place relabel + conRow := entity{Group: Consumer, Name: "db:7"} + require.NoError(t, db.Instance.Create(&conRow).Error) + + got, err := createEntity(Consumer, "db:7", "") + require.NoError(t, err) + require.Equal(t, conRow.Id, got.Id, "must reuse existing consumer row") + + // meter row untouched + var stored entity + require.NoError(t, db.Instance.First(&stored, meterRow.Id).Error) + require.Equal(t, Meter, stored.Group) +} diff --git a/i18n/de.json b/i18n/de.json index a0d57dcf1..7d6843cf7 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -535,6 +535,10 @@ }, "meter": { "cancel": "Abbrechen", + "convertToConsumer": { + "confirm": "Wandelt diesen \"zusätzlichen Zähler\" in einen \"Verbraucher\" um.", + "link": "In Verbraucher umwandeln" + }, "delete": "Löschen", "generic": "Generische Integrationen", "option": { diff --git a/i18n/en.json b/i18n/en.json index 74bf648e2..f304c5f99 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -534,6 +534,10 @@ }, "meter": { "cancel": "Cancel", + "convertToConsumer": { + "confirm": "Converts this \"additional meter\" to a \"consumer\".", + "link": "Convert to consumer" + }, "delete": "Delete", "generic": "Generic integrations", "option": { diff --git a/tests/config-ext-meter.spec.ts b/tests/config-ext-meter.spec.ts index 4be5b7e94..39410191e 100644 --- a/tests/config-ext-meter.spec.ts +++ b/tests/config-ext-meter.spec.ts @@ -86,6 +86,56 @@ test.describe("ext meter", async () => { await expect(page.getByTestId("fatal-error")).not.toBeVisible(); }); + test("convert charge ext meter to consumer", async ({ page }) => { + await page.goto("/#/config"); + await expect(page.getByTestId("ext")).toHaveCount(0); + await expect(page.getByTestId("consumer")).toHaveCount(0); + + // additional meter defaults to usage charge + await createAdditionalMeter(page, "Fridge", "150"); + await expect(page.getByTestId("ext")).toHaveCount(1); + + const meterModal = page.getByTestId("meter-modal"); + await page.getByTestId("ext").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(meterModal); + + page.once("dialog", (dialog) => dialog.accept()); + await meterModal.getByRole("button", { name: "Convert to consumer" }).click(); + await expectModalHidden(meterModal); + + // moved from additional meters into consumers + await expect(page.getByTestId("ext")).toHaveCount(0); + await expect(page.getByTestId("consumer")).toHaveCount(1); + await expect(page.getByTestId("consumer")).toContainText("Fridge"); + + // persists across restart (history reconciled on boot) + await restart(CONFIG_GRID_ONLY); + await page.reload(); + await expect(page.getByTestId("ext")).toHaveCount(0); + await expect(page.getByTestId("consumer")).toHaveCount(1); + await expect(page.getByTestId("consumer")).toContainText("Fridge"); + await expect(page.getByTestId("fatal-error")).not.toBeVisible(); + }); + + test("convert option hidden for non-charge ext meter", async ({ page }) => { + await page.goto("/#/config"); + + await page.getByRole("button", { name: "Add additional meter" }).click(); + const meterModal = page.getByTestId("meter-modal"); + await expectModalVisible(meterModal); + + await meterModal.getByLabel("Usage").selectOption("battery"); + await meterModal.getByLabel("Manufacturer").selectOption("Demo battery"); + await meterModal.getByLabel("Title").fill("House battery"); + await meterModal.getByLabel("Charge").fill("75"); + await meterModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(meterModal); + + await page.getByTestId("ext").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(meterModal); + await expect(meterModal.getByRole("button", { name: "Convert to consumer" })).toHaveCount(0); + }); + test("switch from template to custom ext meter", async ({ page }) => { await page.goto("/#/config"); From bc1a5ae842cf6504c87ed9477d00624c369d9f34 Mon Sep 17 00:00:00 2001 From: svenger87 <35897839+svenger87@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:30:48 +0200 Subject: [PATCH 038/733] Add Zendure 3CT meter (#31141) --- templates/definition/meter/zendure-3ct.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 templates/definition/meter/zendure-3ct.yaml diff --git a/templates/definition/meter/zendure-3ct.yaml b/templates/definition/meter/zendure-3ct.yaml new file mode 100644 index 000000000..360db47e6 --- /dev/null +++ b/templates/definition/meter/zendure-3ct.yaml @@ -0,0 +1,19 @@ +template: zendure-3ct +products: + - brand: Zendure + description: + generic: 3CT +params: + - name: usage + choice: ["grid"] + - name: host + - name: cache + advanced: true + default: 1s +render: | + type: custom + power: + source: http + uri: http://{{ .host }}/properties/report + jq: .total_power + cache: {{ .cache }} From 382c430509324b483d18e832c3cbc772d0f70b7c Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 23 Jun 2026 10:39:47 +0200 Subject: [PATCH 039/733] chore: upgrade modules --- go.mod | 20 ++++++++++---------- go.sum | 52 ++++++++++++++++++++++++++-------------------------- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/go.mod b/go.mod index 6a656ab5f..61a8221c7 100644 --- a/go.mod +++ b/go.mod @@ -23,8 +23,8 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 github.com/cli/browser v1.3.0 github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 - github.com/coder/websocket v1.8.14 - github.com/coreos/go-oidc/v3 v3.18.0 + github.com/coder/websocket v1.8.15 + github.com/coreos/go-oidc/v3 v3.19.0 github.com/d2r2/go-i2c v0.0.0-20191123181816-73a8a799d6bc github.com/denisbrodbeck/machineid v1.0.1 github.com/dylanmei/iso8601 v0.1.0 @@ -33,7 +33,7 @@ require ( github.com/enbility/ship-go v0.6.1-0.20260518113001-134687068e3c github.com/enbility/spine-go v0.7.1-0.20260520153416-0104ce40c885 github.com/evcc-io/openapi-mcp v0.6.1-0.20260503092507-6199c7ad3baf - github.com/evcc-io/optimizer v0.0.0-20260531165648-b5cbfebdaa65 + github.com/evcc-io/optimizer v0.0.0-20260613102250-473d3906e657 github.com/evcc-io/rct v0.2.0 github.com/evcc-io/tesla-proxy-client v0.0.0-20260324063928-151fe10796ae github.com/fatih/structs v1.1.0 @@ -43,7 +43,7 @@ require ( github.com/go-telegram/bot v1.21.0 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/godbus/dbus/v5 v5.2.2 - github.com/gokrazy/updater v0.0.0-20250705135802-db129c40879c + github.com/gokrazy/updater v0.0.0-20260620140544-0a84d8ab3878 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-github/v32 v32.1.0 github.com/google/uuid v1.6.0 @@ -82,10 +82,10 @@ require ( github.com/philippseith/signalr v0.8.0 github.com/prometheus-community/pro-bing v0.9.0 github.com/prometheus/client_golang v1.23.2 - github.com/prometheus/common v0.68.1 + github.com/prometheus/common v0.69.0 github.com/robertkrimen/otto v0.5.1 github.com/samber/lo v1.53.0 - github.com/sandrolain/httpcache v1.4.0 + github.com/sandrolain/httpcache v1.4.1 github.com/sethvargo/go-password v0.3.1 github.com/sirupsen/logrus v1.9.4 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 @@ -104,9 +104,9 @@ require ( gitlab.com/bboehmke/sunny v0.17.0 go.bug.st/serial v1.7.1 go.uber.org/mock v0.6.0 - go.yaml.in/yaml/v4 v4.0.0-rc.5.0.20260613141257-7afcb0919cb5 + go.yaml.in/yaml/v4 v4.0.0-rc.6 golang.org/x/crypto v0.53.0 - golang.org/x/crypto/x509roots/fallback v0.0.0-20260609182332-5f2de1a9f1e2 + golang.org/x/crypto/x509roots/fallback v0.0.0-20260618162219-0b316e7ee409 golang.org/x/exp v0.0.0-20260611194520-c48552f49976 golang.org/x/net v0.56.0 golang.org/x/oauth2 v0.36.0 @@ -116,7 +116,7 @@ require ( google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.11 gorm.io/gorm v1.31.1 - modernc.org/sqlite v1.52.0 + modernc.org/sqlite v1.53.0 ) require ( @@ -243,7 +243,7 @@ require ( gopkg.in/go-playground/validator.v9 v9.30.0 // indirect gopkg.in/sourcemap.v1 v1.0.5 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.72.3 // indirect + modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 0b303692a..ba99f6c26 100644 --- a/go.sum +++ b/go.sum @@ -95,10 +95,10 @@ github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoK github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 h1:tuijfIjZyjZaHq9xDUh0tNitwXshJpbLkqMOJv4H3do= github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21/go.mod h1:po7NpZ/QiTKzBKyrsEAxwnTamCoh8uDk/egRpQ7siIc= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= -github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I= +github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -152,8 +152,8 @@ github.com/evcc-io/ocpp-go v0.0.0-20251212212612-b7f92ee0443b h1:5vFr7wOwoi5ylyb github.com/evcc-io/ocpp-go v0.0.0-20251212212612-b7f92ee0443b/go.mod h1:2kcukDdhui4u730VfnYVWuwzDLgw+mBRGDir/QAyBhg= github.com/evcc-io/openapi-mcp v0.6.1-0.20260503092507-6199c7ad3baf h1:rnObcCvSoPJpYVJoHAxFAtzy/lMLsPN1TgK3yq6nWrU= github.com/evcc-io/openapi-mcp v0.6.1-0.20260503092507-6199c7ad3baf/go.mod h1:YyOx4zwr6xVUcchAETHERZ+75cZMIrdcjVIZFwBlE1Q= -github.com/evcc-io/optimizer v0.0.0-20260531165648-b5cbfebdaa65 h1:W6swNuLZBpLBXNxMuVwHo4b/MwrWhrQ2BSK8t+Fchic= -github.com/evcc-io/optimizer v0.0.0-20260531165648-b5cbfebdaa65/go.mod h1:cHMeq6GfoXQ8LxqY4LH1wn/hrgYEn+ka5RPI1CF37SY= +github.com/evcc-io/optimizer v0.0.0-20260613102250-473d3906e657 h1:pZW9CGsXsJNHQnSGOzenl8Caz68+vDsliO22vhPGIKs= +github.com/evcc-io/optimizer v0.0.0-20260613102250-473d3906e657/go.mod h1:cHMeq6GfoXQ8LxqY4LH1wn/hrgYEn+ka5RPI1CF37SY= github.com/evcc-io/rct v0.2.0 h1:qjXKI7NKwW5YpEKEb27MwLMBaR4ne2Kd2cPV2PYTKn4= github.com/evcc-io/rct v0.2.0/go.mod h1:n6MTBU36QOadGlxxiADu86VaT5l0eYdbmFBHF14AD/s= github.com/evcc-io/tesla-proxy-client v0.0.0-20260324063928-151fe10796ae h1:0ihqBMBjkekSlwD3ZYQIiYite6ZAln9ze60evOE6FkQ= @@ -224,8 +224,8 @@ github.com/gokrazy/internal v0.0.0-20251208203110-3c1aa9087c82 h1:4ghNfD9NaZLpFr github.com/gokrazy/internal v0.0.0-20251208203110-3c1aa9087c82/go.mod h1:dQY4EMkD4L5ZjYJ0SPtpgYbV7MIUMCxNIXiOfnZ6jP4= github.com/gokrazy/tools v0.0.0-20260109180632-8ed49b4fafc7 h1:eC9rmwSgeXHqX2ViTu3n6ajvtIioPadZMqKsDpRm2Dk= github.com/gokrazy/tools v0.0.0-20260109180632-8ed49b4fafc7/go.mod h1:UE7KvEl3CClC9Z4Yb/r2kkxAymDi9wL5yNjqdPztGp4= -github.com/gokrazy/updater v0.0.0-20250705135802-db129c40879c h1:j4/v9FR/cOy6nog5rmXUtauBsOU3mm+rTPn5IENUbmg= -github.com/gokrazy/updater v0.0.0-20250705135802-db129c40879c/go.mod h1:EtAn+BPibqnAHnYGj3FW5e284xNsiOOMOL2dJiwu7H4= +github.com/gokrazy/updater v0.0.0-20260620140544-0a84d8ab3878 h1:SKQxwmgnsUTVKEU21Sed30LzCEehl+32cIOmKvIYA5k= +github.com/gokrazy/updater v0.0.0-20260620140544-0a84d8ab3878/go.mod h1:EtAn+BPibqnAHnYGj3FW5e284xNsiOOMOL2dJiwu7H4= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -435,8 +435,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY= -github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= +github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= +github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -465,8 +465,8 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= -github.com/sandrolain/httpcache v1.4.0 h1:Jf4Vx62X2ybvNPSpPvI1kT3xvMdDG1AsApQjOQKO9E0= -github.com/sandrolain/httpcache v1.4.0/go.mod h1:kHBuXveitSn39SNPBhdf/ybG272X706HJ2RJqOQ+Em0= +github.com/sandrolain/httpcache v1.4.1 h1:frupP27mgmpvdIyIGqz890LCpMcU5WzUPBFiF+4asB8= +github.com/sandrolain/httpcache v1.4.1/go.mod h1:kHBuXveitSn39SNPBhdf/ybG272X706HJ2RJqOQ+Em0= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sasha-s/go-deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU= @@ -580,8 +580,8 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -go.yaml.in/yaml/v4 v4.0.0-rc.5.0.20260613141257-7afcb0919cb5 h1:FN2nHSMZESIAuZHWbZPsqsduWNnLLMaJ3cVNojwa5pg= -go.yaml.in/yaml/v4 v4.0.0-rc.5.0.20260613141257-7afcb0919cb5/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= +go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -592,8 +592,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/crypto/x509roots/fallback v0.0.0-20260609182332-5f2de1a9f1e2 h1:nQAdbnDzK0eYA4IKMp9xUaZq2UOkAYU5kBWQOSqwjP8= -golang.org/x/crypto/x509roots/fallback v0.0.0-20260609182332-5f2de1a9f1e2/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260618162219-0b316e7ee409 h1:tCCOYc6P3pGx8nU+VbcOGw5ed3jKRWNt2Vrk7MY4ugw= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260618162219-0b316e7ee409/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -726,20 +726,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= -modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= -modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= -modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= -modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= -modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -748,8 +748,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= -modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= From 3980d5c442012d5abe9bf06268b2a370aa574bcf Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 23 Jun 2026 11:20:39 +0200 Subject: [PATCH 040/733] Revert "GoodWe: remove non-functional wallbox phase switching (#30790)" This reverts commit 3aac67bdd0f635c116c660c900ae6508e50e73e4. --- charger/goodwe.go | 40 ++++++++++++++----- .../definition/charger/goodwe-wallbox.yaml | 2 +- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/charger/goodwe.go b/charger/goodwe.go index 06898d86d..2aa1b6ac3 100644 --- a/charger/goodwe.go +++ b/charger/goodwe.go @@ -46,17 +46,18 @@ type GoodWe struct { } const ( - goodweRegVoltages = 10009 // U16 ×0.1 V, 3 regs (L1/L2/L3) - goodweRegCurrents = 10012 // U16 ×0.1 A, 3 regs (L1/L2/L3) - goodweRegActualPower = 10015 // U16 ×0.1 kW - goodweRegStatus = 10017 // U16 enum (see status mapping) - goodweRegMaxPower = 10029 // U16 ×0.1 kW, raw range [14,220] - goodweRegChargeMode = 10032 // U16 (0=fast, 1=PV, 2=PV+battery) - goodweRegPowerSpec = 10058 // U16 (0=7kW, 1=11kW, 2=22kW) - goodweRegPhaseSpec = 10059 // U16 (0=3p, 1=1p) - goodweRegRfid = 10500 // 7 regs ASCII (14-byte card UID) - goodweRegChargeCommand = 10060 // U16 (1=stop, 2=start) - goodweRegTotalEnergy = 10065 // U32 ×0.1 kWh, 2 regs + goodweRegVoltages = 10009 // U16 ×0.1 V, 3 regs (L1/L2/L3) + goodweRegCurrents = 10012 // U16 ×0.1 A, 3 regs (L1/L2/L3) + goodweRegActualPower = 10015 // U16 ×0.1 kW + goodweRegStatus = 10017 // U16 enum (see status mapping) + goodweRegPhaseSwEnabled = 10023 // U16 (0=Off, 1=On) + goodweRegMaxPower = 10029 // U16 ×0.1 kW, raw range [14,220] + goodweRegChargeMode = 10032 // U16 (0=fast, 1=PV, 2=PV+battery) + goodweRegPowerSpec = 10058 // U16 (0=7kW, 1=11kW, 2=22kW) + goodweRegPhaseSpec = 10059 // U16 (0=3p, 1=1p) + goodweRegRfid = 10500 // 7 regs ASCII (14-byte card UID) + goodweRegChargeCommand = 10060 // U16 (1=stop, 2=start) + goodweRegTotalEnergy = 10065 // U32 ×0.1 kWh, 2 regs goodweChargeStop = 1 goodweChargeStart = 2 @@ -134,6 +135,18 @@ func NewGoodWe(ctx context.Context, uri string, slaveID uint8) (api.Charger, err log.WARN.Printf("read hw phase count failed, defaulting to 3-phase: %v", err) } + // only 3-phase hardware can do 1p/3p switching; conditionally register phase switching + // based on hardware capability. On read error, fall back to fixed-phase operation. + if wb.phases == 3 { + if b, err := wb.conn.ReadHoldingRegisters(goodweRegPhaseSwEnabled, 1); err == nil { + if binary.BigEndian.Uint16(b) == 1 { + implement.Has(wb, implement.PhaseSwitcher(wb.phases1p3p)) + } + } else { + log.WARN.Printf("read phase switch config failed, disabling dynamic phase switching: %v", err) + } + } + // force "fast" charging mode so evcc fully controls power setpoint if _, err := wb.conn.WriteSingleRegister(goodweRegChargeMode, goodweChargeModeFast); err != nil { return nil, fmt.Errorf("set charge mode: %w", err) @@ -255,6 +268,11 @@ func (wb *GoodWe) Voltages() (float64, float64, float64, error) { return wb.phaseValues(goodweRegVoltages, 10) } +func (wb *GoodWe) phases1p3p(phases int) error { + wb.phases = phases + return nil +} + var _ api.Identifier = (*GoodWe)(nil) // Identify implements api.Identifier (RFID UID, 14-byte NUL-padded ASCII). diff --git a/templates/definition/charger/goodwe-wallbox.yaml b/templates/definition/charger/goodwe-wallbox.yaml index dbd28a1d3..e9ca504b5 100644 --- a/templates/definition/charger/goodwe-wallbox.yaml +++ b/templates/definition/charger/goodwe-wallbox.yaml @@ -3,7 +3,7 @@ products: - brand: GoodWe description: generic: HCA Wallbox (Gen2) -capabilities: ["mA", "rfid", "meter"] +capabilities: ["mA", "rfid", "1p3p", "meter"] requirements: evcc: ["sponsorship"] description: From bc55f913f9f3b03640608c8cecf33fa0fa3003c3 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 23 Jun 2026 11:38:58 +0200 Subject: [PATCH 041/733] chore: decode soc with json.Number (#31147) --- vehicle/octopusde/api.go | 51 +++++++++++----------------------------- 1 file changed, 14 insertions(+), 37 deletions(-) diff --git a/vehicle/octopusde/api.go b/vehicle/octopusde/api.go index bf7f29caf..0c4a43e01 100644 --- a/vehicle/octopusde/api.go +++ b/vehicle/octopusde/api.go @@ -3,7 +3,6 @@ package octopusde import ( "context" "encoding/json" - "strconv" "time" "github.com/evcc-io/evcc/api" @@ -11,28 +10,6 @@ import ( "github.com/evcc-io/evcc/util" ) -// jsonFloat is a float64 that unmarshals from either a JSON number or a JSON-encoded string. -// The Octopus Kraken API serialises some numeric fields as quoted strings (e.g. "41.00"). -type jsonFloat float64 - -func (f *jsonFloat) UnmarshalJSON(data []byte) error { - var v float64 - if err := json.Unmarshal(data, &v); err == nil { - *f = jsonFloat(v) - return nil - } - var s string - if err := json.Unmarshal(data, &s); err != nil { - return err - } - v, err := strconv.ParseFloat(s, 64) - if err != nil { - return err - } - *f = jsonFloat(v) - return nil -} - // API is the Octopus Energy Germany Kraken client for vehicle data. It reuses the // authenticated Kraken GraphQL client from the tariff implementation so the JWT // token source and auth transport are not duplicated. @@ -95,14 +72,13 @@ func (v *API) Accounts() ([]string, error) { } // socStatus holds the live state-of-charge values reported for a SmartFlex device. -// Vehicles and charge points expose the same shape via distinct interface types. -// Pointers distinguish an absent value from a reported zero. +// json.Number decodes both bare and quoted numbers (Kraken serialises soc as "41.00"). type socStatus struct { StateOfCharge struct { - Value *jsonFloat + Value *json.Number } StateOfChargeLimit struct { - UpperSocLimit *jsonFloat + UpperSocLimit *json.Number } } @@ -128,22 +104,23 @@ func (d Device) soc() socStatus { return d.Status.ChargePoint } -// Soc returns the battery state of charge in percent, if reported. -func (d Device) Soc() (float64, bool) { - soc := d.soc().StateOfCharge.Value - if soc == nil { +// parseNumber returns the float value of a reported json.Number, if present and valid. +func parseNumber(n *json.Number) (float64, bool) { + if n == nil { return 0, false } - return float64(*soc), true + f, err := n.Float64() + return f, err == nil +} + +// Soc returns the battery state of charge in percent, if reported. +func (d Device) Soc() (float64, bool) { + return parseNumber(d.soc().StateOfCharge.Value) } // TargetSoc returns the configured charge limit in percent, if any. func (d Device) TargetSoc() (float64, bool) { - limit := d.soc().StateOfChargeLimit.UpperSocLimit - if limit == nil { - return 0, false - } - return float64(*limit), true + return parseNumber(d.soc().StateOfChargeLimit.UpperSocLimit) } // krakenDevices lists the SmartFlex devices of an account. From 0d9189d64c10092a58f188f90fa41c452d2bc38e Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 23 Jun 2026 11:50:36 +0200 Subject: [PATCH 042/733] EEBus: add controlbox test (#28908) --- server/eebus/test/controlbox.go | 10 ++++++---- server/eebus/test/cs_test.go | 12 +++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/server/eebus/test/controlbox.go b/server/eebus/test/controlbox.go index e680caad1..6848f198b 100644 --- a/server/eebus/test/controlbox.go +++ b/server/eebus/test/controlbox.go @@ -117,10 +117,12 @@ func (h *controlbox) OnLPCEvent(ski string, device spineapi.DeviceRemoteInterfac switch event { case lpc.UseCaseSupportUpdate: h.registerRemoteEntity(entity, event) - // case lpc.DataUpdateLimit: - // if currentLimit, err := h.uclpc.ConsumptionLimit(entity); err == nil { - // fmt.Println("New consumption limit received", currentLimit.Value, "W") - // } + if currentLimit, err := h.uclpc.ConsumptionLimit(entity); err == nil { + fmt.Println("New consumption limit received", currentLimit.Value, "W") + } + + case lpc.DataUpdateLimit: + h.registerRemoteEntity(entity, event) default: fmt.Println("lpc:", event) } diff --git a/server/eebus/test/cs_test.go b/server/eebus/test/cs_test.go index 83ca3ecb0..a25adbdf2 100644 --- a/server/eebus/test/cs_test.go +++ b/server/eebus/test/cs_test.go @@ -19,8 +19,6 @@ import ( const remotePort = 9001 func TestEEBus(t *testing.T) { - t.Skip() - util.LogLevel("error", map[string]string{"eebus": "trace"}) certificate, err := cert.CreateCertificate("Demo", "Demo", "DE", "Demo-Server-01") @@ -46,7 +44,7 @@ func TestEEBus(t *testing.T) { box, err := createControlbox(t.Context(), server.Ski(), remotePort) require.NoError(t, err, "controlbox") - eventC := make(chan api.EventType, 1) + eventC := make(chan api.EventType, 16) box.remoteEventC = eventC hems, err := hems.NewEEBus(t.Context(), box.ski, eebus.Limits{}, nil, nil, time.Second) @@ -54,9 +52,13 @@ func TestEEBus(t *testing.T) { go hems.Run() - <-eventC + // wait for DataUpdateLimit which signals that limit descriptions and data are available + require.Eventually(t, func() bool { + return len(box.remoteEntity(lpc.DataUpdateLimit)) > 0 + }, 30*time.Second, 100*time.Millisecond, "waiting for lpc.DataUpdateLimit") + t.Log(box.remoteEntities) - srvEntity := box.remoteEntity(lpc.UseCaseSupportUpdate)[0] + srvEntity := box.remoteEntity(lpc.DataUpdateLimit)[0] _, err = box.uclpc.WriteConsumptionLimit(srvEntity, ucapi.LoadLimit{ IsActive: true, From 1b08a3461e9b5c58bf905dd3b03256b90d295b4f Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 24 Jun 2026 10:58:53 +0200 Subject: [PATCH 043/733] OCPP Forwarder: reconnect upstream with backoff and replay BootNotification (#30765) --- charger/ocpp/forwarder.go | 202 +++++++++++++++++++++++---------- charger/ocpp/forwarder_test.go | 174 ++++++++++++++++++++++++++++ docs/agents/ocpp-forwarder.md | 4 + 3 files changed, 322 insertions(+), 58 deletions(-) create mode 100644 charger/ocpp/forwarder_test.go diff --git a/charger/ocpp/forwarder.go b/charger/ocpp/forwarder.go index 16820671c..89365391a 100644 --- a/charger/ocpp/forwarder.go +++ b/charger/ocpp/forwarder.go @@ -15,6 +15,7 @@ import ( "crypto/x509" "encoding/base64" "encoding/json" + "errors" "fmt" "net/http" "slices" @@ -23,6 +24,7 @@ import ( "sync" "time" + "github.com/cenkalti/backoff/v4" "github.com/coder/websocket" "github.com/evcc-io/evcc/util" "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" @@ -102,7 +104,7 @@ func ApplyForwarderRules(rules []ForwarderRule) { pendingMu.Lock() pendingMsgs[r.StationID] = nil pendingMu.Unlock() - go dialUpstreamSidecar(r.StationID, r) + go runUpstreamSidecar(r.StationID, r) } else { go validateUpstream(r.StationID, r) } @@ -111,19 +113,13 @@ func ApplyForwarderRules(rules []ForwarderRule) { notifyUpdated() } -// validateUpstream test-dials a rule's upstream and records/clears the error so -// the UI reflects unreachable hosts. -func validateUpstream(id string, rule ForwarderRule) { - upstreamBase := strings.TrimRight(rule.UpstreamURL, "/") - upstreamPath := rule.upstreamPath(id) - +// dialUpstream opens a websocket connection to the rule's upstream server for the given charger. +func dialUpstream(id string, rule ForwarderRule) (*websocket.Conn, error) { tlsConfig := &tls.Config{InsecureSkipVerify: rule.Insecure} if rule.CaCert != "" { caCertPool := x509.NewCertPool() if ok := caCertPool.AppendCertsFromPEM([]byte(rule.CaCert)); !ok { - recordForwarderError(id, "invalid CA certificate") - notifyUpdated() - return + return nil, errors.New("invalid CA certificate") } tlsConfig.RootCAs = caCertPool } @@ -134,12 +130,20 @@ func validateUpstream(id string, rule ForwarderRule) { } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - conn, _, err := websocket.Dial(ctx, upstreamBase+upstreamPath, &websocket.DialOptions{ + defer cancel() + + conn, _, err := websocket.Dial(ctx, strings.TrimRight(rule.UpstreamURL, "/")+rule.upstreamPath(id), &websocket.DialOptions{ Subprotocols: []string{"ocpp1.6"}, HTTPHeader: header, HTTPClient: &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}}, }) - cancel() + return conn, err +} + +// validateUpstream test-dials a rule's upstream and records/clears the error so +// the UI reflects unreachable hosts. +func validateUpstream(id string, rule ForwarderRule) { + conn, err := dialUpstream(id, rule) // a charger may have connected meanwhile; its sidecar is authoritative sidecarsMu.Lock() @@ -190,10 +194,9 @@ func init() { // sidecar holds an upstream connection for a single charger. type sidecar struct { - chargerID string - upstreamURL string - rule ForwarderRule // rule used to dial; detects param changes - conn *websocket.Conn + chargerID string + rule ForwarderRule // rule used to dial; detects param changes + conn *websocket.Conn // message IDs of upstream-initiated Calls; the charger's reply is routed back to upstream pendingUpstreamCallsMu sync.Mutex @@ -223,8 +226,58 @@ var ( // on connect so BootNotification reaches upstream pendingMu sync.Mutex pendingMsgs = make(map[string][][]byte) + + // last BootNotification per connected charger, replayed to upstream when a + // sidecar (re)connects mid-session so upstream sees a boot before transactions + bootMu sync.Mutex + lastBoot = make(map[string][]byte) ) +// reconnect backoff bounds; variables for testing +var ( + reconnectInitialDelay = 5 * time.Second + reconnectMaxDelay = 5 * time.Minute +) + +// runUpstreamSidecar dials a charger's upstream sidecar and re-dials with +// exponential backoff for as long as the charger stays connected and the rule +// remains in effect. +func runUpstreamSidecar(id string, rule ForwarderRule) { + bo := backoff.NewExponentialBackOff( + backoff.WithInitialInterval(reconnectInitialDelay), + backoff.WithMaxInterval(reconnectMaxDelay), + backoff.WithMaxElapsedTime(0), + ) + + for { + if !dialUpstreamSidecar(id, rule) { + delay := bo.NextBackOff() + forwarderLog.DEBUG.Printf("forwarder: reconnecting upstream for %s in %v", id, delay) + time.Sleep(delay) + continue + } + + bo.Reset() + + if current, ok := resolveRule(id); !ok || current.UpstreamURL == "" || !current.sameConnection(rule) { + return + } + + sidecarsMu.Lock() + connected := connectedChargers[id] + _, active := sidecars[id] + sidecarsMu.Unlock() + + if !connected || active { + return + } + + pendingMu.Lock() + pendingMsgs[id] = nil + pendingMu.Unlock() + } +} + // resolveRule returns the forwarding rule for chargerID, or the "*" fallback. func resolveRule(chargerID string) (ForwarderRule, bool) { forwarderMu.RLock() @@ -261,50 +314,22 @@ func onChargerConnect(ch ws.Channel) { pendingMsgs[id] = nil pendingMu.Unlock() - go dialUpstreamSidecar(id, rule) + go runUpstreamSidecar(id, rule) } -func dialUpstreamSidecar(id string, rule ForwarderRule) { - upstreamBase := strings.TrimRight(rule.UpstreamURL, "/") - upstreamPath := rule.upstreamPath(id) - - var header http.Header - if rule.Username != "" || rule.Password != "" { - header = authHeader(rule.Username, rule.Password) - } - - tlsConfig := &tls.Config{InsecureSkipVerify: rule.Insecure} - if rule.CaCert != "" { - caCertPool := x509.NewCertPool() - if ok := caCertPool.AppendCertsFromPEM([]byte(rule.CaCert)); !ok { - forwarderLog.WARN.Printf("forwarder: failed to parse CA cert for %s; forwarding disabled", id) - recordForwarderError(id, "invalid CA certificate") - notifyUpdated() - drainPendingWithErrors(id, nil) - return - } - tlsConfig.RootCAs = caCertPool - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - conn, _, err := websocket.Dial(ctx, upstreamBase+upstreamPath, &websocket.DialOptions{ - Subprotocols: []string{"ocpp1.6"}, - HTTPHeader: header, - HTTPClient: &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}}, - }) - cancel() +func dialUpstreamSidecar(id string, rule ForwarderRule) bool { + conn, err := dialUpstream(id, rule) if err != nil { - forwarderLog.WARN.Printf("forwarder: dial upstream for %s: %v; forwarding disabled", id, err) + forwarderLog.WARN.Printf("forwarder: dial upstream for %s: %v", id, err) recordForwarderError(id, err.Error()) notifyUpdated() drainPendingWithErrors(id, nil) - return + return false } conn.SetReadLimit(-1) // no limit; OCPP frames can be large sc := &sidecar{ chargerID: id, - upstreamURL: upstreamBase, rule: rule, conn: conn, pendingUpstreamCalls: make(map[string]struct{}), @@ -314,12 +339,35 @@ func dialUpstreamSidecar(id string, rule ForwarderRule) { // install sidecar and drain the pending buffer sidecarsMu.Lock() pendingMu.Lock() + old := sidecars[id] sidecars[id] = sc buffered := pendingMsgs[id] delete(pendingMsgs, id) pendingMu.Unlock() sidecarsMu.Unlock() + // a concurrent dial may have installed a sidecar meanwhile; this one wins + if old != nil { + old.conn.CloseNow() + } + + // replay the charger's BootNotification (with a fresh message id) when the + // buffer doesn't already carry one, i.e. the sidecar (re)connects mid-session; + // upstream's reply is discarded as evcc has long answered the charger + if !slices.ContainsFunc(buffered, func(frame []byte) bool { + msgType, _, action, err := parseOCPPFrame(frame) + return err == nil && msgType == ocppj.CALL && action == "BootNotification" + }) { + bootMu.Lock() + boot := lastBoot[id] + bootMu.Unlock() + if boot != nil { + if frame, err := withMessageID(boot, fmt.Sprintf("evcc-boot-%d", time.Now().UnixNano())); err == nil { + buffered = append([][]byte{frame}, buffered...) + } + } + } + // flush buffered Calls; register relay actions so upstream's reply routes back. // CallResults/Errors are skipped: evcc already answered them. flushed := 0 @@ -333,12 +381,20 @@ func dialUpstreamSidecar(id string, rule ForwarderRule) { sc.pendingChargerCalls[msgID] = struct{}{} sc.pendingChargerCallsMu.Unlock() } - if err := conn.Write(context.Background(), websocket.MessageText, frame); err != nil { - forwarderLog.ERROR.Printf("forwarder: write buffered frame to upstream for %s: %v", id, err) + writeCtx, writeCancel := context.WithTimeout(context.Background(), 10*time.Second) + writeErr := conn.Write(writeCtx, websocket.MessageText, frame) + writeCancel() + if writeErr != nil { + forwarderLog.ERROR.Printf("forwarder: write buffered frame to upstream for %s: %v", id, writeErr) conn.CloseNow() - recordForwarderError(id, err.Error()) + sidecarsMu.Lock() + if sidecars[id] == sc { + delete(sidecars, id) + } + sidecarsMu.Unlock() + recordForwarderError(id, writeErr.Error()) notifyUpdated() - return + return false } flushed++ } @@ -349,9 +405,11 @@ func dialUpstreamSidecar(id string, rule ForwarderRule) { clearForwarderError(id) notifyUpdated() - forwarderLog.INFO.Printf("forwarder: %s → %s", id, upstreamBase+upstreamPath) + forwarderLog.INFO.Printf("forwarder: %s → %s", id, strings.TrimRight(rule.UpstreamURL, "/")+rule.upstreamPath(id)) - sc.readFromUpstream(rule.ReadOnly) + sc.readFromUpstream() + + return true } // drainPendingWithErrors discards charger id's pending buffer, sending a CallError @@ -403,10 +461,13 @@ func drainPendingWithErrors(id string, sc *sidecar) { func onChargerDisconnect(ch ws.Channel) { id := ch.ID() - // discard pending buffer + // discard pending buffer and cached boot frame pendingMu.Lock() delete(pendingMsgs, id) pendingMu.Unlock() + bootMu.Lock() + delete(lastBoot, id) + bootMu.Unlock() sidecarsMu.Lock() delete(connectedChargers, id) @@ -439,6 +500,13 @@ func onChargerMessage(ch ws.Channel, data []byte) bool { return false } + // remember the charger's boot frame for replay on sidecar (re)connect + if msgType == ocppj.CALL && action == "BootNotification" { + bootMu.Lock() + lastBoot[id] = slices.Clone(data) + bootMu.Unlock() + } + sidecarsMu.Lock() sc := sidecars[id] sidecarsMu.Unlock() @@ -506,7 +574,7 @@ func onChargerMessage(ch ws.Channel, data []byte) bool { // readFromUpstream relays frames from upstream: Calls are injected into the // charger (reply routed back), responses to bypassed charger Calls are relayed // to the charger, others discarded. Calls are rejected in read-only mode. -func (sc *sidecar) readFromUpstream(readOnly bool) { +func (sc *sidecar) readFromUpstream() { defer func() { sidecarsMu.Lock() if sidecars[sc.chargerID] == sc { @@ -541,7 +609,8 @@ func (sc *sidecar) readFromUpstream(readOnly bool) { switch msgType { case ocppj.CALL: - if readOnly { + // resolve the current rule so a runtime ReadOnly toggle applies live + if rule, ok := resolveRule(sc.chargerID); ok && rule.ReadOnly { forwarderLog.DEBUG.Printf("forwarder: blocking upstream call %s in read-only session %s", msgID, sc.chargerID) errFrame, _ := (&ocppj.CallError{ MessageTypeId: ocppj.CALL_ERROR, @@ -724,6 +793,23 @@ func extractMeterValueSampleInterval(msg []byte) (time.Duration, bool) { return time.Duration(secs) * time.Second, true } +// withMessageID returns a copy of a raw OCPP frame with its message id replaced. +func withMessageID(frame []byte, msgID string) ([]byte, error) { + var parts []json.RawMessage + if err := json.Unmarshal(frame, &parts); err != nil { + return nil, err + } + if len(parts) < 2 { + return nil, fmt.Errorf("invalid OCPP frame") + } + id, err := json.Marshal(msgID) + if err != nil { + return nil, err + } + parts[1] = id + return json.Marshal(parts) +} + // parseOCPPFrame extracts the message type, id and (for Calls) action from a raw frame. func parseOCPPFrame(msg []byte) (msgType ocppj.MessageType, msgID string, action string, err error) { var frame []json.RawMessage diff --git a/charger/ocpp/forwarder_test.go b/charger/ocpp/forwarder_test.go new file mode 100644 index 000000000..7eded2504 --- /dev/null +++ b/charger/ocpp/forwarder_test.go @@ -0,0 +1,174 @@ +package ocpp + +import ( + "crypto/tls" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/lorenzodonini/ocpp-go/ocppj" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testBootFrame = `[2,"orig-id","BootNotification",{"chargePointVendor":"v","chargePointModel":"m"}]` + +func TestForwarderWithMessageID(t *testing.T) { + frame, err := withMessageID([]byte(testBootFrame), "new-id") + require.NoError(t, err) + + msgType, msgID, action, err := parseOCPPFrame(frame) + require.NoError(t, err) + assert.Equal(t, ocppj.CALL, msgType) + assert.Equal(t, "new-id", msgID) + assert.Equal(t, "BootNotification", action) + assert.Contains(t, string(frame), `"chargePointVendor":"v"`) + + _, err = withMessageID([]byte(`{}`), "new-id") + assert.Error(t, err) +} + +// fakeChannel implements ws.Channel for hook tests +type fakeChannel string + +func (c fakeChannel) ID() string { return string(c) } +func (c fakeChannel) RemoteAddr() net.Addr { return nil } +func (c fakeChannel) TLSConnectionState() *tls.ConnectionState { return nil } +func (c fakeChannel) IsConnected() bool { return true } + +func TestForwarderBootNotificationCache(t *testing.T) { + const id = "fwd-boot-cache-test" + + onChargerMessage(fakeChannel(id), []byte(testBootFrame)) + + bootMu.Lock() + boot := lastBoot[id] + bootMu.Unlock() + assert.Equal(t, testBootFrame, string(boot)) + + onChargerDisconnect(fakeChannel(id)) + + bootMu.Lock() + boot = lastBoot[id] + bootMu.Unlock() + assert.Nil(t, boot) +} + +// forwarderTestSetup marks a charger as connected with a cached boot frame and +// fast reconnect, returning a cleanup function. +func forwarderTestSetup(t *testing.T, id string) func() { + t.Helper() + + delay := reconnectInitialDelay + reconnectInitialDelay = 10 * time.Millisecond + + sidecarsMu.Lock() + connectedChargers[id] = true + sidecarsMu.Unlock() + + bootMu.Lock() + lastBoot[id] = []byte(testBootFrame) + bootMu.Unlock() + + return func() { + ApplyForwarderRules(nil) + sidecarsMu.Lock() + delete(connectedChargers, id) + sidecarsMu.Unlock() + bootMu.Lock() + delete(lastBoot, id) + bootMu.Unlock() + reconnectInitialDelay = delay + } +} + +// expectBootReplay asserts that a replayed BootNotification with a fresh message id arrives. +func expectBootReplay(t *testing.T, frames <-chan []byte) { + t.Helper() + + select { + case data := <-frames: + msgType, msgID, action, err := parseOCPPFrame(data) + require.NoError(t, err) + assert.Equal(t, ocppj.CALL, msgType) + assert.Equal(t, "BootNotification", action) + assert.NotEqual(t, "orig-id", msgID) + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for upstream reconnect") + } +} + +func TestForwarderUpstreamReconnect(t *testing.T) { + const id = "fwd-reconnect-test" + + var conns atomic.Int32 + frames := make(chan []byte, 1) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{"ocpp1.6"}}) + if err != nil { + return + } + if conns.Add(1) == 1 { + // simulate upstream dropping the connection without close handshake + c.CloseNow() + return + } + if _, data, err := c.Read(r.Context()); err == nil { + select { + case frames <- data: + default: + } + } + c.CloseNow() + })) + defer srv.Close() + + cleanup := forwarderTestSetup(t, id) + defer cleanup() + + ApplyForwarderRules([]ForwarderRule{{StationID: id, UpstreamURL: "ws" + strings.TrimPrefix(srv.URL, "http")}}) + + expectBootReplay(t, frames) + assert.GreaterOrEqual(t, conns.Load(), int32(2)) +} + +func TestForwarderDialRetry(t *testing.T) { + const id = "fwd-dial-retry-test" + + var reqs atomic.Int32 + frames := make(chan []byte, 1) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if reqs.Add(1) <= 2 { + // reject the websocket upgrade to simulate an unreachable upstream + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + } + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{"ocpp1.6"}}) + if err != nil { + return + } + if _, data, err := c.Read(r.Context()); err == nil { + select { + case frames <- data: + default: + } + } + c.CloseNow() + })) + defer srv.Close() + + cleanup := forwarderTestSetup(t, id) + defer cleanup() + + ApplyForwarderRules([]ForwarderRule{{StationID: id, UpstreamURL: "ws" + strings.TrimPrefix(srv.URL, "http")}}) + + expectBootReplay(t, frames) + assert.GreaterOrEqual(t, reqs.Load(), int32(3)) +} diff --git a/docs/agents/ocpp-forwarder.md b/docs/agents/ocpp-forwarder.md index 173bf356a..e141589ed 100644 --- a/docs/agents/ocpp-forwarder.md +++ b/docs/agents/ocpp-forwarder.md @@ -41,4 +41,8 @@ When a rule sets `ReadOnly`, upstream may observe but cannot control the charger Frames that arrive from a charger before its sidecar finishes dialling are buffered (`pendingMsgs`) and flushed in order once the sidecar connects, so early messages such as `BootNotification` still reach upstream. If the dial fails or upstream drops mid-session, any buffered or in-flight relay Calls are answered to the charger with a `CallError` so it is not left hanging, and the failure is surfaced to the UI via `forwarderErrors`. +When the upstream connection fails while the charger stays connected, the sidecar is re-dialled automatically with exponential backoff (`runUpstreamSidecar`, 5s up to 5min; a successful session resets the backoff). Between attempts the relay actions fall back to evcc's local handler, so charging continues but upstream misses those transactions. The reconnect loop ends when the charger disconnects, the rule is removed, or its connection parameters change (`ApplyForwarderRules` dials its own sidecar in that case). + +The last `BootNotification` of each connected charger is cached (`lastBoot`). When a sidecar connects mid-session (after an upstream reconnect or a rule added at runtime) and the pending buffer does not already carry a boot frame, the cached frame is replayed to upstream with a fresh message id, since many backends expect a boot before accepting transactions. Upstream's reply to the replay is discarded; evcc answered the charger's original boot long ago. + Rules can be changed at runtime through `ApplyForwarderRules`. Sidecars for removed rules are closed, rules with changed connection parameters are re-dialled, and rules for chargers that are not connected are test-dialled to surface unreachable hosts immediately. From b715251e28b0bc75752c5619feb6e11b2cfdfa89 Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 24 Jun 2026 11:01:13 +0200 Subject: [PATCH 044/733] DriveSomethingGreater: gracefully handle empty vehicles --- vehicle/vw/eudataact/api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vehicle/vw/eudataact/api.go b/vehicle/vw/eudataact/api.go index c363d6f0c..1c34841b1 100644 --- a/vehicle/vw/eudataact/api.go +++ b/vehicle/vw/eudataact/api.go @@ -268,7 +268,7 @@ func (v *API) Vehicles() ([]Vehicle, error) { // the response is either a bare array or wrapped in {"vehicles": [...]} var arr []Vehicle - if err := json.Unmarshal(b, &arr); err == nil && len(arr) > 0 { + if err := json.Unmarshal(b, &arr); err == nil && len(b) > 0 { return arr, nil } From a193236395a3c627c6d49e00f9aab242a3ceeb7e Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 24 Jun 2026 11:12:01 +0200 Subject: [PATCH 045/733] chore: minor --- templates/definition/charger/luxtronik.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/definition/charger/luxtronik.yaml b/templates/definition/charger/luxtronik.yaml index 7a41f4d1b..a57dd7bc3 100644 --- a/templates/definition/charger/luxtronik.yaml +++ b/templates/definition/charger/luxtronik.yaml @@ -33,8 +33,8 @@ group: heating capabilities: ["dim"] requirements: description: - de: Für Wärmepumpen mit Luxtronik 2.1 Steuerung. Nutzt modbus-tcp. Braucht mindestens Software v3.90.3. Aktivierung über SERVICE, Systemsteuerung, Konnektivität, Smart-Home-Interface. - en: For heatpumps with Luxtronik 2.1 controller. Uses modbus-tcp. Requires software v3.90.3 or later. Enable via SERVICE, Systemsteuerung, Konnektivität, Smart-Home-Interface. + de: Für Wärmepumpen mit Luxtronik 2.1 Steuerung. Nutzt Modbus TCP. Braucht mindestens Software v3.90.3. Aktivierung über SERVICE, Systemsteuerung, Konnektivität, Smart-Home-Interface. + en: For heatpumps with Luxtronik 2.1 controller. Uses Modbus TCP. Requires software v3.90.3 or later. Enable via SERVICE, Systemsteuerung, Konnektivität, Smart-Home-Interface. # evcc: ["sponsorship"] params: - name: modbus From 4c907c5afafcdad84c5140d43b7b95559d5e8c34 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 24 Jun 2026 15:45:15 +0200 Subject: [PATCH 046/733] Merge commit from fork * Config: require admin password for script plugins * refactor --- .../components/Auth/AdminPasswordPrompt.vue | 27 ++ .../components/Config/DeviceModal/Actions.vue | 2 + .../Config/DeviceModal/DeviceModalBase.vue | 57 ++- .../js/components/Config/DeviceModal/index.ts | 39 +- assets/js/components/Config/YamlEditor.vue | 1 - .../components/Config/YamlEditorContainer.vue | 10 - assets/js/components/Config/utils/test.ts | 5 + docs/agents/api-security.md | 10 + i18n/de.json | 5 + i18n/en.json | 5 + server/http.go | 8 +- server/http_auth.go | 16 + server/http_auth_test.go | 74 ++++ server/http_config_device_handler.go | 371 +++++++++--------- server/http_config_helper.go | 32 ++ server/http_config_helper_test.go | 32 ++ tests/config-script-plugin.spec.ts | 107 +++++ 17 files changed, 594 insertions(+), 207 deletions(-) create mode 100644 assets/js/components/Auth/AdminPasswordPrompt.vue create mode 100644 server/http_auth_test.go create mode 100644 tests/config-script-plugin.spec.ts diff --git a/assets/js/components/Auth/AdminPasswordPrompt.vue b/assets/js/components/Auth/AdminPasswordPrompt.vue new file mode 100644 index 000000000..b2a689af2 --- /dev/null +++ b/assets/js/components/Auth/AdminPasswordPrompt.vue @@ -0,0 +1,27 @@ + + + diff --git a/assets/js/components/Config/DeviceModal/Actions.vue b/assets/js/components/Config/DeviceModal/Actions.vue index faddd5183..a5fe69235 100644 --- a/assets/js/components/Config/DeviceModal/Actions.vue +++ b/assets/js/components/Config/DeviceModal/Actions.vue @@ -1,5 +1,7 @@ - +
+ + +
diff --git a/assets/js/components/Config/HemsModal.vue b/assets/js/components/Config/HemsModal.vue index d215cafc5..7d748e70a 100644 --- a/assets/js/components/Config/HemsModal.vue +++ b/assets/js/components/Config/HemsModal.vue @@ -1,28 +1,55 @@ + + + + - - - - - + @@ -551,6 +556,7 @@ import type { ConfigVehicle, ConfigCircuit, ConfigMessenger, + ConfigHems, ConfigLoadpoint, ConfigMeter, Timeout, @@ -562,7 +568,7 @@ import type { Notification, Remote, } from "@/types/evcc"; -import { CURRENCY } from "@/types/evcc"; +import { ConfigType, CURRENCY } from "@/types/evcc"; import { circuitTree, type CircuitNode } from "@/utils/circuits"; type DeviceValuesMap = Record>; @@ -653,6 +659,7 @@ export default defineComponent({ loadpoints: [] as ConfigLoadpoint[], chargers: [] as ConfigCharger[], circuits: [] as ConfigCircuit[], + hemsDevices: [] as ConfigHems[], tariffs: [] as any[], // ConfigTariff[] - tariff device entities tariffRefs: { grid: "", @@ -814,11 +821,10 @@ export default defineComponent({ return store.state?.hems; }, hemsTags(): DeviceTags { - const type = this.hems?.config?.type; - const status = store.state?.hems?.status; - if (!type && !status) { + if (this.hemsDevices.length === 0 && !this.hems?.config?.configured) { return { configured: { value: false } }; } + const status = store.state?.hems?.status; if (!status) { return { configured: { value: true } }; } @@ -842,6 +848,13 @@ export default defineComponent({ return result; }, + hemsLabel(): string { + const dev = this.hemsDevices[0]; + if (!dev) return ""; + if (dev.deviceProduct) return dev.deviceProduct; + if (dev.type === ConfigType.Custom) return this.$t("config.hems.customOption"); + return ""; + }, remote(): Remote | undefined { return store.state?.remote; }, @@ -1000,6 +1013,7 @@ export default defineComponent({ await this.loadMessengers(); await this.loadTariffs(); await this.loadTariffRefs(); + await this.loadHems(); await this.loadDirty(); this.updateValues(); }, @@ -1026,6 +1040,9 @@ export default defineComponent({ async loadMeters() { this.meters = (await this.loadConfig("devices/meter")) || []; }, + async loadHems() { + this.hemsDevices = (await this.loadConfig("devices/hems")) || []; + }, async loadCircuits() { this.circuits = (await this.loadConfig("devices/circuit")) || []; }, @@ -1123,6 +1140,10 @@ export default defineComponent({ await this.loadDirty(); this.updateValues(); }, + async hemsChanged() { + await this.loadHems(); + await this.loadDirty(); + }, async loadpointChanged() { await this.loadLoadpoints(); this.loadDirty(); diff --git a/cmd/config_delete.go b/cmd/config_delete.go index 3226b40b8..6d5db61d2 100644 --- a/cmd/config_delete.go +++ b/cmd/config_delete.go @@ -5,6 +5,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" + "github.com/evcc-io/evcc/hems/hems" "github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/templates" "github.com/spf13/cobra" @@ -56,6 +57,8 @@ func runConfigDelete(cmd *cobra.Command, args []string) { deleteDevice[api.Tariff](c) case templates.Circuit: deleteDevice[api.Circuit](c) + case templates.Hems: + deleteDevice[hems.API](c) case templates.Loadpoint: deleteDevice[loadpoint.API](c) } diff --git a/cmd/root.go b/cmd/root.go index 1afa88364..9595f946c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -16,6 +16,7 @@ import ( "github.com/evcc-io/evcc/charger/ocpp" "github.com/evcc-io/evcc/core" "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/hems/hems" "github.com/evcc-io/evcc/messenger" "github.com/evcc-io/evcc/server" "github.com/evcc-io/evcc/server/db" @@ -353,12 +354,18 @@ func runRoot(cmd *cobra.Command, args []string) { } // start HEMS server + var hemsInstance hems.API if err == nil { - if hems, errConf := configureHEMS(&conf.HEMS, site); errConf == nil && hems != nil { + hemsInstance, err = configureHEMS(&conf.HEMS, site) + if err != nil { + err = wrapErrorWithClass(ClassHEMS, err) + } else if hemsInstance != nil { // republish when HEMS state updates - hems.SetUpdated(func() { + hemsInstance.SetUpdated(func() { valueChan <- util.Param{Key: keys.Hems, Val: globalconfig.ConfigStatus{ - Config: conf.HEMS.Redacted(), + Config: struct { + Configured bool `json:"configured"` + }{hemsInstance != nil}, YamlSource: yamlSource.hems, Status: struct { Dimmed *bool `json:"dimmed,omitempty"` @@ -366,15 +373,13 @@ func runRoot(cmd *cobra.Command, args []string) { MaxConsumptionPower float64 `json:"maxConsumptionPower,omitempty"` MaxProductionPower *float64 `json:"maxProductionPower,omitempty"` }{ - Dimmed: hems.Dimmed(), - Curtailed: hems.Curtailed(), - MaxConsumptionPower: hems.MaxConsumptionPower(), - MaxProductionPower: hems.MaxProductionPower(), + Dimmed: hemsInstance.Dimmed(), + Curtailed: hemsInstance.Curtailed(), + MaxConsumptionPower: hemsInstance.MaxConsumptionPower(), + MaxProductionPower: hemsInstance.MaxProductionPower(), }, }} }) - } else { - err = wrapErrorWithClass(ClassHEMS, errConf) } } @@ -429,7 +434,9 @@ func runRoot(cmd *cobra.Command, args []string) { }} valueChan <- util.Param{Key: keys.Hems, Val: globalconfig.ConfigStatus{ - Config: conf.HEMS.Redacted(), + Config: struct { + Configured bool `json:"configured"` + }{hemsInstance != nil}, YamlSource: yamlSource.hems, }} valueChan <- util.Param{Key: keys.Tariffs, Val: globalconfig.ConfigStatus{ diff --git a/cmd/setup.go b/cmd/setup.go index 852610751..19e23db71 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -779,38 +779,85 @@ func configureGo(conf []globalconfig.Go) error { // setup HEMS func configureHEMS(conf *globalconfig.Hems, site *core.Site) (hemsapi.API, error) { - // use yaml if configured - if conf.Type == "" { - if settings.Exists(keys.Hems) { - *conf = globalconfig.Hems{} - if err := settings.Yaml(keys.Hems, new(map[string]any), &conf); err != nil { - return nil, err - } - yamlSource.hems = globalconfig.YamlSourceDb - } - } else { + if conf.Type != "" { yamlSource.hems = globalconfig.YamlSourceFile + + typ, other, err := config.CustomDevice(conf.Type, conf.Other) + if err != nil { + return nil, fmt.Errorf("cannot decode custom hems '%s': %w", conf.Type, err) + } + + instance, err := hems.NewFromConfig(context.TODO(), typ, other, site) + if err != nil { + return nil, fmt.Errorf("failed configuring hems: %w", err) + } + + site.SetHEMS(instance) + + go instance.Run() + + return instance, nil } - if conf.Type == "" { - return nil, nil + // migrate legacy keys.Hems setting (pre-UI) to a device-managed custom entry + if err := migrateLegacyHemsSetting(); err != nil { + return nil, fmt.Errorf("migrating legacy hems setting: %w", err) } - typ, other, err := config.CustomDevice(conf.Type, conf.Other) + return configureDbHems(site) +} + +// migrateLegacyHemsSetting moves a keys.Hems YAML setting to a device-managed custom hems entry. +func migrateLegacyHemsSetting() error { + raw, err := settings.String(keys.Hems) + if err != nil || strings.TrimSpace(raw) == "" { + return nil + } + + _, err = config.AddConfig(templates.Hems, + map[string]any{"yaml": raw}, + config.WithProperties(config.Properties{Type: "custom"}), + ) if err != nil { - return nil, fmt.Errorf("cannot decode custom hems '%s': %w", conf.Type, err) + return err } - hems, err := hems.NewFromConfig(context.TODO(), typ, other, site) + log.INFO.Println("migrated legacy hems yaml setting to device config") + return settings.Delete(keys.Hems) +} + +func configureDbHems(site *core.Site) (hemsapi.API, error) { + dev, err := config.ConfigurationByClass(templates.Hems) + if err != nil || dev == nil { + return nil, err + } + + cc := dev.Named() + + var instance hemsapi.API + typ, other, err := config.CustomDevice(cc.Type, cc.Other) if err != nil { - return nil, fmt.Errorf("failed configuring hems: %w", err) + err = &DeviceError{cc.Name, fmt.Errorf("cannot decode custom %s '%s': %w", typ, cc.Name, err)} + } else { + instance, err = hems.NewFromConfig(context.TODO(), typ, other, site) + if err != nil { + err = &DeviceError{cc.Name, fmt.Errorf("cannot create %s '%s': %w", typ, cc.Name, err)} + } } - site.SetHEMS(hems) + // register device even on factory error so UI can edit/delete the broken config + if addErr := config.Hems().Add(config.NewConfigurableDevice(dev, instance)); addErr != nil && err == nil { + err = &DeviceError{cc.Name, addErr} + } - go hems.Run() + if err != nil { + return nil, err + } - return hems, nil + site.SetHEMS(instance) + + go instance.Run() + return instance, nil } // networkSettings reads/migrates network settings diff --git a/cmd/setup_hems_test.go b/cmd/setup_hems_test.go new file mode 100644 index 000000000..1c6a49db8 --- /dev/null +++ b/cmd/setup_hems_test.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "testing" + + "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/evcc-io/evcc/util/templates" + "github.com/stretchr/testify/require" +) + +func TestMigrateLegacyHemsSetting(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, db.Instance.AutoMigrate(&config.Config{})) + + const legacyYaml = `type: relay +maxpower: 4200 +limit: + source: mqtt + topic: hems/limit/status +` + settings.SetString(keys.Hems, legacyYaml) + + require.NoError(t, migrateLegacyHemsSetting()) + + require.False(t, settings.Exists(keys.Hems)) + + devices, err := config.ConfigurationsByClass(templates.Hems) + require.NoError(t, err) + require.Len(t, devices, 1) + require.Equal(t, "custom", devices[0].Type) + require.Equal(t, legacyYaml, devices[0].Data["yaml"]) + + // second run is a noop + require.NoError(t, migrateLegacyHemsSetting()) + devices, err = config.ConfigurationsByClass(templates.Hems) + require.NoError(t, err) + require.Len(t, devices, 1) +} + +func TestMigrateLegacyHemsSetting_noop(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, db.Instance.AutoMigrate(&config.Config{})) + + require.NoError(t, migrateLegacyHemsSetting()) + + devices, err := config.ConfigurationsByClass(templates.Hems) + require.NoError(t, err) + require.Empty(t, devices) +} diff --git a/hems/config.go b/hems/config.go index 89119487c..03c62d64b 100644 --- a/hems/config.go +++ b/hems/config.go @@ -6,24 +6,26 @@ import ( "strings" "github.com/evcc-io/evcc/core/site" - "github.com/evcc-io/evcc/hems/eebus" - "github.com/evcc-io/evcc/hems/fnn" + "github.com/evcc-io/evcc/hems/config" + _ "github.com/evcc-io/evcc/hems/eebus" + _ "github.com/evcc-io/evcc/hems/fnn" "github.com/evcc-io/evcc/hems/hems" - "github.com/evcc-io/evcc/hems/relay" + _ "github.com/evcc-io/evcc/hems/relay" ) -// NewFromConfig creates new HEMS from config +var registry = config.Registry + +// Types returns the list of types +func Types() []string { + return registry.Types() +} + +// NewFromConfig creates hems from configuration func NewFromConfig(ctx context.Context, typ string, other map[string]any, site site.API) (hems.API, error) { switch strings.ToLower(typ) { case "sma", "shm", "semp": return nil, errors.New("breaking change: Sunny Home Manager integration is always on. See https://github.com/evcc-io/evcc/releases and https://docs.evcc.io/en/docs/integrations/sma-sunny-home-manager") - case "eebus": - return eebus.NewFromConfig(ctx, other, site) - case "fnn", "fnn-3": - return fnn.NewFromConfig(ctx, other, site) - case "relay": - return relay.NewFromConfig(ctx, other, site) default: - return nil, errors.New("unknown hems: " + typ) + return config.NewFromConfig(ctx, typ, other, site) } } diff --git a/hems/config/config.go b/hems/config/config.go new file mode 100644 index 000000000..c848578a5 --- /dev/null +++ b/hems/config/config.go @@ -0,0 +1,35 @@ +package config + +import ( + "context" + "fmt" + "strings" + + "github.com/evcc-io/evcc/core/site" + "github.com/evcc-io/evcc/hems/hems" + reg "github.com/evcc-io/evcc/hems/registry" +) + +var Registry = reg.New[hems.API]("hems") + +// AddCtx registers a factory returning a concrete type that implements hems.API +func AddCtx[T hems.API](name string, factory func(context.Context, map[string]any, site.API) (T, error)) { + Registry.AddCtx(name, func(ctx context.Context, other map[string]any, site site.API) (hems.API, error) { + return factory(ctx, other, site) + }) +} + +// NewFromConfig creates hems from configuration +func NewFromConfig(ctx context.Context, typ string, other map[string]any, site site.API) (hems.API, error) { + factory, err := Registry.Get(strings.ToLower(typ)) + if err != nil { + return nil, err + } + + v, err := factory(ctx, other, site) + if err != nil { + return nil, fmt.Errorf("cannot create hems type '%s': %w", typ, err) + } + + return v, nil +} diff --git a/hems/eebus/eebus.go b/hems/eebus/eebus.go index 5a1c43551..79c10fd46 100644 --- a/hems/eebus/eebus.go +++ b/hems/eebus/eebus.go @@ -8,12 +8,17 @@ import ( ucapi "github.com/enbility/eebus-go/usecases/api" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/site" + "github.com/evcc-io/evcc/hems/config" "github.com/evcc-io/evcc/hems/smartgrid" "github.com/evcc-io/evcc/plugin" "github.com/evcc-io/evcc/server/eebus" "github.com/evcc-io/evcc/util" ) +func init() { + config.AddCtx("eebus", NewFromConfig) +} + type EEBus struct { mux sync.RWMutex log *util.Logger @@ -63,7 +68,10 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*E Interval time.Duration }{ Limits: Limits{ - ContractualConsumptionNominalMax: 24800, + // contractual max power at the grid connection point reported to the control box + // (EEBus LPC, EMS device type). Default: standard 3x35A x 230V house connection. + // This is the connection capacity, not the SteuVE Pmin (see failsafe limit below). + ContractualConsumptionNominalMax: 24150, // 3 * 35A * 230V FailsafeConsumptionActivePowerLimit: 4200, ProductionNominalMax: 0, diff --git a/hems/fnn/fnn.go b/hems/fnn/fnn.go index c58b88c48..32bd16780 100644 --- a/hems/fnn/fnn.go +++ b/hems/fnn/fnn.go @@ -9,11 +9,17 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/site" + "github.com/evcc-io/evcc/hems/config" "github.com/evcc-io/evcc/hems/smartgrid" "github.com/evcc-io/evcc/plugin" "github.com/evcc-io/evcc/util" ) +func init() { + config.AddCtx("fnn", NewFromConfig) + config.AddCtx("fnn-3", NewFromConfig) +} + // NewFromConfig creates an FNN HEMS from generic config. func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*Fnn, error) { cc := struct { diff --git a/hems/registry/registry.go b/hems/registry/registry.go new file mode 100644 index 000000000..613b874f5 --- /dev/null +++ b/hems/registry/registry.go @@ -0,0 +1,52 @@ +package registry + +import ( + "context" + "fmt" + "maps" + "slices" + + "github.com/evcc-io/evcc/core/site" +) + +type ( + // TODO check is this can be abstracted into a generic type parameter + factoryFunc[T any] func(context.Context, map[string]any, site.API) (T, error) + + registry[T any] struct { + typ string + data map[string]factoryFunc[T] + } +) + +func (r registry[T]) Add(name string, factory func(map[string]any, site.API) (T, error)) { + r.AddCtx(name, func(_ context.Context, cc map[string]any, site site.API) (T, error) { + return factory(cc, site) + }) +} + +func (r registry[T]) AddCtx(name string, factory factoryFunc[T]) { + if _, exists := r.data[name]; exists { + panic(fmt.Sprintf("cannot register duplicate %s type: %s", r.typ, name)) + } + r.data[name] = factory +} + +func (r registry[T]) Get(name string) (factoryFunc[T], error) { + factory, exists := r.data[name] + if !exists { + return nil, fmt.Errorf("invalid %s type: %s", r.typ, name) + } + return factory, nil +} + +func (r registry[T]) Types() []string { + return slices.Sorted(maps.Keys(r.data)) +} + +func New[T any](typ string) registry[T] { + return registry[T]{ + typ: typ, + data: make(map[string]factoryFunc[T]), + } +} diff --git a/hems/relay/relay.go b/hems/relay/relay.go index 9db2dfc93..4fe63bab8 100644 --- a/hems/relay/relay.go +++ b/hems/relay/relay.go @@ -9,11 +9,16 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/site" + "github.com/evcc-io/evcc/hems/config" "github.com/evcc-io/evcc/hems/smartgrid" "github.com/evcc-io/evcc/plugin" "github.com/evcc-io/evcc/util" ) +func init() { + config.AddCtx("relay", NewFromConfig) +} + type Relay struct { mu sync.Mutex log *util.Logger diff --git a/hems/template.go b/hems/template.go new file mode 100644 index 000000000..472393d4c --- /dev/null +++ b/hems/template.go @@ -0,0 +1,22 @@ +package hems + +import ( + "context" + + "github.com/evcc-io/evcc/core/site" + "github.com/evcc-io/evcc/hems/hems" + "github.com/evcc-io/evcc/util/templates" +) + +func init() { + registry.AddCtx("template", NewHemsFromTemplateConfig) +} + +func NewHemsFromTemplateConfig(ctx context.Context, other map[string]any, site site.API) (hems.API, error) { + instance, err := templates.RenderInstance(templates.Hems, other) + if err != nil { + return nil, err + } + + return NewFromConfig(ctx, instance.Type, instance.Other, site) +} diff --git a/i18n/de.json b/i18n/de.json index 2bab9c2d9..51728b635 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -308,6 +308,7 @@ "titleEdit": "Netzzähler bearbeiten" }, "hems": { + "changeConfirm": "Bestehende Konfiguration wird gelöscht. Fortfahren?", "csv": { "created": "Startzeit", "finished": "Endzeit", @@ -315,10 +316,13 @@ "limitpower": "Limit (kW)", "type": "Typ" }, - "description": "Leistungsbegrenzung durch externe Systeme (z.B. §14a EnWG, §9 EEG Schnittstelle oder übergeordnetes Energiemanagementsystem). Spielt mit dem Lastmanagement Feature zusammen.", + "customOption": "Benutzerdefinierte Integration", + "description": "Leistungsbegrenzung durch externe Systeme (z.B. §14a EnWG, §9 EEG Schnittstelle oder übergeordnetes Energiemanagementsystem). Spielt mit dem Lastmanagement Feature zusammen. Das Abregeln der Solarproduktion wird ebenfalls für ausgewählte Geräte unterstützt. Der konkrete Abregelungswert muss in der jeweiligen Wechselrichter-Konfiguration eingestellt werden.", "downloadCsv": "CSV herunterladen", "eventsRecorded": "{count} Netzbegrenzungen gespeichert.", - "lastEvent": "Zuletzt {timeAgo}.", + "integrations": "Integrationen", + "lastEvent": "Letzter Eintrag {timeAgo}.", + "template": "Integration", "title": "Externe Begrenzung" }, "icon": { diff --git a/i18n/en.json b/i18n/en.json index 58f6fe75b..10d72cb69 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -307,6 +307,7 @@ "titleEdit": "Edit Grid Meter" }, "hems": { + "changeConfirm": "Existing configuration will be deleted. Continue?", "csv": { "created": "Created", "finished": "Finished", @@ -314,10 +315,14 @@ "limitpower": "Limit (kW)", "type": "Type" }, - "description": "Power limitation by external systems (e.g. §14a EnWG, §9 EEG interface or higher-level energy management system). Works together with the load management feature.", + "customOption": "User-defined integration", + "description": "Power limitation by external systems (e.g. §14a EnWG, §9 EEG interface or higher-level energy management system). Works together with the load management feature. Curtailing solar production is also supported for selected devices. The actual curtailment limit must be set on each inverter configuration.", "downloadCsv": "Download CSV", "eventsRecorded": "Recorded {count} grid limitation events.", + "generic": "Generic integrations", + "integrations": "Integrations", "lastEvent": "Most recent {timeAgo}.", + "template": "Integration", "title": "External Limit" }, "icon": { diff --git a/meter/eebus.go b/meter/eebus.go index c9069b12a..4c9afe145 100644 --- a/meter/eebus.go +++ b/meter/eebus.go @@ -77,8 +77,7 @@ func init() { // NewEEBusFromConfig creates an EEBus meter from generic config func NewEEBusFromConfig(ctx context.Context, other map[string]any) (api.Meter, error) { var cc struct { - Ski string - Ip string + Ski, Ip string Usage *templates.Usage Timeout_ time.Duration `mapstructure:"timeout"` // TODO deprecated } diff --git a/server/http.go b/server/http.go index fbb6faae4..420dee139 100644 --- a/server/http.go +++ b/server/http.go @@ -298,11 +298,11 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti "devicestatus": {"GET", "/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler}, "dirty": {"GET", "/dirty", getHandler(ConfigDirty)}, "evccyaml": {"GET", "/evcc.yaml", configYamlHandler(configFile)}, - "newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler(auth)}, - "updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler(auth)}, + "newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler(site, auth)}, + "updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler(site, auth)}, "deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler(site)}, - "testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler(auth)}, - "testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler(auth)}, + "testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler(site, auth)}, + "testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler(site, auth)}, "interval": {"POST", "/interval/{value:[0-9.]+}", settingsSetDurationHandler(keys.Interval, pub)}, "updatesponsortoken": {"POST", "/sponsortoken", updateSponsortokenHandler(pub)}, "deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler(pub)}, diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go index b92e85ef1..f370245e0 100644 --- a/server/http_config_device_handler.go +++ b/server/http_config_device_handler.go @@ -12,11 +12,14 @@ import ( "time" "dario.cat/mergo" + "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api/globalconfig" "github.com/evcc-io/evcc/charger" "github.com/evcc-io/evcc/core/circuit" "github.com/evcc-io/evcc/core/keys" "github.com/evcc-io/evcc/core/site" + "github.com/evcc-io/evcc/hems" + hemsapi "github.com/evcc-io/evcc/hems/hems" "github.com/evcc-io/evcc/messenger" "github.com/evcc-io/evcc/meter" "github.com/evcc-io/evcc/server/db/settings" @@ -78,6 +81,9 @@ func devicesConfigHandler(w http.ResponseWriter, r *http.Request) { case templates.Circuit: res, err = devicesConfig(class, config.Circuits(), hidePrivate) + case templates.Hems: + res, err = devicesConfig(class, config.Hems(), hidePrivate) + case templates.Tariff: res, err = devicesConfig(class, config.Tariffs(), hidePrivate) @@ -204,6 +210,9 @@ func deviceConfigHandler(w http.ResponseWriter, r *http.Request) { case templates.Circuit: res, err = deviceConfig(class, id, config.Circuits(), hidePrivate) + case templates.Hems: + res, err = deviceConfig(class, id, config.Hems(), hidePrivate) + case templates.Tariff: res, err = deviceConfig(class, id, config.Tariffs(), hidePrivate) @@ -266,6 +275,9 @@ func deviceStatusHandler(w http.ResponseWriter, r *http.Request) { case templates.Circuit: instance, err = deviceStatus(name, config.Circuits()) + case templates.Hems: + err = api.ErrNotAvailable + case templates.Tariff: instance, err = deviceStatus(name, config.Tariffs()) @@ -308,8 +320,24 @@ func newDevice[T any](ctx context.Context, class templates.Class, req configReq, return &conf, h.Add(config.NewConfigurableDevice(&conf, instance)) } +// newHemsFactory adapts hems.NewFromConfig to the generic newFromConfFunc signature. +func newHemsFactory(site site.API) newFromConfFunc[hemsapi.API] { + return func(ctx context.Context, typ string, other map[string]any) (hemsapi.API, error) { + return hems.NewFromConfig(ctx, typ, other, site) + } +} + +// HemsStatus returns the publish payload for the configured flag. +func HemsStatus(configured bool) globalconfig.ConfigStatus { + return globalconfig.ConfigStatus{ + Config: struct { + Configured bool `json:"configured"` + }{configured}, + } +} + // newDeviceHandler creates a new device by class -func newDeviceHandler(authObject auth.Auth) http.HandlerFunc { +func newDeviceHandler(site site.API, authObject auth.Auth) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) @@ -347,6 +375,13 @@ func newDeviceHandler(authObject auth.Auth) http.HandlerFunc { case templates.Circuit: conf, err = newDevice(ctx, class, req, circuit.NewFromConfig, config.Circuits(), force) + case templates.Hems: + if existing, _ := config.ConfigurationByClass(templates.Hems); existing != nil { + err = errors.New("hems already configured") + } else { + conf, err = newDevice(ctx, class, req, newHemsFactory(site), config.Hems(), force) + } + case templates.Tariff: conf, err = newDevice(ctx, class, req, tariff.NewFromConfig, config.Tariffs(), force) @@ -365,6 +400,10 @@ func newDeviceHandler(authObject auth.Auth) http.HandlerFunc { setConfigDirty() + if class == templates.Hems { + site.Publish(keys.Hems, HemsStatus(true)) + } + res := struct { ID int `json:"id"` Name string `json:"name"` @@ -395,7 +434,7 @@ func updateDevice[T any](ctx context.Context, id int, class templates.Class, req } // updateDeviceHandler updates database device's configuration by class -func updateDeviceHandler(authObject auth.Auth) http.HandlerFunc { +func updateDeviceHandler(site site.API, authObject auth.Auth) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) @@ -438,6 +477,9 @@ func updateDeviceHandler(authObject auth.Auth) http.HandlerFunc { case templates.Circuit: err = updateDevice(ctx, id, class, req, circuit.NewFromConfig, config.Circuits(), force) + case templates.Hems: + err = updateDevice(ctx, id, class, req, newHemsFactory(site), config.Hems(), force) + case templates.Tariff: err = updateDevice(ctx, id, class, req, tariff.NewFromConfig, config.Tariffs(), force) @@ -612,6 +654,9 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ } } + case templates.Hems: + err = deleteDevice(id, config.Hems()) + case templates.Tariff: err = deleteDevice(id, config.Tariffs()) @@ -631,6 +676,10 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ return } + if class == templates.Hems { + site.Publish(keys.Hems, HemsStatus(false)) + } + res := struct { ID int `json:"id"` }{ @@ -657,7 +706,7 @@ func testConfig[T any](ctx context.Context, id int, class templates.Class, req c } // testConfigHandler tests a configuration by class -func testConfigHandler(authObject auth.Auth) http.HandlerFunc { +func testConfigHandler(site site.API, authObject auth.Auth) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) @@ -703,6 +752,9 @@ func testConfigHandler(authObject auth.Auth) http.HandlerFunc { case templates.Circuit: instance, err = testConfig(ctx, id, class, req, circuit.NewFromConfig, config.Circuits()) + case templates.Hems: + instance, err = testConfig(ctx, id, class, req, newHemsFactory(site), config.Hems()) + case templates.Tariff: instance, err = testConfig(ctx, id, class, req, tariff.NewFromConfig, config.Tariffs()) diff --git a/templates/definition/embed.go b/templates/definition/embed.go index 4a147796b..3202a14b6 100644 --- a/templates/definition/embed.go +++ b/templates/definition/embed.go @@ -2,5 +2,5 @@ package definition import "embed" -//go:embed charger/*.yaml meter/*.yaml vehicle/*.yaml tariff/*.yaml messenger/*.yaml circuit/*.yaml +//go:embed charger/*.yaml meter/*.yaml vehicle/*.yaml tariff/*.yaml messenger/*.yaml circuit/*.yaml hems/*.yaml var YamlTemplates embed.FS diff --git a/templates/definition/hems/fnn-eebus.yaml b/templates/definition/hems/fnn-eebus.yaml new file mode 100644 index 000000000..bb029c351 --- /dev/null +++ b/templates/definition/hems/fnn-eebus.yaml @@ -0,0 +1,82 @@ +template: fnn-eebus +products: + - description: + generic: FNN-Steuerbox via EEBus LPC&LPP (§ 14a EnWG, § 9 EEG) +params: + - preset: eebus + - name: ski + help: + en: SKI of the FNN control box (shown in its web interface) + de: SKI der FNN-Steuerbox (im Webinterface der Steuerbox einsehbar) + - name: consumptionnominalmax + type: float + unit: W + advanced: true + description: + en: Max. consumption power (grid connection) + de: Max. Bezugsleistung (Netzanschluss) + help: + en: Contractual maximum power of the grid connection point reported to the control box. This is the connection capacity agreed with the grid operator, NOT the minimum consumption power (Pmin) of the control box. Default 24.15 kVA (3x35A). + de: Vertragliche Gesamtmaximalleistung des Netzanschlusspunkts, die der Steuerbox gemeldet wird. Dies ist die mit dem Netzbetreiber vereinbarte Anschlusskapazität, NICHT die SteuVE-Mindestleistung (Pmin). Standard 24,15 kVA (3x35A). + - name: failsafeconsumptionpower + type: float + unit: W + advanced: true + description: + en: Failsafe consumption limit (Pmin, LPC) + de: Failsafe-Bezugsgrenze der SteuVE (Pmin) + help: + en: Failsafe minimum consumption power (Pmin) for controllable loads applied when the heartbeat from the control box is lost; it is overwritten by the control box once it transmits its own failsafe values. (Default 4.2 kW) + de: Verbleibende Mindestbezugsleistung (Pmin) für steuerbare Verbrauchseinrichtungen, die bei ausbleibendem Heartbeat der Steuerbox angewendet wird. Dies ist die per GZF berechnete Mindestbezugsleistung nach BK6-22-300; wird von der Steuerbox überschrieben, sobald diese eigene Failsafe-Werte überträgt. (Standard 4,2 kW nach § 14a EnWG) + - name: productionnominalmax + type: float + unit: W + advanced: true + description: + en: Installed generator power (Wp) + de: Installierte Generatorleistung (Wp) + help: + en: Rated generator/module power (Wp) of the installation. + de: Gesamtnennleistung bzw. Modul- oder Generatorleistung der Anlage (Bezugsgröße nach § 9 EEG). + - name: failsafeproductionpower + type: float + unit: W + advanced: true + description: + en: Failsafe production limit (LPP) + de: Failsafe-Einspeisebegrenzung + help: + en: Remaining maximum feed-in power applied when the heartbeat from the control box is lost. Initial value; it is overwritten by the control box once it transmits its own failsafe values. + de: Verbleibende maximale Einspeiseleistung, die bei verlorenem Heartbeat der Steuerbox angewendet wird. Initialwert; wird von der Steuerbox überschrieben, sobald diese eigene Failsafe-Werte überträgt. + - name: failsafeduration + type: duration + advanced: true + description: + en: Failsafe minimum duration + de: Failsafe-Mindestdauer + help: + en: Minimum time the failsafe limit stays active once entered. Initial value (default 2h); the control box overwrites it once it transmits its own value. + de: Mindestdauer, für die der Failsafe-Wert nach Aktivierung gehalten wird. Initialwert (Standard 2h); wird von der Steuerbox überschrieben, sobald diese einen eigenen Wert überträgt. + - name: passthroughpin + type: int + advanced: true + description: + en: Passthrough GPIO pin (LPC) + de: Passthrough GPIO-Pin (SteuVE dimmen) + help: + en: Output pin that mirrors the active LPC signal to an external system + de: Ausgangspin, der das aktive Dimmsignal an ein externes System weiterleitet +render: | + type: eebus + ski: {{ .ski }} + contractualconsumptionnominalmax: {{ .consumptionnominalmax }} + failsafeconsumptionactivepowerlimit: {{ .failsafeconsumptionpower }} + productionnominalmax: {{ .productionnominalmax }} + failsafeproductionactivepowerlimit: {{ .failsafeproductionpower }} + failsafedurationminimum: {{ .failsafeduration }} + {{- if .passthroughpin }} + passthrough: + source: gpio + pin: {{ .passthroughpin }} + function: write + {{- end }} diff --git a/templates/definition/hems/fnn-gpio.yaml b/templates/definition/hems/fnn-gpio.yaml new file mode 100644 index 000000000..a92448de3 --- /dev/null +++ b/templates/definition/hems/fnn-gpio.yaml @@ -0,0 +1,83 @@ +template: fnn-gpio +products: + - description: + generic: FNN-Steuerbox via GPIO (§ 14a EnWG, § 9 EEG) +params: + - name: maxconsumptionpower + type: float + unit: W + required: true + description: + en: Guaranteed minimum power (Pmin) + de: Garantierte Mindestleistung (Pmin) + help: + en: Power limit applied to the root circuit while the limit is active. + de: Leistungsbegrenzung des Hauptstromkreises während die Limitierung aktiv ist. + - name: w4pin + type: int + required: true + description: + en: W4 GPIO pin + de: W4 GPIO-Pin + help: + en: Input pin for the W4 signal (Dimmer for controllable loads) + de: Eingangspin für das W4-Signal (Dimmen der steuerbaren Verbrauchseinrichtungen) + - name: maxproductionpower + type: float + unit: W + required: true + description: + en: Installed generator power (Wp) + de: Installierte Generatorleistung (Wp) + help: + en: Rated generator/module power (Wp) of the installation. + de: Gesamtnennleistung bzw. Modul- oder Generatorleistung der Anlage (Bezugsgröße nach § 9 EEG). + - name: w3pin + type: int + required: true + description: + en: W3 GPIO pin + de: W3 GPIO-Pin + help: + en: Input pin for the W3 signal (Zero export, 0%) + de: Eingangspin für das W3-Signal (Keine Netzeinspeisung, 0%) + - name: s1pin + type: int + description: + en: S1 GPIO pin + de: S1 GPIO-Pin + help: + en: Input pin for the S1 signal (60% export power limit) + de: Eingangspin für das S1-Signal (Reduzierung der Einspeiseleistung auf 60%) + - name: s2pin + type: int + description: + en: S2 GPIO pin + de: S2 GPIO-Pin + help: + en: Input pin for the S2 signal (30% export power limit) + de: Eingangspin für das S2-Signal (Reduzierung der Einspeiseleistung auf 30%) +render: | + type: fnn + maxdimpower: {{ .maxconsumptionpower }} + w4: + source: gpio + pin: {{ .w4pin }} + function: read + maxcurtailpower: {{ .maxproductionpower }} + w3: + source: gpio + pin: {{ .w3pin }} + function: read + {{- if .s1pin }} + s1: + source: gpio + pin: {{ .s1pin }} + function: read + {{- end }} + {{- if .s2pin }} + s2: + source: gpio + pin: {{ .s2pin }} + function: read + {{- end }} diff --git a/templates/definition/hems/hemspro-eebus.yaml b/templates/definition/hems/hemspro-eebus.yaml new file mode 100644 index 000000000..c56fc2775 --- /dev/null +++ b/templates/definition/hems/hemspro-eebus.yaml @@ -0,0 +1,72 @@ +template: hemspro-eebus +products: + - brand: Awatt Electronic + description: + generic: HEMS PRO (EEBus) +params: + - preset: eebus + - name: ski + help: + en: SKI of the FNN control box (shown in its web interface) + de: SKI der FNN-Steuerbox (im Webinterface der Steuerbox einsehbar) + - name: consumptionnominalmax + type: float + unit: W + advanced: true + description: + en: Max. consumption power (grid connection) + de: Max. Bezugsleistung (Netzanschluss) + help: + en: Contractual maximum power of the grid connection point reported to the control box. This is the connection capacity agreed with the grid operator, NOT the minimum consumption power (Pmin) of the control box. Default 24.15 kVA (3x35A). + de: Vertragliche Gesamtmaximalleistung des Netzanschlusspunkts, die der Steuerbox gemeldet wird. Dies ist die mit dem Netzbetreiber vereinbarte Anschlusskapazität, NICHT die SteuVE-Mindestleistung (Pmin). Standard 24,15 kVA (3x35A). + - name: failsafeconsumptionpower + type: float + unit: W + advanced: true + description: + en: Failsafe consumption limit (Pmin, LPC) + de: Failsafe-Bezugsgrenze der SteuVE (Pmin) + help: + en: Failsafe minimum consumption power (Pmin) for controllable loads applied when the heartbeat from the control box is lost; it is overwritten by the control box once it transmits its own failsafe values. (Default 4.2 kW) + de: Verbleibende Mindestbezugsleistung (Pmin) für steuerbare Verbrauchseinrichtungen, die bei ausbleibendem Heartbeat der Steuerbox angewendet wird. Dies ist die per GZF berechnete Mindestbezugsleistung nach BK6-22-300; wird von der Steuerbox überschrieben, sobald diese eigene Failsafe-Werte überträgt. (Standard 4,2 kW nach § 14a EnWG) + - name: productionnominalmax + type: float + unit: W + advanced: true + description: + en: Installed generator power (Wp) + de: Installierte Generatorleistung (Wp) + help: + en: Rated generator/module power (Wp) of the installation. + de: Gesamtnennleistung bzw. Modul- oder Generatorleistung der Anlage (Bezugsgröße nach § 9 EEG). + - name: failsafeproductionpower + type: float + unit: W + advanced: true + description: + en: Failsafe production limit (LPP) + de: Failsafe-Einspeisebegrenzung + help: + en: Remaining maximum feed-in power applied when the heartbeat from the control box is lost. Initial value; it is overwritten by the control box once it transmits its own failsafe values. + de: Verbleibende maximale Einspeiseleistung, die bei verlorenem Heartbeat der Steuerbox angewendet wird. Initialwert; wird von der Steuerbox überschrieben, sobald diese eigene Failsafe-Werte überträgt. + - name: failsafeduration + type: duration + advanced: true + description: + en: Failsafe minimum duration + de: Failsafe-Mindestdauer + help: + en: Minimum time the failsafe limit stays active once entered. Initial value (default 2h); the control box overwrites it once it transmits its own value. + de: Mindestdauer, für die der Failsafe-Wert nach Aktivierung gehalten wird. Initialwert (Standard 2h); wird von der Steuerbox überschrieben, sobald diese einen eigenen Wert überträgt. +render: | + type: eebus + ski: {{ .ski }} + contractualconsumptionnominalmax: {{ .consumptionnominalmax }} + failsafeconsumptionactivepowerlimit: {{ .failsafeconsumptionpower }} + productionnominalmax: {{ .productionnominalmax }} + failsafeproductionactivepowerlimit: {{ .failsafeproductionpower }} + failsafedurationminimum: {{ .failsafeduration }} + passthrough: + source: gpio + pin: 19 + function: write diff --git a/templates/definition/hems/hemspro-gpio.yaml b/templates/definition/hems/hemspro-gpio.yaml new file mode 100644 index 000000000..93897afda --- /dev/null +++ b/templates/definition/hems/hemspro-gpio.yaml @@ -0,0 +1,46 @@ +template: hemspro-gpio +products: + - brand: Awatt Electronic + description: + generic: HEMS PRO (FNN-Schaltkontakte) +params: + - name: maxconsumptionpower + type: float + unit: W + required: true + description: + en: Guaranteed minimum power (Pmin) + de: Garantierte Mindestleistung (Pmin) + help: + en: Power limit applied to the root circuit while the limit is active. + de: Leistungsbegrenzung des Hauptstromkreises während die Limitierung aktiv ist. + - name: maxproductionpower + type: float + unit: W + required: true + description: + en: Installed generator power (Wp) + de: Installierte Generatorleistung (Wp) + help: + en: Rated generator/module power (Wp) of the installation. + de: Gesamtnennleistung bzw. Modul- oder Generatorleistung der Anlage (Bezugsgröße nach § 9 EEG). +render: | + type: fnn + maxdimpower: {{ .maxconsumptionpower }} + w4: + source: gpio + pin: 13 + function: read + maxcurtailpower: {{ .maxproductionpower }} + w3: + source: gpio + pin: 5 + function: read + s1: + source: gpio + pin: 17 + function: read + s2: + source: gpio + pin: 27 + function: read diff --git a/tests/hems.spec.ts b/tests/hems.spec.ts index 30ee2444e..6434a8f90 100644 --- a/tests/hems.spec.ts +++ b/tests/hems.spec.ts @@ -23,7 +23,7 @@ test.afterEach(async () => { const CONFIG = "fast.evcc.yaml"; test.describe("HEMS", () => { - test("grid sessions", async ({ page }) => { + test("grid sessions in template modal", async ({ page }) => { await start(CONFIG, "hems.sql"); await page.goto("/#/config"); @@ -38,15 +38,16 @@ test.describe("HEMS", () => { const csvLink = hemsModal.getByRole("link", { name: "Download CSV" }); await expect(csvLink).toHaveAttribute("href", /\.\/api\/gridsessions\?format=csv&lang=en/); - await expect(hemsModal.getByTestId("yaml-editor")).toBeVisible(); - await expect(hemsModal.getByRole("button", { name: "Save" })).toBeVisible(); + // template selector is shown in create mode, yaml editor hidden until "User-defined" is chosen + await expect(hemsModal.getByLabel("Integration")).toBeVisible(); + await expect(hemsModal.getByTestId("yaml-editor")).not.toBeVisible(); await expect(hemsModal).not.toContainText("Configured via evcc.yaml"); - await hemsModal.getByRole("button", { name: "Cancel" }).click(); + await hemsModal.getByRole("button", { name: "Close" }).click(); await expectModalHidden(hemsModal); }); - test("modal yaml-configured", async ({ page }) => { + test("modal yaml-configured is locked", async ({ page }) => { await start("hems-yaml.evcc.yaml"); await page.goto("/#/config"); @@ -56,26 +57,28 @@ test.describe("HEMS", () => { await expect(hemsModal.getByTestId("grid-sessions")).not.toBeVisible(); await expect(hemsModal).toContainText("Configured via evcc.yaml"); + await expect(hemsModal.getByLabel("Integration")).not.toBeVisible(); await expect(hemsModal.getByTestId("yaml-editor")).not.toBeVisible(); await expect(hemsModal.getByRole("button", { name: "Save" })).not.toBeVisible(); - await expect(hemsModal.getByRole("button", { name: "Cancel" })).toBeVisible(); - await hemsModal.getByRole("button", { name: "Cancel" }).click(); + await hemsModal.getByRole("button", { name: "Close" }).click(); await expectModalHidden(hemsModal); }); - test("external control without circuits", async ({ page }) => { + test("user-defined relay drives external limit without circuits", async ({ page }) => { const GRID_CONFIG = "hems-grid.evcc.yaml"; await startSimulator(); await start(GRID_CONFIG); await page.goto("/#/config"); - // configure hems + // configure hems via user-defined provider await page.getByTestId("hems").getByRole("button", { name: "edit" }).click(); const hemsModal = page.getByTestId("hems-modal"); await expectModalVisible(hemsModal); + await hemsModal.getByLabel("Integration").selectOption({ label: "User-defined integration" }); const hemsEditor = hemsModal.getByTestId("yaml-editor"); + await expect(hemsEditor).toBeVisible(); await editorClear(hemsEditor); await editorPaste( hemsEditor, @@ -88,7 +91,7 @@ limit: uri: ${simulatorUrl()}/api/state jq: .hems.relay` ); - await hemsModal.getByRole("button", { name: "Save" }).click(); + await hemsModal.getByRole("button", { name: "Validate & save" }).click(); await expectModalHidden(hemsModal); await restart(GRID_CONFIG); @@ -117,6 +120,53 @@ limit: await stopSimulator(); }); + test("change button resets modal and unconfigures the card", async ({ page }) => { + await start("hems-grid.evcc.yaml"); + await page.goto("/#/config"); + + // configure hems via user-defined + await page.getByTestId("hems").getByRole("button", { name: "edit" }).click(); + const hemsModal = page.getByTestId("hems-modal"); + await expectModalVisible(hemsModal); + await hemsModal.getByLabel("Integration").selectOption({ label: "User-defined integration" }); + const hemsEditor = hemsModal.getByTestId("yaml-editor"); + await editorClear(hemsEditor); + await editorPaste( + hemsEditor, + page, + `type: relay +maxPower: 4200 +limit: + source: const + value: false` + ); + await hemsModal.getByRole("button", { name: "Validate & save" }).click(); + await expectModalHidden(hemsModal); + + // card shows user-defined label + await expect(page.getByTestId("hems")).toContainText("User-defined integration"); + + // reopen modal in edit mode + await page.getByTestId("hems").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(hemsModal); + await expect(hemsEditor).toBeVisible(); + await expect(hemsModal.getByLabel("Integration")).toBeDisabled(); + + // click pen (change) and confirm + page.once("dialog", (d) => d.accept()); + await hemsModal.getByRole("button", { name: "Change" }).click(); + + // provider selector reappears, yaml editor gone + await expect(hemsModal.getByLabel("Integration")).toBeEnabled(); + await expect(hemsEditor).not.toBeVisible(); + await expect(hemsModal.getByRole("button", { name: "Change" })).not.toBeVisible(); + + // close modal, card shows unconfigured + await hemsModal.getByRole("button", { name: "Close" }).click(); + await expectModalHidden(hemsModal); + await expect(page.getByTestId("hems")).toContainText(["Configured", "no"].join("")); + }); + test("fnn signals", async ({ page }) => { const GRID_CONFIG = "hems-grid.evcc.yaml"; await startSimulator(); @@ -138,7 +188,9 @@ limit: await page.getByTestId("hems").getByRole("button", { name: "edit" }).click(); const hemsModal = page.getByTestId("hems-modal"); await expectModalVisible(hemsModal); + await hemsModal.getByLabel("Integration").selectOption({ label: "User-defined integration" }); const hemsEditor = hemsModal.getByTestId("yaml-editor"); + await expect(hemsEditor).toBeVisible(); await editorClear(hemsEditor); const signalSource = (signal: string) => ` source: http @@ -156,7 +208,7 @@ s1:${signalSource("s1")} s2:${signalSource("s2")} w4:${signalSource("w4")}` ); - await hemsModal.getByRole("button", { name: "Save" }).click(); + await hemsModal.getByRole("button", { name: "Validate & save" }).click(); await expectModalHidden(hemsModal); await restart(GRID_CONFIG); @@ -236,7 +288,9 @@ w4:${signalSource("w4")}` await page.getByTestId("hems").getByRole("button", { name: "edit" }).click(); const hemsModal = page.getByTestId("hems-modal"); await expectModalVisible(hemsModal); + await hemsModal.getByLabel("Integration").selectOption({ label: "User-defined integration" }); const hemsEditor = hemsModal.getByTestId("yaml-editor"); + await expect(hemsEditor).toBeVisible(); await editorClear(hemsEditor); await editorPaste( hemsEditor, @@ -248,7 +302,7 @@ w3: source: const value: true` ); - await hemsModal.getByRole("button", { name: "Save" }).click(); + await hemsModal.getByRole("button", { name: "Validate & save" }).click(); await expectModalHidden(hemsModal); await restart(GRID_CONFIG); @@ -279,7 +333,7 @@ w3: }); }); - test("external control with circuits", async ({ page }) => { + test("user-defined relay drives external limit with circuits", async ({ page }) => { const GRID_CONFIG = "hems-grid.evcc.yaml"; await start(GRID_CONFIG); @@ -295,11 +349,13 @@ w3: await circuitsModal.getByRole("button", { name: "Save" }).click(); await expectModalHidden(circuitsModal); - // configure hems + // configure hems via user-defined provider await page.getByTestId("hems").getByRole("button", { name: "edit" }).click(); const hemsModal = page.getByTestId("hems-modal"); await expectModalVisible(hemsModal); + await hemsModal.getByLabel("Integration").selectOption({ label: "User-defined integration" }); const hemsEditor = hemsModal.getByTestId("yaml-editor"); + await expect(hemsEditor).toBeVisible(); await editorClear(hemsEditor); await editorPaste( hemsEditor, @@ -311,7 +367,7 @@ limit: source: const value: true` ); - await hemsModal.getByRole("button", { name: "Save" }).click(); + await hemsModal.getByRole("button", { name: "Validate & save" }).click(); await expectModalHidden(hemsModal); await restart(GRID_CONFIG); diff --git a/util/config/config.go b/util/config/config.go index 6a082163c..879c73eec 100644 --- a/util/config/config.go +++ b/util/config/config.go @@ -108,6 +108,16 @@ func ConfigurationsByClass(class templates.Class) ([]Config, error) { return res, tx.Error } +// ConfigurationByClass returns the single configuration for class, or nil if none +func ConfigurationByClass(class templates.Class) (*Config, error) { + configs, err := ConfigurationsByClass(class) + if err != nil || len(configs) == 0 { + return nil, err + } + + return &configs[0], nil +} + // ConfigByID returns device by id from the database func ConfigByID(id int) (Config, error) { var config Config diff --git a/util/config/instance.go b/util/config/instance.go index 1f2e46c7c..430a3b2d4 100644 --- a/util/config/instance.go +++ b/util/config/instance.go @@ -4,6 +4,7 @@ import ( evbus "github.com/asaskevich/EventBus" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" + "github.com/evcc-io/evcc/hems/hems" ) var bus = evbus.New() @@ -13,6 +14,7 @@ var instance struct { chargers *handler[api.Charger] vehicles *handler[api.Vehicle] circuits *handler[api.Circuit] + hems *handler[hems.API] messengers *handler[api.Messenger] loadpoints *handler[loadpoint.API] tariffs *handler[api.Tariff] @@ -27,6 +29,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.hems = &handler[hems.API]{topic: "hems"} instance.messengers = &handler[api.Messenger]{topic: "messenger"} instance.loadpoints = &handler[loadpoint.API]{topic: "loadpoint"} instance.tariffs = &handler[api.Tariff]{topic: "tariff"} @@ -60,6 +63,10 @@ func Circuits() Handler[api.Circuit] { return instance.circuits } +func Hems() Handler[hems.API] { + return instance.hems +} + func Loadpoints() Handler[loadpoint.API] { return instance.loadpoints } diff --git a/util/templates/class.go b/util/templates/class.go index 520e7e2e8..11dcfee9c 100644 --- a/util/templates/class.go +++ b/util/templates/class.go @@ -12,4 +12,5 @@ const ( Loadpoint Circuit Messenger + Hems ) diff --git a/util/templates/class_enumer.go b/util/templates/class_enumer.go index 3c26b2288..c957a61f8 100644 --- a/util/templates/class_enumer.go +++ b/util/templates/class_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _ClassName = "chargermetervehicletariffloadpointcircuitmessenger" +const _ClassName = "chargermetervehicletariffloadpointcircuitmessengerhems" -var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 34, 41, 50} +var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 34, 41, 50, 54} -const _ClassLowerName = "chargermetervehicletariffloadpointcircuitmessenger" +const _ClassLowerName = "chargermetervehicletariffloadpointcircuitmessengerhems" func (i Class) String() string { i -= 1 @@ -32,9 +32,10 @@ func _ClassNoOp() { _ = x[Loadpoint-(5)] _ = x[Circuit-(6)] _ = x[Messenger-(7)] + _ = x[Hems-(8)] } -var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Loadpoint, Circuit, Messenger} +var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Loadpoint, Circuit, Messenger, Hems} var _ClassNameToValueMap = map[string]Class{ _ClassName[0:7]: Charger, @@ -51,6 +52,8 @@ var _ClassNameToValueMap = map[string]Class{ _ClassLowerName[34:41]: Circuit, _ClassName[41:50]: Messenger, _ClassLowerName[41:50]: Messenger, + _ClassName[50:54]: Hems, + _ClassLowerName[50:54]: Hems, } var _ClassNames = []string{ @@ -61,6 +64,7 @@ var _ClassNames = []string{ _ClassName[25:34], _ClassName[34:41], _ClassName[41:50], + _ClassName[50:54], } // ClassString retrieves an enum value from the enum constants string name. diff --git a/util/templates/init.go b/util/templates/init.go index 9fbfa53bb..c3083a7a8 100644 --- a/util/templates/init.go +++ b/util/templates/init.go @@ -33,7 +33,7 @@ func init() { baseTmpl = template.Must(FuncMap(template.New("base")).ParseFS(includeFS, "includes/*.tpl")) - for _, class := range []Class{Charger, Meter, Vehicle, Tariff, Messenger, Circuit} { + for _, class := range []Class{Charger, Meter, Vehicle, Tariff, Messenger, Circuit, Hems} { load(class) } } From 3000b94e424a4238e1ec8905a1e451b3238d63ca Mon Sep 17 00:00:00 2001 From: Ingo <71161062+iseeberg79@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:08:28 +0200 Subject: [PATCH 071/733] Kostal Plenticore: replace older plenticore template (BC) (#30854) --- .../meter/kostal-plenticore-gen2.yaml | 21 +++-- .../definition/meter/kostal-plenticore.yaml | 91 ------------------- 2 files changed, 12 insertions(+), 100 deletions(-) delete mode 100644 templates/definition/meter/kostal-plenticore.yaml diff --git a/templates/definition/meter/kostal-plenticore-gen2.yaml b/templates/definition/meter/kostal-plenticore-gen2.yaml index e3b7b1bc1..860385d5c 100644 --- a/templates/definition/meter/kostal-plenticore-gen2.yaml +++ b/templates/definition/meter/kostal-plenticore-gen2.yaml @@ -2,20 +2,23 @@ template: kostal-plenticore-gen2 products: - brand: Kostal description: - de: Plenticore Hybrid, inkl. Netzladung der Hausbatterie - en: Plenticore Hybrid, incl. grid charging of the house battery + de: Plenticore Hybrid + en: Plenticore Hybrid capabilities: ["battery-control"] -covers: ["kostal-plenticore-hw0200"] +covers: ["kostal-plenticore-hw0200", "kostal-plenticore"] +caveats: + - description: + de: | + Nur ein System kann und darf auf den Wechselrichter zugreifen! + en: | + Only a single system may access the inverter! + link: https://github.com/evcc-io/evcc/wiki/Kostal-Plenticore_ requirements: description: de: | - Nur ein System kann und darf auf den Wechselrichter zugreifen! Für die aktive Batteriesteuerung muss die Funktion externe Batteriesteuerung über Modbus mit dem Handwerkerzugang aktiviert sein. **_Ist grundsätzlich anwendbar für verschiedene Wechselrichter Generationen (G1/G2/G3)._** - **Das Netzladen der Batterie steht mit dieser Vorlage zur Verfügung, ist aktuell jedoch inkompatibel mit wenigen älteren Wechselrichtern - _sorgfältig testen_!** - _siehe auch https://github.com/evcc-io/evcc/wiki/Kostal-Plenticore_ + Anwendbar für verschiedene Wechselrichter Generationen (G1/G2/G3). Für die aktive Batteriesteuerung muss die Funktion externe Batteriesteuerung über Modbus mit dem Handwerkerzugang aktiviert sein. en: | - Only a single system may access the inverter! For active battery control, the feature external battery control via modbus must be activated using installer access. **_Can basically be used for various inverter generations (G1/G2/G3)._** - **The function for grid charging the battery is available using this template, but is currently incompatible with some older inverters - _test carefully_!** - _see also https://github.com/evcc-io/evcc/wiki/Kostal-Plenticore_ + Compatible with various inverter generations (G1/G2/G3). For active battery control, the feature external battery control via Modbus must be activated using installer access. params: - name: usage choice: ["pv", "battery"] diff --git a/templates/definition/meter/kostal-plenticore.yaml b/templates/definition/meter/kostal-plenticore.yaml deleted file mode 100644 index 92da42597..000000000 --- a/templates/definition/meter/kostal-plenticore.yaml +++ /dev/null @@ -1,91 +0,0 @@ -template: kostal-plenticore -products: - - brand: Kostal - description: - generic: Plenticore Hybrid -capabilities: ["battery-control"] -requirements: - description: - de: | - Nur ein System kann und darf auf den Wechselrichter zugreifen! Für die aktive Batteriesteuerung muss die Funktion externe Batteriesteuerung über Modbus mit dem Handwerkerzugang aktiviert sein. - **Das Netzladen der Batterie steht nicht zur Verfügung!** _siehe auch https://github.com/evcc-io/evcc/wiki/Kostal-Plenticore_ - en: | - Only a single system may access the inverter! For active battery control the function external battery control via Modbus must be activated using installer access. - **Grid charging is not available!** _see also https://github.com/evcc-io/evcc/wiki/Kostal-Plenticore_ -params: - - name: usage - choice: ["pv", "battery"] - - name: modbus - choice: ["tcpip"] - id: 71 - port: 1502 - - name: endianness - description: - de: Byte-Reihenfolge (Little/Big) - en: Endianness (Little/Big) - type: choice - choice: ["little", "big"] - default: little - advanced: true - - name: maxacpower - service: modbus/read?address=531&type=holding&encoding=uint16&{modbus} - - preset: battery-params - - name: capacity - service: modbus/read?address=1068&type=holding&encoding=float32s&scale=0.001&{modbus} - - name: minsoc - service: modbus/read?address=1042&type=holding&encoding=float32s&{modbus} - - name: maxsoc - service: modbus/read?address=1044&type=holding&encoding=float32s&{modbus} - - name: maxchargepower - service: modbus/read?address=1038&type=holding&encoding=float32s&resulttype=int&{modbus} - - name: maxdischargepower - service: modbus/read?address=1040&type=holding&encoding=float32s&resulttype=int&{modbus} - - name: watchdog - default: 60s -render: | - {{- if eq .usage "pv" }} - type: custom - power: - source: calc - add: # The add plugin sums up all string values - - source: sunspec - {{- include "modbus" . | indent 4 }} - value: 160:1:DCW # string 1 - - source: sunspec - {{- include "modbus" . | indent 4 }} - value: 160:2:DCW # string 2 - - source: sunspec - {{- include "modbus" . | indent 4 }} - value: 160:3:DCW # string 3 - energy: - source: modbus - {{- include "modbus" . | indent 2 }} - register: - address: 1056 # total DC PV energy (sum), Wh - type: holding - encoding: {{ if (eq .endianness "big") }}float32{{ else }}float32s{{ end }} - scale: 0.001 # Wh -> kWh - maxacpower: {{ .maxacpower }} # W - {{- end }} - {{- if eq .usage "battery" }} - type: custom - power: - source: sunspec - {{- include "modbus" . | indent 2 }} - value: 802:W # 802 battery control - soc: - source: sunspec - {{- include "modbus" . | indent 2 }} - value: 802:SoC # 802 battery control - limitsoc: - source: watchdog - timeout: {{ .watchdog }} # re-write at timeout/2 - set: - source: modbus - {{- include "modbus" . | indent 4 }} - register: - address: 1042 # limit soc - type: writemultiple - encoding: {{ if (eq .endianness "big") }}float32{{ else }}float32s{{ end }} - {{- include "battery-params" . }} - {{- end }} From c5ad9ec249a749b8548de46b666985a46aec69d6 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 27 Jun 2026 17:37:03 +0200 Subject: [PATCH 072/733] chore: minor --- templates/definition/meter/kostal-plenticore-gen2.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/definition/meter/kostal-plenticore-gen2.yaml b/templates/definition/meter/kostal-plenticore-gen2.yaml index 860385d5c..a9787b019 100644 --- a/templates/definition/meter/kostal-plenticore-gen2.yaml +++ b/templates/definition/meter/kostal-plenticore-gen2.yaml @@ -12,7 +12,7 @@ caveats: Nur ein System kann und darf auf den Wechselrichter zugreifen! en: | Only a single system may access the inverter! - link: https://github.com/evcc-io/evcc/wiki/Kostal-Plenticore_ + link: https://github.com/evcc-io/evcc/wiki/Kostal-Plenticore requirements: description: de: | From e4316fd2b7da4a7a2c80577ca2739c9db7dcaa5f Mon Sep 17 00:00:00 2001 From: Bryce Lampe Date: Sat, 27 Jun 2026 13:28:55 -0700 Subject: [PATCH 073/733] Mercedes: fix NORAM 418 (#31272) --- vehicle/mercedes/helper.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vehicle/mercedes/helper.go b/vehicle/mercedes/helper.go index a53a670f9..7bc3ada48 100644 --- a/vehicle/mercedes/helper.go +++ b/vehicle/mercedes/helper.go @@ -39,7 +39,7 @@ const ( RisSdkVersionEMEA = "4.4.2" RisApplicationVersionAPAC = "1.65.0" RisSdkVersionAPAC = "4.4.2" - RisApplicationVersionNORAM = "3.65.0" + RisApplicationVersionNORAM = "3.65.1" RisSdkVersionNORAM = "4.4.2" RisOsVersion = "26.3" RisOsName = "ios" @@ -103,7 +103,7 @@ func mbheaders(includeAuthServerHeader bool, region string) map[string]string { headers["Ris-Sdk-Version"] = RisSdkVersionNORAM headers["Ris-Application-Version"] = RisApplicationVersionNORAM headers["X-Applicationname"] = XApplicationNameNORAM - headers["User-Agent"] = fmt.Sprintf(UserAgent, RisApplicationVersionEMEA, "ece", RisOsName, RisOsVersion) + headers["User-Agent"] = fmt.Sprintf(UserAgent, RisApplicationVersionNORAM, "ece", RisOsName, RisOsVersion) } if includeAuthServerHeader { From ede1248b643f21f56f5b8670089730a8a4944c12 Mon Sep 17 00:00:00 2001 From: Ingo <71161062+iseeberg79@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:29:45 +0200 Subject: [PATCH 074/733] core: retry optimizer when battery measurements are not yet ready (#31276) --- core/site_optimizer.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index f00037756..ca7105538 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -110,6 +110,10 @@ type requestDetails struct { const slotsPerHour = float64(time.Hour / tariff.SlotDuration) +// errOptimizerNotReady means battery measurements aren't available yet (e.g. at +// startup); the slot gate is left open so the next cycle retries. +var errOptimizerNotReady = errors.New("battery measurements not ready") + func (site *Site) optimizerUpdateAsync() { if !mu.TryLock() { return @@ -123,12 +127,17 @@ func (site *Site) optimizerUpdateAsync() { var err error defer func() { - optimizerUpdated = time.Now() - if r := recover(); r != nil { err = fmt.Errorf("panic %v", r) } + // not ready yet: keep the gate open for an immediate retry next cycle + if errors.Is(err, errOptimizerNotReady) { + return + } + + optimizerUpdated = time.Now() + if err != nil { site.log.ERROR.Println("optimizer:", err) } @@ -258,9 +267,13 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { add(site.batteryRequest(dev, b, grid, minLen, firstSlotDuration)) } - // empty request- all loadpoints disabled if len(req.Batteries) == 0 { - return nil + // meters configured but measurements not in yet: retry instead of + // consuming the slot gate + if len(site.batteryMeters) > 0 { + return errOptimizerNotReady + } + return nil // nothing to optimize } httpClient := request.NewClient(site.log) From b8284ab4b8c417927d9c35af5f2a1446d1809c1f Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sun, 28 Jun 2026 11:28:34 +0200 Subject: [PATCH 075/733] Metrics: add soc and temp (#30926) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- core/loadpoint.go | 3 ++ core/metrics/accumulator.go | 8 +++++ core/metrics/collector.go | 20 +++++++++-- core/metrics/collector_test.go | 59 +++++++++++++++++++++++++++++++- core/metrics/db.go | 25 ++++++++------ core/metrics/db_entities_test.go | 4 +-- core/metrics/db_history.go | 54 ++++++++++++++++++++++++----- core/metrics/db_history_test.go | 56 ++++++++++++++++++++++++++++++ core/metrics/db_test.go | 22 ++++++------ core/site.go | 3 ++ 10 files changed, 218 insertions(+), 36 deletions(-) diff --git a/core/loadpoint.go b/core/loadpoint.go index 92bd04778..c1582b3f4 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -1816,6 +1816,9 @@ func (lp *Loadpoint) publishChargeProgress() { if lp.chargeEnergy != nil { lp.chargeEnergy.AddEnergy(importTotal, nil, lp.chargePower) + if v := lp.GetSoc(); v > 0 { + lp.chargeEnergy.SetSocTemp(v, lp.chargerHasFeature(api.Heating)) + } } } diff --git a/core/metrics/accumulator.go b/core/metrics/accumulator.go index aaf3830ec..b19644893 100644 --- a/core/metrics/accumulator.go +++ b/core/metrics/accumulator.go @@ -15,6 +15,14 @@ type Accumulator struct { returnEnergyMeter *float64 // kWh Energy float64 `json:"energy"` // kWh ReturnEnergy float64 `json:"returnEnergy"` // kWh + SocTemp *float64 `json:"socTemp,omitempty"` +} + +// setSocTemp keeps the first reading per slot. +func (m *Accumulator) setSocTemp(value float64) { + if m.SocTemp == nil { + m.SocTemp = &value + } } func WithClock(clock clock.Clock) func(*Accumulator) { diff --git a/core/metrics/collector.go b/core/metrics/collector.go index 69e2a15b7..65de4480d 100644 --- a/core/metrics/collector.go +++ b/core/metrics/collector.go @@ -68,6 +68,16 @@ func (e *entity) updateTitle(title string) error { return db.Instance.Model(e).UpdateColumn("title", title).Error } +// updateIsTemp refreshes the entity's stored is_temp flag if it changed +func (e *entity) updateIsTemp(isTemp bool) error { + if e.IsTemp == isTemp { + return nil + } + + e.IsTemp = isTemp + return db.Instance.Model(e).UpdateColumn("is_temp", isTemp).Error +} + // UpdateTitle refreshes the collector entity's stored title if it changed. func (c *Collector) UpdateTitle(title string) error { return c.entity.updateTitle(title) @@ -103,12 +113,18 @@ func (c *Collector) process(fun func()) error { c.accu.Energy = 0 c.accu.ReturnEnergy = 0 - + c.accu.SocTemp = nil return nil } func (c *Collector) persist() error { - return persist(c.entity, c.started, c.accu.Energy, c.accu.ReturnEnergy) + return persist(c.entity, c.started, c.accu.Energy, c.accu.ReturnEnergy, c.accu.SocTemp) +} + +// SetSocTemp records the slot-start soc (temperature when isTemp). Call after AddEnergy. +func (c *Collector) SetSocTemp(value float64, isTemp bool) error { + c.accu.setSocTemp(value) + return c.entity.updateIsTemp(isTemp) } func (c *Collector) EnergyProfile(from time.Time) (*[96]float64, error) { diff --git a/core/metrics/collector_test.go b/core/metrics/collector_test.go index c85927d9d..58f371660 100644 --- a/core/metrics/collector_test.go +++ b/core/metrics/collector_test.go @@ -310,6 +310,63 @@ func TestCreateEntityRefreshesTitle(t *testing.T) { require.EqualValues(t, 1, count, "must not duplicate existing rows") } +func TestCollectorSetSocTemp(t *testing.T) { + clk := clock.NewMock() // 1970-01-01 00:00:00 UTC, on a slot boundary + + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + col, err := NewCollector(Battery, "bat", "", WithClock(clk)) + require.NoError(t, err) + + require.NoError(t, col.AddEnergy(nil, nil, 0)) + require.NoError(t, col.SetSocTemp(50, false)) + require.NoError(t, col.SetSocTemp(60, false)) // first reading per slot wins + require.Equal(t, 50.0, *col.accu.SocTemp) + + // cross into the next slot: prior slot persisted, snapshot cleared + clk.Add(15 * time.Minute) + require.NoError(t, col.AddEnergy(nil, nil, 0)) + require.Nil(t, col.accu.SocTemp) + + var m meter + require.NoError(t, db.Instance.Where("meter = ?", col.entity.Id).First(&m).Error) + require.Equal(t, 50.0, *m.SocTemp) + + // battery never marks is_temp, so the value is soc + require.False(t, col.entity.IsTemp) + + // fresh slot captures its own start value + require.NoError(t, col.SetSocTemp(70, false)) + require.Equal(t, 70.0, *col.accu.SocTemp) +} + +func TestCollectorSetSocTempHeating(t *testing.T) { + clk := clock.NewMock() + + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + col, err := NewCollector(Loadpoint, "lp", "", WithClock(clk)) + require.NoError(t, err) + + require.NoError(t, col.AddEnergy(nil, nil, 0)) + require.NoError(t, col.SetSocTemp(21.5, true)) // heating charger: value is temperature + require.Equal(t, 21.5, *col.accu.SocTemp) + + clk.Add(15 * time.Minute) + require.NoError(t, col.AddEnergy(nil, nil, 0)) + + var m meter + require.NoError(t, db.Instance.Where("meter = ?", col.entity.Id).First(&m).Error) + require.Equal(t, 21.5, *m.SocTemp) + + // is_temp persisted on the entity row + var e entity + require.NoError(t, db.Instance.Where("id = ?", col.entity.Id).First(&e).Error) + require.True(t, e.IsTemp) +} + // a meter regrouped to consumer keeps its id and history via in-place relabel func TestCreateEntityReconcilesExtToConsumer(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) @@ -318,7 +375,7 @@ func TestCreateEntityReconcilesExtToConsumer(t *testing.T) { // ext meter with a persisted history slot ext, err := createEntity(Meter, "db:5", "Fridge") require.NoError(t, err) - require.NoError(t, persist(ext, time.Unix(15*60, 0), 0.3, 0)) + require.NoError(t, persist(ext, time.Unix(15*60, 0), 0.3, 0, nil)) // reconfigured as consumer: same row relabeled, history intact con, err := createEntity(Consumer, "db:5", "Fridge") diff --git a/core/metrics/db.go b/core/metrics/db.go index 40cf4dc0e..3f20d2c2e 100644 --- a/core/metrics/db.go +++ b/core/metrics/db.go @@ -12,18 +12,20 @@ import ( ) type meter struct { - Meter int `json:"meter" gorm:"column:meter;uniqueIndex:meters_meter_ts"` - Timestamp int64 `json:"ts" gorm:"column:ts;uniqueIndex:meters_meter_ts"` // start of 15min slot - Entity entity `json:"-" gorm:"foreignkey:Meter;references:Id"` - Energy float64 `json:"energy" gorm:"column:energy"` - ReturnEnergy float64 `json:"returnEnergy" gorm:"column:return_energy"` + Meter int `json:"meter" gorm:"column:meter;uniqueIndex:meters_meter_ts"` + Timestamp int64 `json:"ts" gorm:"column:ts;uniqueIndex:meters_meter_ts"` // start of 15min slot + Entity entity `json:"-" gorm:"foreignkey:Meter;references:Id"` + Energy float64 `json:"energy" gorm:"column:energy"` + ReturnEnergy float64 `json:"returnEnergy" gorm:"column:return_energy"` + SocTemp *float64 `json:"socTemp,omitempty" gorm:"column:soc_temp"` // at start of slot } type entity struct { - Id int `gorm:"column:id;primarykey"` - Group string `gorm:"column:group;uniqueIndex:entities_group_name"` - Name string `gorm:"column:name;uniqueIndex:entities_group_name"` - Title string `gorm:"column:title"` + Id int `gorm:"column:id;primarykey"` + Group string `gorm:"column:group;uniqueIndex:entities_group_name"` + Name string `gorm:"column:name;uniqueIndex:entities_group_name"` + Title string `gorm:"column:title"` + IsTemp bool `gorm:"column:is_temp"` // soc_temp holds temperature, not soc } func init() { @@ -123,12 +125,13 @@ func SetupSchema() error { return db.Instance.AutoMigrate(new(meter)) } -// persist stores 15min consumption in kWh -func persist(entity entity, ts time.Time, energy, returnEnergy float64) error { +// persist stores a completed 15min slot +func persist(entity entity, ts time.Time, energy, returnEnergy float64, socTemp *float64) error { return db.Instance.Create(&meter{ Meter: entity.Id, Timestamp: ts.Truncate(tariff.SlotDuration).Unix(), Energy: energy, ReturnEnergy: returnEnergy, + SocTemp: socTemp, }).Error } diff --git a/core/metrics/db_entities_test.go b/core/metrics/db_entities_test.go index 660ad64ce..008edf266 100644 --- a/core/metrics/db_entities_test.go +++ b/core/metrics/db_entities_test.go @@ -19,8 +19,8 @@ func TestListEntities(t *testing.T) { require.NoError(t, db.Instance.Create(&pv).Error) base := time.Date(2026, 4, 15, 16, 0, 0, 0, time.Now().Location()) - require.NoError(t, persist(grid, base, 1, 0)) - require.NoError(t, persist(grid, base.Add(time.Hour), 2, 0)) + require.NoError(t, persist(grid, base, 1, 0, nil)) + require.NoError(t, persist(grid, base.Add(time.Hour), 2, 0, nil)) entities, err := ListEntities() require.NoError(t, err) diff --git a/core/metrics/db_history.go b/core/metrics/db_history.go index 3d6a057f7..63cbc4b3e 100644 --- a/core/metrics/db_history.go +++ b/core/metrics/db_history.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "math" + "slices" "sort" "strconv" "time" @@ -20,6 +21,7 @@ type Slot struct { End time.Time `json:"end"` Energy float64 `json:"energy"` ReturnEnergy float64 `json:"returnEnergy"` + SocTemp *float64 `json:"socTemp,omitempty"` } // roundEnergy rounds kWh to Wh precision and clamps negative noise to zero. @@ -29,9 +31,10 @@ func roundEnergy(v float64) float64 { // Series represents an energy series for one title group or one entity group. type Series struct { - Title string `json:"title,omitempty"` - Group string `json:"group"` - Data []Slot `json:"data"` + Title string `json:"title,omitempty"` + Group string `json:"group"` + IsTemp bool `json:"isTemp,omitempty"` // socTemp values are temperature, not soc + Data []Slot `json:"data"` } // SeriesCSV wraps a slice of Series for CSV export. @@ -80,13 +83,21 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, Start SqlTime Energy float64 ReturnEnergy float64 + SocTemp *float64 + IsTemp bool + } + + // soc_temp reports the bucket's first slot; omitted for grouped sums + socCols := `, m.soc_temp AS soc_temp, e.is_temp AS is_temp` + if grouped { + socCols = `` } tx := db.Instance.Table("meters m"). Select(selectTitle + `, e."group", MIN(m.ts) AS start, COALESCE(SUM(m.energy), 0) AS energy, - COALESCE(SUM(m.return_energy), 0) AS return_energy`). + COALESCE(SUM(m.return_energy), 0) AS return_energy` + socCols). Joins("JOIN entities e ON m.meter = e.id"). Group(groupCols). Order(groupCols) @@ -106,7 +117,7 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, var res []Series for _, r := range rows { if n := len(res); n == 0 || res[n-1].Title != r.Title || res[n-1].Group != r.Group { - res = append(res, Series{Title: r.Title, Group: r.Group}) + res = append(res, Series{Title: r.Title, Group: r.Group, IsTemp: r.IsTemp}) } s := &res[len(res)-1] @@ -115,6 +126,7 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, End: addDuration(time.Time(r.Start)), Energy: roundEnergy(r.Energy), ReturnEnergy: roundEnergy(r.ReturnEnergy), + SocTemp: r.SocTemp, }) } @@ -128,6 +140,18 @@ func hasReturnEnergy(group string) bool { return group == Grid || group == Battery } +func seriesHasSocTemp(s *Series) bool { + return slices.ContainsFunc(s.Data, func(slot Slot) bool { return slot.SocTemp != nil }) +} + +// formatSocTemp renders a soc/temp value rounded to 0.1, empty when unset. +func formatSocTemp(v *float64) string { + if v == nil { + return "" + } + return strconv.FormatFloat(math.Round(*v*10)/10, 'f', -1, 64) +} + // WriteCsv emits a wide-table CSV with columns // // time.start, time.end, ..energy.Wh[, ..returnEnergy.Wh], … @@ -175,6 +199,7 @@ func (s SeriesCSV) WriteCsv(ctx context.Context, w io.Writer) error { type col struct { series *Series returnEnergy bool + socTemp bool } cols := []col{{}, {}} tsSet := make(map[int64]time.Time) @@ -206,6 +231,14 @@ func (s SeriesCSV) WriteCsv(ctx context.Context, w io.Writer) error { header = append(header, p+".returnEnergy.Wh") cols = append(cols, col{series: e, returnEnergy: true}) } + if seriesHasSocTemp(e) { + unit := ".soc.pct" + if e.IsTemp { + unit = ".temp.degC" + } + header = append(header, p+unit) + cols = append(cols, col{series: e, socTemp: true}) + } for _, slot := range e.Data { tsSet[slot.Start.UnixNano()] = slot.Start endByStart[slot.Start.UnixNano()] = slot.End @@ -254,11 +287,14 @@ func (s SeriesCSV) WriteCsv(ctx context.Context, w io.Writer) error { row[i] = "" continue } - v := slot.Energy - if c.returnEnergy { - v = slot.ReturnEnergy + switch { + case c.socTemp: + row[i] = formatSocTemp(slot.SocTemp) + case c.returnEnergy: + row[i] = strconv.FormatInt(int64(math.Round(slot.ReturnEnergy*1000)), 10) + default: + row[i] = strconv.FormatInt(int64(math.Round(slot.Energy*1000)), 10) } - row[i] = strconv.FormatInt(int64(math.Round(v*1000)), 10) } if err := ww.Write(row); err != nil { return err diff --git a/core/metrics/db_history_test.go b/core/metrics/db_history_test.go index 04a900b0a..48f653bab 100644 --- a/core/metrics/db_history_test.go +++ b/core/metrics/db_history_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/evcc-io/evcc/server/db" "github.com/evcc-io/evcc/util/locale" "github.com/stretchr/testify/require" ) @@ -132,3 +133,58 @@ func TestSeriesCSV_BatteryHasReturnEnergyColumn(t *testing.T) { require.Equal(t, "500", rows[1][2]) require.Equal(t, "0", rows[1][3]) } + +func TestSeriesCSV_SocTempColumns(t *testing.T) { + t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC) + t1 := t0.Add(15 * time.Minute) + + slot := func(t time.Time, socTemp *float64) Slot { + s := mkSlot(t, 1, 0) + s.SocTemp = socTemp + return s + } + + series := SeriesCSV{ + {Group: Battery, Title: "bat", Data: []Slot{slot(t0, new(90.0)), slot(t1, new(80.0))}}, + {Group: Loadpoint, Title: "heater", IsTemp: true, Data: []Slot{slot(t0, new(45.0)), slot(t1, new(46.0))}}, + {Group: PV, Title: "pv", Data: []Slot{mkSlot(t0, 2, 0), mkSlot(t1, 3, 0)}}, + } + + var buf bytes.Buffer + require.NoError(t, series.WriteCsv(context.Background(), &buf)) + out := buf.String() + + require.Contains(t, out, "battery.bat.soc.pct") + require.Contains(t, out, "loadpoint.heater.temp.degC") + require.NotContains(t, out, "battery.bat.temp") + require.NotContains(t, out, "pv.soc") +} + +func TestQueryEnergySoc(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + e := entity{Id: 2, Name: "bat", Group: Battery} + require.NoError(t, db.Instance.Create(&e).Error) + + base := time.Date(2026, 4, 15, 16, 0, 0, 0, time.Now().Location()) + require.NoError(t, persist(e, base, 1, 0, new(80.0))) + require.NoError(t, persist(e, base.Add(15*time.Minute), 1, 0, new(70.0))) + + from := base.Add(-time.Hour).UTC() + to := base.Add(time.Hour).UTC() + + // hourly bucket reports the first slot's snapshot, not an average + res, err := QueryEnergy(from, to, "hour", false) + require.NoError(t, err) + require.Len(t, res, 1) + require.Len(t, res[0].Data, 1) + require.Equal(t, 80.0, *res[0].Data[0].SocTemp) + require.False(t, res[0].IsTemp) // battery: value is soc + + // grouped sums omit the per-entity snapshot + res, err = QueryEnergy(from, to, "hour", true) + require.NoError(t, err) + require.Len(t, res, 1) + require.Nil(t, res[0].Data[0].SocTemp) +} diff --git a/core/metrics/db_test.go b/core/metrics/db_test.go index 6ac497335..396f4f216 100644 --- a/core/metrics/db_test.go +++ b/core/metrics/db_test.go @@ -20,7 +20,7 @@ func TestSqliteTimestamp(t *testing.T) { entity := entity{Name: "foo"} require.NoError(t, db.Instance.FirstOrCreate(&entity).Error) - persist(entity, clock.Now(), 0, 0) + persist(entity, clock.Now(), 0, 0, nil) db, err := db.Instance.DB() require.NoError(t, err) @@ -55,8 +55,8 @@ func TestQueryEnergyUTCFilter(t *testing.T) { loc := time.Now().Location() base := time.Date(2026, 4, 15, 16, 0, 0, 0, loc) - require.NoError(t, persist(e, base, 0, 1)) - require.NoError(t, persist(e, base.Add(time.Hour), 0, 2)) + require.NoError(t, persist(e, base, 0, 1, nil)) + require.NoError(t, persist(e, base.Add(time.Hour), 0, 2, nil)) // query with UTC times spanning both slots from := base.Add(-time.Hour).UTC() @@ -83,10 +83,10 @@ func TestQueryEnergyGrouped(t *testing.T) { loc := time.Now().Location() base := time.Date(2026, 4, 15, 16, 0, 0, 0, loc) - require.NoError(t, persist(e1, base, 1, 0)) - require.NoError(t, persist(e2, base, 2, 0)) - require.NoError(t, persist(e1, base.Add(time.Hour), 3, 0)) - require.NoError(t, persist(e2, base.Add(time.Hour), 4, 0)) + require.NoError(t, persist(e1, base, 1, 0, nil)) + require.NoError(t, persist(e2, base, 2, 0, nil)) + require.NoError(t, persist(e1, base.Add(time.Hour), 3, 0, nil)) + require.NoError(t, persist(e2, base.Add(time.Hour), 4, 0, nil)) from := base.Add(-time.Hour).UTC() to := base.Add(3 * time.Hour).UTC() @@ -125,9 +125,9 @@ func TestQueryEnergyMultipleSeries(t *testing.T) { // 2 hourly slots per entity for i := range 2 { ts := base.Add(time.Duration(i) * time.Hour) - require.NoError(t, persist(eGrid, ts, float64(1+i), 0)) - require.NoError(t, persist(ePv1, ts, 0, float64(10+i))) - require.NoError(t, persist(ePv2, ts, 0, float64(20+i))) + require.NoError(t, persist(eGrid, ts, float64(1+i), 0, nil)) + require.NoError(t, persist(ePv1, ts, 0, float64(10+i), nil)) + require.NoError(t, persist(ePv2, ts, 0, float64(20+i), nil)) } from := base.Add(-time.Hour).UTC() @@ -189,7 +189,7 @@ func TestUpdateProfile(t *testing.T) { // day 1: 0 ... 95 // day 2: 96 ... 181 for i := range 4 * 2 * 24 { - persist(entity, clock.Now(), float64(i), float64(i)) + persist(entity, clock.Now(), float64(i), float64(i), nil) clock.Add(15 * time.Minute) } diff --git a/core/site.go b/core/site.go index 0e19a5dc2..514e81dd8 100644 --- a/core/site.go +++ b/core/site.go @@ -717,6 +717,9 @@ func (site *Site) updateBatteryMeters() { if err := c.AddEnergy(mm[i].ReturnEnergy, mm[i].Energy, -mm[i].Power); err != nil { site.log.ERROR.Printf("persist battery %d energy: %v", i+1, err) } + if mm[i].Soc != nil { + c.SetSocTemp(*mm[i].Soc, false) + } } site.publish(keys.Battery, site.battery) From d87f77b7d91497889f6699cdbd67be911273939b Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 28 Jun 2026 15:36:32 +0200 Subject: [PATCH 076/733] Meter: fix spurious energy spikes (grid nonZeroEnergy + SolarEdge uint64snans) (#31295) --- core/site.go | 6 ++++-- templates/definition/meter/solaredge-hybrid.yaml | 4 ++-- util/modbus/register.go | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/core/site.go b/core/site.go index 514e81dd8..81415192a 100644 --- a/core/site.go +++ b/core/site.go @@ -844,8 +844,9 @@ func (site *Site) updateGridMeter() error { } // grid energy (import); nil when the device has no MeterEnergy capability or the read fails + // ignore spurious zero readings (NaN-derived or nightly reset, #30950) if energyMeter, ok := api.Cap[api.MeterEnergy](site.gridMeter); ok { - if f, err := energyMeter.TotalEnergy(); err == nil { + if f, err := nonZeroEnergy(energyMeter.TotalEnergy()); err == nil { mm.Energy = &f } else if !errors.Is(err, api.ErrNotAvailable) { site.log.ERROR.Printf("grid energy: %v", err) @@ -853,8 +854,9 @@ func (site *Site) updateGridMeter() error { } // grid return energy (export); nil when the device has no MeterReturnEnergy capability or the read fails + // ignore spurious zero readings as above if returnEnergyMeter, ok := api.Cap[api.MeterReturnEnergy](site.gridMeter); ok { - if f, err := returnEnergyMeter.ReturnEnergy(); err == nil { + if f, err := nonZeroEnergy(returnEnergyMeter.ReturnEnergy()); err == nil { mm.ReturnEnergy = &f } else if !errors.Is(err, api.ErrNotAvailable) { site.log.ERROR.Printf("grid return energy: %v", err) diff --git a/templates/definition/meter/solaredge-hybrid.yaml b/templates/definition/meter/solaredge-hybrid.yaml index a60fb2364..40da8696f 100644 --- a/templates/definition/meter/solaredge-hybrid.yaml +++ b/templates/definition/meter/solaredge-hybrid.yaml @@ -130,7 +130,7 @@ render: | register: address: 0xE176 # Battery 1 Lifetime Export Energy Counter (discharged) type: holding - decode: uint64s + decode: uint64snan scale: 0.001 returnenergy: source: modbus @@ -138,7 +138,7 @@ render: | register: address: 0xE17A # Battery 1 Lifetime Import Energy Counter (charged) type: holding - decode: uint64s + decode: uint64snan scale: 0.001 batterymode: source: watchdog diff --git a/util/modbus/register.go b/util/modbus/register.go index f62c0f3a3..96e36f055 100644 --- a/util/modbus/register.go +++ b/util/modbus/register.go @@ -125,6 +125,8 @@ func (r Register) DecodeFunc() (func([]byte) float64, error) { return asFloat64(encoding.Uint64), nil case "uint64s": return asFloat64(uint64LswFirst), nil + case "uint64snan": + return decodeNaN64(asFloat64(uint64LswFirst), 1<<64-1), nil case "uint64nan": return decodeNaN64(asFloat64(encoding.Uint64), 1<<64-1), nil case "float64": From 659328092ff158c634f79b0a2b1e11f08223c1ae Mon Sep 17 00:00:00 2001 From: Markus Kaindl Date: Sun, 28 Jun 2026 15:37:44 +0200 Subject: [PATCH 077/733] vehicle: mark climaterdisabled parameter as deprecated in iso15118 and offline templates (#31297) --- templates/definition/vehicle/iso15118.yaml | 2 ++ templates/definition/vehicle/offline.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/templates/definition/vehicle/iso15118.yaml b/templates/definition/vehicle/iso15118.yaml index 2661a9b3a..fa6a8db96 100644 --- a/templates/definition/vehicle/iso15118.yaml +++ b/templates/definition/vehicle/iso15118.yaml @@ -18,6 +18,8 @@ requirements: Otherwise the vehicle cannot be put into sleep mode. params: - preset: vehicle-common + - name: climaterdisabled + deprecated: true render: | type: custom {{ include "vehicle-common" . }} diff --git a/templates/definition/vehicle/offline.yaml b/templates/definition/vehicle/offline.yaml index 946b266ff..dba4b88e4 100644 --- a/templates/definition/vehicle/offline.yaml +++ b/templates/definition/vehicle/offline.yaml @@ -12,6 +12,8 @@ params: - name: coarsecurrent advanced: true - name: welcomecharge + - name: climaterdisabled + deprecated: true render: | type: custom {{- include "vehicle-common" . }} From bfc97db357b2e76eadebffe0526d4a7743382d8f Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 29 Jun 2026 10:08:58 +0200 Subject: [PATCH 078/733] Remote Access: improve back off behavior and error messages (#31267) --- server/remote/remote.go | 5 +++++ server/remote/tunnel.go | 29 ++++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/server/remote/remote.go b/server/remote/remote.go index 80158dc10..b95a272d8 100644 --- a/server/remote/remote.go +++ b/server/remote/remote.go @@ -93,6 +93,11 @@ func (r *Remote) Enabled() bool { } func (r *Remote) connect() { + if !sponsor.IsAuthorizedForApi() { + r.log.WARN.Println("remote access requires a sponsor token") + return + } + r.mu.Lock() token := r.settings.Token r.mu.Unlock() diff --git a/server/remote/tunnel.go b/server/remote/tunnel.go index 303acf92b..40945cb38 100644 --- a/server/remote/tunnel.go +++ b/server/remote/tunnel.go @@ -16,6 +16,11 @@ import ( "github.com/hashicorp/yamux" ) +// bad registration token or sponsor token +var errCredentialsRejected = errors.New("remote access rejected") + +const minUptime = 5 * time.Second + // Tunnel manages a WebSocket+yamux tunnel to the cloud proxy. type Tunnel struct { tunnelURL string @@ -63,7 +68,12 @@ func (t *Tunnel) run() { t.log.ERROR.Printf("tunnel: %v", err) } - // reset backoff after successful connection + // rejected credentials will not self-heal; a new token requires a restart + if errors.Is(err, errCredentialsRejected) { + return + } + + // reset backoff only after a session that stayed connected if ok { bo.Reset() } @@ -77,13 +87,24 @@ func (t *Tunnel) run() { } func (t *Tunnel) connect(ctx context.Context) (bool, error) { - conn, _, err := websocket.Dial(ctx, t.tunnelURL, &websocket.DialOptions{ + conn, resp, err := websocket.Dial(ctx, t.tunnelURL, &websocket.DialOptions{ HTTPHeader: http.Header{ "Authorization": []string{"Bearer " + t.token}, "X-Sponsor-Token": []string{sponsor.Token}, }, }) if err != nil { + // rejected before the websocket upgrade; resp carries the HTTP status + if resp != nil { + switch resp.StatusCode { + case http.StatusConflict: + return false, errors.New("another evcc instance is already connected with these credentials") + case http.StatusUnauthorized: + return false, fmt.Errorf("%w (access revoked or sponsor token missing)", errCredentialsRejected) + case http.StatusForbidden: + return false, fmt.Errorf("%w (sponsor token invalid or expired)", errCredentialsRejected) + } + } return false, fmt.Errorf("websocket dial: %w", err) } @@ -99,6 +120,7 @@ func (t *Tunnel) connect(ctx context.Context) (bool, error) { } t.changeState(session, nil) + start := time.Now() // accept streams from the proxy srv := &http.Server{ @@ -109,7 +131,8 @@ func (t *Tunnel) connect(ctx context.Context) (bool, error) { t.changeState(nil, err) } - return true, nil + // only a sustained connection resets backoff; an instant close keeps backing off + return time.Since(start) >= minUptime, nil } func (t *Tunnel) changeState(session *yamux.Session, err error) { From cdb2914308488d7212736afbad395b370f93c426 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 29 Jun 2026 11:35:28 +0200 Subject: [PATCH 079/733] Vehicle: test feature param consistency for shared include (#31321) --- templates/definition/vehicle/iso15118.yaml | 2 ++ templates/definition/vehicle/offline.yaml | 2 ++ vehicle/template_test.go | 23 ++++++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/templates/definition/vehicle/iso15118.yaml b/templates/definition/vehicle/iso15118.yaml index fa6a8db96..3e44b5e06 100644 --- a/templates/definition/vehicle/iso15118.yaml +++ b/templates/definition/vehicle/iso15118.yaml @@ -20,6 +20,8 @@ params: - preset: vehicle-common - name: climaterdisabled deprecated: true + - name: autodetectdisabled + deprecated: true render: | type: custom {{ include "vehicle-common" . }} diff --git a/templates/definition/vehicle/offline.yaml b/templates/definition/vehicle/offline.yaml index dba4b88e4..6437c90e2 100644 --- a/templates/definition/vehicle/offline.yaml +++ b/templates/definition/vehicle/offline.yaml @@ -14,6 +14,8 @@ params: - name: welcomecharge - name: climaterdisabled deprecated: true + - name: autodetectdisabled + deprecated: true render: | type: custom {{- include "vehicle-common" . }} diff --git a/vehicle/template_test.go b/vehicle/template_test.go index 76dda1ae9..43451ffbc 100644 --- a/vehicle/template_test.go +++ b/vehicle/template_test.go @@ -1,6 +1,7 @@ package vehicle import ( + "strings" "testing" "github.com/evcc-io/evcc/api" @@ -40,3 +41,25 @@ func TestTemplates(t *testing.T) { } }) } + +// universalVehicleFeatures render via the shared vehicle-features include, so a +// stored config may carry them; dropping the param breaks reload (discussion #31291). +var universalVehicleFeatures = []string{"climaterdisabled", "autodetectdisabled"} + +func TestVehicleFeatureParamsConsistent(t *testing.T) { + for _, tmpl := range templates.ByClass(templates.Vehicle, templates.WithDeprecated()) { + if !strings.Contains(tmpl.Render, "vehicle-features") { + continue + } + + for _, feat := range universalVehicleFeatures { + values := tmpl.Defaults(templates.RenderModeUnitTest) + values["template"] = tmpl.Template + values[feat] = true + + if _, _, err := tmpl.RenderResult(templates.RenderModeInstance, values); err != nil { + t.Errorf("%s: feature %q must stay a declared param: %v", tmpl.Template, feat, err) + } + } + } +} From 1005f075a2d2a5faf8f234050c761e09646e223a Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 29 Jun 2026 11:54:53 +0200 Subject: [PATCH 080/733] EEBus: add OHPCF heat pump compressor flexibility (#30636) --- api/api.go | 7 +- api/mock.go | 44 +- charger/eebus-ohpcf.go | 457 ++++++++++++++++++ charger/eebus-ohpcf_test.go | 69 +++ core/loadpoint_effective.go | 14 + core/loadpoint_effective_test.go | 23 + go.mod | 4 +- go.sum | 4 +- server/eebus/eebus.go | 9 +- server/eebus/scenarios.go | 5 + templates/definition/charger/eebus-ohpcf.yaml | 27 ++ 11 files changed, 656 insertions(+), 7 deletions(-) create mode 100644 charger/eebus-ohpcf.go create mode 100644 charger/eebus-ohpcf_test.go create mode 100644 templates/definition/charger/eebus-ohpcf.yaml diff --git a/api/api.go b/api/api.go index 7bb11c8b2..fc4eb53e6 100644 --- a/api/api.go +++ b/api/api.go @@ -9,7 +9,7 @@ import ( "golang.org/x/oauth2" ) -//go:generate go tool mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff +//go:generate go tool mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,PowerLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff // Meter provides total active power in W type Meter interface { @@ -192,6 +192,11 @@ type CurrentLimiter interface { GetMinMaxCurrent() (float64, float64, error) } +// PowerLimiter returns the power limits in W +type PowerLimiter interface { + GetMinMaxPower() (float64, float64, error) +} + // SocLimiter returns the soc limit type SocLimiter interface { GetLimitSoc() (int64, error) diff --git a/api/mock.go b/api/mock.go index 549bffcca..86663c9a0 100644 --- a/api/mock.go +++ b/api/mock.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff) +// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,CurrentLimiter,PowerLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff) // // Generated by this command: // -// mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff +// mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,PowerLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff // // Package api is a generated GoMock package. @@ -177,6 +177,46 @@ func (mr *MockCurrentLimiterMockRecorder) GetMinMaxCurrent() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMinMaxCurrent", reflect.TypeOf((*MockCurrentLimiter)(nil).GetMinMaxCurrent)) } +// MockPowerLimiter is a mock of PowerLimiter interface. +type MockPowerLimiter struct { + ctrl *gomock.Controller + recorder *MockPowerLimiterMockRecorder + isgomock struct{} +} + +// MockPowerLimiterMockRecorder is the mock recorder for MockPowerLimiter. +type MockPowerLimiterMockRecorder struct { + mock *MockPowerLimiter +} + +// NewMockPowerLimiter creates a new mock instance. +func NewMockPowerLimiter(ctrl *gomock.Controller) *MockPowerLimiter { + mock := &MockPowerLimiter{ctrl: ctrl} + mock.recorder = &MockPowerLimiterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPowerLimiter) EXPECT() *MockPowerLimiterMockRecorder { + return m.recorder +} + +// GetMinMaxPower mocks base method. +func (m *MockPowerLimiter) GetMinMaxPower() (float64, float64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMinMaxPower") + ret0, _ := ret[0].(float64) + ret1, _ := ret[1].(float64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetMinMaxPower indicates an expected call of GetMinMaxPower. +func (mr *MockPowerLimiterMockRecorder) GetMinMaxPower() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMinMaxPower", reflect.TypeOf((*MockPowerLimiter)(nil).GetMinMaxPower)) +} + // MockCurrentGetter is a mock of CurrentGetter interface. type MockCurrentGetter struct { ctrl *gomock.Controller diff --git a/charger/eebus-ohpcf.go b/charger/eebus-ohpcf.go new file mode 100644 index 000000000..4a3360268 --- /dev/null +++ b/charger/eebus-ohpcf.go @@ -0,0 +1,457 @@ +package charger + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + eebusapi "github.com/enbility/eebus-go/api" + ucapi "github.com/enbility/eebus-go/usecases/api" + "github.com/enbility/eebus-go/usecases/cem/ohpcf" + "github.com/enbility/eebus-go/usecases/ma/mdt" + "github.com/enbility/eebus-go/usecases/ma/mpc" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/server/eebus" + "github.com/evcc-io/evcc/util" +) + +// EEBusOHPCF controls a remote heat pump compressor via the EEBus OHPCF use case +// (Optimization of Self-Consumption by Heat Pump Compressor Flexibility). +// +// The compressor announces an optional power consumption that the CEM may +// schedule, pause, resume or abort. evcc models this as an on/off switch: +// enabling the charger schedules or resumes the optional consumption, disabling +// it pauses or aborts the running process. +type EEBusOHPCF struct { + *embed + cem *eebus.CustomerEnergyManagement + ma *eebus.MonitoringAppliance + + ctx context.Context + reboost time.Duration + + mu sync.RWMutex + log *util.Logger + compressor spineapi.EntityRemoteInterface + mpcEntity spineapi.EntityRemoteInterface + dhwEntity spineapi.EntityRemoteInterface + enabled bool + reboosting bool + + connector *eebus.Connector +} + +// errNotConnected is returned whenever the compressor entity is not (yet) available. +var errNotConnected = errors.New("not connected") + +func init() { + registry.AddCtx("eebus-ohpcf", NewEEBusOHPCFFromConfig) +} + +// NewEEBusOHPCFFromConfig creates an EEBus OHPCF charger from generic config +func NewEEBusOHPCFFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) { + cc := struct { + embed `mapstructure:",squash"` + Ski string + Ip string + Reboost time.Duration + }{ + embed: embed{ + Features_: []api.Feature{api.Heating, api.IntegratedDevice}, + }, + Reboost: 10 * time.Minute, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + return NewEEBusOHPCF(ctx, &cc.embed, cc.Ski, cc.Ip, cc.Reboost) +} + +// NewEEBusOHPCF creates an EEBus OHPCF charger, registers it with the EEBus +// instance and waits for the connection. +func NewEEBusOHPCF(ctx context.Context, embed *embed, ski, ip string, reboost time.Duration) (api.Charger, error) { + inst, err := eebus.Instance() + if err != nil { + return nil, err + } + + c := &EEBusOHPCF{ + embed: embed, + log: util.NewLogger("eebus-ohpcf"), + cem: inst.CustomerEnergyManagement(), + ma: inst.MonitoringAppliance(), + connector: eebus.NewConnector(), + ctx: ctx, + reboost: reboost, + } + + if err := inst.RegisterDevice(ski, ip, c); err != nil { + return nil, err + } + + if err := c.connector.Wait(ctx); err != nil { + inst.UnregisterDevice(ski, c) + return nil, err + } + + // unregister device when context is cancelled (e.g. UI config validation) + go func() { + <-ctx.Done() + inst.UnregisterDevice(ski, c) + }() + + return c, nil +} + +var _ eebus.Device = (*EEBusOHPCF)(nil) + +// Connect implements the eebus.Device interface +func (c *EEBusOHPCF) Connect(connected bool) { + c.connector.Connect(connected) + + if connected { + return + } + + c.mu.Lock() + defer c.mu.Unlock() + + c.compressor = nil + c.mpcEntity = nil + c.dhwEntity = nil +} + +// UseCaseEvent implements the eebus.Device interface +func (c *EEBusOHPCF) UseCaseEvent(_ spineapi.DeviceRemoteInterface, entity spineapi.EntityRemoteInterface, event eebusapi.EventType) { + // device/entity removal fires the use case update event with a nil entity + if entity == nil { + return + } + + switch event { + case ohpcf.UseCaseSupportUpdate, + ohpcf.DataUpdateRequestedPowerEstimate, + ohpcf.DataUpdateRequestedPowerMax, + ohpcf.DataUpdateConsumptionIsStoppable, + ohpcf.DataUpdateConsumptionIsPausable, + ohpcf.DataUpdateConsumptionStartTime, + ohpcf.DataUpdateConsumptionState, + ohpcf.DataUpdateMinimalRunDuration, + ohpcf.DataUpdateMinimalPauseDuration: + c.mu.Lock() + c.compressor = entity + c.mu.Unlock() + + // Monitoring Appliance MPC provides the measured power consumption + case mpc.UseCaseSupportUpdate: + c.mu.Lock() + // use most specific selector + if c.mpcEntity == nil || len(entity.Address().Entity) < len(c.mpcEntity.Address().Entity) { + c.mpcEntity = entity + } + c.mu.Unlock() + + // Monitoring Appliance MDT provides the DHW temperature + case mdt.UseCaseSupportUpdate, mdt.DataUpdateTemperature: + c.mu.Lock() + c.dhwEntity = entity + c.mu.Unlock() + } +} + +func (c *EEBusOHPCF) connectedCompressor() (spineapi.EntityRemoteInterface, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.compressor, c.compressor != nil +} + +func (c *EEBusOHPCF) setEnabled(enabled bool) { + c.mu.Lock() + defer c.mu.Unlock() + + c.enabled = enabled +} + +func (c *EEBusOHPCF) lastEnabled() bool { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.enabled +} + +// ohpcfStatus maps the compressor process state to a charge status: running is +// consuming (C), any other connected state is standby (B). Disconnected (A) is handled in Status. +func ohpcfStatus(state ucapi.CompressorPowerConsumptionStateType) api.ChargeStatus { + if state == ucapi.CompressorPowerConsumptionStateRunning { + return api.StatusC + } + return api.StatusB +} + +var _ api.Charger = (*EEBusOHPCF)(nil) + +// Status implements the api.Charger interface +func (c *EEBusOHPCF) Status() (api.ChargeStatus, error) { + entity, ok := c.connectedCompressor() + if !ok { + return api.StatusNone, errNotConnected + } + + state, err := c.cem.OHPCF.PowerConsumptionProcessState(entity) + if err != nil { + // connected but no flexibility announced yet: standby, not disconnected + return api.StatusB, nil + } + + return ohpcfStatus(state), nil +} + +// Enabled reports the commanded on/off intent; Status reflects the actual +// compressor state. +func (c *EEBusOHPCF) Enabled() (bool, error) { + if _, ok := c.connectedCompressor(); !ok { + return false, errNotConnected + } + + return c.lastEnabled(), nil +} + +// Enable schedules/resumes the optional consumption when on, pauses/aborts it +// when off; while on a reboost loop reschedules newly announced consumption. +func (c *EEBusOHPCF) Enable(enable bool) error { + c.setEnabled(enable) + + if enable { + c.startReboost() + } + + return c.apply() +} + +// startReboost launches the reboost loop, unless one is already running or no +// reboost interval is configured. +func (c *EEBusOHPCF) startReboost() { + if c.reboost <= 0 { + return + } + + c.mu.Lock() + defer c.mu.Unlock() + + if c.reboosting { + return + } + + c.reboosting = true + go c.reboostLoop() +} + +// reboostLoop reschedules a freshly announced optional consumption after each +// reboost interval; it exits when the charger is disabled or the context ends. +func (c *EEBusOHPCF) reboostLoop() { + defer func() { + c.mu.Lock() + c.reboosting = false + c.mu.Unlock() + }() + + for { + select { + case <-c.ctx.Done(): + return + case <-time.After(c.reboost): + if !c.lastEnabled() { + return + } + if err := c.apply(); err != nil { + c.log.DEBUG.Printf("reboost: %v", err) + } + } + } +} + +type ohpcfAction int + +const ( + ohpcfNone ohpcfAction = iota + ohpcfSchedule + ohpcfResume + ohpcfStop +) + +// ohpcfControlAction returns the command needed to reach the desired on/off +// state; it returns an action only on a state transition, so repeats are no-ops. +func ohpcfControlAction(state ucapi.CompressorPowerConsumptionStateType, enable bool) ohpcfAction { + if enable { + switch state { + case ucapi.CompressorPowerConsumptionStateAvailable: + return ohpcfSchedule + case ucapi.CompressorPowerConsumptionStatePaused: + return ohpcfResume + } + return ohpcfNone + } + + switch state { + case ucapi.CompressorPowerConsumptionStateRunning, + ucapi.CompressorPowerConsumptionStateScheduled: + return ohpcfStop + } + + return ohpcfNone +} + +// stop pauses the optional consumption if the compressor permits it, otherwise +// it aborts the process. +func (c *EEBusOHPCF) stop(entity spineapi.EntityRemoteInterface) error { + if pausable, err := c.cem.OHPCF.ConsumptionIsPausable(entity); err == nil && pausable { + return c.await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + return c.cem.OHPCF.PausePowerConsumptionProcess(entity, cb) + }) + } + + if stoppable, err := c.cem.OHPCF.ConsumptionIsStoppable(entity); err == nil && stoppable { + return c.await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + return c.cem.OHPCF.AbortPowerConsumptionProcess(entity, cb) + }) + } + + return api.ErrNotAvailable +} + +// ohpcfWriteTimeout bounds how long a control write waits for its result. +const ohpcfWriteTimeout = 10 * time.Second + +// await runs a control write and waits for the heat pump's result, returning an +// error if the write is rejected or no result arrives within the timeout. +func (c *EEBusOHPCF) await(write func(func(model.ResultDataType)) (*model.MsgCounterType, error)) error { + res := make(chan model.ResultDataType, 1) + + if _, err := write(func(r model.ResultDataType) { res <- r }); err != nil { + return err + } + + select { + case r := <-res: + if r.ErrorNumber != nil && *r.ErrorNumber != 0 { + err := fmt.Errorf("write rejected: %d", *r.ErrorNumber) + if r.Description != nil { + err = fmt.Errorf("%w (%s)", err, *r.Description) + } + c.log.ERROR.Println(err) + return err + } + return nil + case <-time.After(ohpcfWriteTimeout): + return errors.New("write result timeout") + } +} + +// MaxCurrent implements the api.Charger interface. OHPCF is on/off and cannot +// be modulated, so the offered current is ignored. +func (c *EEBusOHPCF) MaxCurrent(int64) error { + return c.apply() +} + +// apply issues the command to align the optional consumption with the on/off +// intent. It is idempotent: ohpcfControlAction only acts on a state transition. +func (c *EEBusOHPCF) apply() error { + entity, ok := c.connectedCompressor() + if !ok { + return errNotConnected + } + + state, err := c.cem.OHPCF.PowerConsumptionProcessState(entity) + if err != nil { + // no process state announced yet, nothing to control + return nil + } + + switch ohpcfControlAction(state, c.lastEnabled()) { + case ohpcfSchedule: + return c.await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + // 0 = start immediately (relative schedule, see SchedulePowerConsumptionProcess) + return c.cem.OHPCF.SchedulePowerConsumptionProcess(entity, 0, cb) + }) + case ohpcfResume: + return c.await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + return c.cem.OHPCF.ResumePowerConsumptionProcess(entity, cb) + }) + case ohpcfStop: + return c.stop(entity) + } + + return nil +} + +var _ api.PowerLimiter = (*EEBusOHPCF)(nil) + +// GetMinMaxPower implements the api.PowerLimiter interface, reporting the +// announced optional consumption (estimate..max) or ErrNotAvailable if none. +func (c *EEBusOHPCF) GetMinMaxPower() (float64, float64, error) { + entity, ok := c.connectedCompressor() + if !ok { + return 0, 0, errNotConnected + } + + estimate, err := c.cem.OHPCF.RequestedPowerEstimate(entity) + if err != nil { + return 0, 0, api.ErrNotAvailable + } + + maxPower, err := c.cem.OHPCF.RequestedPowerMax(entity) + if err != nil { + maxPower = estimate + } + + return estimate, maxPower, nil +} + +var _ api.Meter = (*EEBusOHPCF)(nil) + +// CurrentPower implements the api.Meter interface and reports the heat pump's +// measured power consumption via the MPC use case. +func (c *EEBusOHPCF) CurrentPower() (float64, error) { + c.mu.RLock() + entity := c.mpcEntity + c.mu.RUnlock() + + if entity == nil || !c.ma.MaMPCInterface.IsScenarioAvailableAtEntity(entity, eebus.MPCPower) { + return 0, api.ErrNotAvailable + } + + power, err := c.ma.MaMPCInterface.Power(entity) + if err != nil { + return 0, eebus.WrapError(err) + } + + return power, nil +} + +var _ api.Battery = (*EEBusOHPCF)(nil) + +// Soc implements the api.Battery interface and reports the heat pump's domestic +// hot water temperature in °C via the MDT use case. +func (c *EEBusOHPCF) Soc() (float64, error) { + c.mu.RLock() + entity := c.dhwEntity + c.mu.RUnlock() + + if entity == nil || !c.ma.MaMDTInterface.IsScenarioAvailableAtEntity(entity, eebus.MDTTemperature) { + return 0, api.ErrNotAvailable + } + + temp, err := c.ma.MaMDTInterface.Temperature(entity, model.UnitOfMeasurementTypedegC) + if err != nil { + return 0, eebus.WrapError(err) + } + + return temp, nil +} diff --git a/charger/eebus-ohpcf_test.go b/charger/eebus-ohpcf_test.go new file mode 100644 index 000000000..7f34c5303 --- /dev/null +++ b/charger/eebus-ohpcf_test.go @@ -0,0 +1,69 @@ +package charger + +import ( + "testing" + + ucapi "github.com/enbility/eebus-go/usecases/api" + "github.com/evcc-io/evcc/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// methods accessing the compressor entity must error when it is absent, +// so a missing compressor is not mistaken for an idle device. +func TestEEBusOHPCFNotConnected(t *testing.T) { + c := &EEBusOHPCF{} + + status, err := c.Status() + require.ErrorIs(t, err, errNotConnected) + assert.Equal(t, api.StatusNone, status) + + _, err = c.Enabled() + require.ErrorIs(t, err, errNotConnected) + + require.ErrorIs(t, c.Enable(true), errNotConnected) + require.ErrorIs(t, c.MaxCurrent(16), errNotConnected) +} + +// status mapping: running is C, every other connected state (incl. completed +// and stopped after a boost) is standby B, never disconnected. +func TestOHPCFStatus(t *testing.T) { + tc := []struct { + state ucapi.CompressorPowerConsumptionStateType + want api.ChargeStatus + }{ + {ucapi.CompressorPowerConsumptionStateRunning, api.StatusC}, + {ucapi.CompressorPowerConsumptionStateAvailable, api.StatusB}, + {ucapi.CompressorPowerConsumptionStateScheduled, api.StatusB}, + {ucapi.CompressorPowerConsumptionStatePaused, api.StatusB}, + {ucapi.CompressorPowerConsumptionStateCompleted, api.StatusB}, + {ucapi.CompressorPowerConsumptionStateStopped, api.StatusB}, + } + + for _, tc := range tc { + assert.Equal(t, tc.want, ohpcfStatus(tc.state), "%v", tc.state) + } +} + +// on/off control: enable schedules/resumes, disable stops, and an already +// running/scheduled process issues no further command. +func TestOHPCFControlAction(t *testing.T) { + tc := []struct { + state ucapi.CompressorPowerConsumptionStateType + enable bool + want ohpcfAction + }{ + {ucapi.CompressorPowerConsumptionStateAvailable, true, ohpcfSchedule}, + {ucapi.CompressorPowerConsumptionStatePaused, true, ohpcfResume}, + {ucapi.CompressorPowerConsumptionStateScheduled, true, ohpcfNone}, + {ucapi.CompressorPowerConsumptionStateRunning, true, ohpcfNone}, + {ucapi.CompressorPowerConsumptionStateRunning, false, ohpcfStop}, + {ucapi.CompressorPowerConsumptionStateScheduled, false, ohpcfStop}, + {ucapi.CompressorPowerConsumptionStateAvailable, false, ohpcfNone}, + {ucapi.CompressorPowerConsumptionStatePaused, false, ohpcfNone}, + } + + for _, tc := range tc { + assert.Equal(t, tc.want, ohpcfControlAction(tc.state, tc.enable), "%v enable=%v", tc.state, tc.enable) + } +} diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go index 786d8be00..822dba67f 100644 --- a/core/loadpoint_effective.go +++ b/core/loadpoint_effective.go @@ -161,6 +161,14 @@ func (lp *Loadpoint) effectiveMinCurrent() float64 { } } + // power-limited chargers (e.g. EEBus OHPCF heat pump) report their demand in + // W; convert to per-phase current so the PV enable gate covers it + if c, ok := api.Cap[api.PowerLimiter](lp.charger); ok { + if res, _, err := c.GetMinMaxPower(); err == nil && res > 0 { + chargerMin = res / (Voltage * float64(lp.minActivePhases())) + } + } + switch { case max(vehicleMin, chargerMin) == 0: return lpMin @@ -187,6 +195,12 @@ func (lp *Loadpoint) effectiveMaxCurrent() float64 { } } + if c, ok := api.Cap[api.PowerLimiter](lp.charger); ok { + if _, res, err := c.GetMinMaxPower(); err == nil && res > 0 { + maxCurrent = min(maxCurrent, res/(Voltage*float64(lp.maxActivePhases()))) + } + } + return maxCurrent } diff --git a/core/loadpoint_effective_test.go b/core/loadpoint_effective_test.go index 2b6dc2dd4..47a3a7ae2 100644 --- a/core/loadpoint_effective_test.go +++ b/core/loadpoint_effective_test.go @@ -72,6 +72,29 @@ func TestEffectiveMinMaxCurrent(t *testing.T) { } } +func TestEffectivePowerLimiter(t *testing.T) { + Voltage = 230 + ctrl := gomock.NewController(t) + + lp := NewLoadpoint(util.NewLogger("foo"), nil) + phases := float64(lp.minActivePhases()) // == maxActivePhases for default lp + + powerLimiter := api.NewMockPowerLimiter(ctrl) + // min 10A, max 12A worth of power across all phases + powerLimiter.EXPECT().GetMinMaxPower().Return(230*phases*10, 230*phases*12, nil).AnyTimes() + + lp.charger = struct { + api.Charger + api.PowerLimiter + }{ + Charger: api.NewMockCharger(ctrl), + PowerLimiter: powerLimiter, + } + + assert.Equal(t, 10.0, lp.effectiveMinCurrent(), "min") + assert.Equal(t, 12.0, lp.effectiveMaxCurrent(), "max") +} + func TestNextPlan(t *testing.T) { clock := clock.NewMock() diff --git a/go.mod b/go.mod index 55bb980ab..39e0a6974 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/denisbrodbeck/machineid v1.0.1 github.com/dylanmei/iso8601 v0.1.0 github.com/eclipse/paho.mqtt.golang v1.5.1 - github.com/enbility/eebus-go v0.7.1-0.20260520155652-11bc53084557 + github.com/enbility/eebus-go v0.7.1-0.20260608105936-abdcf864d0cf github.com/enbility/ship-go v0.6.1-0.20260518113001-134687068e3c github.com/enbility/spine-go v0.7.1-0.20260520153416-0104ce40c885 github.com/evcc-io/openapi-mcp v0.6.1-0.20260503092507-6199c7ad3baf @@ -261,3 +261,5 @@ tool ( replace github.com/grid-x/modbus => github.com/evcc-io/modbus v0.0.0-20250501165638-8b6f1fbdb7ea replace github.com/lorenzodonini/ocpp-go => github.com/evcc-io/ocpp-go v0.0.0-20251212212612-b7f92ee0443b + +replace github.com/enbility/eebus-go => github.com/evcc-io/eebus-go v0.0.0-20260627085352-933afd2a4ea6 diff --git a/go.sum b/go.sum index a5235aabc..ab55839d2 100644 --- a/go.sum +++ b/go.sum @@ -136,8 +136,6 @@ github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvS github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04= github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= -github.com/enbility/eebus-go v0.7.1-0.20260520155652-11bc53084557 h1:YQvO8wiUG1OEjFAkKtLTrCFze8KpG7YZ1TwrQKj9H54= -github.com/enbility/eebus-go v0.7.1-0.20260520155652-11bc53084557/go.mod h1:L1nBEy/dBASQRoz/9JF7KEmqFdnXHLbZqDpxiA0ezr4= github.com/enbility/go-avahi v0.0.0-20240909195612-d5de6b280d7a h1:foChWb8lhzqa6lWDRs6COYMdp649YlUirFP8GqoT0JQ= github.com/enbility/go-avahi v0.0.0-20240909195612-d5de6b280d7a/go.mod h1:H64mhYcAQUGUUnVqMdZQf93kPecH4M79xwH95Lddt3U= github.com/enbility/ship-go v0.6.1-0.20260518113001-134687068e3c h1:ZNYyr/SDaAMHi6ubJqWuoId8QUu5V4xXWCIRf7VnfCY= @@ -146,6 +144,8 @@ github.com/enbility/spine-go v0.7.1-0.20260520153416-0104ce40c885 h1:nBqjAPONpI/ github.com/enbility/spine-go v0.7.1-0.20260520153416-0104ce40c885/go.mod h1:ddWZU5BQGyzRGF20Z7rUKFFzbCp+cfu9mZgYtOMW/fM= github.com/enbility/zeroconf/v2 v2.0.0-20240920094356-be1cae74fda6 h1:XOYvxKtT1oxT37w/5oEiRLuPbm9FuJPt3fiYhX0h8Po= github.com/enbility/zeroconf/v2 v2.0.0-20240920094356-be1cae74fda6/go.mod h1:BszP9qFV14mPXgyIREbgIdQtWxbAj3OKqvK02HihMoM= +github.com/evcc-io/eebus-go v0.0.0-20260627085352-933afd2a4ea6 h1:ax+dfxmeF/hN3IIUXFnDw3y3BE7pr0m/699k08+Q9sM= +github.com/evcc-io/eebus-go v0.0.0-20260627085352-933afd2a4ea6/go.mod h1:L1nBEy/dBASQRoz/9JF7KEmqFdnXHLbZqDpxiA0ezr4= github.com/evcc-io/modbus v0.0.0-20250501165638-8b6f1fbdb7ea h1:F6eyC8V8wvc3ranlsR4coAls+OPBkVvxyX0a2VqdlPc= github.com/evcc-io/modbus v0.0.0-20250501165638-8b6f1fbdb7ea/go.mod h1:swrNGAVgI1r/3d/sEKE7qgujdRR9aHVPYKyc3gvpVTc= github.com/evcc-io/ocpp-go v0.0.0-20251212212612-b7f92ee0443b h1:5vFr7wOwoi5ylybUnxxBk1U3FRF2+3Sf0sKx8thxw18= diff --git a/server/eebus/eebus.go b/server/eebus/eebus.go index 57afae406..3f98ca09c 100644 --- a/server/eebus/eebus.go +++ b/server/eebus/eebus.go @@ -17,12 +17,14 @@ import ( "github.com/enbility/eebus-go/usecases/cem/evcem" "github.com/enbility/eebus-go/usecases/cem/evsecc" "github.com/enbility/eebus-go/usecases/cem/evsoc" + "github.com/enbility/eebus-go/usecases/cem/ohpcf" "github.com/enbility/eebus-go/usecases/cem/opev" "github.com/enbility/eebus-go/usecases/cem/oscev" csplc "github.com/enbility/eebus-go/usecases/cs/lpc" cslpp "github.com/enbility/eebus-go/usecases/cs/lpp" eglpc "github.com/enbility/eebus-go/usecases/eg/lpc" eglpp "github.com/enbility/eebus-go/usecases/eg/lpp" + "github.com/enbility/eebus-go/usecases/ma/mdt" "github.com/enbility/eebus-go/usecases/ma/mgcp" "github.com/enbility/eebus-go/usecases/ma/mpc" shipapi "github.com/enbility/ship-go/api" @@ -47,6 +49,7 @@ type CustomerEnergyManagement struct { EvSoc ucapi.CemEVSOCInterface OpEV ucapi.CemOPEVInterface OscEV ucapi.CemOSCEVInterface + OHPCF ucapi.CemOHPCFInterface } // Controllable System @@ -59,6 +62,7 @@ type ControllableSystem struct { type MonitoringAppliance struct { ucapi.MaMGCPInterface ucapi.MaMPCInterface + ucapi.MaMDTInterface } // Energy Guard @@ -200,12 +204,14 @@ func NewServer(other Config) (*EEBus, error) { OpEV: opev.NewOPEV(localEntity, c.ucCallback), OscEV: oscev.NewOSCEV(localEntity, c.ucCallback), EvSoc: evsoc.NewEVSOC(localEntity, c.ucCallback), + OHPCF: ohpcf.NewOHPCF(localEntity, c.ucCallback), } // monitoring appliance to meters c.ma = MonitoringAppliance{ MaMGCPInterface: mgcp.NewMGCP(localEntity, c.ucCallback), MaMPCInterface: mpc.NewMPC(localEntity, c.ucCallback), + MaMDTInterface: mdt.NewMDT(localEntity, c.ucCallback), } } @@ -240,8 +246,9 @@ func NewServer(other Config) (*EEBus, error) { c.cem.EvseCC, c.cem.EvCC, c.cem.EvCem, c.cem.OpEV, c.cem.OscEV, c.cem.EvSoc, + c.cem.OHPCF, c.cs.CsLPCInterface, c.cs.CsLPPInterface, - c.ma.MaMGCPInterface, c.ma.MaMPCInterface, + c.ma.MaMGCPInterface, c.ma.MaMPCInterface, c.ma.MaMDTInterface, c.eg.EgLPCInterface, c.eg.EgLPPInterface, } { c.service.AddUseCase(uc) diff --git a/server/eebus/scenarios.go b/server/eebus/scenarios.go index d40671f4c..84bbbaa62 100644 --- a/server/eebus/scenarios.go +++ b/server/eebus/scenarios.go @@ -29,6 +29,11 @@ const ( MPCFrequency uint = 5 // S5 frequency ) +// MDT — Monitoring of Domestic Hot Water Temperature (UC TS v1.0.0) +const ( + MDTTemperature uint = 1 // S1 DHW temperature +) + // LPC — Limitation of Power Consumption (UC TS v1.0.0). Same scenario layout for CS and EG roles. const ( LPCLimit uint = 1 // S1 LoadControl: consumption limit diff --git a/templates/definition/charger/eebus-ohpcf.yaml b/templates/definition/charger/eebus-ohpcf.yaml new file mode 100644 index 000000000..e933432d6 --- /dev/null +++ b/templates/definition/charger/eebus-ohpcf.yaml @@ -0,0 +1,27 @@ +template: eebus-ohpcf +products: + - description: + de: EEBUS Wärmepumpe (OHPCF) + en: EEBUS heat pump (OHPCF) +group: heating +capabilities: ["meter"] +params: + - preset: eebus + - name: reboost + type: duration + default: 10m + advanced: true + description: + de: Reboost-Intervall + en: Reboost interval + help: + de: Wartezeit, nach der eine neu angekündigte optionale Leistungsaufnahme erneut eingeplant wird. + en: Wait time after which a newly announced optional power consumption is scheduled again. +render: | + type: eebus-ohpcf + ski: {{ .ski }} + {{ if .ip }}ip: {{ .ip }}{{ end }} + reboost: {{ .reboost }} + features: + - heating + - integrateddevice From 6fb096f61f5ebaa2cc0b3aa7c108bae498726512 Mon Sep 17 00:00:00 2001 From: RDMN Date: Mon, 29 Jun 2026 14:35:16 +0200 Subject: [PATCH 081/733] Meter (DSMR): accept binary WebSocket frames (#31324) --- meter/dsmr_test.go | 52 +++++++++++++++++++++++++++------------------- meter/dsmr_ws.go | 6 +++--- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/meter/dsmr_test.go b/meter/dsmr_test.go index 101f0d3c5..5ef977f95 100644 --- a/meter/dsmr_test.go +++ b/meter/dsmr_test.go @@ -257,33 +257,43 @@ func TestDsmrTCP(t *testing.T) { } func TestDsmrWebSocket(t *testing.T) { - payload := dsmrFrame(dsmrTelegram50) + for _, tc := range []struct { + name string + message websocket.MessageType + }{ + {"text", websocket.MessageText}, + {"binary", websocket.MessageBinary}, + } { + t.Run(tc.name, func(t *testing.T) { + payload := dsmrFrame(dsmrTelegram50) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := websocket.Accept(w, r, nil) - if err != nil { - return - } - defer conn.CloseNow() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer conn.CloseNow() - for { - if err := conn.Write(r.Context(), websocket.MessageText, payload); err != nil { - return - } - time.Sleep(50 * time.Millisecond) - } - })) - defer srv.Close() + for { + if err := conn.Write(r.Context(), tc.message, payload); err != nil { + return + } + time.Sleep(50 * time.Millisecond) + } + })) + defer srv.Close() - uri := "ws" + strings.TrimPrefix(srv.URL, "http") + uri := "ws" + strings.TrimPrefix(srv.URL, "http") - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() - m, err := NewDsmr(ctx, uri, time.Second) - require.NoError(t, err) + m, err := NewDsmr(ctx, uri, time.Second) + require.NoError(t, err) - assertReadings(t, m) + assertReadings(t, m) + }) + } } // TestDsmrIgnoresGarbage verifies that leading garbage and a CRC-mismatched diff --git a/meter/dsmr_ws.go b/meter/dsmr_ws.go index 1f25eb103..475f5e504 100644 --- a/meter/dsmr_ws.go +++ b/meter/dsmr_ws.go @@ -48,9 +48,9 @@ func (r *wsReader) Read(p []byte) (int, error) { return 0, err } - // The P1 telegram is streamed as text frames; skip any other frame - // type so metadata or binary frames are not fed to the DSMR parser. - if msgType != websocket.MessageText { + // The P1 telegram may be streamed as text or binary frames, depending on + // the dongle firmware. Skip control or metadata frames. + if msgType != websocket.MessageText && msgType != websocket.MessageBinary { continue } From 7e9195d0e3c47ccd2d9a040625a8c916573a1419 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 30 Jun 2026 13:45:49 +0200 Subject: [PATCH 082/733] EEBus: add heating brands (BC) (#31344) --- charger/{eebus.go => eebus-evse.go} | 2 +- charger/eebus-ohpcf.go | 72 +++++++++++++++++-- charger/eebus-ohpcf_test.go | 3 + .../charger/{eebus.yaml => eebus-evse.yaml} | 4 +- .../charger/eebus-ohpcf-wolfvaillant.yaml | 38 ++++++++++ templates/definition/charger/eebus-ohpcf.yaml | 7 +- .../charger/elli-charger-connect.yaml | 1 + .../definition/charger/elli-charger-pro.yaml | 1 + templates/definition/charger/evbox-livo.yaml | 1 + .../definition/charger/porsche-pmcc.yaml | 1 + .../definition/charger/porsche-pmcp.yaml | 1 + templates/definition/meter/eebus-mgcp.yaml | 1 + templates/definition/meter/eebus-mpc.yaml | 1 + util/templates/includes/eebus.tpl | 1 - 14 files changed, 120 insertions(+), 14 deletions(-) rename charger/{eebus.go => eebus-evse.go} (99%) rename templates/definition/charger/{eebus.yaml => eebus-evse.yaml} (78%) create mode 100644 templates/definition/charger/eebus-ohpcf-wolfvaillant.yaml diff --git a/charger/eebus.go b/charger/eebus-evse.go similarity index 99% rename from charger/eebus.go rename to charger/eebus-evse.go index b47091209..b4a1d9200 100644 --- a/charger/eebus.go +++ b/charger/eebus-evse.go @@ -49,7 +49,7 @@ type EEBus struct { } func init() { - registry.AddCtx("eebus", NewEEBusFromConfig) + registry.AddCtx("eebus-evse", NewEEBusFromConfig) } // NewEEBusFromConfig creates an EEBus charger from generic config diff --git a/charger/eebus-ohpcf.go b/charger/eebus-ohpcf.go index 4a3360268..15d347e43 100644 --- a/charger/eebus-ohpcf.go +++ b/charger/eebus-ohpcf.go @@ -10,6 +10,7 @@ import ( eebusapi "github.com/enbility/eebus-go/api" ucapi "github.com/enbility/eebus-go/usecases/api" "github.com/enbility/eebus-go/usecases/cem/ohpcf" + "github.com/enbility/eebus-go/usecases/eg/lpc" "github.com/enbility/eebus-go/usecases/ma/mdt" "github.com/enbility/eebus-go/usecases/ma/mpc" spineapi "github.com/enbility/spine-go/api" @@ -30,17 +31,19 @@ type EEBusOHPCF struct { *embed cem *eebus.CustomerEnergyManagement ma *eebus.MonitoringAppliance + eg *eebus.EnergyGuard ctx context.Context reboost time.Duration - mu sync.RWMutex - log *util.Logger - compressor spineapi.EntityRemoteInterface - mpcEntity spineapi.EntityRemoteInterface - dhwEntity spineapi.EntityRemoteInterface - enabled bool - reboosting bool + mu sync.RWMutex + log *util.Logger + compressor spineapi.EntityRemoteInterface + mpcEntity spineapi.EntityRemoteInterface + dhwEntity spineapi.EntityRemoteInterface + egLpcEntity spineapi.EntityRemoteInterface + enabled bool + reboosting bool connector *eebus.Connector } @@ -86,6 +89,7 @@ func NewEEBusOHPCF(ctx context.Context, embed *embed, ski, ip string, reboost ti log: util.NewLogger("eebus-ohpcf"), cem: inst.CustomerEnergyManagement(), ma: inst.MonitoringAppliance(), + eg: inst.EnergyGuard(), connector: eebus.NewConnector(), ctx: ctx, reboost: reboost, @@ -125,6 +129,7 @@ func (c *EEBusOHPCF) Connect(connected bool) { c.compressor = nil c.mpcEntity = nil c.dhwEntity = nil + c.egLpcEntity = nil } // UseCaseEvent implements the eebus.Device interface @@ -162,6 +167,15 @@ func (c *EEBusOHPCF) UseCaseEvent(_ spineapi.DeviceRemoteInterface, entity spine c.mu.Lock() c.dhwEntity = entity c.mu.Unlock() + + // Energy Guard LPC carries the §14a/LPC consumption limit + case lpc.UseCaseSupportUpdate: + c.mu.Lock() + // use most specific selector + if c.egLpcEntity == nil || len(entity.Address().Entity) < len(c.egLpcEntity.Address().Entity) { + c.egLpcEntity = entity + } + c.mu.Unlock() } } @@ -360,6 +374,50 @@ func (c *EEBusOHPCF) MaxCurrent(int64) error { return c.apply() } +var _ api.Dimmer = (*EEBusOHPCF)(nil) + +// Dimmed implements the api.Dimmer interface, reporting whether a §14a/LPC +// consumption limit is currently active on the heat pump. +func (c *EEBusOHPCF) Dimmed() (bool, error) { + c.mu.RLock() + entity := c.egLpcEntity + c.mu.RUnlock() + + if entity == nil || !c.eg.EgLPCInterface.IsScenarioAvailableAtEntity(entity, eebus.LPCLimit) { + return false, api.ErrNotAvailable + } + + limit, err := c.eg.EgLPCInterface.ConsumptionLimit(entity) + if err != nil { + // scenario announced but no usable value yet + if errors.Is(err, eebusapi.ErrDataNotAvailable) || + errors.Is(err, eebusapi.ErrMetadataNotAvailable) || + errors.Is(err, eebusapi.ErrDataInvalid) { + return false, api.ErrNotAvailable + } + return false, err + } + + return limit.IsActive && limit.Value > 0, nil +} + +// Dim implements the api.Dimmer interface. It writes a §14a/LPC consumption +// limit (fixed 0W safe limit) to the heat pump while dimmed, releasing it otherwise. +func (c *EEBusOHPCF) Dim(dim bool) error { + c.mu.RLock() + entity := c.egLpcEntity + c.mu.RUnlock() + + if entity == nil || !c.eg.EgLPCInterface.IsScenarioAvailableAtEntity(entity, eebus.LPCLimit) { + return api.ErrNotAvailable + } + + // TODO: change api.Dimmer to make the limit configurable; use a fixed 0W safe limit for now + return c.await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + return c.eg.EgLPCInterface.WriteConsumptionLimit(entity, ucapi.LoadLimit{Value: 0, IsActive: dim}, cb) + }) +} + // apply issues the command to align the optional consumption with the on/off // intent. It is idempotent: ohpcfControlAction only acts on a state transition. func (c *EEBusOHPCF) apply() error { diff --git a/charger/eebus-ohpcf_test.go b/charger/eebus-ohpcf_test.go index 7f34c5303..bcada9107 100644 --- a/charger/eebus-ohpcf_test.go +++ b/charger/eebus-ohpcf_test.go @@ -23,6 +23,9 @@ func TestEEBusOHPCFNotConnected(t *testing.T) { require.ErrorIs(t, c.Enable(true), errNotConnected) require.ErrorIs(t, c.MaxCurrent(16), errNotConnected) + + // dimming uses EG LPC, which is unavailable without an LPC entity + require.ErrorIs(t, c.Dim(true), api.ErrNotAvailable) } // status mapping: running is C, every other connected state (incl. completed diff --git a/templates/definition/charger/eebus.yaml b/templates/definition/charger/eebus-evse.yaml similarity index 78% rename from templates/definition/charger/eebus.yaml rename to templates/definition/charger/eebus-evse.yaml index 8efd412c8..a270a29b9 100644 --- a/templates/definition/charger/eebus.yaml +++ b/templates/definition/charger/eebus-evse.yaml @@ -1,4 +1,5 @@ -template: eebus +template: eebus-evse +covers: ["eebus"] products: - description: de: EEBUS kompatibel @@ -8,5 +9,6 @@ capabilities: ["mA", "meter", "dim"] params: - preset: eebus render: | + type: eebus-evse {{ include "eebus" . }} meter: true diff --git a/templates/definition/charger/eebus-ohpcf-wolfvaillant.yaml b/templates/definition/charger/eebus-ohpcf-wolfvaillant.yaml new file mode 100644 index 000000000..ccdcfd55a --- /dev/null +++ b/templates/definition/charger/eebus-ohpcf-wolfvaillant.yaml @@ -0,0 +1,38 @@ +template: eebus-ohpcf-wolfvaillant +products: + - brand: Wolf + description: + generic: CHA-Monoblock (07, 10, 16/20, 20/24 with EEBus) + - brand: Wolf + description: + generic: FHA-Monoblock (EEBus) + - brand: Wolf + description: + generic: EEBus + - brand: Vaillant + description: + generic: aroTHERM plus (EEBus) + - brand: Vaillant + description: + generic: EEBus +group: heating +capabilities: ["meter", "dim"] +params: + - preset: eebus + - name: reboost + type: duration + default: 10m + advanced: true + description: + de: Reboost-Intervall + en: Reboost interval + help: + de: Wartezeit, nach der eine neu angekündigte optionale Leistungsaufnahme erneut eingeplant wird. + en: Wait time after which a newly announced optional power consumption is scheduled again. +render: | + type: eebus-ohpcf + {{ include "eebus" . }} + reboost: {{ .reboost }} + features: + - heating + - integrateddevice diff --git a/templates/definition/charger/eebus-ohpcf.yaml b/templates/definition/charger/eebus-ohpcf.yaml index e933432d6..8594f698d 100644 --- a/templates/definition/charger/eebus-ohpcf.yaml +++ b/templates/definition/charger/eebus-ohpcf.yaml @@ -3,8 +3,8 @@ products: - description: de: EEBUS Wärmepumpe (OHPCF) en: EEBUS heat pump (OHPCF) -group: heating -capabilities: ["meter"] +group: heatinggeneric +capabilities: ["meter", "dim"] params: - preset: eebus - name: reboost @@ -19,8 +19,7 @@ params: en: Wait time after which a newly announced optional power consumption is scheduled again. render: | type: eebus-ohpcf - ski: {{ .ski }} - {{ if .ip }}ip: {{ .ip }}{{ end }} + {{ include "eebus" . }} reboost: {{ .reboost }} features: - heating diff --git a/templates/definition/charger/elli-charger-connect.yaml b/templates/definition/charger/elli-charger-connect.yaml index 5e32a7738..37d1db2f2 100644 --- a/templates/definition/charger/elli-charger-connect.yaml +++ b/templates/definition/charger/elli-charger-connect.yaml @@ -43,4 +43,5 @@ params: - preset: eebus - name: ip render: | + type: eebus-evse {{ include "eebus" . }} diff --git a/templates/definition/charger/elli-charger-pro.yaml b/templates/definition/charger/elli-charger-pro.yaml index 96953e89f..7b1addf48 100644 --- a/templates/definition/charger/elli-charger-pro.yaml +++ b/templates/definition/charger/elli-charger-pro.yaml @@ -39,6 +39,7 @@ params: - preset: eebus - name: ip render: | + type: eebus-evse {{ include "eebus" . }} meter: true chargedEnergy: false diff --git a/templates/definition/charger/evbox-livo.yaml b/templates/definition/charger/evbox-livo.yaml index 623f6a21d..dc01f056d 100644 --- a/templates/definition/charger/evbox-livo.yaml +++ b/templates/definition/charger/evbox-livo.yaml @@ -11,4 +11,5 @@ requirements: params: - preset: eebus render: | + type: eebus-evse {{ include "eebus" . }} diff --git a/templates/definition/charger/porsche-pmcc.yaml b/templates/definition/charger/porsche-pmcc.yaml index 898f359d9..ed431ecf0 100644 --- a/templates/definition/charger/porsche-pmcc.yaml +++ b/templates/definition/charger/porsche-pmcc.yaml @@ -7,5 +7,6 @@ capabilities: ["iso151182", "mA", "meter", "dim"] params: - preset: eebus render: | + type: eebus-evse {{ include "eebus" . }} meter: true diff --git a/templates/definition/charger/porsche-pmcp.yaml b/templates/definition/charger/porsche-pmcp.yaml index 0b3288ece..f9e9e29d4 100644 --- a/templates/definition/charger/porsche-pmcp.yaml +++ b/templates/definition/charger/porsche-pmcp.yaml @@ -7,5 +7,6 @@ capabilities: ["meter", "dim"] params: - preset: eebus render: | + type: eebus-evse {{ include "eebus" . }} meter: true diff --git a/templates/definition/meter/eebus-mgcp.yaml b/templates/definition/meter/eebus-mgcp.yaml index a38bece0e..f4c912e0c 100644 --- a/templates/definition/meter/eebus-mgcp.yaml +++ b/templates/definition/meter/eebus-mgcp.yaml @@ -14,5 +14,6 @@ params: choice: ["grid"] - preset: eebus render: | + type: eebus {{ include "eebus" . }} usage: {{ .usage }} diff --git a/templates/definition/meter/eebus-mpc.yaml b/templates/definition/meter/eebus-mpc.yaml index e6c660d69..10b1c324e 100644 --- a/templates/definition/meter/eebus-mpc.yaml +++ b/templates/definition/meter/eebus-mpc.yaml @@ -15,5 +15,6 @@ params: choice: ["charge"] - preset: eebus render: | + type: eebus {{ include "eebus" . }} usage: {{ .usage }} diff --git a/util/templates/includes/eebus.tpl b/util/templates/includes/eebus.tpl index db81f30cd..5e56538bd 100644 --- a/util/templates/includes/eebus.tpl +++ b/util/templates/includes/eebus.tpl @@ -1,5 +1,4 @@ {{ define "eebus" }} -type: eebus ski: {{ .ski }} {{ if .ip }}ip: {{ .ip }}{{ end }} {{- end}} From 2ac2fae0a344498062bc82a85e443a994caed570 Mon Sep 17 00:00:00 2001 From: GurliGebis Date: Tue, 30 Jun 2026 14:10:48 +0200 Subject: [PATCH 083/733] Tariff (Nordpool): add average by hour option (#31351) --- templates/definition/tariff/nordpool.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/templates/definition/tariff/nordpool.yaml b/templates/definition/tariff/nordpool.yaml index 470798c4a..3f1c12d65 100644 --- a/templates/definition/tariff/nordpool.yaml +++ b/templates/definition/tariff/nordpool.yaml @@ -43,9 +43,11 @@ params: - name: currency choice: ["DKK", "EUR", "NOK", "PLN", "RON", "SEK"] - preset: tariff-base + - preset: tariff-features render: | type: custom {{ include "tariff-base" . }} + {{ include "tariff-features" . }} forecast: source: go script: | From 258dcb41b5c6b7fa04c560fde607ddfc28ecb6b5 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 30 Jun 2026 14:14:32 +0200 Subject: [PATCH 084/733] EEBus: await control-write results across all use cases (#31350) --- charger/eebus-evse.go | 24 ++++++--------------- charger/eebus-ohpcf.go | 39 +++++----------------------------- charger/eebus_test.go | 23 +++++++++++++------- meter/eebus.go | 48 +++++++++++------------------------------- server/eebus/helper.go | 30 ++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 96 deletions(-) diff --git a/charger/eebus-evse.go b/charger/eebus-evse.go index b4a1d9200..ca0e72ecd 100644 --- a/charger/eebus-evse.go +++ b/charger/eebus-evse.go @@ -345,7 +345,9 @@ func (c *EEBus) writeCurrentLimitData(evEntity spineapi.EntityRemoteInterface, c } // always set overload protection limits (obligation) - if _, err := c.cem.OpEV.WriteLoadControlLimits(evEntity, limits, c.callbackResult("opEV limits")); err != nil { + if err := eebus.Await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + return c.cem.OpEV.WriteLoadControlLimits(evEntity, limits, cb) + }); err != nil { return err } @@ -395,27 +397,13 @@ func (c *EEBus) writeOscevLimits(evEntity spineapi.EntityRemoteInterface, curren limits = append(limits, limit) } - if _, err := c.cem.OscEV.WriteLoadControlLimits(evEntity, limits, c.callbackResult("oscEV limits")); err != nil { + if err := eebus.Await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + return c.cem.OscEV.WriteLoadControlLimits(evEntity, limits, cb) + }); err != nil { c.log.DEBUG.Println("failed to write OSCEV limits:", err) } } -// callbackResult logs a rejected eebus write; a successful result is ignored. -func (c *EEBus) callbackResult(msg string) func(model.ResultDataType) { - return func(result model.ResultDataType) { - if result.ErrorNumber == nil || *result.ErrorNumber == 0 { - return - } - - if result.Description != nil { - c.log.ERROR.Printf("%s: write rejected: %d (%s)", msg, *result.ErrorNumber, *result.Description) - return - } - - c.log.ERROR.Printf("%s: write rejected: %d", msg, *result.ErrorNumber) - } -} - // MaxCurrent implements the api.Charger interface func (c *EEBus) MaxCurrent(current int64) error { return c.MaxCurrentMillis(float64(current)) diff --git a/charger/eebus-ohpcf.go b/charger/eebus-ohpcf.go index 15d347e43..f935cedb7 100644 --- a/charger/eebus-ohpcf.go +++ b/charger/eebus-ohpcf.go @@ -3,7 +3,6 @@ package charger import ( "context" "errors" - "fmt" "sync" "time" @@ -326,13 +325,13 @@ func ohpcfControlAction(state ucapi.CompressorPowerConsumptionStateType, enable // it aborts the process. func (c *EEBusOHPCF) stop(entity spineapi.EntityRemoteInterface) error { if pausable, err := c.cem.OHPCF.ConsumptionIsPausable(entity); err == nil && pausable { - return c.await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + return eebus.Await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { return c.cem.OHPCF.PausePowerConsumptionProcess(entity, cb) }) } if stoppable, err := c.cem.OHPCF.ConsumptionIsStoppable(entity); err == nil && stoppable { - return c.await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + return eebus.Await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { return c.cem.OHPCF.AbortPowerConsumptionProcess(entity, cb) }) } @@ -340,34 +339,6 @@ func (c *EEBusOHPCF) stop(entity spineapi.EntityRemoteInterface) error { return api.ErrNotAvailable } -// ohpcfWriteTimeout bounds how long a control write waits for its result. -const ohpcfWriteTimeout = 10 * time.Second - -// await runs a control write and waits for the heat pump's result, returning an -// error if the write is rejected or no result arrives within the timeout. -func (c *EEBusOHPCF) await(write func(func(model.ResultDataType)) (*model.MsgCounterType, error)) error { - res := make(chan model.ResultDataType, 1) - - if _, err := write(func(r model.ResultDataType) { res <- r }); err != nil { - return err - } - - select { - case r := <-res: - if r.ErrorNumber != nil && *r.ErrorNumber != 0 { - err := fmt.Errorf("write rejected: %d", *r.ErrorNumber) - if r.Description != nil { - err = fmt.Errorf("%w (%s)", err, *r.Description) - } - c.log.ERROR.Println(err) - return err - } - return nil - case <-time.After(ohpcfWriteTimeout): - return errors.New("write result timeout") - } -} - // MaxCurrent implements the api.Charger interface. OHPCF is on/off and cannot // be modulated, so the offered current is ignored. func (c *EEBusOHPCF) MaxCurrent(int64) error { @@ -413,7 +384,7 @@ func (c *EEBusOHPCF) Dim(dim bool) error { } // TODO: change api.Dimmer to make the limit configurable; use a fixed 0W safe limit for now - return c.await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + return eebus.Await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { return c.eg.EgLPCInterface.WriteConsumptionLimit(entity, ucapi.LoadLimit{Value: 0, IsActive: dim}, cb) }) } @@ -434,12 +405,12 @@ func (c *EEBusOHPCF) apply() error { switch ohpcfControlAction(state, c.lastEnabled()) { case ohpcfSchedule: - return c.await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + return eebus.Await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { // 0 = start immediately (relative schedule, see SchedulePowerConsumptionProcess) return c.cem.OHPCF.SchedulePowerConsumptionProcess(entity, 0, cb) }) case ohpcfResume: - return c.await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + return eebus.Await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { return c.cem.OHPCF.ResumePowerConsumptionProcess(entity, cb) }) case ohpcfStop: diff --git a/charger/eebus_test.go b/charger/eebus_test.go index 434df0375..d08954f46 100644 --- a/charger/eebus_test.go +++ b/charger/eebus_test.go @@ -8,6 +8,7 @@ import ( ucapi "github.com/enbility/eebus-go/usecases/api" evcemuc "github.com/enbility/eebus-go/usecases/cem/evcem" "github.com/enbility/eebus-go/usecases/mocks" + spineapi "github.com/enbility/spine-go/api" spinemocks "github.com/enbility/spine-go/mocks" "github.com/enbility/spine-go/model" "github.com/evcc-io/evcc/api" @@ -131,6 +132,12 @@ func opevLimits3p(min, max, def float64) ([]float64, []float64, []float64, error return []float64{min, min, min}, []float64{max, max, max}, []float64{def, def, def}, nil } +// ackWrite makes a mocked WriteLoadControlLimits invoke its result callback with a +// success result, as the real eebus-go does, so eebus.Await completes. +func ackWrite(_ spineapi.EntityRemoteInterface, _ []ucapi.LoadLimitsPhase, resultCB func(model.ResultDataType)) { + resultCB(model.ResultDataType{}) +} + func TestWriteCurrentLimitData_OpevOnly(t *testing.T) { eebus, opev, oscev, evEntity := newTestEEBus(t) _ = eebus @@ -140,7 +147,7 @@ func TestWriteCurrentLimitData_OpevOnly(t *testing.T) { opev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(6, 16, 0)) opev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { return len(limits) == 3 && limits[0].IsActive && limits[0].Value == 10 - }), mock.Anything).Return(nil, nil) + }), mock.Anything).Run(ackWrite).Return(nil, nil) oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(false) @@ -158,7 +165,7 @@ func TestWriteCurrentLimitData_OpevAndOscev(t *testing.T) { opev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { // OPEV: active at 10A (below max of 16) return len(limits) == 3 && limits[0].IsActive && limits[0].Value == 10 - }), mock.Anything).Return(nil, nil) + }), mock.Anything).Run(ackWrite).Return(nil, nil) oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) oscev.EXPECT().LoadControlLimits(evEntity).Return([]ucapi.LoadLimitsPhase{}, nil) @@ -166,7 +173,7 @@ func TestWriteCurrentLimitData_OpevAndOscev(t *testing.T) { oscev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { // OSCEV: active at 10A (>= min of 2, recommendation to charge) return len(limits) == 3 && limits[0].IsActive && limits[0].Value == 10 - }), mock.Anything).Return(nil, nil) + }), mock.Anything).Run(ackWrite).Return(nil, nil) err := eebus.writeCurrentLimitData(evEntity, 10) require.NoError(t, err) @@ -182,7 +189,7 @@ func TestWriteCurrentLimitData_AtMax(t *testing.T) { opev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { // OPEV: inactive at max (no restriction needed) return len(limits) == 3 && !limits[0].IsActive && limits[0].Value == 16 - }), mock.Anything).Return(nil, nil) + }), mock.Anything).Run(ackWrite).Return(nil, nil) oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) oscev.EXPECT().LoadControlLimits(evEntity).Return([]ucapi.LoadLimitsPhase{}, nil) @@ -190,7 +197,7 @@ func TestWriteCurrentLimitData_AtMax(t *testing.T) { oscev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { // OSCEV: active at 16A (>= min, recommend charging) return len(limits) == 3 && limits[0].IsActive && limits[0].Value == 16 - }), mock.Anything).Return(nil, nil) + }), mock.Anything).Run(ackWrite).Return(nil, nil) err := eebus.writeCurrentLimitData(evEntity, 16) require.NoError(t, err) @@ -206,7 +213,7 @@ func TestWriteCurrentLimitData_Disable(t *testing.T) { opev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { // OPEV: active at 0A (hard stop) return len(limits) == 3 && limits[0].IsActive && limits[0].Value == 0 - }), mock.Anything).Return(nil, nil) + }), mock.Anything).Run(ackWrite).Return(nil, nil) oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) oscev.EXPECT().LoadControlLimits(evEntity).Return([]ucapi.LoadLimitsPhase{}, nil) @@ -214,7 +221,7 @@ func TestWriteCurrentLimitData_Disable(t *testing.T) { oscev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { // OSCEV: inactive at 0A (no recommendation, < min) return len(limits) == 3 && !limits[0].IsActive && limits[0].Value == 0 - }), mock.Anything).Return(nil, nil) + }), mock.Anything).Run(ackWrite).Return(nil, nil) err := eebus.writeCurrentLimitData(evEntity, 0) require.NoError(t, err) @@ -227,7 +234,7 @@ func TestWriteCurrentLimitData_OscevNoLimitData(t *testing.T) { // OSCEV scenario available but no limit data (e.g. PCMP wallbox) opev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) opev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(6, 16, 0)) - opev.EXPECT().WriteLoadControlLimits(evEntity, mock.Anything, mock.Anything).Return(nil, nil) + opev.EXPECT().WriteLoadControlLimits(evEntity, mock.Anything, mock.Anything).Run(ackWrite).Return(nil, nil) oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) oscev.EXPECT().LoadControlLimits(evEntity).Return(nil, errors.New("data not available")) diff --git a/meter/eebus.go b/meter/eebus.go index 4c9afe145..ed410c7b6 100644 --- a/meter/eebus.go +++ b/meter/eebus.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "strings" "sync" "time" @@ -250,18 +249,16 @@ func (c *EEBus) Dim(dim bool) error { } c.mu.Lock() - defer c.mu.Unlock() + entity := c.egLpcEntity + c.mu.Unlock() - if c.egLpcEntity == nil || !c.eg.EgLPCInterface.IsScenarioAvailableAtEntity(c.egLpcEntity, eebus.LPCLimit) { + if entity == nil || !c.eg.EgLPCInterface.IsScenarioAvailableAtEntity(entity, eebus.LPCLimit) { return api.ErrNotAvailable } - _, err := c.eg.EgLPCInterface.WriteConsumptionLimit(c.egLpcEntity, ucapi.LoadLimit{ - Value: value, - IsActive: dim, - }, c.callbackResult("consumption limit")) - - return err + return eebus.Await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + return c.eg.EgLPCInterface.WriteConsumptionLimit(entity, ucapi.LoadLimit{Value: value, IsActive: dim}, cb) + }) } var _ api.Curtailer = (*EEBus)(nil) @@ -294,35 +291,14 @@ func (c *EEBus) Curtail(curtail bool) error { } c.mu.Lock() - defer c.mu.Unlock() + entity := c.egLppEntity + c.mu.Unlock() - if c.egLppEntity == nil || !c.eg.EgLPPInterface.IsScenarioAvailableAtEntity(c.egLppEntity, eebus.LPPLimit) { + if entity == nil || !c.eg.EgLPPInterface.IsScenarioAvailableAtEntity(entity, eebus.LPPLimit) { return api.ErrNotAvailable } - _, err := c.eg.EgLPPInterface.WriteProductionLimit(c.egLppEntity, ucapi.LoadLimit{ - Value: value, - IsActive: curtail, - }, c.callbackResult("production limit")) - - return err -} - -func (c *EEBus) callbackResult(typ string) func(result model.ResultDataType) { - return func(result model.ResultDataType) { - sb := new(strings.Builder) - - if result.ErrorNumber != nil { - fmt.Fprint(sb, *result.ErrorNumber) - } - if result.Description != nil { - if sb.Len() > 0 { - fmt.Print(sb, ":") - } - fmt.Print(sb, *result.Description) - } - if sb.Len() > 0 { - c.log.ERROR.Printf("%s: %s", typ, sb.String()) - } - } + return eebus.Await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + return c.eg.EgLPPInterface.WriteProductionLimit(entity, ucapi.LoadLimit{Value: value, IsActive: curtail}, cb) + }) } diff --git a/server/eebus/helper.go b/server/eebus/helper.go index e6e587960..c2ea4e28c 100644 --- a/server/eebus/helper.go +++ b/server/eebus/helper.go @@ -2,9 +2,12 @@ package eebus import ( "errors" + "fmt" "log" + "time" eebusapi "github.com/enbility/eebus-go/api" + "github.com/enbility/spine-go/model" "github.com/evcc-io/evcc/api" ) @@ -15,6 +18,33 @@ func WrapError(err error) error { return err } +// WriteTimeout bounds how long an awaited eebus write waits for its result. +const WriteTimeout = 10 * time.Second + +// Await runs a control write and waits for the remote device's result, returning +// an error if the write is rejected or no result arrives within WriteTimeout. +func Await(write func(func(model.ResultDataType)) (*model.MsgCounterType, error)) error { + res := make(chan model.ResultDataType, 1) + + if _, err := write(func(r model.ResultDataType) { res <- r }); err != nil { + return err + } + + select { + case r := <-res: + if r.ErrorNumber != nil && *r.ErrorNumber != 0 { + err := fmt.Errorf("write rejected: %d", *r.ErrorNumber) + if r.Description != nil { + err = fmt.Errorf("%w (%s)", err, *r.Description) + } + return err + } + return nil + case <-time.After(WriteTimeout): + return errors.New("write result timeout") + } +} + func LogEntities(log *log.Logger, actor string, uc eebusapi.UseCaseInterface) { ss := uc.RemoteEntitiesScenarios() if len(ss) > 0 { From 1d24da13eba2d982baf50775fc68c3e8f5a36474 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 30 Jun 2026 19:24:08 +0200 Subject: [PATCH 085/733] Add AMTRON Professional Twincharge with infotext for modbus port 502/503 (#31361) --- templates/definition/charger/bender-cc.yaml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/templates/definition/charger/bender-cc.yaml b/templates/definition/charger/bender-cc.yaml index 4dd8fef8b..f7e6f15c0 100644 --- a/templates/definition/charger/bender-cc.yaml +++ b/templates/definition/charger/bender-cc.yaml @@ -10,6 +10,9 @@ products: - brand: Mennekes description: generic: AMTRON Professional + - brand: Mennekes + description: + generic: AMTRON Professional Twincharge - brand: Mennekes description: generic: AMEDIO Professional @@ -67,11 +70,16 @@ requirements: de: | Der 'Modbus TCP Server für Energiemanagement-Systeme' muss aktiviert sein. 'Registersatz' darf NICHT auf 'Phoenix' oder 'TQ-DM100' eingestellt sein. Die dritte Auswahlmöglichkeit 'Ebee', 'Bender', 'MENNEKES' etc. ist richtig. 'UID Übertragung erlauben' muss aktiviert sein. - Für Phasenumschaltung ist mindestens Firmware 5.33 notwendig. Das 'SEMP interface' muss aktiviert sein, der 'SEMP Charging Mode' muss auf 'Surplus Charging' stehen. 'Software function to use phase switching' muss auf SEMP konfiguriert sein. + Für Phasenumschaltung ist mindestens Firmware 5.33 notwendig. Das 'SEMP interface' muss aktiviert sein, der 'SEMP Charging Mode' muss auf 'Surplus Charging' stehen. 'Software function to use phase switching' muss auf SEMP konfiguriert sein. + + Bei Ladestationen mit zwei Ladepunkten (z.B. AMEDIO, Twincharge) sind die Ladepunkte separat anzulegen, wobei Ladepunkt 1 = Port 502 und Ladepunkt 2 = Port 503 zu setzen ist. en: | The 'Modbus TCP Server' must be enabled. The setting 'Register Address Set' must NOT be set to 'Phoenix' or 'TQ-DM100'. Use the third selection labeled 'Ebee', 'Bender', 'MENNEKES' etc. Set 'Allow UID Disclose' to On. Firmware 5.33 or higher is required for phase switching. The 'SEMP interface' must be enabled, the 'SEMP Charging Mode' must be set to 'Surplus Charging'. Set 'Software function to use phase switching' to SEMP. + + For charging stations with two charging points (e.g. AMEDIO, Twincharge), each charging point must be configured separately. Set charging point 1 to Port 502 and charging point 2 to Port 503. + evcc: ["sponsorship"] params: - name: modbus From 38a5dfd9da96bc0353dcdab28c290956a79dc8e0 Mon Sep 17 00:00:00 2001 From: StefanSchoof <4662023+StefanSchoof@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:48:51 +0200 Subject: [PATCH 086/733] dsg: add soc via battery_state_report.soc key (#31340) --- vehicle/vw/eudataact/eudataact_test.go | 19 +++++++++++++++++++ vehicle/vw/eudataact/provider.go | 2 +- vehicle/vw/eudataact/types.go | 9 +++++---- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/vehicle/vw/eudataact/eudataact_test.go b/vehicle/vw/eudataact/eudataact_test.go index c03e6d3eb..ba98df148 100644 --- a/vehicle/vw/eudataact/eudataact_test.go +++ b/vehicle/vw/eudataact/eudataact_test.go @@ -204,6 +204,25 @@ func TestSocFreshestField(t *testing.T) { assert.Equal(t, 61.0, soc, "the still-updating field wins over the stale higher-priority field") } +func TestSocBatteryStateReportField(t *testing.T) { + data := []point{{Key: KeyBatteryStateReportSoc, Name: "battery_state_report.soc", Value: "36"}} + + soc, err := testProvider(data).Soc() + require.NoError(t, err) + assert.Equal(t, 36.0, soc) +} + +func TestSocBatteryStateReportOnlyFallbackField(t *testing.T) { + data := []point{ + {Key: KeyBatteryStateReportSoc, Name: "battery_state_report.soc", Value: "36"}, + {Name: FieldHvBatteryLevelValue, Value: "40"}, + } + + soc, err := testProvider(data).Soc() + require.NoError(t, err) + assert.Equal(t, 40.0, soc) +} + // TestPoints guards that a data point with a generic field name ("value") is // stored once yet found by both its unique key and its name. func TestPoints(t *testing.T) { diff --git a/vehicle/vw/eudataact/provider.go b/vehicle/vw/eudataact/provider.go index 36d9f8583..3022eca84 100644 --- a/vehicle/vw/eudataact/provider.go +++ b/vehicle/vw/eudataact/provider.go @@ -81,7 +81,7 @@ func (v *Provider) Soc() (float64, error) { return 0, err } - if p := lookup(data, FieldHvBatteryLevelValue, FieldSoc, FieldHvSoc); p != nil { + if p := lookup(data, FieldHvBatteryLevelValue, FieldSoc, FieldHvSoc, KeyBatteryStateReportSoc); p != nil { return strconv.ParseFloat(p.Value, 64) } diff --git a/vehicle/vw/eudataact/types.go b/vehicle/vw/eudataact/types.go index b9f56ee24..7920322b7 100644 --- a/vehicle/vw/eudataact/types.go +++ b/vehicle/vw/eudataact/types.go @@ -127,10 +127,11 @@ const ( FieldTargetSoc = "settings.target_soc" // range - FieldRangeCombined = "cruising_range_combined" - FieldRangePrimary = "cruising_range_primary_engine" - FieldRangeSecondary = "cruising_range_secondary_engine" - KeyRangeID3 = "0ca40e18-0564-3eda-bcc0-7aee9ef44f04" // VW ID.3 cruising range, delivered as "value" + FieldRangeCombined = "cruising_range_combined" + FieldRangePrimary = "cruising_range_primary_engine" + FieldRangeSecondary = "cruising_range_secondary_engine" + KeyRangeID3 = "0ca40e18-0564-3eda-bcc0-7aee9ef44f04" // VW ID.3 range + KeyBatteryStateReportSoc = "506cb83e-f99f-3af3-bbeb-0429b69a78d9" // VW ID.3 soc // odo FieldOdometer = "mileage" From c4f79ef83b0b8c664560bf364b8d2a9b48f58959 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 30 Jun 2026 19:49:13 +0200 Subject: [PATCH 087/733] Versicharge: fix power reading (#31359) (#31363) --- charger/versicharge.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charger/versicharge.go b/charger/versicharge.go index 36d3cb68f..eb3cc8dcc 100644 --- a/charger/versicharge.go +++ b/charger/versicharge.go @@ -160,7 +160,7 @@ func (wb *Versicharge) CurrentPower() (float64, error) { var sum float64 for i := range 3 { if u := binary.BigEndian.Uint16(b[2*i:]); u != 0xFFFF { - sum += float64(u) + sum += float64(int16(u)) } } From a099c36d577de972201f9a2c11ea3d3c97c00b58 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:36:36 +0000 Subject: [PATCH 088/733] Bump golangci/golangci-lint-action from 9.2.0 to 9.3.0 (#31370) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index c27bc564b..24d9236b2 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -148,7 +148,7 @@ jobs: - run: mkdir dist && touch dist/empty - name: Lint - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 with: version: latest args: --timeout 5m From f8b73a5cea672b419d85dfaacc7f4c405bb59787 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:36:40 +0000 Subject: [PATCH 089/733] Bump actions/cache from 5 to 6 (#31371) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/default.yml | 14 +++++++------- .github/workflows/nightly.yml | 4 ++-- .github/workflows/release.yml | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 24d9236b2..8e23d88a5 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -26,7 +26,7 @@ jobs: cache: false # avoid cache thrashing id: go - - uses: actions/cache@v5 + - uses: actions/cache@v6 with: path: | ~/.cache/go-build @@ -66,7 +66,7 @@ jobs: cache: false id: go - - uses: actions/cache@v5 + - uses: actions/cache@v6 with: path: | ~/.cache/go-build @@ -104,7 +104,7 @@ jobs: cache: false id: go - - uses: actions/cache@v5 + - uses: actions/cache@v6 with: path: | ~/.cache/go-build @@ -135,7 +135,7 @@ jobs: cache: false # avoid cache thrashing id: go - - uses: actions/cache@v5 + - uses: actions/cache@v6 with: path: | ~/.cache/go-build @@ -191,7 +191,7 @@ jobs: - name: Cache dist # avoid cache thrashing by nightly if: github.event_name == 'push' || github.event_name == 'pull_request' - uses: actions/cache/save@v5 + uses: actions/cache/save@v6 id: cache-dist with: path: dist @@ -220,7 +220,7 @@ jobs: cache: false id: go - - uses: actions/cache@v5 + - uses: actions/cache@v6 with: path: | ~/.cache/go-build @@ -246,7 +246,7 @@ jobs: run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package-lock.json').packages['node_modules/@playwright/test'].version)")" >> $GITHUB_ENV - name: Cache Playwright browsers - uses: actions/cache@v5 + uses: actions/cache@v6 id: playwright-cache with: path: ~/.cache/ms-playwright diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 785e2f2c5..2afa792a7 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -59,7 +59,7 @@ jobs: persist-credentials: false - name: Get dist from cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@v6 id: cache-dist with: path: dist @@ -168,7 +168,7 @@ jobs: run: make patch-asn1-sudo - name: Get dist from cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@v6 id: cache-dist with: path: dist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0635b5a00..faeda8141 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,7 +82,7 @@ jobs: run: make patch-asn1-sudo - name: Get dist from cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@v6 id: cache-dist with: path: dist From 710667d5f967b1cc44dc165bd1d9b685a3ee6c3b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:36:51 +0000 Subject: [PATCH 090/733] Bump actions/checkout from 4 to 7 (#31373) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/claude-code-review-run.yml | 2 +- .github/workflows/claude.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/default.yml | 12 ++++++------ .github/workflows/documentation.yml | 4 ++-- .github/workflows/hassio-changelog.yml | 2 +- .github/workflows/language-reminder.yml | 2 +- .github/workflows/nightly.yml | 8 ++++---- .github/workflows/openapi-validate.yml | 2 +- .github/workflows/release.yml | 8 ++++---- .github/workflows/schema.yml | 2 +- .github/workflows/triage-agent.lock.yml | 2 +- .github/workflows/website.yml | 2 +- 13 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/claude-code-review-run.yml b/.github/workflows/claude-code-review-run.yml index 2377e01e4..5019905a1 100644 --- a/.github/workflows/claude-code-review-run.yml +++ b/.github/workflows/claude-code-review-run.yml @@ -93,7 +93,7 @@ jobs: # Default checkout (base ref) only — do NOT check out the untrusted PR head. - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 with: fetch-depth: 1 diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 6b15fac7a..580e80a98 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -26,7 +26,7 @@ jobs: actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 1 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d20a3b2f5..f527375d2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 8e23d88a5..5dacfc5bd 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -16,7 +16,7 @@ jobs: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -56,7 +56,7 @@ jobs: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -94,7 +94,7 @@ jobs: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -125,7 +125,7 @@ jobs: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -164,7 +164,7 @@ jobs: runs-on: depot-ubuntu-24.04-arm steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -210,7 +210,7 @@ jobs: actions: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index f6111d9f9..37d36e1a4 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -16,7 +16,7 @@ jobs: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -51,7 +51,7 @@ jobs: if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false diff --git a/.github/workflows/hassio-changelog.yml b/.github/workflows/hassio-changelog.yml index 8886ed181..ab8cc1b5f 100644 --- a/.github/workflows/hassio-changelog.yml +++ b/.github/workflows/hassio-changelog.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout hassio-addon - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: evcc-io/hassio-addon token: ${{ secrets.HASSIO_DEPLOY_TOKEN }} diff --git a/.github/workflows/language-reminder.yml b/.github/workflows/language-reminder.yml index 5b52e3512..503a0d331 100644 --- a/.github/workflows/language-reminder.yml +++ b/.github/workflows/language-reminder.yml @@ -22,7 +22,7 @@ jobs: if: false && (github.event.issue.pull_request || github.event.pull_request) steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Check language id: language_check diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2afa792a7..14315b765 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -17,7 +17,7 @@ jobs: outputs: should_run: ${{ steps.should_run.outputs.should_run }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false - name: print latest_commit @@ -52,7 +52,7 @@ jobs: actions: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: ref: refs/heads/master # force master fetch-depth: 0 @@ -112,7 +112,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: evcc-io/hassio-addon token: ${{ secrets.HASSIO_DEPLOY_TOKEN }} @@ -154,7 +154,7 @@ jobs: actions: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/openapi-validate.yml b/.github/workflows/openapi-validate.yml index 4131f51b3..6e0999ad6 100644 --- a/.github/workflows/openapi-validate.yml +++ b/.github/workflows/openapi-validate.yml @@ -16,7 +16,7 @@ jobs: runs-on: depot-ubuntu-24.04-arm steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Go uses: actions/setup-go@v6 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index faeda8141..8038f9883 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 persist-credentials: false @@ -67,7 +67,7 @@ jobs: actions: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 persist-credentials: false @@ -135,7 +135,7 @@ jobs: env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false - uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # master @@ -152,7 +152,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: evcc-io/hassio-addon token: ${{ secrets.HASSIO_DEPLOY_TOKEN }} diff --git a/.github/workflows/schema.yml b/.github/workflows/schema.yml index 4fbf3dd55..f4d7cc800 100644 --- a/.github/workflows/schema.yml +++ b/.github/workflows/schema.yml @@ -18,7 +18,7 @@ jobs: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false - uses: nwisbeta/validate-yaml-schema@c3734e647d2a3beb98b9132330067e900fdbd1a2 # v2.0.0 diff --git a/.github/workflows/triage-agent.lock.yml b/.github/workflows/triage-agent.lock.yml index b5432998a..3b0826d07 100644 --- a/.github/workflows/triage-agent.lock.yml +++ b/.github/workflows/triage-agent.lock.yml @@ -99,7 +99,7 @@ jobs: with: destination: /opt/gh-aw/actions - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Create gh-aw temp directory diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 67a625fd6..dc00ab188 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -16,7 +16,7 @@ jobs: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false From a7cd171541bb6723e4e03576d52f491ccdc78d1c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:36:56 +0000 Subject: [PATCH 091/733] Bump actions/upload-artifact from 4 to 7 (#31372) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/claude-code-review.yml | 2 +- .github/workflows/default.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index d0e0945b5..bf38dc001 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -25,7 +25,7 @@ jobs: run: echo "$PR_NUMBER" > pr-number.txt - name: Upload PR number - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: pr-number path: pr-number.txt diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 5dacfc5bd..eedd4ab41 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -264,7 +264,7 @@ jobs: TZ: Europe/Berlin - name: Upload Playwright Report - uses: actions/upload-artifact@v7.0.1 + uses: actions/upload-artifact@v7 if: ${{ !cancelled() }} with: name: playwright-report @@ -272,7 +272,7 @@ jobs: retention-days: 14 - name: Upload Playwright Raw Test Results - uses: actions/upload-artifact@v7.0.1 + uses: actions/upload-artifact@v7 if: failure() with: name: playwright-test-results From cb6a9910f17c8b18f2d28d1768c42fa77a4ea2d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:37:07 +0000 Subject: [PATCH 092/733] Bump goreleaser/goreleaser-action from 7.2.2 to 7.2.3 (#31374) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/nightly.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 14315b765..12d692054 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -175,7 +175,7 @@ jobs: key: ${{ runner.os }}-${{ github.sha }}-dist - name: Create nightly build - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7 + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: version: '~> v2' args: --snapshot -f .goreleaser-nightly.yml --clean diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8038f9883..8fc82748b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,7 +103,7 @@ jobs: # run: make image-rootfs - name: Create Github Release - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7 + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: version: '~> v2' args: release --clean From e910ee2779f0ee326b1fd68251732a71e952d60c Mon Sep 17 00:00:00 2001 From: Michael Kebe Date: Wed, 1 Jul 2026 17:09:29 +0200 Subject: [PATCH 093/733] Fix details height on reload with expanded loadpoints (#31369) --- assets/js/components/Energyflow/Energyflow.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index 3700c2bcd..569e82a74 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -616,7 +616,7 @@ export default defineComponent({ batteryMode() { this.$nextTick(this.updateHeight); }, - activeLoadpointsCount() { + loadpoints() { this.$nextTick(this.updateHeight); }, }, From 0299650c31c60c55e7a9c355eec9e6eeda7c7c77 Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 1 Jul 2026 21:50:08 +0200 Subject: [PATCH 094/733] HEMS: run FNN and relay control loops immediately on start (#31392) --- hems/fnn/fnn.go | 3 ++- hems/relay/relay.go | 3 ++- tests/hems-yaml.evcc.yaml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/hems/fnn/fnn.go b/hems/fnn/fnn.go index 32bd16780..01f90dad8 100644 --- a/hems/fnn/fnn.go +++ b/hems/fnn/fnn.go @@ -120,7 +120,8 @@ func (c *Fnn) SetUpdated(f func()) { // Run starts the FNN control loop. func (c *Fnn) Run() { - for range time.Tick(c.interval) { + // run immediately, then on every tick + for tick := time.Tick(c.interval); ; <-tick { if err := c.runCurtail(); err != nil { c.log.ERROR.Println(err) } diff --git a/hems/relay/relay.go b/hems/relay/relay.go index 4fe63bab8..601da0bf4 100644 --- a/hems/relay/relay.go +++ b/hems/relay/relay.go @@ -88,7 +88,8 @@ func (c *Relay) SetUpdated(f func()) { } func (c *Relay) Run() { - for range time.Tick(c.interval) { + // run immediately, then on every tick + for tick := time.Tick(c.interval); ; <-tick { if err := c.run(); err != nil { c.log.ERROR.Println(err) } diff --git a/tests/hems-yaml.evcc.yaml b/tests/hems-yaml.evcc.yaml index 8c958d028..ab004e8c8 100644 --- a/tests/hems-yaml.evcc.yaml +++ b/tests/hems-yaml.evcc.yaml @@ -15,4 +15,4 @@ hems: maxPower: 4200 limit: source: const - value: true + value: false # inactive: avoids recording a grid session so the locked modal shows no history From 56513e3489be00d59b82dd9d522ea928b54485c7 Mon Sep 17 00:00:00 2001 From: Petapton <14984292+Petapton@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:53:10 +0200 Subject: [PATCH 095/733] PUN: add zone parameter (#31388) --- tariff/pun.go | 41 +++++++++++++++++++++++----- templates/definition/tariff/pun.yaml | 10 +++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/tariff/pun.go b/tariff/pun.go index dfa16bc20..23958d5e9 100644 --- a/tariff/pun.go +++ b/tariff/pun.go @@ -28,6 +28,7 @@ var romeLocation *time.Location type Pun struct { *embed + zone string log *util.Logger data *util.Monitor[api.Rates] } @@ -37,10 +38,15 @@ type NewDataSet struct { Prezzi []Prezzo `xml:"Prezzi"` } +type Zone struct { + XMLName xml.Name + Value string `xml:",chardata"` +} + type Prezzo struct { - Data string `xml:"Data"` - Ora string `xml:"Ora"` - PUN string `xml:"PUN"` + Data string `xml:"Data"` + Ora string `xml:"Ora"` + Zones []Zone `xml:",any"` } type Rate struct { @@ -57,7 +63,12 @@ func init() { } func NewPunFromConfig(other map[string]any) (api.Tariff, error) { - var cc embed + var cc struct { + embed `mapstructure:",squash"` + Zone string + } + + logger := util.NewLogger("pun") if err := util.DecodeOther(other, &cc); err != nil { return nil, err @@ -67,9 +78,15 @@ func NewPunFromConfig(other map[string]any) (api.Tariff, error) { return nil, err } + var zone = strings.ToUpper(strings.TrimSpace(cc.Zone)) + if cc.Zone == "" { + zone = "PUN" + } + t := &Pun{ - log: util.NewLogger("pun"), - embed: &cc, + log: logger, + zone: zone, + embed: &cc.embed, data: util.NewMonitor[api.Rates](2 * time.Hour), } @@ -131,6 +148,16 @@ func (t *Pun) Type() api.TariffType { return api.TariffTypePriceForecast } +func (t *Pun) priceForZone(p Prezzo) (float64, error) { + for _, z := range p.Zones { + if strings.ToUpper(strings.TrimSpace(z.XMLName.Local)) == t.zone { + price, err := strconv.ParseFloat(strings.ReplaceAll(z.Value, ",", "."), 64) + return price, err + } + } + return 0, fmt.Errorf("zone %s not found for hour %s", t.zone, p.Ora) +} + func (t *Pun) getData(day time.Time) (api.Rates, error) { client := request.NewClient(t.log) client.Jar, _ = cookiejar.New(nil) @@ -213,7 +240,7 @@ func (t *Pun) getData(day time.Time) (api.Rates, error) { date = date.AddDate(0, 0, -1) } - price, err := strconv.ParseFloat(strings.ReplaceAll(p.PUN, ",", "."), 64) + price, err := t.priceForZone(p) if err != nil { return nil, fmt.Errorf("parse price: %w", err) } diff --git a/templates/definition/tariff/pun.yaml b/templates/definition/tariff/pun.yaml index 8113b24c1..8736d59b1 100644 --- a/templates/definition/tariff/pun.yaml +++ b/templates/definition/tariff/pun.yaml @@ -10,6 +10,16 @@ group: price countries: ["IT"] params: - preset: tariff-base + - name: zone + type: choice + choice: ["NORD", "CNOR", "CSUD", "SUD", "CALA", "SICI", "SARD"] + description: + de: Marktzone + en: Market zone + help: + de: Italienische Strommarktzone. Wenn keine Zone angegeben wird, wird der nationale Einheitspreis (PUN) verwendet. + en: Italian electricity market zone. If no zone is specified, the national unit price (PUN) is used. render: | type: pun {{ include "tariff-base" . }} + zone: {{ .zone }} From eb293b285ac9ad82146e95b79d5f2f5db2ab0eac Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 1 Jul 2026 22:04:48 +0200 Subject: [PATCH 096/733] =?UTF-8?q?EEBus:=20release=20=C2=A714a/LPC=20limi?= =?UTF-8?q?t=20when=20it=20expires=20(#31393)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- charger/eebus-ohpcf.go | 4 +++- meter/eebus.go | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/charger/eebus-ohpcf.go b/charger/eebus-ohpcf.go index f935cedb7..25c3f7d3c 100644 --- a/charger/eebus-ohpcf.go +++ b/charger/eebus-ohpcf.go @@ -369,7 +369,9 @@ func (c *EEBusOHPCF) Dimmed() (bool, error) { return false, err } - return limit.IsActive && limit.Value > 0, nil + // an active limit means dimmed; the applied §14a limit value is 0W, so a + // value-based check would never report the dimmed state and never release it + return limit.IsActive, nil } // Dim implements the api.Dimmer interface. It writes a §14a/LPC consumption diff --git a/meter/eebus.go b/meter/eebus.go index ed410c7b6..0884aef15 100644 --- a/meter/eebus.go +++ b/meter/eebus.go @@ -231,8 +231,9 @@ func (c *EEBus) Dimmed() (bool, error) { return false, err } - // Check if limit is active and has a valid power value - return limit.IsActive && limit.Value > 0, nil + // an active limit means dimmed; the applied limit value is 0W, so a + // value-based check would never report the dimmed state and never release it + return limit.IsActive, nil } // Dim implements the api.Dimmer interface From 3482e79f14fa59a9bc7faddd137254f70fb3c928 Mon Sep 17 00:00:00 2001 From: Chris685 Date: Wed, 1 Jul 2026 22:39:05 +0200 Subject: [PATCH 097/733] GreenGridCompass: fix uri (#31389) --- templates/definition/tariff/green-grid-compass.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/definition/tariff/green-grid-compass.yaml b/templates/definition/tariff/green-grid-compass.yaml index 347bf34cc..018e3e8b2 100644 --- a/templates/definition/tariff/green-grid-compass.yaml +++ b/templates/definition/tariff/green-grid-compass.yaml @@ -38,7 +38,7 @@ render: | clientsecret: {{ .clientsecret }} tokenurl: https://signin.energy/am/oauth2/realms/root/realms/difesp/access_token scopes: [esp] - uri: https://explore.traxes.io/greengrid-compass/v1/co2-intensity-forecast?zone={{ .zone }}&start={{ `{{ now | date "2006-01-02T" }}` }}00:00:00Z&end={{ `{{ addDate now 0 0 5 | date "2006-01-02T" }}` }}00:00:00Z&time-resolution=hourly&calculation-type=production&emission-type=operational&forecast-type=actual + uri: https://explore.traxes.io/explore/greengrid-compass/v1/co2-intensity-forecast?zone={{ .zone }}&start={{ `{{ now | date "2006-01-02T" }}` }}00:00:00Z&end={{ `{{ addDate now 0 0 5 | date "2006-01-02T" }}` }}00:00:00Z&time-resolution=hourly&calculation-type=production&emission-type=operational&forecast-type=actual jq: | .measurements[0].measurementValues | map({ "start": .timestamp, From d0a461714467894fecae25908394a07b92a5cf53 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 1 Jul 2026 22:39:46 +0200 Subject: [PATCH 098/733] Battery UI: soc history and future, new layout (#31383) --- assets/js/colors.ts | 10 +- .../components/Battery/BatteryConfigCard.vue | 242 +++++++++ .../Battery/BatteryExperimental.vue | 173 ++++++ .../Battery/BatteryHistoryCard.stories.ts | 108 ++++ .../components/Battery/BatteryHistoryCard.vue | 165 ++++++ .../Battery/BatteryHistoryChart.vue | 495 ++++++++++++++++++ .../Battery/BatteryStatusCard.stories.ts | 88 ++++ .../components/Battery/BatteryStatusCard.vue | 104 ++++ .../Battery/BatteryStatusCards.stories.ts | 82 +++ .../components/Battery/BatteryStatusCards.vue | 86 +++ .../js/components/Battery/InlineSocSelect.vue | 34 ++ .../js/components/Battery/OptimizerInfo.vue | 67 +++ assets/js/components/Battery/SocGauge.vue | 61 +++ assets/js/components/Battery/WindowNav.vue | 35 ++ assets/js/components/Battery/history.test.ts | 101 ++++ assets/js/components/Battery/history.ts | 55 ++ assets/js/components/Battery/types.ts | 42 ++ assets/js/components/Config/OcppModal.vue | 2 +- .../Energyflow/BatteryIcon.stories.ts | 18 +- assets/js/components/Forecast/echarts.ts | 6 + assets/js/components/Helper/Card.vue | 77 +++ assets/js/components/Helper/CustomSelect.vue | 8 +- assets/js/components/History/GroupChart.vue | 9 +- .../js/components/MaterialIcon/Optimizer.vue | 2 +- assets/js/mixins/formatter.ts | 13 + assets/js/settings.ts | 4 + assets/js/types/evcc.ts | 1 + assets/js/utils/ocpp.ts | 2 +- assets/js/views/Battery.vue | 21 +- assets/js/views/History.vue | 39 +- core/keys/site.go | 1 + core/metrics/db.go | 16 +- core/metrics/db_history.go | 22 +- core/metrics/db_test.go | 48 ++ core/site.go | 3 + i18n/de.json | 39 ++ i18n/en.json | 39 ++ server/http_history_handler.go | 8 +- server/mcp/openapi.json | 43 ++ server/mcp/openapi.md | 6 + server/openapi.yaml | 33 ++ tests/battery-experimental.evcc.yaml | 58 ++ tests/battery-experimental.spec.ts | 82 +++ tests/battery-experimental.sql | 13 + 44 files changed, 2509 insertions(+), 52 deletions(-) create mode 100644 assets/js/components/Battery/BatteryConfigCard.vue create mode 100644 assets/js/components/Battery/BatteryExperimental.vue create mode 100644 assets/js/components/Battery/BatteryHistoryCard.stories.ts create mode 100644 assets/js/components/Battery/BatteryHistoryCard.vue create mode 100644 assets/js/components/Battery/BatteryHistoryChart.vue create mode 100644 assets/js/components/Battery/BatteryStatusCard.stories.ts create mode 100644 assets/js/components/Battery/BatteryStatusCard.vue create mode 100644 assets/js/components/Battery/BatteryStatusCards.stories.ts create mode 100644 assets/js/components/Battery/BatteryStatusCards.vue create mode 100644 assets/js/components/Battery/InlineSocSelect.vue create mode 100644 assets/js/components/Battery/OptimizerInfo.vue create mode 100644 assets/js/components/Battery/SocGauge.vue create mode 100644 assets/js/components/Battery/WindowNav.vue create mode 100644 assets/js/components/Battery/history.test.ts create mode 100644 assets/js/components/Battery/history.ts create mode 100644 assets/js/components/Battery/types.ts create mode 100644 assets/js/components/Helper/Card.vue create mode 100644 tests/battery-experimental.evcc.yaml create mode 100644 tests/battery-experimental.spec.ts create mode 100644 tests/battery-experimental.sql diff --git a/assets/js/colors.ts b/assets/js/colors.ts index fe35b6f26..53a52b8a4 100644 --- a/assets/js/colors.ts +++ b/assets/js/colors.ts @@ -29,6 +29,7 @@ const colors: { light: string | null; selfPalette: string[]; palette: string[]; + batteryPalette: string[]; } = reactive({ text: null, muted: null, @@ -69,10 +70,11 @@ const colors: { "#6D28D9", // violet "#334155", // gray ], + batteryPalette: ["#0BA631", "#7FC41B", "#0A6E63", "#34D399", "#0E9E8F"], }); // normalize 6-digit hex to 8-digit, then replace alpha -const setAlpha = (color: string | null, alpha: string): string | undefined => { +export const setAlpha = (color: string | null, alpha: string): string | undefined => { if (!color) return undefined; const c = color.trim().toLowerCase(); // #rrggbb → append alpha, #rrggbbaa → replace alpha @@ -113,6 +115,12 @@ export function resolveColors(ids: string[], overrides: DeviceColors = {}): Devi return result; } +// dedicated battery palette, assigned by index (battery page + history battery group) +export function batteryColor(index: number): string { + const p = colors.batteryPalette; + return p[index % p.length] || ""; +} + export const dimColor = (color: string | null) => setAlpha(color, "20"); export const lighterColor = (color: string | null) => setAlpha(color, "aa"); diff --git a/assets/js/components/Battery/BatteryConfigCard.vue b/assets/js/components/Battery/BatteryConfigCard.vue new file mode 100644 index 000000000..89d973fc2 --- /dev/null +++ b/assets/js/components/Battery/BatteryConfigCard.vue @@ -0,0 +1,242 @@ + + + diff --git a/assets/js/components/Battery/BatteryExperimental.vue b/assets/js/components/Battery/BatteryExperimental.vue new file mode 100644 index 000000000..ffc687738 --- /dev/null +++ b/assets/js/components/Battery/BatteryExperimental.vue @@ -0,0 +1,173 @@ + + + diff --git a/assets/js/components/Battery/BatteryHistoryCard.stories.ts b/assets/js/components/Battery/BatteryHistoryCard.stories.ts new file mode 100644 index 000000000..d3c34647c --- /dev/null +++ b/assets/js/components/Battery/BatteryHistoryCard.stories.ts @@ -0,0 +1,108 @@ +import BatteryHistoryCard from "./BatteryHistoryCard.vue"; +import type { SocPoint, BatterySeries } from "./types"; +import type { Meta, StoryFn } from "@storybook/vue3"; + +export default { + title: "Battery/BatteryHistoryCard", + component: BatteryHistoryCard, + parameters: { + layout: "fullscreen", + backgrounds: { + default: "black", + values: [{ name: "black", value: "#000" }], + }, + }, +} as Meta; + +const HOUR = 3600 * 1000; +const STEP = 15 * 60 * 1000; // 15min, matches the history/energy "15m" aggregate +const NOW = new Date("2026-07-01T12:30:00"); + +const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); +const smoothstep = (a: number, b: number, x: number) => { + const t = clamp((x - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; + +// relative daily soc profile (hour -> 0..~1.1): flat low overnight, charge ramp late morning, +// overshoot midday so it plateaus at full, steep discharge in the evening. Not a sine. +const PROFILE: [number, number][] = [ + [0, 0.2], + [5, 0.05], + [8, 0.08], + [11, 0.5], + [14, 1.12], + [17, 1.05], + [19, 0.6], + [21, 0.32], + [24, 0.2], +]; + +function dayShape(hour: number): number { + for (let i = 1; i < PROFILE.length; i++) { + const [h0, v0] = PROFILE[i - 1]!; + const [h1, v1] = PROFILE[i]!; + if (hour <= h1) return v0 + (v1 - v0) * smoothstep(h0, h1, hour); + } + return PROFILE.at(-1)![1]; +} + +// per-battery soc timeline at 15min resolution, same shape as the /history/energy data. +// each battery shifts the daily rhythm and its floor/ceiling; a slow continuous wave adds +// day-to-day variation. clamping the overshoot creates realistic full/empty plateaus. +function generateTimeline(from: number, to: number, seed: number): SocPoint[] { + const offset = seed * 1.6; // shift the daily rhythm per battery + const floor = 8 + seed * 5; + const ceil = 100 - seed * 8; + const pts: SocPoint[] = []; + for (let t = Math.ceil(from / STEP) * STEP; t <= to; t += STEP) { + const d = new Date(t); + const hour = (((d.getHours() + d.getMinutes() / 60 - offset) % 24) + 24) % 24; + const drift = 6 * Math.sin(t / (37 * HOUR) + seed); + const soc = floor + (ceil - floor) * clamp(dayShape(hour), 0, 1) + drift; + pts.push({ t, soc: clamp(soc, 5, 100) }); + } + return pts; +} + +const TITLES = ["Sungrow", "Anker", "Fronius", "BYD"]; +const CAPACITIES = [13.5, 7.5, 10, 5]; + +function buildBatteries(count: number, withForecast: boolean, now: number): BatterySeries[] { + return Array.from({ length: count }, (_, i) => { + const history = generateTimeline(now - 60 * HOUR, now, i); + const title = TITLES[i] ?? `Battery ${i + 1}`; + return { + id: title, + title, + capacity: CAPACITIES[i] ?? 8, + currentSoc: history.at(-1)?.soc ?? 50, + history, + forecast: withForecast ? generateTimeline(now, now + 36 * HOUR, i) : [], + }; + }); +} + +function scenario(count: number, withForecast: boolean): StoryFn { + return () => ({ + components: { BatteryHistoryCard }, + setup() { + return { batteries: buildBatteries(count, withForecast, NOW.getTime()), now: NOW }; + }, + template: `
+ +
`, + }); +} + +export const OneBattery = scenario(1, false); +export const OneBatteryForecast = scenario(1, true); + +export const TwoBatteries = scenario(2, false); +export const TwoBatteriesForecast = scenario(2, true); + +export const ThreeBatteries = scenario(3, false); +export const ThreeBatteriesForecast = scenario(3, true); + +export const FourBatteries = scenario(4, false); +export const FourBatteriesForecast = scenario(4, true); diff --git a/assets/js/components/Battery/BatteryHistoryCard.vue b/assets/js/components/Battery/BatteryHistoryCard.vue new file mode 100644 index 000000000..c420d6980 --- /dev/null +++ b/assets/js/components/Battery/BatteryHistoryCard.vue @@ -0,0 +1,165 @@ + + + diff --git a/assets/js/components/Battery/BatteryHistoryChart.vue b/assets/js/components/Battery/BatteryHistoryChart.vue new file mode 100644 index 000000000..deb6d5e93 --- /dev/null +++ b/assets/js/components/Battery/BatteryHistoryChart.vue @@ -0,0 +1,495 @@ + + + + + diff --git a/assets/js/components/Battery/BatteryStatusCard.stories.ts b/assets/js/components/Battery/BatteryStatusCard.stories.ts new file mode 100644 index 000000000..b1118e8a2 --- /dev/null +++ b/assets/js/components/Battery/BatteryStatusCard.stories.ts @@ -0,0 +1,88 @@ +import BatteryStatusCard from "./BatteryStatusCard.vue"; +import type { Meta, StoryFn } from "@storybook/vue3"; + +export default { + title: "Battery/BatteryStatusCard", + component: BatteryStatusCard, + parameters: { + layout: "centered", + }, + argTypes: { + title: { control: "text" }, + soc: { control: { type: "range", min: 0, max: 100 } }, + power: { control: "number", description: "W, + charging / - discharging" }, + capacity: { control: "number", description: "kWh, 0 = unspecified" }, + color: { control: "color" }, + }, +} as Meta; + +// narrow: how the card looks as a column in a multi-battery grid +const Narrow: StoryFn = (args) => ({ + components: { BatteryStatusCard }, + setup() { + return { args }; + }, + template: '
', +}); + +const base = { title: "Sungrow", capacity: 13.5, color: "#0BA631" }; + +export const Charging = Narrow.bind({}); +Charging.args = { ...base, soc: 76, power: 800 }; + +export const Discharging = Narrow.bind({}); +Discharging.args = { + ...base, + title: "Anker", + capacity: 7.5, + color: "#7FC41B", + soc: 40, + power: -1200, +}; + +export const Idle = Narrow.bind({}); +Idle.args = { ...base, title: "Battery", capacity: 13.4, soc: 98, power: 0 }; + +export const Empty = Narrow.bind({}); +Empty.args = { ...base, title: "Battery", capacity: 13.4, soc: 4, power: -600 }; + +export const NoCapacity = Narrow.bind({}); +NoCapacity.args = { ...base, title: "Battery", capacity: 0, soc: 55, power: 300 }; + +// optimizer states, stacked below the values (as on the combined card) +export const OptimizerNormal = Narrow.bind({}); +OptimizerNormal.args = { ...base, soc: 62, power: 900, suggestion: { action: "normal" } }; + +export const OptimizerHold = Narrow.bind({}); +OptimizerHold.args = { ...base, soc: 88, power: 0, suggestion: { action: "hold" } }; + +export const OptimizerCharge = Narrow.bind({}); +OptimizerCharge.args = { + ...base, + soc: 48, + power: 2500, + suggestion: { action: "charge" }, +}; + +export const OptimizerHoldCharge = Narrow.bind({}); +OptimizerHoldCharge.args = { ...base, soc: 70, power: 0, suggestion: { action: "holdcharge" } }; + +export const WithForecast = Narrow.bind({}); +WithForecast.args = { + ...base, + soc: 62, + power: 900, + forecast: { + highest: { soc: 100, time: "2026-07-01T16:30:00+02:00" }, + lowest: { soc: 12, time: "2026-07-02T06:00:00+02:00" }, + }, +}; + +export const SuggestionAndForecast = Narrow.bind({}); +SuggestionAndForecast.args = { + ...base, + soc: 48, + power: 2500, + suggestion: { action: "charge" }, + forecast: { highest: { soc: 100, time: "2026-07-01T18:00:00+02:00", limit: true } }, +}; diff --git a/assets/js/components/Battery/BatteryStatusCard.vue b/assets/js/components/Battery/BatteryStatusCard.vue new file mode 100644 index 000000000..2275172eb --- /dev/null +++ b/assets/js/components/Battery/BatteryStatusCard.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/assets/js/components/Battery/BatteryStatusCards.stories.ts b/assets/js/components/Battery/BatteryStatusCards.stories.ts new file mode 100644 index 000000000..5deb8a4e3 --- /dev/null +++ b/assets/js/components/Battery/BatteryStatusCards.stories.ts @@ -0,0 +1,82 @@ +import BatteryStatusCards from "./BatteryStatusCards.vue"; +import type { Battery, BatteryMeter } from "@/types/evcc"; +import type { Meta, StoryFn } from "@storybook/vue3"; + +export default { + title: "Battery/BatteryStatusCards", + component: BatteryStatusCards, + parameters: { + layout: "fullscreen", + }, +} as Meta; + +const Template: StoryFn = (args) => ({ + components: { BatteryStatusCards }, + setup() { + return { args }; + }, + template: '
', +}); + +function device(overrides: Partial): BatteryMeter { + return { power: 0, soc: 50, controllable: false, capacity: 10, ...overrides }; +} + +function battery(devices: BatteryMeter[], forecast?: Battery["forecast"]): Battery { + return { + power: devices.reduce((s, d) => s + d.power, 0), + capacity: devices.reduce((s, d) => s + d.capacity, 0), + soc: 50, + devices, + forecast, + }; +} + +export const Single = Template.bind({}); +Single.args = { + battery: battery([device({ title: "Sungrow", soc: 76, power: 800, capacity: 13.5 })]), +}; + +export const Multiple = Template.bind({}); +Multiple.args = { + battery: { + ...battery([ + device({ title: "Sungrow", soc: 76, power: 800, capacity: 13.5, controllable: true }), + device({ title: "Anker", soc: 40, power: -1200, capacity: 7.5 }), + ]), + soc: 63, + }, +}; + +// single battery: optimizer gets its own card, showing suggestion + high + low +export const WithForecast = Template.bind({}); +WithForecast.args = { + battery: battery([device({ title: "Battery", soc: 62, power: 900, capacity: 10 })], { + highest: { soc: 100, time: "2026-07-01T16:30:00+02:00" }, + lowest: { soc: 12, time: "2026-07-02T06:00:00+02:00" }, + }), + suggestion: { action: "charge" }, +}; + +// forecast is a site-aggregate value, so it lands on the combined card +export const MultipleWithForecast = Template.bind({}); +MultipleWithForecast.args = { + battery: { + ...battery( + [ + device({ title: "Sungrow", soc: 76, power: 800, capacity: 13.5, controllable: true }), + device({ title: "Anker", soc: 40, power: -1200, capacity: 7.5 }), + ], + { + highest: { soc: 100, time: "2026-07-01T16:30:00+02:00" }, + lowest: { soc: 15, time: "2026-07-02T05:00:00+02:00" }, + } + ), + soc: 63, + }, +}; + +export const SocOnly = Template.bind({}); +SocOnly.args = { + battery: battery([device({ title: "Battery", soc: 55, power: 300, capacity: 0 })]), +}; diff --git a/assets/js/components/Battery/BatteryStatusCards.vue b/assets/js/components/Battery/BatteryStatusCards.vue new file mode 100644 index 000000000..20a427e4c --- /dev/null +++ b/assets/js/components/Battery/BatteryStatusCards.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/assets/js/components/Battery/InlineSocSelect.vue b/assets/js/components/Battery/InlineSocSelect.vue new file mode 100644 index 000000000..96f86081a --- /dev/null +++ b/assets/js/components/Battery/InlineSocSelect.vue @@ -0,0 +1,34 @@ + + + diff --git a/assets/js/components/Battery/OptimizerInfo.vue b/assets/js/components/Battery/OptimizerInfo.vue new file mode 100644 index 000000000..552e581f2 --- /dev/null +++ b/assets/js/components/Battery/OptimizerInfo.vue @@ -0,0 +1,67 @@ + + + diff --git a/assets/js/components/Battery/SocGauge.vue b/assets/js/components/Battery/SocGauge.vue new file mode 100644 index 000000000..48a43006d --- /dev/null +++ b/assets/js/components/Battery/SocGauge.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/assets/js/components/Battery/WindowNav.vue b/assets/js/components/Battery/WindowNav.vue new file mode 100644 index 000000000..e25b23661 --- /dev/null +++ b/assets/js/components/Battery/WindowNav.vue @@ -0,0 +1,35 @@ + + + diff --git a/assets/js/components/Battery/history.test.ts b/assets/js/components/Battery/history.test.ts new file mode 100644 index 000000000..07ec65001 --- /dev/null +++ b/assets/js/components/Battery/history.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from "vitest"; +import { historyToSeries, forecastToSeries, buildChartBatteries } from "./history"; +import type { BatteryMeter, EvOpt } from "@/types/evcc"; +import type { BatteryHistorySeries } from "./types"; + +const t = (iso: string) => new Date(iso).getTime(); + +describe("historyToSeries", () => { + it("keys by title, drops slots without socTemp, maps start to epoch", () => { + const raw: BatteryHistorySeries[] = [ + { + title: "Anker", + group: "battery", + data: [ + { start: "2026-01-01T00:00:00Z", socTemp: 50 }, + { start: "2026-01-01T00:15:00Z", socTemp: null }, + { start: "2026-01-01T00:30:00Z" }, + { start: "2026-01-01T00:45:00Z", socTemp: 55 }, + ], + }, + ]; + const res = historyToSeries(raw); + expect(res).toHaveLength(1); + expect(res[0]!.key).toBe("Anker"); + expect(res[0]!.points).toEqual([ + { t: t("2026-01-01T00:00:00Z"), soc: 50 }, + { t: t("2026-01-01T00:45:00Z"), soc: 55 }, + ]); + }); + + it("falls back to group when title is missing", () => { + const res = historyToSeries([{ group: "battery", data: [] }]); + expect(res[0]!.key).toBe("battery"); + }); +}); + +describe("forecastToSeries", () => { + const evopt = { + res: { + batteries: [ + { state_of_charge: [4000, 5000, 6000] }, // Wh + { state_of_charge: [1000, 2000, 3000] }, + ], + }, + details: { + timestamp: ["2026-01-01T00:00:00Z", "2026-01-01T01:00:00Z", "2026-01-01T02:00:00Z"], + batteryDetails: [ + { type: "battery", name: "bat1", title: "Anker", capacity: 10 }, + { type: "vehicle", name: "car", title: "EV", capacity: 60 }, + ], + }, + } as unknown as EvOpt; + + it("keys by device name and converts Wh to percent of capacity", () => { + const res = forecastToSeries(evopt, t("2026-01-01T00:00:00Z")); + expect(res).toHaveLength(1); // vehicle skipped + expect(res[0]!.key).toBe("bat1"); + // 10 kWh -> 5000 Wh = 50 % + expect(res[0]!.points).toEqual([ + { t: t("2026-01-01T00:00:00Z"), soc: 40 }, + { t: t("2026-01-01T01:00:00Z"), soc: 50 }, + { t: t("2026-01-01T02:00:00Z"), soc: 60 }, + ]); + }); + + it("drops points before now", () => { + const res = forecastToSeries(evopt, t("2026-01-01T01:00:00Z")); + expect(res[0]!.points.map((p) => p.soc)).toEqual([50, 60]); + }); + + it("returns empty for missing optimizer data", () => { + expect(forecastToSeries(undefined, 0)).toEqual([]); + }); +}); + +describe("buildChartBatteries", () => { + const devices = [ + { name: "bat1", title: "Anker", soc: 60, capacity: 10, controllable: false }, + ] as BatteryMeter[]; + + it("matches history by title and forecast by device name", () => { + const history = [{ key: "Anker", points: [{ t: 1, soc: 42 }] }]; + const forecast = [{ key: "bat1", points: [{ t: 2, soc: 80 }] }]; + const res = buildChartBatteries(devices, history, forecast); + expect(res).toHaveLength(1); + expect(res[0]).toMatchObject({ + id: "Anker", + title: "Anker", + capacity: 10, + currentSoc: 60, + history: [{ t: 1, soc: 42 }], + forecast: [{ t: 2, soc: 80 }], + }); + }); + + it("yields empty timelines when nothing matches", () => { + const res = buildChartBatteries(devices, [], []); + expect(res[0]!.history).toEqual([]); + expect(res[0]!.forecast).toEqual([]); + }); +}); diff --git a/assets/js/components/Battery/history.ts b/assets/js/components/Battery/history.ts new file mode 100644 index 000000000..af55d3570 --- /dev/null +++ b/assets/js/components/Battery/history.ts @@ -0,0 +1,55 @@ +// Pure transforms that turn the raw /history/energy response and the optimizer result into +// the per-battery soc timelines the chart consumes. Kept framework-free for unit testing. + +import type { BatteryMeter, EvOpt } from "@/types/evcc"; +import type { SocPoint, BatterySeries, BatteryHistorySeries } from "./types"; + +// A soc timeline tagged with the key used to match it back to a battery device. +export interface KeyedSeries { + key: string; + points: SocPoint[]; +} + +// History is grouped per title (no device name), so key it by title, falling back to the group. +export function historyToSeries(raw: BatteryHistorySeries[]): KeyedSeries[] { + return raw.map((s) => ({ + key: s.title || s.group, + points: s.data + .filter((d) => d.socTemp != null) + .map((d) => ({ t: new Date(d.start).getTime(), soc: d.socTemp as number })), + })); +} + +// Optimizer soc per battery, keyed by the device name; only points at/after now, converted +// from Wh to percent using the battery capacity (kWh). +export function forecastToSeries(evopt: EvOpt | undefined, nowMs: number): KeyedSeries[] { + if (!evopt?.res?.batteries || !evopt.details?.timestamp) return []; + const ts = evopt.details.timestamp.map((t) => new Date(t).getTime()); + return evopt.res.batteries.flatMap((bat, i): KeyedSeries[] => { + const det = evopt.details.batteryDetails?.[i]; + if (!det || det.type !== "battery" || !det.capacity) return []; + const points: SocPoint[] = (bat.state_of_charge || []) + .map((wh, j) => ({ t: ts[j]!, soc: (wh / (det.capacity * 1000)) * 100 })) + .filter((p) => p.t >= nowMs && !Number.isNaN(p.soc)); + return [{ key: det.name, points }]; + }); +} + +// Merge per-device meta with its matched history (by title) and forecast (by device name). +export function buildChartBatteries( + devices: BatteryMeter[], + history: KeyedSeries[], + forecast: KeyedSeries[] +): BatterySeries[] { + return devices.map((d) => { + const title = d.title || d.name || ""; + return { + id: title, + title, + capacity: d.capacity || 0, + currentSoc: d.soc, + history: history.find((s) => s.key === title)?.points ?? [], + forecast: forecast.find((s) => s.key === d.name)?.points ?? [], + }; + }); +} diff --git a/assets/js/components/Battery/types.ts b/assets/js/components/Battery/types.ts new file mode 100644 index 000000000..f9d01afb5 --- /dev/null +++ b/assets/js/components/Battery/types.ts @@ -0,0 +1,42 @@ +// Shared view-model types for the experimental battery page. + +import type { BatteryForecast } from "@/types/evcc"; + +export interface SocPoint { + t: number; // epoch ms + soc: number; // 0..100 +} + +export interface BatterySeries { + id: string; + title: string; + capacity: number; // kWh + currentSoc: number; // 0..100 + history: SocPoint[]; // ascending t, <= now + forecast: SocPoint[]; // ascending t, >= now (may be empty) +} + +export interface BatterySuggestion { + action: "normal" | "hold" | "charge" | "holdcharge"; +} + +export interface BatteryStatusCardModel { + id: string; + title: string; + soc: number; + power: number; + capacity: number; + color: string; + suggestion: BatterySuggestion | null; + forecast: BatteryForecast | null; +} + +export interface BatteryHistorySlot { + start: string; + socTemp?: number | null; +} +export interface BatteryHistorySeries { + title?: string; + group: string; + data: BatteryHistorySlot[]; +} diff --git a/assets/js/components/Config/OcppModal.vue b/assets/js/components/Config/OcppModal.vue index 9ac301e1e..458763aa6 100644 --- a/assets/js/components/Config/OcppModal.vue +++ b/assets/js/components/Config/OcppModal.vue @@ -126,7 +126,7 @@ export default defineComponent({ entries(): StationEntry[] { const byId = new Map(); const published = new Set(); - for (const station of this.ocpp.status.stations) { + for (const station of this.ocpp.status?.stations ?? []) { byId.set(station.id, { id: station.id, status: station.status, diff --git a/assets/js/components/Energyflow/BatteryIcon.stories.ts b/assets/js/components/Energyflow/BatteryIcon.stories.ts index 4cb3aecd2..709cf8c59 100644 --- a/assets/js/components/Energyflow/BatteryIcon.stories.ts +++ b/assets/js/components/Energyflow/BatteryIcon.stories.ts @@ -9,17 +9,13 @@ export default { }, } as Meta; -const Template: StoryFn = (args) => { - const story = () => ({ - components: { BatteryIcon }, - setup() { - return { args }; - }, - template: '', - }); - story.args = args; - return story; -}; +const Template: StoryFn = (args) => ({ + components: { BatteryIcon }, + setup() { + return { args }; + }, + template: '', +}); export const Empty = Template.bind({}); Empty.args = { soc: 0 }; diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts index 5095a9dc9..902453f46 100644 --- a/assets/js/components/Forecast/echarts.ts +++ b/assets/js/components/Forecast/echarts.ts @@ -7,6 +7,9 @@ import { GridComponent, TooltipComponent, MarkPointComponent, + MarkLineComponent, + MarkAreaComponent, + GraphicComponent, AxisPointerComponent, } from "echarts/components"; import { SVGRenderer } from "echarts/renderers"; @@ -18,6 +21,9 @@ echarts.use([ TooltipComponent, AxisPointerComponent, MarkPointComponent, + MarkLineComponent, + MarkAreaComponent, + GraphicComponent, SVGRenderer, ]); diff --git a/assets/js/components/Helper/Card.vue b/assets/js/components/Helper/Card.vue new file mode 100644 index 000000000..1a3f28db6 --- /dev/null +++ b/assets/js/components/Helper/Card.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/assets/js/components/Helper/CustomSelect.vue b/assets/js/components/Helper/CustomSelect.vue index 3758ff750..32e7b2b58 100644 --- a/assets/js/components/Helper/CustomSelect.vue +++ b/assets/js/components/Helper/CustomSelect.vue @@ -1,5 +1,10 @@ diff --git a/assets/js/mixins/formatter.ts b/assets/js/mixins/formatter.ts index 82c1577c8..ad1fece43 100644 --- a/assets/js/mixins/formatter.ts +++ b/assets/js/mixins/formatter.ts @@ -241,6 +241,19 @@ export default defineComponent({ return `${weekday} ${hour}`.trim(); }, + // relative day plus time, e.g. "heute 16:30", "morgen 5:00", "Freitag 12:15" + fmtDayTime(date: Date) { + const time = this.fmtHourMinute(date); + const startOfDay = (d: Date) => new Date(d).setHours(0, 0, 0, 0); + const days = Math.round((startOfDay(date) - startOfDay(new Date())) / 86400000); + if (days === 0 || days === 1) { + const day = new Intl.RelativeTimeFormat(this.$i18n?.locale, { + numeric: "auto", + }).format(days, "day"); + return `${day} ${time}`; + } + return `${this.weekdayLong(date)} ${time}`; + }, fmtHourMinute(date: Date) { return new Intl.DateTimeFormat(this.$i18n?.locale, { hour: "numeric", diff --git a/assets/js/settings.ts b/assets/js/settings.ts index 2b96b3574..ab4df7177 100644 --- a/assets/js/settings.ts +++ b/assets/js/settings.ts @@ -19,6 +19,7 @@ const SAVINGS_REGION = "savings_region"; const SAVINGS_INDICATOR = "savings_indicator"; const SESSIONS_GROUP = "sessions_group"; const SESSIONS_TYPE = "sessions_type"; +const BATTERY_UNIT = "battery_unit"; const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted"; const SETTINGS_PRICE_ZOOM = "settings_price_zoom"; const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin"; @@ -124,6 +125,7 @@ export interface Settings { savingsIndicator: string; sessionsGroup: string; sessionsType: string; + batteryUnit: string; solarAdjusted: boolean; priceZoom: boolean; hideFeedin: boolean; @@ -153,6 +155,7 @@ const settings: Settings = reactive({ savingsIndicator: read(SAVINGS_INDICATOR), sessionsGroup: read(SESSIONS_GROUP), sessionsType: read(SESSIONS_TYPE), + batteryUnit: read(BATTERY_UNIT), solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED), priceZoom: readBool(SETTINGS_PRICE_ZOOM), hideFeedin: readBool(SETTINGS_HIDE_FEEDIN), @@ -181,6 +184,7 @@ watch(() => settings.savingsRegion, save(SAVINGS_REGION)); watch(() => settings.savingsIndicator, save(SAVINGS_INDICATOR)); watch(() => settings.sessionsGroup, save(SESSIONS_GROUP)); watch(() => settings.sessionsType, save(SESSIONS_TYPE)); +watch(() => settings.batteryUnit, save(BATTERY_UNIT)); watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED)); watch(() => settings.priceZoom, saveBool(SETTINGS_PRICE_ZOOM)); watch(() => settings.hideFeedin, saveBool(SETTINGS_HIDE_FEEDIN)); diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index b79235706..df72ba367 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -118,6 +118,7 @@ export interface State { batteryGridChargeLimit?: number | null; smartCostAvailable?: boolean; smartCostType?: SMART_COST_TYPE; + historyUpdated?: string; // ISO timestamp, bumped each 15min metrics persist siteTitle?: string; deviceColors?: DeviceColorEntry[]; vehicles: Record; diff --git a/assets/js/utils/ocpp.ts b/assets/js/utils/ocpp.ts index 79915c015..31801b4a5 100644 --- a/assets/js/utils/ocpp.ts +++ b/assets/js/utils/ocpp.ts @@ -2,7 +2,7 @@ import type { Ocpp } from "@/types/evcc"; export function getOcppUrl(ocpp: Ocpp): string { // User specified url, e.g., for reverse proxy setups - if (ocpp.status.externalUrl) { + if (ocpp.status?.externalUrl) { return ocpp.status.externalUrl; } return `ws://${window.location.hostname}:${ocpp.config.port}/`; diff --git a/assets/js/views/Battery.vue b/assets/js/views/Battery.vue index 074898f4f..ac289a532 100644 --- a/assets/js/views/Battery.vue +++ b/assets/js/views/Battery.vue @@ -3,11 +3,18 @@
-