From ca7bbba0a41b5f91b6d684abd7f38fcbe483999b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 11:30:17 +0100 Subject: [PATCH 0001/1700] chore(deps): bump githubnext/gh-aw from 0.37.22 to 0.38.5 (#27151) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/triage-agent.lock.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/triage-agent.lock.yml b/.github/workflows/triage-agent.lock.yml index 474cb8762..3d0bcfda9 100644 --- a/.github/workflows/triage-agent.lock.yml +++ b/.github/workflows/triage-agent.lock.yml @@ -57,7 +57,7 @@ jobs: comment_repo: "" steps: - name: Setup Scripts - uses: githubnext/gh-aw/actions/setup@afac8d00988d0af8af1eefb0d71cd98122b325b2 # v0.37.22 + uses: githubnext/gh-aw/actions/setup@c62cb1aecba894a20fdb4c6bbc39992bc6ce37e2 # v0.38.5 with: destination: /opt/gh-aw/actions - name: Check workflow file timestamps @@ -95,7 +95,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: githubnext/gh-aw/actions/setup@afac8d00988d0af8af1eefb0d71cd98122b325b2 # v0.37.22 + uses: githubnext/gh-aw/actions/setup@c62cb1aecba894a20fdb4c6bbc39992bc6ce37e2 # v0.38.5 with: destination: /opt/gh-aw/actions - name: Checkout repository @@ -781,7 +781,7 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: githubnext/gh-aw/actions/setup@afac8d00988d0af8af1eefb0d71cd98122b325b2 # v0.37.22 + uses: githubnext/gh-aw/actions/setup@c62cb1aecba894a20fdb4c6bbc39992bc6ce37e2 # v0.38.5 with: destination: /opt/gh-aw/actions - name: Debug job inputs @@ -884,7 +884,7 @@ jobs: success: ${{ steps.parse_results.outputs.success }} steps: - name: Setup Scripts - uses: githubnext/gh-aw/actions/setup@afac8d00988d0af8af1eefb0d71cd98122b325b2 # v0.37.22 + uses: githubnext/gh-aw/actions/setup@c62cb1aecba894a20fdb4c6bbc39992bc6ce37e2 # v0.38.5 with: destination: /opt/gh-aw/actions - name: Download agent artifacts @@ -1022,7 +1022,7 @@ jobs: activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} steps: - name: Setup Scripts - uses: githubnext/gh-aw/actions/setup@afac8d00988d0af8af1eefb0d71cd98122b325b2 # v0.37.22 + uses: githubnext/gh-aw/actions/setup@c62cb1aecba894a20fdb4c6bbc39992bc6ce37e2 # v0.38.5 with: destination: /opt/gh-aw/actions - name: Check team membership for workflow @@ -1061,7 +1061,7 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: githubnext/gh-aw/actions/setup@afac8d00988d0af8af1eefb0d71cd98122b325b2 # v0.37.22 + uses: githubnext/gh-aw/actions/setup@c62cb1aecba894a20fdb4c6bbc39992bc6ce37e2 # v0.38.5 with: destination: /opt/gh-aw/actions - name: Download agent output artifact From 768656e0c3182af611bbe58716b239a7ed382a12 Mon Sep 17 00:00:00 2001 From: Maschga <88616799+Maschga@users.noreply.github.com> Date: Sun, 1 Feb 2026 11:59:02 +0100 Subject: [PATCH 0002/1700] EEBUS: configure by default (#26944) --- api/globalconfig/types.go | 28 ++-- assets/js/components/Config/DeviceCard.vue | 13 +- assets/js/components/Config/EebusModal.vue | 147 ++++++++++++++++-- assets/js/components/Config/HemsModal.vue | 2 +- assets/js/components/Config/JsonModal.vue | 12 +- .../components/Config/PropertyCertField.vue | 3 +- assets/js/components/Config/PropertyField.vue | 4 +- .../components/Config/defaultYaml/eebus.yaml | 12 -- assets/js/types/evcc.ts | 25 ++- assets/js/views/Config.vue | 109 +++++-------- cmd/helper.go | 16 ++ cmd/helper_test.go | 34 ++++ cmd/root.go | 34 ++-- cmd/setup.go | 46 ++---- i18n/bg.json | 7 +- i18n/bs.json | 5 +- i18n/cs.json | 7 +- i18n/da.json | 7 +- i18n/de.json | 26 +++- i18n/el.json | 7 +- i18n/en.json | 26 +++- i18n/es.json | 7 +- i18n/fi.json | 7 +- i18n/fr.json | 7 +- i18n/hr.json | 7 +- i18n/hu.json | 7 +- i18n/it.json | 7 +- i18n/lb.json | 7 +- i18n/lt.json | 7 +- i18n/nl.json | 7 +- i18n/no.json | 7 +- i18n/pl.json | 7 +- i18n/pt.json | 7 +- i18n/ro.json | 7 +- i18n/ru.json | 7 +- i18n/sl.json | 7 +- i18n/sv.json | 7 +- i18n/ta.json | 4 +- i18n/tr.json | 7 +- i18n/uk.json | 7 +- i18n/zh-Hans.json | 4 +- server/eebus/eebus.go | 44 ++---- server/eebus/test/cs_test.go | 5 +- server/eebus/types.go | 73 ++++++++- server/http.go | 2 +- server/http_config_site_other_handler.go | 15 +- server/http_global_settings_handler.go | 15 +- tests/config-eebus.evcc.yaml | 27 ++++ tests/config-eebus.spec.ts | 137 ++++++++++++++++ tests/config-ocpp.spec.ts | 2 - util/redact.go | 8 + util/sponsor/auth.go | 4 +- 52 files changed, 712 insertions(+), 324 deletions(-) delete mode 100644 assets/js/components/Config/defaultYaml/eebus.yaml create mode 100644 cmd/helper_test.go create mode 100644 tests/config-eebus.evcc.yaml create mode 100644 tests/config-eebus.spec.ts create mode 100644 util/redact.go diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go index 3756f2f8c..4925bae63 100644 --- a/api/globalconfig/types.go +++ b/api/globalconfig/types.go @@ -18,6 +18,13 @@ import ( "github.com/evcc-io/evcc/util/modbus" ) +// ConfigStatus for publishing config, status and source to UI and external systems +type ConfigStatus struct { + Config any `json:"config,omitempty"` + Status any `json:"status,omitempty"` + FromYaml bool `json:"fromYaml,omitempty"` +} + type All struct { Network Network Ocpp ocpp.Config @@ -79,13 +86,6 @@ func (c Hems) Redacted() any { var _ api.Redactor = (*Mqtt)(nil) -func masked(s any) string { - if s != "" { - return "***" - } - return "" -} - type Mqtt struct { mqtt.Config `mapstructure:",squash"` Topic string `json:"topic"` @@ -97,12 +97,12 @@ func (m Mqtt) Redacted() any { Config: mqtt.Config{ Broker: m.Broker, User: m.User, - Password: masked(m.Password), + Password: util.Masked(m.Password), ClientID: m.ClientID, Insecure: m.Insecure, - CaCert: masked(m.CaCert), - ClientCert: masked(m.ClientCert), - ClientKey: masked(m.ClientKey), + CaCert: util.Masked(m.CaCert), + ClientCert: util.Masked(m.ClientCert), + ClientKey: util.Masked(m.ClientKey), }, Topic: m.Topic, } @@ -124,10 +124,10 @@ func (c Influx) Redacted() any { return Influx{ URL: c.URL, Database: c.Database, - Token: masked(c.Token), + Token: util.Masked(c.Token), Org: c.Org, User: c.User, - Password: masked(c.Password), + Password: util.Masked(c.Password), Insecure: c.Insecure, } } @@ -147,7 +147,7 @@ type MessagingEventTemplate struct { Title, Msg string } -func (c Messaging) Configured() bool { +func (c Messaging) IsConfigured() bool { return len(c.Services) > 0 || len(c.Events) > 0 } diff --git a/assets/js/components/Config/DeviceCard.vue b/assets/js/components/Config/DeviceCard.vue index 5c8eddbf4..aa8c030a8 100644 --- a/assets/js/components/Config/DeviceCard.vue +++ b/assets/js/components/Config/DeviceCard.vue @@ -6,9 +6,10 @@ 'round-box--error': error, 'round-box--warning': warning, 'root--unconfigured': unconfigured, + 'root--with-tags': $slots.tags, }" > -
+
@@ -33,8 +34,10 @@
-
- +
+
+ +
@@ -67,7 +70,7 @@ export default { } let title = `${this.$t("config.main.name")}: ${this.name}`; if (!this.editable) { - title += `
${this.$t("config.main.yaml")}
`; + title += `
${this.$t("config.general.fromYamlHint")}
`; } return `
${title}
`; }, @@ -105,6 +108,8 @@ export default { list-style-type: none; border-radius: 1rem; padding: 1rem 1.5rem; +} +.root--with-tags { min-height: 8rem; } .root--unconfigured { diff --git a/assets/js/components/Config/EebusModal.vue b/assets/js/components/Config/EebusModal.vue index d4e499669..ff7c48df5 100644 --- a/assets/js/components/Config/EebusModal.vue +++ b/assets/js/components/Config/EebusModal.vue @@ -1,26 +1,153 @@ - diff --git a/assets/js/components/Config/HemsModal.vue b/assets/js/components/Config/HemsModal.vue index cd293ef25..cab449f58 100644 --- a/assets/js/components/Config/HemsModal.vue +++ b/assets/js/components/Config/HemsModal.vue @@ -31,7 +31,7 @@

- {{ $t("config.hems.yamlConfigured") }} + {{ $t("config.general.fromYamlHint") }}

diff --git a/assets/js/components/Config/JsonModal.vue b/assets/js/components/Config/JsonModal.vue index e3338bf55..010028e01 100644 --- a/assets/js/components/Config/JsonModal.vue +++ b/assets/js/components/Config/JsonModal.vue @@ -88,6 +88,7 @@ export default { saveMethod: { type: String, default: "post" }, storeValuesInArray: Boolean, size: { type: String }, + confirmRemove: String, }, emits: ["changed", "open"], data() { @@ -121,7 +122,13 @@ export default { await this.load(); }, async load() { - this.serverValues = this.stateKey ? store.state[this.stateKey] : store.state; + if (this.stateKey) { + // Support nested keys like "eebus.config" + const keys = this.stateKey.split("."); + this.serverValues = keys.reduce((obj, key) => obj?.[key], store.state); + } else { + this.serverValues = store.state; + } if (this.transformReadValues) { this.serverValues = this.transformReadValues(this.serverValues); } @@ -155,6 +162,9 @@ export default { this.saving = false; }, async remove() { + if (this.confirmRemove && !window.confirm(this.confirmRemove)) { + return; + } this.removing = true; this.error = ""; try { diff --git a/assets/js/components/Config/PropertyCertField.vue b/assets/js/components/Config/PropertyCertField.vue index c71722301..df64a242d 100644 --- a/assets/js/components/Config/PropertyCertField.vue +++ b/assets/js/components/Config/PropertyCertField.vue @@ -1,6 +1,6 @@ + +
+

{{ $t("issue.downloadHint") }}

+
+ +
+
-
+
@@ -304,6 +330,7 @@ { await expect(email).toHaveAttribute("href", "mailto:support@example.com"); }); + test("issue page uses email flow", async ({ page }) => { + await page.goto("/#/issue"); + + // github-only elements gone + await expect(page.getByRole("radio")).toHaveCount(0); + await expect(page.getByLabel("Steps to Reproduce")).toHaveCount(0); + await expect(page.getByText("Please write your issue in English")).toHaveCount(0); + + await expect(page.getByRole("button", { name: "Send request by email" })).toBeVisible(); + await expect( + page.getByText("Attach this file to your email if more information is needed.") + ).toBeVisible(); + + const downloadButton = page.getByRole("button", { name: "Download debug information" }); + await expect(downloadButton).toBeVisible(); + const downloadPromise = page.waitForEvent("download"); + await downloadButton.click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/^evcc-debug-v.+\.txt$/); + }); + test("help modal shows contact button instead of github discussions", async ({ page }) => { await page.goto("/"); const menu = await openMoreMenu(page); From 7317b3437a45f325522c1dfab10e161956c0efd2 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 7 Aug 2026 18:31:18 +0200 Subject: [PATCH 1465/1700] UI: hide chart tooltips on touch release (#32603) --- .../Battery/BatteryHistoryChart.vue | 2 + assets/js/components/Forecast/Chart.vue | 548 ------------------ assets/js/components/Forecast/chartMixin.ts | 9 +- assets/js/components/Forecast/echarts.ts | 14 + assets/js/components/History/GroupChart.vue | 2 + assets/js/components/Optimize/ChargeChart.vue | 4 +- assets/js/components/Optimize/PriceChart.vue | 4 +- assets/js/components/Optimize/SocChart.vue | 11 +- assets/js/components/Sessions/chartConfig.ts | 17 + 9 files changed, 53 insertions(+), 558 deletions(-) delete mode 100644 assets/js/components/Forecast/Chart.vue diff --git a/assets/js/components/Battery/BatteryHistoryChart.vue b/assets/js/components/Battery/BatteryHistoryChart.vue index 3ae4ff3fe..f4d8d786e 100644 --- a/assets/js/components/Battery/BatteryHistoryChart.vue +++ b/assets/js/components/Battery/BatteryHistoryChart.vue @@ -9,6 +9,7 @@ import { FONT_FAMILY, forecastXAxes, forecastYAxis, + registerTouchTooltipReset, tooltipStyle, tooltipTable, type TooltipRow, @@ -241,6 +242,7 @@ export default defineComponent({ const el = this.$refs["chartEl"] as HTMLElement; this.chart = markRaw(echarts.init(el)); this.chart.setOption(this.chartOption); + registerTouchTooltipReset(this.chart, el); this.$nextTick(() => this.updateGraphic()); window.addEventListener("resize", this.resize); // initial render done here (not via the watcher), so paging animates from the first click diff --git a/assets/js/components/Forecast/Chart.vue b/assets/js/components/Forecast/Chart.vue deleted file mode 100644 index 27f4115e1..000000000 --- a/assets/js/components/Forecast/Chart.vue +++ /dev/null @@ -1,548 +0,0 @@ - - - diff --git a/assets/js/components/Forecast/chartMixin.ts b/assets/js/components/Forecast/chartMixin.ts index d8c9cecf3..eed9f6bc2 100644 --- a/assets/js/components/Forecast/chartMixin.ts +++ b/assets/js/components/Forecast/chartMixin.ts @@ -1,5 +1,5 @@ import { defineComponent, markRaw } from "vue"; -import { echarts } from "./echarts"; +import { echarts, registerTouchTooltipReset } from "./echarts"; import "./chartStyles.css"; // chartOption is provided by each consuming component's computed @@ -63,12 +63,7 @@ export default defineComponent({ this.chart.on("hideTip", () => { this.tooltipVisible = false; }); - const resetTouch = () => { - this.chart?.dispatchAction({ type: "hideTip" }); - this.tooltipVisible = false; - }; - el.addEventListener("touchend", resetTouch); - el.addEventListener("touchcancel", resetTouch); + registerTouchTooltipReset(this.chart, el, () => (this.tooltipVisible = false)); }, }, }); diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts index f0ed5a3b4..4cfc60f47 100644 --- a/assets/js/components/Forecast/echarts.ts +++ b/assets/js/components/Forecast/echarts.ts @@ -117,6 +117,20 @@ export function tooltipStyle( }; } +// hide tooltip when finger lifts; mouse hover unchanged +export function registerTouchTooltipReset( + chart: Pick, + el: HTMLElement, + onReset?: () => void +) { + const reset = () => { + chart.dispatchAction({ type: "hideTip" }); + onReset?.(); + }; + el.addEventListener("touchend", reset); + el.addEventListener("touchcancel", reset); +} + export interface TooltipRow { name?: string; values: string[]; diff --git a/assets/js/components/History/GroupChart.vue b/assets/js/components/History/GroupChart.vue index 1da173649..8d2951dc4 100644 --- a/assets/js/components/History/GroupChart.vue +++ b/assets/js/components/History/GroupChart.vue @@ -11,6 +11,7 @@ import { FONT_FAMILY, forecastGrid, forecastYAxis, + registerTouchTooltipReset, tooltipStyle, tooltipTable, xAxisLabelStyle, @@ -719,6 +720,7 @@ export default defineComponent({ const zr = this.chart.getZr(); zr.on("mousemove", this.onChartMouseMove); zr.on("globalout", this.clearHighlight); + registerTouchTooltipReset(this.chart, el, this.clearHighlight); this.mediaQuery = window.matchMedia("(max-width: 575.98px)"); this.isMobile = this.mediaQuery.matches; this.mediaQuery.addEventListener("change", this.onMediaChange); diff --git a/assets/js/components/Optimize/ChargeChart.vue b/assets/js/components/Optimize/ChargeChart.vue index 7b4e332c3..61d8a3613 100644 --- a/assets/js/components/Optimize/ChargeChart.vue +++ b/assets/js/components/Optimize/ChargeChart.vue @@ -37,6 +37,7 @@ import formatter from "@/mixins/formatter"; import colors from "@/colors"; import LegendList from "../Sessions/LegendList.vue"; import type { Legend } from "../Sessions/types"; +import { touchDismissPlugin } from "../Sessions/chartConfig"; ChartJS.register( CategoryScale, @@ -48,7 +49,8 @@ ChartJS.register( PointElement, Title, Tooltip, - ChartLegendPlugin + ChartLegendPlugin, + touchDismissPlugin ); export default defineComponent({ diff --git a/assets/js/components/Optimize/PriceChart.vue b/assets/js/components/Optimize/PriceChart.vue index db31b0a1a..87361b6a8 100644 --- a/assets/js/components/Optimize/PriceChart.vue +++ b/assets/js/components/Optimize/PriceChart.vue @@ -35,6 +35,7 @@ import formatter from "@/mixins/formatter"; import colors from "@/colors"; import LegendList from "../Sessions/LegendList.vue"; import type { Legend } from "../Sessions/types"; +import { touchDismissPlugin } from "../Sessions/chartConfig"; const tension = 0; @@ -46,7 +47,8 @@ ChartJS.register( PointElement, Title, Tooltip, - ChartLegendPlugin + ChartLegendPlugin, + touchDismissPlugin ); export default defineComponent({ diff --git a/assets/js/components/Optimize/SocChart.vue b/assets/js/components/Optimize/SocChart.vue index 88d8d64d7..cca83377e 100644 --- a/assets/js/components/Optimize/SocChart.vue +++ b/assets/js/components/Optimize/SocChart.vue @@ -41,8 +41,17 @@ import type { EvoptData } from "./TimeSeriesDataTable.vue"; import type { CURRENCY, BatteryDetail } from "@/types/evcc"; import formatter from "@/mixins/formatter"; import colors from "@/colors"; +import { touchDismissPlugin } from "../Sessions/chartConfig"; -ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, ChartLegendPlugin); +ChartJS.register( + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + ChartLegendPlugin, + touchDismissPlugin +); export default defineComponent({ name: "SocChart", diff --git a/assets/js/components/Sessions/chartConfig.ts b/assets/js/components/Sessions/chartConfig.ts index e2b09193f..f01c008b0 100644 --- a/assets/js/components/Sessions/chartConfig.ts +++ b/assets/js/components/Sessions/chartConfig.ts @@ -3,6 +3,7 @@ import { Tooltip, type ChartComponentLike, type ChartType, + type Plugin, type Point, type TooltipItem, } from "chart.js"; @@ -20,6 +21,22 @@ Chart.defaults.font.family = window Chart.defaults.font.size = 14; Chart.defaults.layout.padding = 0; +// hide tooltip when finger lifts; mouse hover unchanged +export const touchDismissPlugin: Plugin = { + id: "touchDismiss", + afterInit(chart) { + const dismiss = () => { + if (!chart.ctx) return; // destroyed + chart.setActiveElements([]); + chart.tooltip?.setActiveElements([], { x: 0, y: 0 }); + chart.update(); + }; + chart.canvas.addEventListener("touchend", dismiss); + chart.canvas.addEventListener("touchcancel", dismiss); + }, +}; +Chart.register(touchDismissPlugin); + // Custom tooltip positioners (Tooltip.positioners as any)["center"] = function () { const { chart } = this; From 4314a64896fa98c840ff54408d6d7f73320b6471 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 7 Aug 2026 18:47:01 +0200 Subject: [PATCH 1466/1700] UI Charts: show/hide tooltips on touch (#32613) --- .../Battery/BatteryHistoryChart.vue | 4 +- assets/js/components/Forecast/chartMixin.ts | 4 +- assets/js/components/Forecast/echarts.ts | 11 +- assets/js/components/History/GroupChart.vue | 4 +- assets/js/components/Optimize/ChargeChart.vue | 5 +- assets/js/components/Optimize/PriceChart.vue | 5 +- assets/js/components/Optimize/SocChart.vue | 12 +-- assets/js/components/Sessions/chartConfig.ts | 11 +- assets/js/utils/swipe.ts | 100 ++++++++++++++++++ 9 files changed, 122 insertions(+), 34 deletions(-) diff --git a/assets/js/components/Battery/BatteryHistoryChart.vue b/assets/js/components/Battery/BatteryHistoryChart.vue index f4d8d786e..59c16366e 100644 --- a/assets/js/components/Battery/BatteryHistoryChart.vue +++ b/assets/js/components/Battery/BatteryHistoryChart.vue @@ -9,7 +9,7 @@ import { FONT_FAMILY, forecastXAxes, forecastYAxis, - registerTouchTooltipReset, + registerTouchTooltip, tooltipStyle, tooltipTable, type TooltipRow, @@ -242,7 +242,7 @@ export default defineComponent({ const el = this.$refs["chartEl"] as HTMLElement; this.chart = markRaw(echarts.init(el)); this.chart.setOption(this.chartOption); - registerTouchTooltipReset(this.chart, el); + registerTouchTooltip(this.chart, el); this.$nextTick(() => this.updateGraphic()); window.addEventListener("resize", this.resize); // initial render done here (not via the watcher), so paging animates from the first click diff --git a/assets/js/components/Forecast/chartMixin.ts b/assets/js/components/Forecast/chartMixin.ts index eed9f6bc2..2c32f105e 100644 --- a/assets/js/components/Forecast/chartMixin.ts +++ b/assets/js/components/Forecast/chartMixin.ts @@ -1,5 +1,5 @@ import { defineComponent, markRaw } from "vue"; -import { echarts, registerTouchTooltipReset } from "./echarts"; +import { echarts, registerTouchTooltip } from "./echarts"; import "./chartStyles.css"; // chartOption is provided by each consuming component's computed @@ -63,7 +63,7 @@ export default defineComponent({ this.chart.on("hideTip", () => { this.tooltipVisible = false; }); - registerTouchTooltipReset(this.chart, el, () => (this.tooltipVisible = false)); + registerTouchTooltip(this.chart, el, () => (this.tooltipVisible = false)); }, }, }); diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts index 4cfc60f47..a4c5dabd9 100644 --- a/assets/js/components/Forecast/echarts.ts +++ b/assets/js/components/Forecast/echarts.ts @@ -1,5 +1,6 @@ import * as echarts from "echarts/core"; import colors from "@/colors"; +import { attachTouchTooltipGate } from "@/utils/swipe"; import escapeHtml from "@/utils/escapeHtml"; import type { UiForecastSlot } from "@/types/evcc"; import { BarChart, LineChart } from "echarts/charts"; @@ -117,18 +118,16 @@ export function tooltipStyle( }; } -// hide tooltip when finger lifts; mouse hover unchanged -export function registerTouchTooltipReset( +// touch tooltips: show on dwell, follow the finger, hide when it lifts; mouse hover unchanged +export function registerTouchTooltip( chart: Pick, el: HTMLElement, onReset?: () => void ) { - const reset = () => { + attachTouchTooltipGate(el, () => { chart.dispatchAction({ type: "hideTip" }); onReset?.(); - }; - el.addEventListener("touchend", reset); - el.addEventListener("touchcancel", reset); + }); } export interface TooltipRow { diff --git a/assets/js/components/History/GroupChart.vue b/assets/js/components/History/GroupChart.vue index 8d2951dc4..bb213ef37 100644 --- a/assets/js/components/History/GroupChart.vue +++ b/assets/js/components/History/GroupChart.vue @@ -11,7 +11,7 @@ import { FONT_FAMILY, forecastGrid, forecastYAxis, - registerTouchTooltipReset, + registerTouchTooltip, tooltipStyle, tooltipTable, xAxisLabelStyle, @@ -720,7 +720,7 @@ export default defineComponent({ const zr = this.chart.getZr(); zr.on("mousemove", this.onChartMouseMove); zr.on("globalout", this.clearHighlight); - registerTouchTooltipReset(this.chart, el, this.clearHighlight); + registerTouchTooltip(this.chart, el, this.clearHighlight); this.mediaQuery = window.matchMedia("(max-width: 575.98px)"); this.isMobile = this.mediaQuery.matches; this.mediaQuery.addEventListener("change", this.onMediaChange); diff --git a/assets/js/components/Optimize/ChargeChart.vue b/assets/js/components/Optimize/ChargeChart.vue index 61d8a3613..48c7783a2 100644 --- a/assets/js/components/Optimize/ChargeChart.vue +++ b/assets/js/components/Optimize/ChargeChart.vue @@ -37,7 +37,7 @@ import formatter from "@/mixins/formatter"; import colors from "@/colors"; import LegendList from "../Sessions/LegendList.vue"; import type { Legend } from "../Sessions/types"; -import { touchDismissPlugin } from "../Sessions/chartConfig"; +import "../Sessions/chartConfig"; ChartJS.register( CategoryScale, @@ -49,8 +49,7 @@ ChartJS.register( PointElement, Title, Tooltip, - ChartLegendPlugin, - touchDismissPlugin + ChartLegendPlugin ); export default defineComponent({ diff --git a/assets/js/components/Optimize/PriceChart.vue b/assets/js/components/Optimize/PriceChart.vue index 87361b6a8..c096781ce 100644 --- a/assets/js/components/Optimize/PriceChart.vue +++ b/assets/js/components/Optimize/PriceChart.vue @@ -35,7 +35,7 @@ import formatter from "@/mixins/formatter"; import colors from "@/colors"; import LegendList from "../Sessions/LegendList.vue"; import type { Legend } from "../Sessions/types"; -import { touchDismissPlugin } from "../Sessions/chartConfig"; +import "../Sessions/chartConfig"; const tension = 0; @@ -47,8 +47,7 @@ ChartJS.register( PointElement, Title, Tooltip, - ChartLegendPlugin, - touchDismissPlugin + ChartLegendPlugin ); export default defineComponent({ diff --git a/assets/js/components/Optimize/SocChart.vue b/assets/js/components/Optimize/SocChart.vue index cca83377e..9c27cd3b9 100644 --- a/assets/js/components/Optimize/SocChart.vue +++ b/assets/js/components/Optimize/SocChart.vue @@ -41,17 +41,9 @@ import type { EvoptData } from "./TimeSeriesDataTable.vue"; import type { CURRENCY, BatteryDetail } from "@/types/evcc"; import formatter from "@/mixins/formatter"; import colors from "@/colors"; -import { touchDismissPlugin } from "../Sessions/chartConfig"; +import "../Sessions/chartConfig"; -ChartJS.register( - CategoryScale, - LinearScale, - BarElement, - Title, - Tooltip, - ChartLegendPlugin, - touchDismissPlugin -); +ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, ChartLegendPlugin); export default defineComponent({ name: "SocChart", diff --git a/assets/js/components/Sessions/chartConfig.ts b/assets/js/components/Sessions/chartConfig.ts index f01c008b0..fd228d1e5 100644 --- a/assets/js/components/Sessions/chartConfig.ts +++ b/assets/js/components/Sessions/chartConfig.ts @@ -8,6 +8,7 @@ import { type TooltipItem, } from "chart.js"; import colors from "@/colors"; +import { attachTouchTooltipGate } from "@/utils/swipe"; import type { Context } from "chartjs-plugin-datalabels"; // Register common components export function registerChartComponents(components: ChartComponentLike[]) { @@ -21,18 +22,16 @@ Chart.defaults.font.family = window Chart.defaults.font.size = 14; Chart.defaults.layout.padding = 0; -// hide tooltip when finger lifts; mouse hover unchanged -export const touchDismissPlugin: Plugin = { +// touch tooltips: show on dwell, follow the finger, hide when it lifts; mouse hover unchanged +const touchDismissPlugin: Plugin = { id: "touchDismiss", afterInit(chart) { - const dismiss = () => { + attachTouchTooltipGate(chart.canvas.parentElement ?? chart.canvas, () => { if (!chart.ctx) return; // destroyed chart.setActiveElements([]); chart.tooltip?.setActiveElements([], { x: 0, y: 0 }); chart.update(); - }; - chart.canvas.addEventListener("touchend", dismiss); - chart.canvas.addEventListener("touchcancel", dismiss); + }); }, }; Chart.register(touchDismissPlugin); diff --git a/assets/js/utils/swipe.ts b/assets/js/utils/swipe.ts index c74d2ae1a..0dd00250c 100644 --- a/assets/js/utils/swipe.ts +++ b/assets/js/utils/swipe.ts @@ -6,6 +6,106 @@ interface SwipeOptions { maxDuration?: number; } +const DWELL_MS = 100; +const DWELL_SLOP_PX = 10; + +// Touch tooltip gate: the chart never sees raw touch events (zrender would otherwise +// ignore mouse events for 700ms after every touchend). The tooltip appears only after +// the finger rests for a moment, then follows it; fast movement (swipes, scrolling) +// keeps it hidden and a pause re-arms it. The tooltip is driven by a synthetic +// mousemove at the finger position, which both chart libs handle natively. +export function attachTouchTooltipGate(el: HTMLElement, hide: () => void): () => void { + let timer: ReturnType | null = null; + let shown = false; + let latched = false; + let x = 0; + let y = 0; + let anchorX = 0; + let anchorY = 0; + let gestureX = 0; + let gestureY = 0; + + const show = () => { + const target = document.elementFromPoint(x, y); + if (!target || !el.contains(target)) return; + shown = true; + const event = new MouseEvent("mousemove", { clientX: x, clientY: y, bubbles: true }); + // WebKit computes offsetX/Y of synthetic events against the wrong base element, + // and both chart libs read them; provide correct values relative to the chart + const rect = el.getBoundingClientRect(); + Object.defineProperty(event, "offsetX", { value: x - rect.left }); + Object.defineProperty(event, "offsetY", { value: y - rect.top }); + target.dispatchEvent(event); + }; + + const restartDwell = () => { + if (timer) clearTimeout(timer); + anchorX = x; + anchorY = y; + timer = setTimeout(show, DWELL_MS); + }; + + const onTouchStart = (e: TouchEvent) => { + const touch = e.touches[0]; + if (!touch) return; + e.stopPropagation(); + shown = false; + latched = false; + x = touch.clientX; + y = touch.clientY; + gestureX = x; + gestureY = y; + restartDwell(); + }; + + const onTouchMove = (e: TouchEvent) => { + const touch = e.touches[0]; + if (!touch) return; + e.stopPropagation(); + x = touch.clientX; + y = touch.clientY; + // large displacement before the tooltip ever showed = scroll or swipe, + // no tooltip for the rest of this gesture (scroll deceleration would + // otherwise pass the dwell check while the page is still moving) + if (!shown && !latched && Math.hypot(x - gestureX, y - gestureY) > 30) { + latched = true; + if (timer) clearTimeout(timer); + } + if (latched) return; + if (shown) { + show(); + } else if (Math.hypot(x - anchorX, y - anchorY) > DWELL_SLOP_PX) { + restartDwell(); + } + }; + + const onTouchEnd = (e: TouchEvent) => { + e.stopPropagation(); + // suppress compat mouse events after touch, they would re-trigger the tooltip + if (e.cancelable) e.preventDefault(); + if (timer) clearTimeout(timer); + shown = false; + hide(); + }; + + // long-press must not start text selection of chart or tooltip content + el.classList.add("user-select-none"); + + el.addEventListener("touchstart", onTouchStart, { capture: true, passive: true }); + el.addEventListener("touchmove", onTouchMove, { capture: true, passive: true }); + el.addEventListener("touchend", onTouchEnd, { capture: true }); + el.addEventListener("touchcancel", onTouchEnd, { capture: true }); + + return () => { + if (timer) clearTimeout(timer); + el.classList.remove("user-select-none"); + el.removeEventListener("touchstart", onTouchStart, { capture: true }); + el.removeEventListener("touchmove", onTouchMove, { capture: true }); + el.removeEventListener("touchend", onTouchEnd, { capture: true }); + el.removeEventListener("touchcancel", onTouchEnd, { capture: true }); + }; +} + export function attachSwipeHandler(el: HTMLElement, options: SwipeOptions): () => void { const minDistance = options.minDistance ?? 60; const maxDuration = options.maxDuration ?? 600; From 950ca489e5ee7606f88ec4752d8fec6eb9415b5d Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 7 Aug 2026 18:47:25 +0200 Subject: [PATCH 1467/1700] UI Charts: swipe on charts to switch time range (#32614) --- .../components/Battery/BatteryHistoryCard.vue | 33 +++++++++++++++++++ assets/js/components/Battery/WindowNav.vue | 4 +++ .../js/components/Sessions/DateNavigator.vue | 4 ++- assets/js/utils/haptic.ts | 5 +-- assets/js/utils/swipe.ts | 14 ++++---- 5 files changed, 50 insertions(+), 10 deletions(-) diff --git a/assets/js/components/Battery/BatteryHistoryCard.vue b/assets/js/components/Battery/BatteryHistoryCard.vue index a7ded3a3b..27241fd49 100644 --- a/assets/js/components/Battery/BatteryHistoryCard.vue +++ b/assets/js/components/Battery/BatteryHistoryCard.vue @@ -5,6 +5,8 @@ :label="windowLabel" :prev-disabled="prevDisabled" :next-disabled="nextDisabled" + :highlight-prev="highlightPrev" + :highlight-next="highlightNext" @prev="changeOffset(-1)" @next="changeOffset(1)" /> @@ -44,6 +46,8 @@ import { batteryColor } from "@/colors"; import type { Legend } from "../Sessions/types"; import type { BatterySeries } from "./types"; import Card from "../Helper/Card.vue"; +import { attachSwipeHandler } from "@/utils/swipe"; +import { hapticFeedback } from "@/utils/haptic"; import SelectGroup from "../Helper/SelectGroup.vue"; import LegendList from "../Sessions/LegendList.vue"; import WindowNav from "./WindowNav.vue"; @@ -71,6 +75,10 @@ export default defineComponent({ dayOffset: 0, focusedBattery: null as number | null, selectedUnit: (settings.batteryUnit || "soc") as "soc" | "energy", + highlightPrev: false, + highlightNext: false, + highlightTimer: null as ReturnType | null, + detachSwipe: null as (() => void) | null, }; }, computed: { @@ -145,7 +153,32 @@ export default defineComponent({ } }, }, + mounted() { + this.detachSwipe = attachSwipeHandler(this.$el as HTMLElement, { + onSwipeLeft: () => this.swipe(1), + onSwipeRight: () => this.swipe(-1), + }); + }, + unmounted() { + this.detachSwipe?.(); + if (this.highlightTimer) clearTimeout(this.highlightTimer); + }, methods: { + swipe(dir: number) { + if (dir < 0 ? this.prevDisabled : this.nextDisabled) return; + this.changeOffset(dir); + this.flashHighlight(dir < 0 ? "prev" : "next"); + hapticFeedback("medium"); + }, + flashHighlight(dir: "prev" | "next") { + if (this.highlightTimer) clearTimeout(this.highlightTimer); + this.highlightPrev = dir === "prev"; + this.highlightNext = dir === "next"; + this.highlightTimer = setTimeout(() => { + this.highlightPrev = false; + this.highlightNext = false; + }, 300); + }, updateUnit(value: string | number | boolean | null) { this.selectedUnit = value === "energy" ? "energy" : "soc"; settings.batteryUnit = this.selectedUnit; diff --git a/assets/js/components/Battery/WindowNav.vue b/assets/js/components/Battery/WindowNav.vue index 2fb1249bd..0b5f63ccc 100644 --- a/assets/js/components/Battery/WindowNav.vue +++ b/assets/js/components/Battery/WindowNav.vue @@ -3,6 +3,7 @@ @@ -12,6 +13,7 @@ @@ -29,6 +31,8 @@ export default defineComponent({ label: { type: String, default: "" }, prevDisabled: Boolean, nextDisabled: Boolean, + highlightPrev: Boolean, + highlightNext: Boolean, }, emits: ["prev", "next"], }); diff --git a/assets/js/components/Sessions/DateNavigator.vue b/assets/js/components/Sessions/DateNavigator.vue index f8cca98a3..a8bb37084 100644 --- a/assets/js/components/Sessions/DateNavigator.vue +++ b/assets/js/components/Sessions/DateNavigator.vue @@ -146,6 +146,7 @@ import DateNavigatorButton from "./DateNavigatorButton.vue"; import formatter from "@/mixins/formatter"; import type { SelectOption } from "@/types/evcc"; import { attachSwipeHandler } from "@/utils/swipe"; +import { hapticFeedback } from "@/utils/haptic"; function daysInMonth(year: number, month: number) { return new Date(year, month, 0).getDate(); @@ -279,7 +280,6 @@ export default defineComponent({ this.detachSwipe = attachSwipeHandler(document.body, { onSwipeLeft: () => this.swipeNext(), onSwipeRight: () => this.swipePrev(), - ignoreSelector: "canvas, [_echarts_instance_]", }); }, unmounted() { @@ -393,6 +393,7 @@ export default defineComponent({ else if (this.showYear && this.hasPrevYear) this.emitPrevYear(); else return; this.flashHighlight("prev"); + hapticFeedback("medium"); }, swipeNext() { if (this.showDay && this.hasNextDay) this.emitNextDay(); @@ -400,6 +401,7 @@ export default defineComponent({ else if (this.showYear && this.hasNextYear) this.emitNextYear(); else return; this.flashHighlight("next"); + hapticFeedback("medium"); }, }, }); diff --git a/assets/js/utils/haptic.ts b/assets/js/utils/haptic.ts index 272d134cd..a1270b6f0 100644 --- a/assets/js/utils/haptic.ts +++ b/assets/js/utils/haptic.ts @@ -1,3 +1,4 @@ -export function hapticFeedback(): void { - navigator.vibrate?.(5); +// the native app shims navigator.vibrate; duration maps to impact strength (<=50 light, <=100 medium) +export function hapticFeedback(style: "light" | "medium" = "light"): void { + navigator.vibrate?.(style === "medium" ? 60 : 5); } diff --git a/assets/js/utils/swipe.ts b/assets/js/utils/swipe.ts index 0dd00250c..3dd49f984 100644 --- a/assets/js/utils/swipe.ts +++ b/assets/js/utils/swipe.ts @@ -1,7 +1,6 @@ interface SwipeOptions { onSwipeLeft?: () => void; onSwipeRight?: () => void; - ignoreSelector?: string; minDistance?: number; maxDuration?: number; } @@ -119,8 +118,7 @@ export function attachSwipeHandler(el: HTMLElement, options: SwipeOptions): () = ignored = true; return; } - const target = e.target as Element | null; - ignored = !!(options.ignoreSelector && target?.closest(options.ignoreSelector)); + ignored = false; startX = e.touches[0].clientX; startY = e.touches[0].clientY; startTime = Date.now(); @@ -139,11 +137,13 @@ export function attachSwipeHandler(el: HTMLElement, options: SwipeOptions): () = else options.onSwipeRight?.(); }; - el.addEventListener("touchstart", onTouchStart, { passive: true }); - el.addEventListener("touchend", onTouchEnd, { passive: true }); + // capture phase, so the touch tooltip gate's stopPropagation on chart elements + // cannot starve the swipe detection + el.addEventListener("touchstart", onTouchStart, { capture: true, passive: true }); + el.addEventListener("touchend", onTouchEnd, { capture: true, passive: true }); return () => { - el.removeEventListener("touchstart", onTouchStart); - el.removeEventListener("touchend", onTouchEnd); + el.removeEventListener("touchstart", onTouchStart, { capture: true }); + el.removeEventListener("touchend", onTouchEnd, { capture: true }); }; } From 6f8a6b8c36024afb29a3afe02cc1fa3976de3c55 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:52:58 +0000 Subject: [PATCH 1468/1700] core: fix nil pointer panic when deleting active grid meter (#32607) --- core/site.go | 6 +- core/site_delete_test.go | 161 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 core/site_delete_test.go diff --git a/core/site.go b/core/site.go index 9711fef09..329f464a6 100644 --- a/core/site.go +++ b/core/site.go @@ -881,7 +881,7 @@ func (site *Site) updateGridMeter() error { return nil } - mm := types.Measurement{Name: site.Meters.GridMeterRef} + mm := types.Measurement{Name: site.gridMeter.Config().Name} meter := site.gridMeter.Instance() @@ -935,7 +935,9 @@ func (site *Site) updateGridMeter() error { } } - site.collectors[site.Meters.GridMeterRef].AddEnergy(mm.Energy, mm.ReturnEnergy, mm.Power) + if c, ok := site.collectors[site.gridMeter.Config().Name]; ok { + c.AddEnergy(mm.Energy, mm.ReturnEnergy, mm.Power) + } site.publish(keys.Grid, mm) diff --git a/core/site_delete_test.go b/core/site_delete_test.go new file mode 100644 index 000000000..06681e2d3 --- /dev/null +++ b/core/site_delete_test.go @@ -0,0 +1,161 @@ +package core + +import ( + "testing" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/core/metrics" + "github.com/evcc-io/evcc/core/types" + "github.com/evcc-io/evcc/server/db" + "github.com/evcc-io/evcc/tariff" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// deleteTestSite mirrors what Prepare wires up: cached devices plus one energy +// collector per meter, keyed by the meter's config name. +type deleteTestSite struct { + *Site + params chan util.Param +} + +func newDeleteTestSite(t *testing.T, ctrl *gomock.Controller) *deleteTestSite { + t.Helper() + + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, metrics.SetupSchema()) + + meter := func(power float64) api.Meter { + m := api.NewMockMeter(ctrl) + m.EXPECT().CurrentPower().Return(power, nil).AnyTimes() + return m + } + + battery := func(power, soc float64) api.Meter { + m := api.NewMockMeter(ctrl) + m.EXPECT().CurrentPower().Return(power, nil).AnyTimes() + b := api.NewMockBattery(ctrl) + b.EXPECT().Soc().Return(soc, nil).AnyTimes() + return &struct { + api.Meter + api.Battery + }{Meter: m, Battery: b} + } + + dev := func(name string, m api.Meter) config.Device[api.Meter] { + return config.NewStaticDevice(config.Named{Name: name}, m) + } + + params := make(chan util.Param, 1024) + + site := NewSite() + site.log = util.NewLogger("foo") + site.valueChan = params + + site.gridMeter = dev("grid", meter(1e3)) + site.pvMeters = []config.Device[api.Meter]{dev("pv", meter(2e3))} + site.batteryMeters = []config.Device[api.Meter]{dev("battery", battery(-500, 42))} + + // collectors as Prepare registers them: keyed by the meter's config name + for _, c := range []struct{ group, name string }{ + {metrics.Grid, "grid"}, + {metrics.PV, "pv"}, + {metrics.Battery, "battery"}, + {metrics.Forecast, metrics.Forecast}, + {metrics.Temperature, metrics.Temperature}, + } { + col, err := metrics.NewCollector(c.group, c.name, c.name) + require.NoError(t, err) + site.collectors[c.name] = col + } + + // site settings pointing at those meters + site.Meters.GridMeterRef = "grid" + site.Meters.PVMetersRef = []string{"pv"} + site.Meters.BatteryMetersRef = []string{"battery"} + + return &deleteTestSite{Site: site, params: params} +} + +// published drains the value channel and returns the last value per key +func (s *deleteTestSite) published() map[string]any { + res := make(map[string]any) + for { + select { + case p := <-s.params: + res[p.Key] = p.Val + default: + return res + } + } +} + +// TestSiteUpdateAfterMeterDeletion deletes the active grid, pv and battery meters while +// running. Only the refs are cleared, the cached devices keep being polled (#32605). +func TestSiteUpdateAfterMeterDeletion(t *testing.T) { + ctrl := gomock.NewController(t) + s := newDeleteTestSite(t, ctrl) + + // baseline: everything still referenced + require.NoError(t, s.updateGridMeter()) + s.updatePvMeters() + s.updateBatteryMeters() + + grid, ok := s.published()[keys.Grid].(types.Measurement) + require.True(t, ok, "grid measurement published") + assert.Equal(t, "grid", grid.Name, "grid measurement carries the meter name") + + // delete all three meters- exactly what deleteDeviceHandler does to the site + s.SetGridMeterRef("") + s.SetPVMeterRefs(nil) + s.SetBatteryMeterRefs(nil) + + require.NotPanics(t, func() { + require.NoError(t, s.updateGridMeter()) + s.updatePvMeters() + s.updateBatteryMeters() + }, "update loop must survive the refs being cleared") + + // the devices are still cached, so the loop keeps producing measurements + pub := s.published() + assert.Equal(t, 2e3, s.pvPower, "pv still polled") + assert.Equal(t, -500.0, s.battery.Power, "battery still polled") + + grid, ok = pub[keys.Grid].(types.Measurement) + require.True(t, ok, "grid measurement still published") + assert.Equal(t, "grid", grid.Name, "grid measurement must keep the meter name, not the cleared ref") +} + +// TestSiteUpdateAfterTariffDeletion deletes a tariff device. cleanupTariffRef only +// rewrites the persisted refs, so the publish path must cope either way. +func TestSiteUpdateAfterTariffDeletion(t *testing.T) { + ctrl := gomock.NewController(t) + s := newDeleteTestSite(t, ctrl) + + solar := api.NewMockTariff(ctrl) + solar.EXPECT().Type().Return(api.TariffTypeSolar).AnyTimes() + solar.EXPECT().Rates().Return(api.Rates{ + {Start: time.Now().Add(-time.Hour), End: time.Now(), Value: 1e3}, + {Start: time.Now(), End: time.Now().Add(time.Hour), Value: 2e3}, + }, nil).AnyTimes() + + s.tariffs = &tariff.Tariffs{Solar: solar} + + require.NotPanics(t, func() { + s.publishTariffs(0, 0) + }, "tariff publish with a solar tariff") + + require.NotNil(t, s.published()[keys.Forecast], "forecast published while the tariff exists") + + // delete the tariff + s.tariffs = &tariff.Tariffs{} + + require.NotPanics(t, func() { + s.publishTariffs(0, 0) + }, "tariff publish must survive the tariff being deleted") +} From afac7ab56d5b84739bb1b33fecb61bbe2a4aedc9 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 7 Aug 2026 21:35:14 +0200 Subject: [PATCH 1469/1700] Config UI: fix duration template param handling (#32572) --- .../Config/DeviceModal/ModbusAdvanced.vue | 2 + .../js/components/Config/LoadpointModal.vue | 3 + assets/js/components/Config/PropertyEntry.vue | 10 +- assets/js/components/Config/PropertyField.vue | 69 ++++++++++++-- assets/js/utils/goDuration.test.ts | 76 +++++++++++++++ assets/js/utils/goDuration.ts | 48 ++++++++++ i18n/de.json | 1 + i18n/en.json | 1 + templates/definition/meter/sessy-p1.yaml | 2 +- tests/config-decimal-input.spec.ts | 4 +- tests/config-duration-demo.tpl.yaml | 25 +++++ tests/config-duration-values.sql | 23 +++++ tests/config-duration.spec.ts | 94 +++++++++++++++++++ util/templates/defaults.yaml | 1 + 14 files changed, 346 insertions(+), 13 deletions(-) create mode 100644 assets/js/utils/goDuration.test.ts create mode 100644 assets/js/utils/goDuration.ts create mode 100644 tests/config-duration-demo.tpl.yaml create mode 100644 tests/config-duration-values.sql create mode 100644 tests/config-duration.spec.ts diff --git a/assets/js/components/Config/DeviceModal/ModbusAdvanced.vue b/assets/js/components/Config/DeviceModal/ModbusAdvanced.vue index 212823c84..8193c660c 100644 --- a/assets/js/components/Config/DeviceModal/ModbusAdvanced.vue +++ b/assets/js/components/Config/DeviceModal/ModbusAdvanced.vue @@ -8,6 +8,7 @@ :id="formId('modbusTimeout')" property="timeout" type="Duration" + :label="$t('config.modbus.timeout')" class="me-2" :model-value="timeout" @update:model-value="(v) => $emit('update:timeout', v)" @@ -22,6 +23,7 @@ :id="formId('modbusDelay')" property="delay" type="Duration" + :label="$t('config.modbus.delay')" class="me-2" :model-value="delay" @update:model-value="(v) => $emit('update:delay', v)" diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue index 3f2703bd0..b48f9d1dc 100644 --- a/assets/js/components/Config/LoadpointModal.vue +++ b/assets/js/components/Config/LoadpointModal.vue @@ -192,6 +192,7 @@ id="loadpointEnableDelay" v-model="values.thresholds.enable.delay" type="Duration" + legacy-duration unit="minute" size="w-25 w-min-200" required @@ -255,6 +256,7 @@ id="loadpointDisableDelay" v-model="values.thresholds.disable.delay" type="Duration" + legacy-duration unit="minute" size="w-25 w-min-200" required @@ -519,6 +521,7 @@ id="loadpointPollInterval" v-model="values.soc.poll.interval" type="Duration" + legacy-duration unit="minute" size="w-25 w-min-200" class="me-2" diff --git a/assets/js/components/Config/PropertyEntry.vue b/assets/js/components/Config/PropertyEntry.vue index 7ef34e012..d14c214e6 100644 --- a/assets/js/components/Config/PropertyEntry.vue +++ b/assets/js/components/Config/PropertyEntry.vue @@ -28,10 +28,13 @@ /* oxlint-disable vue/prop-name-casing */ import FormRow from "./FormRow.vue"; import PropertyField from "./PropertyField.vue"; +import formatter from "@/mixins/formatter"; +import { goDurationToUnit, goDurationUnit } from "@/utils/goDuration"; export default { name: "PropertyEntry", components: { FormRow, PropertyField }, + mixins: [formatter], props: { id: String, Name: String, @@ -66,8 +69,11 @@ export default { return this.Description === this.Help ? undefined : this.Help; }, example() { - // hide example text since config ui doesnt use go duration format (e.g. 5m) - return this.Type === "Duration" ? undefined : this.Example; + // show duration example in its own unit, field unit is user-changeable + const unit = this.Type === "Duration" ? goDurationUnit(this.Example) : null; + if (!unit) return this.Example; + const value = goDurationToUnit(this.Example, unit); + return `${value} ${this.fmtDurationUnit(value, unit)}`; }, }, }; diff --git a/assets/js/components/Config/PropertyField.vue b/assets/js/components/Config/PropertyField.vue index 3e016e0b7..b1ba72ff6 100644 --- a/assets/js/components/Config/PropertyField.vue +++ b/assets/js/components/Config/PropertyField.vue @@ -122,8 +122,23 @@
+ + {{ unitValue }} + { - return { selectMode: false }; + return { selectMode: false, unitOverride: null }; }, computed: { patternRegex() { @@ -240,7 +264,7 @@ export default { }, unitValue() { if (this.type === "Duration") { - return this.fmtDurationUnit(this.value, this.unit); + return this.fmtDurationUnit(this.value, this.durationUnit); } if (this.pricePerKWh) { return this.pricePerKWhUnit(this.currency); @@ -297,7 +321,19 @@ export default { return this.choice.length > 0 || this.chargeModes; }, durationFactor() { - return this.unit === "minute" ? 60 : 1; + return displayFactors[this.durationUnit] ?? 1; + }, + durationUnit() { + return this.unitOverride ?? goDurationUnit(this.modelValue) ?? this.unit ?? "second"; + }, + unitSelectable() { + return this.type === "Duration" && !this.legacyDuration && !this.disabled; + }, + unitOptions() { + return durationUnits.map((value) => ({ + value, + name: this.fmtDurationUnit(2, value), + })); }, selectOptions() { if (this.chargeModes) { @@ -343,8 +379,15 @@ export default { return Array.isArray(this.modelValue) ? this.modelValue.join("\n") : ""; } - if (this.type === "Duration" && typeof this.modelValue === "number") { - return this.modelValue / this.durationFactor / NS_PER_SECOND; + if (this.type === "Duration") { + const ns = + typeof this.modelValue === "string" + ? parseGoDuration(this.modelValue) + : this.modelValue; + if (typeof ns === "number") { + return ns / this.durationFactor / NS_PER_SECOND; + } + return ""; } if (this.pricePerKWh) { @@ -367,7 +410,9 @@ export default { } if (this.type === "Duration" && typeof newValue === "number") { - newValue = newValue * this.durationFactor * NS_PER_SECOND; + newValue = this.legacyDuration + ? newValue * this.durationFactor * NS_PER_SECOND + : toGoDuration(newValue, this.durationUnit); } if (this.pricePerKWh) { @@ -385,6 +430,14 @@ export default { } return val; }, + onUnitChange(e) { + // read display value before override changes the getter's unit + const num = this.value; + this.unitOverride = e.target.value; + if (typeof num === "number") { + this.$emit("update:modelValue", toGoDuration(num, this.unitOverride)); + } + }, onFieldChange(e) { // unparsable input (e.g. locale decimal separator mismatch) if (e.target.validity?.badInput) return; diff --git a/assets/js/utils/goDuration.test.ts b/assets/js/utils/goDuration.test.ts new file mode 100644 index 000000000..7ceb51d14 --- /dev/null +++ b/assets/js/utils/goDuration.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "vite-plus/test"; +import parseGoDuration, { goDurationToUnit, goDurationUnit, toGoDuration } from "./goDuration"; + +describe("parseGoDuration", () => { + test("parses single unit", () => { + expect(parseGoDuration("24h")).toBe(24 * 3.6e12); + expect(parseGoDuration("30m")).toBe(30 * 6e10); + expect(parseGoDuration("45s")).toBe(45e9); + expect(parseGoDuration("500ms")).toBe(5e8); + expect(parseGoDuration("10us")).toBe(1e4); + expect(parseGoDuration("10µs")).toBe(1e4); + expect(parseGoDuration("100ns")).toBe(100); + }); + + test("parses compound durations", () => { + expect(parseGoDuration("1h30m")).toBe(3.6e12 + 30 * 6e10); + expect(parseGoDuration("1m30s")).toBe(6e10 + 30e9); + }); + + test("parses decimals", () => { + expect(parseGoDuration("0.1s")).toBe(1e8); + expect(parseGoDuration("1.5h")).toBe(1.5 * 3.6e12); + }); + + test("rejects invalid input", () => { + expect(parseGoDuration("")).toBeNull(); + expect(parseGoDuration("6")).toBeNull(); + expect(parseGoDuration("1h30")).toBeNull(); + expect(parseGoDuration("abc")).toBeNull(); + expect(parseGoDuration("6 h")).toBeNull(); + }); +}); + +describe("goDurationToUnit", () => { + test("converts to display unit", () => { + expect(goDurationToUnit("10s")).toBe(10); + expect(goDurationToUnit("5m")).toBe(300); + expect(goDurationToUnit("90s", "minute")).toBe(1.5); + expect(goDurationToUnit("3h", "hour")).toBe(3); + expect(goDurationToUnit("6", "hour")).toBeNull(); + }); +}); + +describe("toGoDuration", () => { + test("formats value with unit suffix", () => { + expect(toGoDuration(6, "hour")).toBe("6h"); + expect(toGoDuration(90, "second")).toBe("90s"); + expect(toGoDuration(30, "minute")).toBe("30m"); + expect(toGoDuration(1.5, "hour")).toBe("1.5h"); + expect(toGoDuration(0.1, "second")).toBe("0.1s"); + expect(toGoDuration(0)).toBe("0s"); + expect(toGoDuration(5, "fortnight")).toBe("5s"); + }); + + test("roundtrips through parseGoDuration", () => { + expect(parseGoDuration(toGoDuration(6, "hour"))).toBe(6 * 3.6e12); + expect(parseGoDuration(toGoDuration(1.5, "minute"))).toBe(90e9); + }); +}); + +describe("goDurationUnit", () => { + test("detects single-unit strings", () => { + expect(goDurationUnit("12h")).toBe("hour"); + expect(goDurationUnit("90s")).toBe("second"); + expect(goDurationUnit("30m")).toBe("minute"); + expect(goDurationUnit("1.5h")).toBe("hour"); + }); + + test("null for composite, numbers, empty", () => { + expect(goDurationUnit("1h30m")).toBeNull(); + expect(goDurationUnit("")).toBeNull(); + expect(goDurationUnit(15000000000)).toBeNull(); + expect(goDurationUnit("6")).toBeNull(); + expect(goDurationUnit("500ms")).toBeNull(); + }); +}); diff --git a/assets/js/utils/goDuration.ts b/assets/js/utils/goDuration.ts new file mode 100644 index 000000000..de68d11b3 --- /dev/null +++ b/assets/js/utils/goDuration.ts @@ -0,0 +1,48 @@ +const factors: Record = { + ns: 1, + us: 1e3, + µs: 1e3, + ms: 1e6, + s: 1e9, + m: 6e10, + h: 3.6e12, +}; + +export const displayFactors: Record = { minute: 60, hour: 3600 }; + +export type DurationUnit = "second" | "minute" | "hour"; +type DurationSuffix = "s" | "m" | "h"; + +const unitSuffixes: Record = { second: "s", minute: "m", hour: "h" }; +const suffixUnits: Record = { s: "second", m: "minute", h: "hour" }; + +export const durationUnits = Object.keys(unitSuffixes) as DurationUnit[]; + +// display value in given Intl unit to go duration string ("6h", "90s", "1.5h") +export function toGoDuration(value: number, unit: string = "second"): string { + return `${value}${unitSuffixes[unit as DurationUnit] ?? "s"}`; +} + +// Intl unit of a single-unit go duration string ("12h" > "hour"), null for composite/number/empty +export function goDurationUnit(value: unknown): DurationUnit | null { + if (typeof value !== "string") return null; + const match = value.match(/^\d+(?:\.\d+)?(s|m|h)$/); + return match ? suffixUnits[match[1] as DurationSuffix] : null; +} + +// go duration string to display value in given Intl unit (default second), null if invalid +export function goDurationToUnit(value: string, unit?: string): number | null { + const ns = parseGoDuration(value); + return ns === null ? null : ns / (displayFactors[unit ?? ""] ?? 1) / 1e9; +} + +// go duration string ("24h", "1h30m", "0.1s") to nanoseconds, null if invalid +export default function parseGoDuration(value: string): number | null { + let total = 0; + let matched = ""; + for (const [all, num, unit] of value.matchAll(/(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g)) { + total += parseFloat(num!) * factors[unit!]!; + matched += all; + } + return matched && matched === value ? total : null; +} diff --git a/i18n/de.json b/i18n/de.json index 6c651dd52..bd29b8201 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -306,6 +306,7 @@ "form": { "danger": "Achtung", "deprecated": "veraltet", + "durationUnit": "Zeiteinheit für {label}", "example": "Beispiel", "optional": "optional" }, diff --git a/i18n/en.json b/i18n/en.json index 48a81d482..91eb2eddb 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -306,6 +306,7 @@ "form": { "danger": "Danger", "deprecated": "deprecated", + "durationUnit": "Time unit for {label}", "example": "Example", "optional": "optional" }, diff --git a/templates/definition/meter/sessy-p1.yaml b/templates/definition/meter/sessy-p1.yaml index 8fc06991b..68a56c375 100644 --- a/templates/definition/meter/sessy-p1.yaml +++ b/templates/definition/meter/sessy-p1.yaml @@ -8,7 +8,7 @@ params: choice: ["grid"] - name: host - name: cache - default: 10 + default: 10s render: | type: custom power: diff --git a/tests/config-decimal-input.spec.ts b/tests/config-decimal-input.spec.ts index 7974117d4..1ba0c8108 100644 --- a/tests/config-decimal-input.spec.ts +++ b/tests/config-decimal-input.spec.ts @@ -31,12 +31,12 @@ test("decimal input with german separator", async ({ page }) => { await modal.getByRole("button", { name: "Show advanced settings" }).click(); // comma accepted as decimal separator - const timeout = modal.getByLabel("Timeout"); + const timeout = modal.getByRole("spinbutton", { name: "Timeout" }); await timeout.pressSequentially("0,5"); await expect(timeout).toHaveValue("0.5"); // intermediate zeros survive typing - const delay = modal.getByLabel("Delay"); + const delay = modal.getByRole("spinbutton", { name: "Delay" }); await delay.pressSequentially("0.004"); await expect(delay).toHaveValue("0.004"); await delay.blur(); diff --git a/tests/config-duration-demo.tpl.yaml b/tests/config-duration-demo.tpl.yaml new file mode 100644 index 000000000..420fe1336 --- /dev/null +++ b/tests/config-duration-demo.tpl.yaml @@ -0,0 +1,25 @@ +template: duration-demo +group: generic +products: + - description: + generic: Duration Demo Meter +params: + - name: usage + choice: ["grid"] + - name: shortduration + type: duration + default: 30s + example: 10s + description: + generic: Short duration + - name: longduration + type: duration + unit: hour + default: 3h + description: + generic: Long duration +render: | + type: custom + power: + source: const + value: 0 diff --git a/tests/config-duration-values.sql b/tests/config-duration-values.sql new file mode 100644 index 000000000..7fa46fbe1 --- /dev/null +++ b/tests/config-duration-values.sql @@ -0,0 +1,23 @@ +BEGIN; + +CREATE TABLE `configs` ( + `id` integer PRIMARY KEY AUTOINCREMENT + , `class` integer + , `type` text + , `title` text + , `icon` text + , `product` text + , `value` text +); +CREATE TABLE `settings` ( + `key` text + , `value` text + , PRIMARY KEY(`key`) +); + +-- longduration as duration string (UI-written format), shortduration as legacy nanosecond number +INSERT INTO configs(id, class, type, title, icon, product, value) VALUES(1, 2, 'template', 'Duration Test', '', 'Duration Demo Meter', '{"template":"duration-demo","usage":"grid","longduration":"12h","shortduration":15000000000}'); + +INSERT INTO settings("key", value) VALUES('gridMeter', 'db:1'); + +COMMIT; diff --git a/tests/config-duration.spec.ts b/tests/config-duration.spec.ts new file mode 100644 index 000000000..e1f3f9be3 --- /dev/null +++ b/tests/config-duration.spec.ts @@ -0,0 +1,94 @@ +import { test, expect, type Page, type Locator } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; +import { expectModalVisible, expectModalHidden } from "./utils"; + +const templateFlags = [ + "--disable-auth", + "--template-type", + "meter", + "--template", + "tests/config-duration-demo.tpl.yaml", +]; + +test.use({ baseURL: baseUrl() }); + +test.afterEach(async () => { + await stop(); +}); + +async function openGridMeterModal(page: Page): Promise { + await page.goto("/#/config"); + await page.getByTestId("grid").getByRole("button", { name: "edit" }).click(); + const modal = page.getByTestId("meter-modal"); + await expectModalVisible(modal); + return modal; +} + +test.describe("duration fields", async () => { + test("template defaults and roundtrip", async ({ page }) => { + await start(undefined, undefined, templateFlags); + await page.goto("/#/config"); + await page.getByRole("button", { name: "Add grid meter" }).click(); + const modal = page.getByTestId("meter-modal"); + await expectModalVisible(modal); + await modal.getByLabel("Manufacturer").selectOption("Duration Demo Meter"); + + await expect(modal.getByRole("spinbutton", { name: "Short duration" })).toHaveValue("30"); + await expect(modal.getByText("Example: 10 seconds", { exact: true })).toBeVisible(); + await expect(modal.getByLabel("Time unit for Short duration")).toHaveValue("second"); + await expect(modal.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("3"); + await expect(modal.getByLabel("Time unit for Long duration")).toHaveValue("hour"); + + await modal.getByRole("spinbutton", { name: "Long duration" }).fill("6"); + await modal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(modal); + + await restart(undefined, templateFlags); + await page.reload(); + + // edited value and untouched default are both stored as duration strings + const reopened = await openGridMeterModal(page); + await expect(reopened.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("6"); + await expect(reopened.getByRole("spinbutton", { name: "Short duration" })).toHaveValue("30"); + }); + + test("existing values as string and nanoseconds", async ({ page }) => { + await start(undefined, "config-duration-values.sql", templateFlags); + + const modal = await openGridMeterModal(page); + await expect(modal.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("12"); + await expect(modal.getByRole("spinbutton", { name: "Short duration" })).toHaveValue("15"); + + await modal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(modal); + + await restart(undefined, templateFlags); + await page.reload(); + + const reopened = await openGridMeterModal(page); + await expect(reopened.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("12"); + await expect(reopened.getByRole("spinbutton", { name: "Short duration" })).toHaveValue("15"); + }); + + test("unit switching keeps number", async ({ page }) => { + await start(undefined, "config-duration-values.sql", templateFlags); + + // stored "12h" drives the unit, ns number falls back to template default + const modal = await openGridMeterModal(page); + await expect(modal.getByLabel("Time unit for Long duration")).toHaveValue("hour"); + await expect(modal.getByLabel("Time unit for Short duration")).toHaveValue("second"); + + await modal.getByLabel("Time unit for Long duration").selectOption("minutes"); + await expect(modal.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("12"); + await modal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(modal); + + await restart(undefined, templateFlags); + await page.reload(); + + // stored "12m" restores the selected unit + const reopened = await openGridMeterModal(page); + await expect(reopened.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("12"); + await expect(reopened.getByLabel("Time unit for Long duration")).toHaveValue("minute"); + }); +}); diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml index f392968a6..262a67f1e 100644 --- a/util/templates/defaults.yaml +++ b/util/templates/defaults.yaml @@ -489,6 +489,7 @@ params: en: Battery storage unit index de: Nummer des Batteriespeichers - name: delay + type: duration description: en: Delay de: Verzögerung From daa3df4b4ead65a25786b096c65139b589253755 Mon Sep 17 00:00:00 2001 From: mi-ch-a Date: Sat, 8 Aug 2026 10:44:45 +0200 Subject: [PATCH 1470/1700] Solplanet AI dongle: add battery control (#31908) --- .../definition/meter/solplanet-ai-dongle.yaml | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/templates/definition/meter/solplanet-ai-dongle.yaml b/templates/definition/meter/solplanet-ai-dongle.yaml index 979dbb046..4db87cec8 100644 --- a/templates/definition/meter/solplanet-ai-dongle.yaml +++ b/templates/definition/meter/solplanet-ai-dongle.yaml @@ -8,9 +8,12 @@ requirements: de: | Nutzt die lokale HTTP-Schnittstelle des AI-Dongle (z.B. BA121T-30) statt Modbus TCP. Die Seriennummer (isn) des Wechselrichters kann über `http://:8484/getdev.cgi?device=2` ausgelesen werden. + Ladeleistung der Batteriesteuerung ist auf 5000 W fest limitiert. en: | Uses the AI-Dongle local HTTP API (e.g. BA121T-30) instead of Modbus TCP. The inverter serial number (isn) can be read via `http://:8484/getdev.cgi?device=2`. + Battery control charge power is fixed at 5000 W. +capabilities: ["battery-control"] params: - name: usage choice: ["grid", "pv", "battery"] @@ -44,5 +47,38 @@ render: | source: http uri: http://{{ .host }}:8484/getdevdata.cgi?device=4&sn={{ .serial }} jq: .soc + batterymode: + source: switch + switch: + - case: 1 # normal + set: + source: http + <<: &fdbg + uri: http://{{ .host }}:8484/fdbg.cgi + method: POST + timeout: 20s + headers: + Content-Type: application/json + body: '{"data":"0306044f0002390e"}' # write reg 1103 (mode) = 2 (normal) + - case: 2 # hold: custom mode + 0 W + set: + source: sequence + set: + - source: http + <<: *fdbg + body: '{"data":"0306044f0004b90c"}' # write reg 1103 (mode) = 4 (custom) + - source: http + <<: *fdbg + body: '{"data":"03060480000088f0"}' # write reg 1152 (setpoint) = 0 W + - case: 3 # charge: custom mode + fixed 5000 W (see requirements) + set: + source: sequence + set: + - source: http + <<: *fdbg + body: '{"data":"0306044f0004b90c"}' # write reg 1103 (mode) = 4 (custom) + - source: http + <<: *fdbg + body: '{"data":"03060480ec78c412"}' # write reg 1152 (setpoint) = -5000 W {{- include "battery-params" . }} {{- end }} From 6a976286d1808527d6b2440e942770946c35ece4 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 8 Aug 2026 12:45:07 +0200 Subject: [PATCH 1471/1700] chore: ugprade modules --- go.mod | 56 +++++++++++++-------------- go.sum | 120 ++++++++++++++++++++++++++++----------------------------- 2 files changed, 88 insertions(+), 88 deletions(-) diff --git a/go.mod b/go.mod index 1ebe485dc..2bd90efe2 100644 --- a/go.mod +++ b/go.mod @@ -8,15 +8,15 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 github.com/PanterSoft/comlynx-go v0.1.0 github.com/PuerkitoBio/goquery v1.12.0 - github.com/WulfgarW/sensonet v0.0.7 + github.com/WulfgarW/sensonet v0.0.8 github.com/andig/go-powerwall v0.3.0 github.com/andig/gosunspec v0.0.0-20260705113727-6d585e133512 github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef - github.com/aws/aws-sdk-go-v2 v1.43.2 - github.com/aws/aws-sdk-go-v2/config v1.32.33 - github.com/aws/aws-sdk-go-v2/credentials v1.19.32 - github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.36.2 + github.com/aws/aws-sdk-go-v2 v1.43.4 + github.com/aws/aws-sdk-go-v2/config v1.32.35 + github.com/aws/aws-sdk-go-v2/credentials v1.19.34 + github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.36.4 github.com/basgys/goxml2json v1.1.0 github.com/benbjohnson/clock v1.3.5 github.com/bogosj/tesla v1.3.2-0.20250818120641-a31b7b6396c9 @@ -33,14 +33,14 @@ require ( github.com/enbility/ship-go v0.6.1-0.20260720110450-0aa90f64ac76 github.com/enbility/spine-go v0.7.1-0.20260629113257-b3bcc643f323 github.com/evcc-io/openapi-mcp v0.6.1-0.20260701153510-26c442199ef4 - github.com/evcc-io/optimizer v0.0.0-20260728084523-a51031d7607f + github.com/evcc-io/optimizer v0.0.0-20260808102051-bc47435b4068 github.com/evcc-io/rct v0.2.0 github.com/evcc-io/tesla-proxy-client v0.0.0-20260722080120-05ada06c7368 github.com/fatih/structs v1.1.0 - github.com/getkin/kin-openapi v0.145.0 + github.com/getkin/kin-openapi v0.146.0 github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 github.com/go-playground/validator/v10 v10.30.3 - github.com/go-telegram/bot v1.22.0 + github.com/go-telegram/bot v1.23.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-20260620140544-0a84d8ab3878 @@ -69,17 +69,17 @@ require ( github.com/koron/go-ssdp v0.9.1 github.com/korylprince/ipnetgen v1.0.1 github.com/libp2p/zeroconf/v2 v2.2.0 - github.com/libtnb/sqlite v1.2.1 + github.com/libtnb/sqlite v1.2.2 github.com/lorenzodonini/ocpp-go v0.19.0 github.com/lunixbochs/struc v0.0.0-20241101090106-8d528fa2c543 github.com/mabunixda/wattpilot v1.8.5 github.com/mitchellh/go-homedir v1.1.0 github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/muka/go-bluetooth v0.0.0-20240701044517-04c4f09c514e - github.com/nicholas-fedor/shoutrrr v0.16.3 + github.com/nicholas-fedor/shoutrrr v0.17.0 github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/olekukonko/tablewriter v1.1.4 - github.com/philippseith/signalr v0.8.0 + github.com/philippseith/signalr v0.10.0 github.com/prometheus-community/pro-bing v0.9.1 github.com/prometheus/client_golang v1.24.1 github.com/prometheus/common v0.70.1 @@ -99,7 +99,7 @@ require ( github.com/teslamotors/vehicle-command v0.4.1 github.com/tess1o/go-ecoflow v1.1.1-0.20251003083510-2ccc15a17e29 github.com/traefik/yaegi v0.16.1 - github.com/volkszaehler/mbmd v0.0.0-20260623211432-fb8f9c22f121 + github.com/volkszaehler/mbmd v0.0.0-20260803093438-96221ccf34be github.com/warthog618/go-gpiocdev v0.9.1 github.com/xuri/excelize/v2 v2.11.0 gitlab.com/bboehmke/sunny v0.17.0 @@ -107,7 +107,7 @@ require ( go.uber.org/mock v0.6.0 go.yaml.in/yaml/v4 v4.0.0-rc.6 golang.org/x/crypto v0.54.0 - golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e + golang.org/x/crypto/x509roots/fallback v0.0.0-20260804050112-5ed494470b06 golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 golang.org/x/net v0.57.0 golang.org/x/oauth2 v0.36.0 @@ -117,7 +117,7 @@ require ( google.golang.org/grpc v1.83.0 google.golang.org/protobuf v1.36.11 gorm.io/gorm v1.31.2 - modernc.org/sqlite v1.55.0 + modernc.org/sqlite v1.56.0 ) require ( @@ -126,17 +126,17 @@ require ( github.com/andybalholm/cascadia v1.3.3 // indirect github.com/antihax/optional v1.0.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.33 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.33 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.34 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.14 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.33 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.5.2 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.33.2 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.2 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.45.2 // indirect - github.com/aws/smithy-go v1.27.5 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect + github.com/aws/smithy-go v1.27.6 // indirect github.com/azihsoyn/rijndael256 v0.0.0-20200316065338-d14eefa2b66b // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bitly/go-simplejson v0.5.1 // indirect @@ -164,7 +164,7 @@ require ( github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-kit/log v0.2.1 // indirect - github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/swag/jsonname v0.25.5 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -213,7 +213,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/procfs v0.21.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.60.0 // indirect + github.com/quic-go/quic-go v0.61.0 // indirect github.com/quic-go/webtransport-go v0.11.1 // indirect github.com/relvacode/iso8601 v1.6.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect @@ -250,7 +250,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.74.1 // indirect + modernc.org/libc v1.74.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 ecbbad8af..4cd766dd8 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YF github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/Shopify/toxiproxy v2.1.4+incompatible h1:TKdv8HiTLgE5wdJuEML90aBgNWsokNbMijUGhmcoBJc= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= -github.com/WulfgarW/sensonet v0.0.7 h1:WkrZF7UNoQVyQIvpaUPqZfKTvHfFXTRmSF/zMQKJo+Q= -github.com/WulfgarW/sensonet v0.0.7/go.mod h1:GNOik1nTPswwtZ4bLpIRglEn9dNgEG79iOhVUq4IKgA= +github.com/WulfgarW/sensonet v0.0.8 h1:CkKzQuGTmZiYQPOI7t9tYCynM0mVnl1UNWyiSY5a3cE= +github.com/WulfgarW/sensonet v0.0.8/go.mod h1:uId+Lbatrz6CsUMXfj+zsRPdT5KHrh3H8YOk0xZU/9E= github.com/andig/eebus-go v0.0.0-20260725155950-e735091ff165 h1:mgx3N1XD29NtCtvbm1CEMJpz8lBUgMiSdH0Ku72pDP4= github.com/andig/eebus-go v0.0.0-20260725155950-e735091ff165/go.mod h1:L1nBEy/dBASQRoz/9JF7KEmqFdnXHLbZqDpxiA0ezr4= github.com/andig/go-powerwall v0.3.0 h1:6g50+8xEwxrnzoSaPsxNRpc2fld8gARk0X78NEexFGI= @@ -40,36 +40,36 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7D github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef h1:2JGTg6JapxP9/R33ZaagQtAM4EkkSYnIAlOG5EI8gkM= github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7hed4L1fj0hXcyEejnW57/7LCetXggd+vwrRnYeII= -github.com/aws/aws-sdk-go-v2 v1.43.2 h1:cl+IXwWb3qazClUcm08tGSsB6OiuV83JVJO9B0jQcPc= -github.com/aws/aws-sdk-go-v2 v1.43.2/go.mod h1:WEzLKBh/mEjXvx1FtQMWgSxMSTVqxQzjkRtk5fa3wkg= -github.com/aws/aws-sdk-go-v2/config v1.32.33 h1:M1m/Q6f0OKDEDGwhiNOqx1OjTdrewe3v+GDbHmKczWk= -github.com/aws/aws-sdk-go-v2/config v1.32.33/go.mod h1:fGj1iQj2QpIZzp7jE4aQQ+71TE8cd4z9K4+xCd6EqmE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.32 h1:eNE0JnIblBo1NCvd3tqEYuZz9XDefn69R74CHd3nT7U= -github.com/aws/aws-sdk-go-v2/credentials v1.19.32/go.mod h1:yYJu+6tqKUYZuJSYcpSGjz/6sV/SUaAaKIufnWKx2OU= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.33 h1:MobhiR6KIerWxmO74Zit5I3379+mSc2DOdZ3DeRFB9w= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.33/go.mod h1:xu02847OdZfNr/jAfZpHtyRk0b3v4d0kaoxNHxZGG/w= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33 h1:HAp1wLFZzch054uh3FK7rcVYg4v7J2FxVf3h3IGNZas= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33/go.mod h1:mJk5fmqnF+WUlMdPG37pR2Fh3oh6r8F6ZGUgPKvzu0c= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.33 h1:0YA0aCKgsJyno6xkFfaIgjE3/wK08+Qxo9nQfe1UrWM= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.33/go.mod h1:UZqj4WIdTH+ga8Y/DgpAuy/8cGjM3h7gDCliJYGg2SE= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.34 h1:HQYnjFnXpX8EbPW5M1QT8mXzesRPwly0HEPTcFlS02Y= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.34/go.mod h1:tGzj56niKYZBbDIRhwPGDqrULzmWv5b6uBQGqyNaFZw= -github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.36.2 h1:ir+wsl0RWI1HMe1OeJmKMhDjq0GGdnmkQnAxlDaliJA= -github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.36.2/go.mod h1:EqAg5u9NN5mlqjCJUj+eQkf0kjDPb47SrYdXWp4SCA8= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.14 h1:SA43nfaY7+1jjMNIc2ywu99JLJLButtIdLP6j+bT870= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.14/go.mod h1:Du3llKcwbQvHsTXSLzTOGQz0DTDBMEzdg7DAGu7inrY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.33 h1:mqI7OrxN/DUH85F5OqVn3cIfuZ3+HVcebUm2N8mLlgQ= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.33/go.mod h1:eZ5jdEpvaaOU8nWWE4cTAJETSEA5FZoWxvNRao4piHY= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.2 h1:EjI1CZzDcBxPkTa3j1BdtIrUDbqnOGssFMeyUS+6W0I= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.2/go.mod h1:vN3eb5H8MEAZ4dx0F5Wc9LT8eb3eW7bZZ5BjGJdbw9k= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.2 h1:zMP1FDFE08L7sM5f1QqkH/ZgKKg8Uc0Dz7KhSSYqWkw= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.2/go.mod h1:0LoIZSUKjdo2BleHfT1hv/jlD33LQS00IrBlzoUsoUQ= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.2 h1:9eTqUYl+SyVmaRPMyBXSO9wwqC6TRwZB82pKENK2hdQ= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.2/go.mod h1:DThweuz22kiLc7lGHop5vQ9c3bx5W6Azs/YqSHa2fu8= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.2 h1:EJd8vZO3E8SE6nmPqxuxlQ1NeSb8as50sf6eGdV4Saw= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.2/go.mod h1:OgpPvKzsO2Ranjpli/20djMkg6UrV5mw4W3pZpq1Mqo= -github.com/aws/smithy-go v1.27.5 h1:d1ro7KpYOYwP6m73YFa+Kc/A130VsAdX68SpsJwARMM= -github.com/aws/smithy-go v1.27.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE= +github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= +github.com/aws/aws-sdk-go-v2/config v1.32.35 h1:UEzXuET8E42lxBPijuACu/tEK7v5lFPlk0Q+GT5WD9E= +github.com/aws/aws-sdk-go-v2/config v1.32.35/go.mod h1:KaMtJpFa2JlL2BStjjHQVwQpzZEmw+ND/EgVrfFoo2g= +github.com/aws/aws-sdk-go-v2/credentials v1.19.34 h1:y6GkSmcv5myd1ngrYbGmiLlwQqB6TQhOuN/tbSSuWDY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.34/go.mod h1:w3dTcnDVoQIewjo7JG45hduAToikiIFLC4FIO7fndvw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 h1:+S7kbJoLDDQ5tE+lHrUBgMkzC8NLgsaioS2F3dVoFAE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35/go.mod h1:Ak7xXviIARfFdNUJ9Etb0bdVDt/KAvKjMGJVLWXDzik= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 h1:jbGY4CXLzZElOXgGsexlC3Hi+3YM0rSmk4opFXKqg/k= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36/go.mod h1:uBu/9aKsS/UQGc72RAt3y54kjgYQxmhut8ZD2dXCDNE= +github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.36.4 h1:ajrCz05CyO6ijM0WXGO4Mb3NNzrL9h+KjJgOS24x25M= +github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.36.4/go.mod h1:nOtzj3w1wGSA1kn0yWw1jObneu9GBhcKZfZkcOWxpdM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 h1:cOJELVNrq5Q3Udry2GLuHUM7MhwpeaQRdYaoa6GI/yI= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.4/go.mod h1:f4LxzKBtaTxD7xh3PiVg3CE1tchQemfmghaJr+NbK2c= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 h1:AMW7a7S8iQaHjBYZdU3PCq4GKRPijTPRAc7e6XtEThY= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.4/go.mod h1:QQNsFV1DVXoXcZt18FS8lI8rtUrlDyAuWZLQ5shunv4= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 h1:AsbZcJAQPRmHDJG8K1N0pof/1zPWjVT8TFlTWuGLSvo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4/go.mod h1:6imqztH0//t0mKbl6yWl7swSEl7F/w32oAmqB3vP1ag= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTwWJNTv3IMex30Y+4= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc= +github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= +github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/azihsoyn/rijndael256 v0.0.0-20200316065338-d14eefa2b66b h1:/2dABok/UswXOj5rjbR5bZ411ApGBq1pAEZdy5rvFrY= github.com/azihsoyn/rijndael256 v0.0.0-20200316065338-d14eefa2b66b/go.mod h1:ef+2vMUkiKcy2Tz7HykB01KbgUnkK4gQKq4ZeR4RYVs= github.com/basgys/goxml2json v1.1.0 h1:4ln5i4rseYfXNd86lGEB+Vi652IsIXIvggKM/BhUKVw= @@ -152,8 +152,8 @@ github.com/evcc-io/ocpp-go v0.0.0-20260727074919-195c10b8758d h1:UxgSUmwiuVURrma github.com/evcc-io/ocpp-go v0.0.0-20260727074919-195c10b8758d/go.mod h1:2kcukDdhui4u730VfnYVWuwzDLgw+mBRGDir/QAyBhg= github.com/evcc-io/openapi-mcp v0.6.1-0.20260701153510-26c442199ef4 h1:aGM3NwDsKbnmiaO10ptbvEolRAUThWJEsObiAWFDU/c= github.com/evcc-io/openapi-mcp v0.6.1-0.20260701153510-26c442199ef4/go.mod h1:YyOx4zwr6xVUcchAETHERZ+75cZMIrdcjVIZFwBlE1Q= -github.com/evcc-io/optimizer v0.0.0-20260728084523-a51031d7607f h1:mwExmgCCH8ldBLe8qNIXmMy7qT+OTsptXwnI6LQo+mQ= -github.com/evcc-io/optimizer v0.0.0-20260728084523-a51031d7607f/go.mod h1:cHMeq6GfoXQ8LxqY4LH1wn/hrgYEn+ka5RPI1CF37SY= +github.com/evcc-io/optimizer v0.0.0-20260808102051-bc47435b4068 h1:qB2mxyFU9kLz4eXtVWTLnKoW/FrPLclxtDYS21fgNfc= +github.com/evcc-io/optimizer v0.0.0-20260808102051-bc47435b4068/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-20260722080120-05ada06c7368 h1:Vek18PwCpXWBVaNfzq7qwru0VsQgWtlL5yIQbLqFwWg= @@ -171,8 +171,8 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/getkin/kin-openapi v0.145.0 h1:htBX+Q7SevVaCUqymFegUKzH2WCbewl9tsmyn2FMGWY= -github.com/getkin/kin-openapi v0.145.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= +github.com/getkin/kin-openapi v0.146.0 h1:RA/1RdxrSJW4oc1+6IfnYB6AO9CaGy8GTKPh0k4Ordo= +github.com/getkin/kin-openapi v0.146.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 h1:zga7zaRE8HCbWjcXMDlfvmQtH0/kMVLo7cQ48dy6kWg= github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1/go.mod h1:PumS+5d59wmAGsZo6IfRpVNaJUq+6xjC4Utt/k8GO6Q= github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27 h1:O6yi4xa9b2DMosGsXzlMe2E9qXgXCVkRLCoRX+5amxI= @@ -183,8 +183,8 @@ github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= -github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= -github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= +github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -207,8 +207,8 @@ github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJ github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-telegram/bot v1.22.0 h1:zK29OoTYMmR5emJrCtGa2SjaGleeZiUB/C1i7kc2lXE= -github.com/go-telegram/bot v1.22.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= +github.com/go-telegram/bot v1.23.0 h1:CKKQq115G/GUGBG8uuWl5uXbiBHyVjZBp/qqOLWZjJk= +github.com/go-telegram/bot v1.23.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= @@ -245,8 +245,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw= -github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg= github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -343,8 +343,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/libp2p/zeroconf/v2 v2.2.0 h1:Cup06Jv6u81HLhIj1KasuNM/RHHrJ8T7wOTS4+Tv53Q= github.com/libp2p/zeroconf/v2 v2.2.0/go.mod h1:fuJqLnUwZTshS3U/bMRJ3+ow/v9oid1n0DmyYyNO1Xs= -github.com/libtnb/sqlite v1.2.1 h1:lrhN3yG49pj1gmtMMP6Es0lhKIkzDeqBul2EWOLyjpY= -github.com/libtnb/sqlite v1.2.1/go.mod h1:pGq13BirCjkM6ihkWWxs0PGFhBiwUyA0PqdSmgDWfmM= +github.com/libtnb/sqlite v1.2.2 h1:Ku5hAPP5B3A4kQcDn4Z3qyNMafKzuAbnFZX4oD9wR4I= +github.com/libtnb/sqlite v1.2.2/go.mod h1:JkAuxM7HHo0tc7dQENjIGpp/yIfrLX5nKr5ME9wuvmI= github.com/lunixbochs/struc v0.0.0-20241101090106-8d528fa2c543 h1:GxMuVb9tJajC1QpbQwYNY1ZAo1EIE8I+UclBjOfjz/M= github.com/lunixbochs/struc v0.0.0-20241101090106-8d528fa2c543/go.mod h1:vy1vK6wD6j7xX6O6hXe621WabdtNkou2h7uRtTfRMyg= github.com/mabunixda/wattpilot v1.8.5 h1:pjm//7dnEiR0OHfMZ+SLlLV557Y0LMrxgjwudOsuHbQ= @@ -389,8 +389,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/nicholas-fedor/shoutrrr v0.16.3 h1:8n2Sp6futI8Q/zEzQXQKUJhn1izZEwr4DGiLgeLdC5k= -github.com/nicholas-fedor/shoutrrr v0.16.3/go.mod h1:XTUvtSfZxjo9f0mqk9W7ygAjfS9bw5Bhcvoz01Fk+Tc= +github.com/nicholas-fedor/shoutrrr v0.17.0 h1:xfp3z5QbE8jXvUhUEwWDk47SJ/b912VoB8MJJDU+q4E= +github.com/nicholas-fedor/shoutrrr v0.17.0/go.mod h1:s4ldyLs6uwBy9lIjYrY+8lyTqJtPvZSrILw0CyMLock= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -424,8 +424,8 @@ github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8 github.com/peterbourgon/ff v1.2.0/go.mod h1:ljiF7yxtUvZaxUDyUqQa0+uiEOgwVboj+Q2S2+0nq40= github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw= github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= -github.com/philippseith/signalr v0.8.0 h1:CvylMNn7TkJi4adUlk75K08OwljdmBqo6jd12Pz2Guw= -github.com/philippseith/signalr v0.8.0/go.mod h1:ZIAyv2b3xIsh+8j++0Omtp0Xe4CwDnwfyyZBEh5Z9uk= +github.com/philippseith/signalr v0.10.0 h1:VTL/e36Id8ImVLLF5QttNNvNk3JUs8PszlfTBirrIKA= +github.com/philippseith/signalr v0.10.0/go.mod h1:HUuLr3FuirOWkZTJaBnnCChHNOupoE8tcvu6QWBuBqU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -445,8 +445,8 @@ github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4 github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= -github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= +github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= +github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= github.com/quic-go/webtransport-go v0.11.1 h1:rrFQMO+7/52ZDJ04fsrjIaWqn6q1z1MYo9iVFq6JtbA= github.com/quic-go/webtransport-go v0.11.1/go.mod h1:SHgEzUFVyj+9WUSuGB1P6Zd351Pww2leWV3SwlTovkA= github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU= @@ -553,8 +553,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/volkszaehler/mbmd v0.0.0-20260623211432-fb8f9c22f121 h1:eDW8HHhwxp1HQQu9uNPMs7TUKZQkTKhe5LkBfhQ+bZA= -github.com/volkszaehler/mbmd v0.0.0-20260623211432-fb8f9c22f121/go.mod h1:hx00iJeEiVti6jY7HwLIiShGWdjAAxY7vDdhx/Ys1ys= +github.com/volkszaehler/mbmd v0.0.0-20260803093438-96221ccf34be h1:q0kgjSngsgSWiKGhLNOfUJDO76+Ctx31f5Y50MO3Bf4= +github.com/volkszaehler/mbmd v0.0.0-20260803093438-96221ccf34be/go.mod h1:hx00iJeEiVti6jY7HwLIiShGWdjAAxY7vDdhx/Ys1ys= github.com/warthog618/go-gpiocdev v0.9.1 h1:pwHPaqjJfhCipIQl78V+O3l9OKHivdRDdmgXYbmhuCI= github.com/warthog618/go-gpiocdev v0.9.1/go.mod h1:dN3e3t/S2aSNC+hgigGE/dBW8jE1ONk9bDSEYfoPyl8= github.com/warthog618/go-gpiosim v0.1.1 h1:MRAEv+T+itmw+3GeIGpQJBfanUVyg0l3JCTwHtwdre4= @@ -609,8 +609,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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e h1:9TjMDOuGaMMTP5f7GXeHeA0JvFqGmv4DYRIWkzoePGI= -golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260804050112-5ed494470b06 h1:/63HmWYvASiBqlJK1XRa/ZJWz/u4cHpfKk389DTfTEE= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260804050112-5ed494470b06/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM= golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= @@ -749,8 +749,8 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= -modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= -modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= @@ -761,8 +761,8 @@ modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= modernc.org/gc/v3 v3.1.4/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.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= -modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= 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= @@ -771,8 +771,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.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= -modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= 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 29137181f1b520dd733de538d47f739b982679a6 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 8 Aug 2026 12:58:44 +0200 Subject: [PATCH 1472/1700] Optimizer: stop demanding charge once the vehicle is full (#32633) --- core/site_optimizer.go | 25 ++++++++++- core/site_optimizer_demand_test.go | 66 ++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 core/site_optimizer_demand_test.go diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 17d50e69c..87027d4b6 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -800,12 +800,35 @@ func (site *Site) loadpointRequest(lp loadpoint.API, minLen int, firstSlotDurati } if demand != nil { - bat.PDemand = prorate(demand, firstSlotDuration) + // after prorate, so the shortened first slot counts with the energy it really carries + bat.PDemand = clearDemandWhenFull(prorate(demand, firstSlotDuration), bat.SMax-bat.SInitial) } return bat, detail } +// clearDemandWhenFull zeroes the charge demand from the slot the accumulated energy fills the +// vehicle. The optimizer drops the demand at s_max anyway, but pays two binaries per slot to +// detect it, so slots that cannot bind are worth not asking about. Losses are accounted for. +// +// The cut assumes the demand is met every slot. A grid import limit can throttle charging below +// it, moving the real fill point later than the estimate - the next request corrects that from +// the measured soc, and the near slots are never affected because the cut sits a full charge away. +func clearDemandWhenFull(demand []float32, headroom float32) []float32 { + res := slices.Clone(demand) + + var acc float32 + for i, d := range res { + if acc >= headroom { + res[i] = 0 + continue + } + acc += d * eta + } + + return res +} + func (site *Site) batteryRequest(dev config.Device[api.Meter], b types.Measurement, grid api.Rates, minLen int, firstSlotDuration time.Duration) (optimizer.BatteryConfig, batteryDetail) { bat := optimizer.BatteryConfig{ CMax: batteryPower, diff --git a/core/site_optimizer_demand_test.go b/core/site_optimizer_demand_test.go new file mode 100644 index 000000000..4fa89f024 --- /dev/null +++ b/core/site_optimizer_demand_test.go @@ -0,0 +1,66 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestClearDemandWhenFull(t *testing.T) { + for _, tc := range []struct { + name string + demand []float32 + headroom float32 + expected []float32 + }{ + { + // 345Wh demanded per slot stores 310.5Wh, so 1000Wh of headroom is covered + // during the fourth slot- that slot still binds, the ones after it cannot + "cut once the accumulated energy fills the vehicle", + []float32{345, 345, 345, 345, 345, 345}, + 1000, + []float32{345, 345, 345, 345, 0, 0}, + }, + { + "headroom beyond the horizon leaves the demand untouched", + []float32{345, 345, 345}, + 1e6, + []float32{345, 345, 345}, + }, + { + // already at the soc limit: every slot is relaxed at s_max anyway + "no headroom clears the whole demand", + []float32{345, 345, 345}, + 0, + []float32{0, 0, 0}, + }, + { + // the shortened first slot carries less energy and must not pull the cut in + "partial first slot counts with its own energy", + []float32{100, 345, 345, 345}, + 600, + []float32{100, 345, 345, 0}, + }, + { + "empty demand stays empty", + []float32{}, + 1000, + []float32{}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, clearDemandWhenFull(tc.demand, tc.headroom)) + }) + } +} + +// the request contract requires every series to have the same length +func TestClearDemandWhenFullKeepsLength(t *testing.T) { + demand := make([]float32, 96) + for i := range demand { + demand[i] = 345 + } + + assert.Len(t, clearDemandWhenFull(demand, 1000), len(demand)) + assert.Len(t, clearDemandWhenFull(demand, 0), len(demand)) +} From 856ac800840a95c7b46dac70d62d6f7f6fb9a9c7 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 8 Aug 2026 13:16:38 +0200 Subject: [PATCH 1473/1700] chore: upgrade modules --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 2bd90efe2..d89270a01 100644 --- a/go.mod +++ b/go.mod @@ -79,7 +79,7 @@ require ( github.com/nicholas-fedor/shoutrrr v0.17.0 github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/olekukonko/tablewriter v1.1.4 - github.com/philippseith/signalr v0.10.0 + github.com/philippseith/signalr v0.10.1-0.20260806083734-d62f30bf7a90 github.com/prometheus-community/pro-bing v0.9.1 github.com/prometheus/client_golang v1.24.1 github.com/prometheus/common v0.70.1 @@ -214,7 +214,7 @@ require ( github.com/prometheus/procfs v0.21.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.61.0 // indirect - github.com/quic-go/webtransport-go v0.11.1 // indirect + github.com/quic-go/webtransport-go v0.12.0 // indirect github.com/relvacode/iso8601 v1.6.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/richardlehane/mscfb v1.0.7 // indirect diff --git a/go.sum b/go.sum index 4cd766dd8..259dfba03 100644 --- a/go.sum +++ b/go.sum @@ -424,8 +424,8 @@ github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8 github.com/peterbourgon/ff v1.2.0/go.mod h1:ljiF7yxtUvZaxUDyUqQa0+uiEOgwVboj+Q2S2+0nq40= github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw= github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= -github.com/philippseith/signalr v0.10.0 h1:VTL/e36Id8ImVLLF5QttNNvNk3JUs8PszlfTBirrIKA= -github.com/philippseith/signalr v0.10.0/go.mod h1:HUuLr3FuirOWkZTJaBnnCChHNOupoE8tcvu6QWBuBqU= +github.com/philippseith/signalr v0.10.1-0.20260806083734-d62f30bf7a90 h1:s5PiRL6iuCdjV7iwYhi/YxrhKfdRoaIqIGSeBoBiWuo= +github.com/philippseith/signalr v0.10.1-0.20260806083734-d62f30bf7a90/go.mod h1:9VAFjplkNr3RKc3mH3L/L0KvCdF1xeV9Y7cDhNwXvZ8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -447,8 +447,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= -github.com/quic-go/webtransport-go v0.11.1 h1:rrFQMO+7/52ZDJ04fsrjIaWqn6q1z1MYo9iVFq6JtbA= -github.com/quic-go/webtransport-go v0.11.1/go.mod h1:SHgEzUFVyj+9WUSuGB1P6Zd351Pww2leWV3SwlTovkA= +github.com/quic-go/webtransport-go v0.12.0 h1:CpnKNwZvdV0LD73xoHO8QaR0NI3llqpWRwnazdZS0sE= +github.com/quic-go/webtransport-go v0.12.0/go.mod h1:GHne8aRFJ24h73pAMrcywXtuaz/ShBXCLXLvG/NPFdU= github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU= github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= From 9a276f3ff51712163c371630af18340ac7b59d65 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 8 Aug 2026 13:26:29 +0200 Subject: [PATCH 1474/1700] Skoda: expose welcomecharge parameter (#32636) --- templates/definition/vehicle/skoda.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/templates/definition/vehicle/skoda.yaml b/templates/definition/vehicle/skoda.yaml index 79d47266a..6b7ea1df3 100644 --- a/templates/definition/vehicle/skoda.yaml +++ b/templates/definition/vehicle/skoda.yaml @@ -6,6 +6,8 @@ products: generic: MyŠkoda params: - preset: vehicle-base + - name: welcomecharge + advanced: true - preset: vehicle-online - name: timeout render: | From aaaa28a42084199fc7ee52d24c0632d9739daab7 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 8 Aug 2026 13:57:02 +0200 Subject: [PATCH 1475/1700] Solcast: keep forecast alive outside active hours (#32639) --- tariff/helper_test.go | 27 +++++++++++++++++++++++++++ tariff/solcast.go | 2 ++ 2 files changed, 29 insertions(+) diff --git a/tariff/helper_test.go b/tariff/helper_test.go index 93e7ddee3..06bf96af7 100644 --- a/tariff/helper_test.go +++ b/tariff/helper_test.go @@ -62,6 +62,33 @@ func TestMergeRatesAfter(t *testing.T) { } } +// TestMergeRatesAfterRefreshesTimestamp asserts that merging without new rates +// keeps the cached rates and un-expires the monitor - this is how solcast keeps +// its forecast alive while fetching is paused outside the from/to window. +func TestMergeRatesAfterRefreshesTimestamp(t *testing.T) { + clock := clock.NewMock() + clock.Set(now.BeginningOfDay()) + + rates := api.Rates{{ + Start: clock.Now().Add(time.Hour), + End: clock.Now().Add(2 * time.Hour), + Value: 1, + }} + + data := util.NewMonitor[api.Rates](time.Hour).WithClock(clock) + data.Set(rates) + + clock.Add(2 * time.Hour) + _, err := data.Get() + require.ErrorIs(t, err, api.ErrOutdated) + + mergeRatesAfter(data, nil, clock.Now().Add(-2*time.Hour)) + + res, err := data.Get() + require.NoError(t, err) + assert.Equal(t, rates, res) +} + type runner struct { res error } diff --git a/tariff/solcast.go b/tariff/solcast.go index 957885167..f6227a44f 100644 --- a/tariff/solcast.go +++ b/tariff/solcast.go @@ -82,6 +82,8 @@ func (t *Solcast) run(interval time.Duration, done chan error) { select { case <-t.data.Done(): if !t.fromTo.IsActive(time.Now().Hour()) { + // keep cached forecast alive while fetching is paused + mergeRatesAfter(t.data, nil, beginningOfDay()) continue } default: From 19f750c417cab26969443fc30668a61b656140f2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:00:40 +0000 Subject: [PATCH 1476/1700] core: keep limitSoc/limitEnergy on integrated device disconnect (#32623) --- core/loadpoint.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/loadpoint.go b/core/loadpoint.go index cd3618be5..e05835cb7 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -628,8 +628,10 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() { } // reset session - lp.SetLimitSoc(0) - lp.SetLimitEnergy(0) + if !lp.chargerHasFeature(api.IntegratedDevice) { + lp.SetLimitSoc(0) + lp.SetLimitEnergy(0) + } // mark plan slot as inactive // this will force a deletion of an outdated plan once plan time is expired in GetPlan() From b8130ea48d2022e1c15ce965fc95f46a9ffc22e8 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 8 Aug 2026 14:56:43 +0200 Subject: [PATCH 1477/1700] Shelly: fix PV energy for devices with reverse power measurement enabled (#32550) --- meter/shelly.go | 25 ++++-- meter/shelly/connection.go | 2 + meter/shelly/connection_test.go | 140 ++++++++++++++++++++++++++++++++ meter/shelly/gen1.go | 10 +++ meter/shelly/gen2.go | 68 ++++++++++++++-- meter/shelly/gen2_test.go | 86 ++++++++++++++++++++ meter/shelly_test.go | 15 ++-- 7 files changed, 326 insertions(+), 20 deletions(-) create mode 100644 meter/shelly/connection_test.go diff --git a/meter/shelly.go b/meter/shelly.go index fe54f2cca..4801e80c1 100644 --- a/meter/shelly.go +++ b/meter/shelly.go @@ -49,12 +49,17 @@ func NewShellyFromConfig(other map[string]any) (api.Meter, error) { // making their totals unsuitable for bidirectional grid metering. if !(c.usage == "grid" && c.conn.IsThreePhase()) { total, ret := c.conn.TotalEnergy, c.conn.ReturnEnergy - if c.usage == "pv" { - // reverse direction + // production is measured in return direction, unless the device has no return + // register at all or already reverses the direction itself + if c.usage == "pv" && c.conn.HasReturnEnergy() && !c.conn.IsReversed() { total, ret = ret, total } implement.Has(c, implement.MeterEnergy(total)) - implement.Has(c, implement.MeterReturnEnergy(ret)) + + // without a return register the second reading is a constant zero + if c.conn.HasReturnEnergy() { + implement.Has(c, implement.MeterReturnEnergy(ret)) + } } if phases, ok := c.conn.Generation.(shelly.Phases); ok { @@ -88,16 +93,20 @@ func (c *Shelly) CurrentPower() (float64, error) { if err != nil { return 0, err } - return c.currentPowerForUsage(power, c.conn.SignedPower()), nil + return c.currentPowerForUsage(power, c.conn.SignedPower(), c.conn.IsReversed()), nil } -// PV usage inverts directional power, otherwise the magnitude is used. -func (c *Shelly) currentPowerForUsage(power float64, signed bool) float64 { +// PV usage inverts directional power unless the device already reverses it, otherwise the magnitude is used. +func (c *Shelly) currentPowerForUsage(power float64, signed, reversed bool) float64 { if c.usage != "pv" { return power } - if signed { + switch { + case !signed: + return math.Abs(power) + case reversed: + return power + default: return -power } - return math.Abs(power) } diff --git a/meter/shelly/connection.go b/meter/shelly/connection.go index 46150008a..c584b9127 100644 --- a/meter/shelly/connection.go +++ b/meter/shelly/connection.go @@ -19,6 +19,8 @@ type Generation interface { api.MeterEnergy api.MeterReturnEnergy IsThreePhase() bool + IsReversed() bool + HasReturnEnergy() bool } type Phases interface { diff --git a/meter/shelly/connection_test.go b/meter/shelly/connection_test.go new file mode 100644 index 000000000..7185c9280 --- /dev/null +++ b/meter/shelly/connection_test.go @@ -0,0 +1,140 @@ +package shelly + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// shellyServer emulates a gen2+ device serving the given rpc responses. Methods +// not listed are reported as unavailable by Shelly.ListMethods and answered 404. +func shellyServer(t *testing.T, rpc map[string]string) *httptest.Server { + t.Helper() + + methods := make([]string, 0, len(rpc)) + for m := range rpc { + methods = append(methods, m) + } + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/shelly": + json.NewEncoder(w).Encode(map[string]any{"gen": 2, "model": "SNSW-001X16EU"}) + + case "/rpc/Shelly.ListMethods": + json.NewEncoder(w).Encode(Gen2Methods{Methods: methods}) + + default: + method := strings.TrimPrefix(r.URL.Path, "/rpc/") + res, ok := rpc[method] + if !ok { + http.Error(w, `{"code":-105,"message":"no handler"}`, http.StatusNotFound) + return + } + w.Write([]byte(res)) + } + })) +} + +// TestNakedSwitchConnection asserts that a switch without power measurement +// (Shelly Plus 1) connects - its status has neither aenergy nor ret_aenergy. +func TestNakedSwitchConnection(t *testing.T) { + srv := shellyServer(t, map[string]string{ + "Switch.GetStatus": `{"id":0,"source":"init","output":true,"temperature":{"tC":45.2,"tF":113.4}}`, + "Switch.GetConfig": `{"id":0,"name":null,"in_mode":"follow","initial_state":"match_input","auto_on":false}`, + }) + defer srv.Close() + + conn, err := NewConnection(srv.URL, "", "", 0, time.Second) + require.NoError(t, err, "naked switch must connect") + + assert.False(t, conn.IsReversed()) + assert.False(t, conn.HasReturnEnergy()) + + enabled, err := conn.Enabled() + require.NoError(t, err) + assert.True(t, enabled) + + total, err := conn.TotalEnergy() + require.NoError(t, err) + assert.Zero(t, total) + + ret, err := conn.ReturnEnergy() + require.NoError(t, err) + assert.Zero(t, ret) +} + +// TestSwitchConnectionStatusError asserts that a temporarily unavailable switch +// status does not break connecting- the error surfaces on read instead. +func TestSwitchConnectionStatusError(t *testing.T) { + // Switch.GetStatus advertised but erroring, e.g. component busy right after boot + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/shelly": + json.NewEncoder(w).Encode(map[string]any{"gen": 2, "model": "SNSW-001X16EU"}) + case "/rpc/Shelly.ListMethods": + json.NewEncoder(w).Encode(Gen2Methods{Methods: []string{"Switch.GetStatus", "Switch.GetConfig"}}) + case "/rpc/Switch.GetConfig": + w.Write([]byte(`{"id":0}`)) + default: + http.Error(w, `{"code":-114,"message":"component not found"}`, http.StatusNotFound) + } + })) + defer srv.Close() + + conn, err := NewConnection(srv.URL, "", "", 0, time.Second) + require.NoError(t, err, "status error must not break connecting") + + _, err = conn.CurrentPower() + require.Error(t, err, "error surfaces on read") +} + +// TestPlugConnection covers the metered plug from #32213: aenergy without ret_aenergy. +func TestPlugConnection(t *testing.T) { + srv := shellyServer(t, map[string]string{ + "Switch.GetStatus": `{"id":0,"output":true,"apower":399.2,"voltage":240.8,"current":1.886,"aenergy":{"total":2574466.629}}`, + "Switch.GetConfig": `{"id":0,"power_limit":2800,"voltage_limit":280}`, + }) + defer srv.Close() + + conn, err := NewConnection(srv.URL, "", "", 0, time.Second) + require.NoError(t, err) + + assert.False(t, conn.IsReversed()) + assert.False(t, conn.HasReturnEnergy(), "plug has no return register") + + total, err := conn.TotalEnergy() + require.NoError(t, err) + assert.Equal(t, 2574.466629, total) +} + +// TestReversedSwitchConnection covers a switch with device-side reverse measurement. +func TestReversedSwitchConnection(t *testing.T) { + srv := shellyServer(t, map[string]string{ + "Switch.GetStatus": `{"id":0,"output":true,"apower":-350,"aenergy":{"total":10000},"ret_aenergy":{"total":4000}}`, + "Switch.GetConfig": `{"id":0,"reverse":true}`, + }) + defer srv.Close() + + conn, err := NewConnection(srv.URL, "", "", 0, time.Second) + require.NoError(t, err) + + assert.True(t, conn.IsReversed()) + assert.True(t, conn.HasReturnEnergy()) + + total, err := conn.TotalEnergy() + require.NoError(t, err) + assert.Equal(t, 6.0, total) + + ret, err := conn.ReturnEnergy() + require.NoError(t, err) + assert.Equal(t, 4.0, ret) +} diff --git a/meter/shelly/gen1.go b/meter/shelly/gen1.go index 65175a15e..a8e007ef5 100644 --- a/meter/shelly/gen1.go +++ b/meter/shelly/gen1.go @@ -144,6 +144,16 @@ func (c *gen1) ReturnEnergy() (float64, error) { return c.energy(energy) / 1000, nil } +// IsReversed reports whether the device reverses its measurement direction. Gen1 devices cannot. +func (c *gen1) IsReversed() bool { + return false +} + +// HasReturnEnergy reports whether the device measures energy in the return direction +func (c *gen1) HasReturnEnergy() bool { + return true +} + // IsThreePhase reports whether the device is a three-phase energy meter. func (c *gen1) IsThreePhase() bool { res, err := c.status.Get() diff --git a/meter/shelly/gen2.go b/meter/shelly/gen2.go index e98841cfc..a34cc6822 100644 --- a/meter/shelly/gen2.go +++ b/meter/shelly/gen2.go @@ -28,6 +28,10 @@ type Gen2Methods struct { Methods []string } +type Gen2Config struct { + Reverse bool +} + type Gen2SwitchStatus struct { Output bool Apower float64 @@ -36,11 +40,23 @@ type Gen2SwitchStatus struct { Aenergy struct { Total float64 } - Ret_Aenergy struct { + // nil on devices without reverse energy metering- they omit the register entirely + Ret_Aenergy *struct { Total float64 } } +// switchEnergy splits the switch registers into import and return energy (kWh). +// https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Switch#status +// NOTE: energy added to ret_aenergy is also added to aenergy, so aenergy holds +// both directions. Without the register aenergy is import only. +func switchEnergy(res Gen2SwitchStatus) (total, ret float64) { + if res.Ret_Aenergy == nil { + return res.Aenergy.Total / 1000, 0 + } + return max(0, res.Aenergy.Total-res.Ret_Aenergy.Total) / 1000, res.Ret_Aenergy.Total / 1000 +} + type Gen2EMStatus struct { TotalActPower float64 `json:"total_act_power"` ACurrent float64 `json:"a_current"` @@ -84,6 +100,7 @@ type gen2 struct { switchchannel int model string methods []string + reversed bool switchstatus util.Cacheable[Gen2SwitchStatus] em1status func() (Gen2EM1Status, error) em1data func() (Gen2EM1Data, error) @@ -139,6 +156,27 @@ func newGen2(helper *request.Helper, uri, model string, channel int, user, passw } else { c.switchstatus = util.ResettableCached(apiCall[Gen2SwitchStatus](c, c.switchchannel, "Switch.GetStatus"), cache) } + // device-side "Reverse power measurement" setting (requires restart, hence static) + var cfgMethod string + cfgChannel := channel + switch { + case c.hasEM1Endpoint(): + cfgMethod = "EM1.GetConfig" + case c.hasMethod("PM1.GetStatus"): + cfgMethod = "PM1.GetConfig" + case c.hasMethod("Switch.GetStatus"): + cfgMethod = "Switch.GetConfig" + cfgChannel = c.switchchannel + } + + if c.hasMethod(cfgMethod) { + var cfg Gen2Config + if err := c.execCmd(cfgChannel, cfgMethod, &cfg); err != nil { + return nil, err + } + c.reversed = cfg.Reverse + } + c.em1status = util.Cached(apiCall[Gen2EM1Status](c, channel, "EM1.GetStatus"), cache) c.em1data = util.Cached(apiCall[Gen2EM1Data](c, channel, "EM1Data.GetStatus"), cache) c.emstatus = util.Cached(apiCall[Gen2EMStatus](c, channel, "EM.GetStatus"), cache) @@ -232,10 +270,8 @@ func (c *gen2) TotalEnergy() (float64, error) { case c.hasSwitchEndpoint(): res, err := c.switchstatus.Get() - // https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Switch#status - // NOTE: ret_aenergy - the active energy added to this container is also added to aenergy container. - // All the consumed energy is collected in aenergy regardless of the direction(consumed or returned) of the active energy. - return max(0, res.Aenergy.Total-res.Ret_Aenergy.Total) / 1000, err + total, _ := switchEnergy(res) + return total, err default: return 0, fmt.Errorf("unknown shelly model: %s", c.model) @@ -255,7 +291,8 @@ func (c *gen2) ReturnEnergy() (float64, error) { case c.hasSwitchEndpoint(): res, err := c.switchstatus.Get() - return res.Ret_Aenergy.Total / 1000, err + _, ret := switchEnergy(res) + return ret, err default: return 0, fmt.Errorf("unknown shelly model: %s", c.model) @@ -340,6 +377,25 @@ func (c *gen2) IsThreePhase() bool { return c.hasEMEndpoint() } +// IsReversed reports whether the device-side "Reverse power measurement" setting is enabled +func (c *gen2) IsReversed() bool { + return c.reversed +} + +// HasReturnEnergy reports whether the device measures energy in the return direction. +// Plain plugs omit ret_aenergy entirely, so the (cached) status decides. +func (c *gen2) HasReturnEnergy() bool { + switch { + case c.hasEM1Endpoint(), c.hasEMEndpoint(): + return true + case c.hasSwitchEndpoint(): + res, err := c.switchstatus.Get() + return err == nil && res.Ret_Aenergy != nil + default: + return false + } +} + // Gen2+ models using EM1.GetStatus endpoint for power and EM1Data.GetStatus for energy // https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/EM1#em1getstatus-example // https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/EM1Data#em1datagetstatus-example diff --git a/meter/shelly/gen2_test.go b/meter/shelly/gen2_test.go index b0e577486..fa2995522 100644 --- a/meter/shelly/gen2_test.go +++ b/meter/shelly/gen2_test.go @@ -2,8 +2,11 @@ package shelly import ( "encoding/json" + "errors" "testing" + "time" + "github.com/evcc-io/evcc/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -80,3 +83,86 @@ func TestUnmarshalGen2StatusResponse(t *testing.T) { assert.Equal(t, 0, parseAddOnSwitchID(channel, res)) } } + +func TestSwitchEnergy(t *testing.T) { + tests := []struct { + name string + status string + total, ret float64 + hasReturnReg bool + }{ + { + // Shelly Plug S Gen3 as pv meter, https://github.com/evcc-io/evcc/issues/32213 + // no ret_aenergy register: aenergy is production, swapping it would report 0 + name: "plug without reverse metering", + status: `{"id":0,"source":"init","output":true,"apower":399.2,"voltage":240.8,"current":1.886,"aenergy":{"total":2574466.629,"by_minute":[7200.069,6121.044,5088.795],"minute_ts":1786033200},"temperature":{"tC":46.3,"tF":115.4}}`, + total: 2574.466629, + }, + { + // aenergy holds both directions, so import is the difference + name: "switch with reverse metering", + status: `{"id":0,"output":true,"apower":-350,"aenergy":{"total":10000},"ret_aenergy":{"total":4000}}`, + total: 6, + ret: 4, + hasReturnReg: true, + }, + { + // pure production: everything lands in ret_aenergy, import must not go negative + name: "switch measuring return only", + status: `{"id":0,"output":true,"aenergy":{"total":4000},"ret_aenergy":{"total":4000}}`, + ret: 4, + hasReturnReg: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var res Gen2SwitchStatus + require.NoError(t, json.Unmarshal([]byte(tc.status), &res)) + + assert.Equal(t, tc.hasReturnReg, res.Ret_Aenergy != nil, "ret_aenergy presence") + + total, ret := switchEnergy(res) + assert.Equal(t, tc.total, total, "total energy") + assert.Equal(t, tc.ret, ret, "return energy") + + // same values through the endpoint dispatch + c := &gen2{ + methods: []string{"Switch.GetStatus"}, + switchstatus: util.ResettableCached(func() (Gen2SwitchStatus, error) { + return res, nil + }, time.Minute), + } + + assert.Equal(t, tc.hasReturnReg, c.HasReturnEnergy(), "HasReturnEnergy") + + totalEnergy, err := c.TotalEnergy() + require.NoError(t, err) + assert.Equal(t, tc.total, totalEnergy, "TotalEnergy") + + returnEnergy, err := c.ReturnEnergy() + require.NoError(t, err) + assert.Equal(t, tc.ret, returnEnergy, "ReturnEnergy") + }) + } +} + +// a failed read yields the zero status, whose ret_aenergy register is nil too +func TestSwitchEnergyReadError(t *testing.T) { + c := &gen2{ + methods: []string{"Switch.GetStatus"}, + switchstatus: util.ResettableCached(func() (Gen2SwitchStatus, error) { + return Gen2SwitchStatus{}, errors.New("offline") + }, time.Minute), + } + + require.NotPanics(t, func() { + total, err := c.TotalEnergy() + require.Error(t, err) + assert.Zero(t, total) + + ret, err := c.ReturnEnergy() + require.Error(t, err) + assert.Zero(t, ret) + }) +} diff --git a/meter/shelly_test.go b/meter/shelly_test.go index 7ebf35cd4..a23bfb8cd 100644 --- a/meter/shelly_test.go +++ b/meter/shelly_test.go @@ -8,23 +8,26 @@ import ( func TestShellyCurrentPowerForUsage(t *testing.T) { tests := []struct { - name string - usage string - signed bool - power float64 - want float64 + name string + usage string + signed bool + reversed bool + power float64 + want float64 }{ {name: "grid keeps sign", usage: "grid", power: -350, want: -350}, {name: "unsigned pv uses absolute value", usage: "pv", power: -350, want: 350}, {name: "unsigned pv keeps positive values", usage: "pv", power: 350, want: 350}, {name: "signed pv inverts positive values", usage: "pv", signed: true, power: 350, want: -350}, {name: "signed pv inverts negative values", usage: "pv", signed: true, power: -350, want: 350}, + {name: "signed reversed pv keeps sign", usage: "pv", signed: true, reversed: true, power: 350, want: 350}, + {name: "signed reversed pv keeps negative sign", usage: "pv", signed: true, reversed: true, power: -350, want: -350}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { m := &Shelly{usage: tc.usage} - assert.Equal(t, tc.want, m.currentPowerForUsage(tc.power, tc.signed)) + assert.Equal(t, tc.want, m.currentPowerForUsage(tc.power, tc.signed, tc.reversed)) }) } } From b70ab2792806591dcb9b8eb4e3ca58008306221c Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 8 Aug 2026 15:15:41 +0200 Subject: [PATCH 1478/1700] Shelly: query return energy support once (#32644) --- meter/shelly.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/meter/shelly.go b/meter/shelly.go index 4801e80c1..35568f960 100644 --- a/meter/shelly.go +++ b/meter/shelly.go @@ -49,15 +49,18 @@ func NewShellyFromConfig(other map[string]any) (api.Meter, error) { // making their totals unsuitable for bidirectional grid metering. if !(c.usage == "grid" && c.conn.IsThreePhase()) { total, ret := c.conn.TotalEnergy, c.conn.ReturnEnergy + + hasReturn := c.conn.HasReturnEnergy() + // production is measured in return direction, unless the device has no return // register at all or already reverses the direction itself - if c.usage == "pv" && c.conn.HasReturnEnergy() && !c.conn.IsReversed() { + if c.usage == "pv" && hasReturn && !c.conn.IsReversed() { total, ret = ret, total } implement.Has(c, implement.MeterEnergy(total)) // without a return register the second reading is a constant zero - if c.conn.HasReturnEnergy() { + if hasReturn { implement.Has(c, implement.MeterReturnEnergy(ret)) } } From b29b49e3056ead3c99ff1f4711b2bb17083114b0 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 8 Aug 2026 16:44:51 +0200 Subject: [PATCH 1479/1700] core: simplify site/optimizer integration (#32646) --- core/site.go | 14 +- core/site_api.go | 6 +- core/site_optimizer.go | 268 +++++++++++++++++++----------------- core/site_optimizer_test.go | 44 ++++-- 4 files changed, 183 insertions(+), 149 deletions(-) diff --git a/core/site.go b/core/site.go index 329f464a6..ad83a1420 100644 --- a/core/site.go +++ b/core/site.go @@ -34,7 +34,6 @@ import ( "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/modbus" - "github.com/evcc-io/evcc/util/sponsor" "github.com/evcc-io/evcc/util/telemetry" "github.com/samber/lo" "github.com/smallnest/chanx" @@ -116,9 +115,11 @@ type Site struct { batteryMode api.BatteryMode // Battery mode (runtime only, not persisted) batteryModeExternal api.BatteryMode // Battery mode (external, runtime only, not persisted) batteryModeExternalTimer time.Time // Battery mode timer for external control - batterySuggestions map[string]types.Suggestion // Optimizer suggestions by battery meter name - loadpointSuggestions map[int]types.Suggestion // Optimizer suggestions by loadpoint id + suggestions map[string]types.Suggestion // Optimizer suggestions by device key suggestionActions map[string]string // last notified actionable optimizer action by device key + + optimizerMu sync.Mutex // guards optimizer runs + optimizerUpdated time.Time // last optimizer run, guarded by optimizerMu } // MetersConfig contains the site's meter configuration @@ -788,8 +789,9 @@ func (site *Site) updateBatteryMeters() { // publishBattery applies the optimizer suggestions and publishes the battery state func (site *Site) publishBattery() { + mode := site.GetBatteryMode().String() for i, d := range site.battery.Devices { - site.battery.Devices[i].Suggestion = site.batterySuggestion(d.Name) + site.battery.Devices[i].Suggestion = site.suggestion(batteryKey(d.Name), mode) } site.publish(keys.Battery, site.battery) @@ -959,9 +961,7 @@ func (site *Site) updateMeters() error { return err } - if sponsor.IsAuthorized() && optimizerEnabled() && time.Since(optimizerUpdated) >= tariff.SlotDuration { - go site.optimizerUpdateAsync() - } + go site.optimizerUpdateAsync(tariff.SlotDuration) return nil } diff --git a/core/site_api.go b/core/site_api.go index e6830c562..eb755a8cd 100644 --- a/core/site_api.go +++ b/core/site_api.go @@ -44,7 +44,7 @@ func (site *Site) Optimize() error { return api.ErrNotAvailable } - go site.optimizerUpdateAsync() + go site.optimizerUpdateAsync(optimizerDebounce) return nil } @@ -369,7 +369,7 @@ func (site *Site) SetGridExportLimit(power float64) error { site.publish(keys.GridExportLimit, power) // re-run the optimizer so the new limit takes effect immediately - site.triggerOptimizer() + go site.optimizerUpdateAsync(0) } return nil @@ -519,7 +519,7 @@ func (site *Site) SetOptimizerChargingStrategy(strategy string) error { site.publish(keys.OptimizerChargingStrategy, strategy) // re-run the optimizer so the new strategy takes effect immediately - site.triggerOptimizer() + go site.optimizerUpdateAsync(0) } return nil diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 87027d4b6..bdd34a44b 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -10,7 +10,6 @@ import ( "os" "slices" "strings" - "sync" "time" "github.com/evcc-io/evcc/api" @@ -30,12 +29,15 @@ import ( "golang.org/x/exp/constraints" ) -var ( - eta = float32(0.9) // efficiency of the battery charging/discharging - batteryPower = float32(6000) // default power of the battery in W +const ( + // eta is the efficiency of the battery charging/discharging + eta = 0.9 - mu sync.Mutex - optimizerUpdated time.Time + // batteryPower is the default power of the battery in W + batteryPower = 6000 + + // optimizerDebounce limits how often on-demand optimizer runs execute + optimizerDebounce = 2 * time.Minute ) // optimizerChargingStrategies are the valid grid charging strategies; the first @@ -53,23 +55,6 @@ const defaultOptimizerChargingStrategy = string(optimizer.OptimizerStrategyCharg // optimizerDecaySlots is the number of slots over which measured values decay into the forecast const optimizerDecaySlots = 4 -// triggerOptimizer re-runs the optimizer immediately so a changed setting takes -// effect without waiting for the next slot. It is a no-op when the optimizer is -// not active or a run is already in progress; the running update reflects the -// change on its next slot. -func (site *Site) triggerOptimizer() { - if !sponsor.IsAuthorized() || !optimizerEnabled() { - return - } - if !mu.TryLock() { - return - } - optimizerUpdated = time.Time{} // bypass the slot/debounce gate - mu.Unlock() - - go site.optimizerUpdateAsync() -} - // optimizerResult wraps the optimizer publish payload to implement BytesMarshaler. // This ensures publishComplex serializes it as a single JSON message instead of // recursively decomposing each struct field and array element into individual MQTT @@ -104,7 +89,34 @@ type batteryDetail struct { Capacity float64 `json:"capacity,omitempty"` loadpoint *int // originating loadpoint id for loadpoint/vehicle entries - controllable bool // battery exposes a controller; only these get suggestions + controllable bool // device can act on suggestions +} + +// batteryKey and loadpointKey build the canonical device keys used for +// suggestion routing and notifications +func batteryKey(name string) string { return "battery:" + name } +func loadpointKey(id int) string { return fmt.Sprintf("loadpoint:%d", id) } + +// key identifies the device across optimizer runs; an empty key means the +// device can't act on a suggestion. +func (d batteryDetail) key() string { + switch { + case d.Type == batteryTypeBattery: + return batteryKey(d.Name) + case d.loadpoint != nil: + return loadpointKey(*d.loadpoint) + default: + return "" + } +} + +// currentAction returns the device's current operating mode for suggestion +// comparison. Must only be called for devices with a non-empty key. +func (d batteryDetail) currentAction(site *Site) string { + if d.Type == batteryTypeBattery { + return site.GetBatteryMode().String() + } + return loadpointCurrentAction(site.loadpoints[*d.loadpoint]) } type batteryResult struct { @@ -136,10 +148,8 @@ type pendingSuggestion struct { event messenger.Event } -// suggestionEvent builds the notification key and event for a device suggestion. -// The key ("loadpoint:" / "battery:") identifies the device across -// runs; an empty key means the device can't act on a suggestion. -func suggestionEvent(detail batteryDetail, s types.Suggestion) (string, messenger.Event) { +// suggestionEvent builds the notification event for a device suggestion +func suggestionEvent(detail batteryDetail, s types.Suggestion) messenger.Event { ev := messenger.Event{Event: evSuggestion, Attributes: map[string]any{ "suggestionAction": s.Action, "suggestionTitle": detail.Title, @@ -148,14 +158,12 @@ func suggestionEvent(detail batteryDetail, s types.Suggestion) (string, messenge switch { case detail.Type == batteryTypeBattery: ev.Attributes["suggestionName"] = detail.Name - return "battery:" + detail.Name, ev case detail.loadpoint != nil: id := *detail.loadpoint ev.Loadpoint = &id - return fmt.Sprintf("loadpoint:%d", id), ev - default: - return "", ev } + + return ev } // currentSlotSuggestion maps the optimizer's first-slot corner result onto an advisory action. @@ -216,55 +224,35 @@ func loadpointCurrentAction(lp *Loadpoint) string { } // setSuggestions replaces the suggestions applied on each publish -func (site *Site) setSuggestions(batteries map[string]types.Suggestion, loadpoints map[int]types.Suggestion) { +func (site *Site) setSuggestions(suggestions map[string]types.Suggestion) { site.Lock() defer site.Unlock() - site.batterySuggestions = batteries - site.loadpointSuggestions = loadpoints + site.suggestions = suggestions } -// batterySuggestion returns the optimizer suggestion for the given battery meter. -// The actionable flag is evaluated on read since the battery mode changes between -// optimizer runs. -func (site *Site) batterySuggestion(name string) *types.Suggestion { - mode := site.GetBatteryMode().String() - +// suggestion returns the optimizer suggestion for the given device key. +// The actionable flag is evaluated on read against the device's current +// action since that changes between optimizer runs. +func (site *Site) suggestion(key, currentAction string) *types.Suggestion { site.RLock() - defer site.RUnlock() - - s, ok := site.batterySuggestions[name] - if !ok { - return nil - } - - s.Actionable = s.Action != mode - - return &s -} - -// loadpointSuggestion returns the optimizer suggestion for the given loadpoint. -// The actionable flag is evaluated on read since the loadpoint's action changes -// between optimizer runs. -func (site *Site) loadpointSuggestion(id int) *types.Suggestion { - site.RLock() - s, ok := site.loadpointSuggestions[id] + s, ok := site.suggestions[key] site.RUnlock() if !ok { return nil } - s.Actionable = s.Action != loadpointCurrentAction(site.loadpoints[id]) + s.Actionable = s.Action != currentAction return &s } // publishSuggestions publishes the loadpoints' suggestions func (site *Site) publishSuggestions() { - for id := range site.loadpoints { + for id, lp := range site.loadpoints { var val any - if s := site.loadpointSuggestion(id); s != nil { + if s := site.suggestion(loadpointKey(id), loadpointCurrentAction(lp)); s != nil { val = *s } site.publishLoadpoint(id, keys.Suggestion, val) @@ -274,7 +262,7 @@ func (site *Site) publishSuggestions() { // clearSuggestions removes all suggestions and the battery forecast when the // optimizer result is stale func (site *Site) clearSuggestions() { - site.setSuggestions(nil, nil) + site.setSuggestions(nil) site.battery.Forecast = nil site.publishBattery() @@ -291,21 +279,17 @@ func (site *Site) pendingSuggestions(details []batteryDetail) map[string]pending pending := make(map[string]pendingSuggestion, len(details)) for _, detail := range details { - var s *types.Suggestion - - switch { - case detail.Type == batteryTypeBattery: - s = site.batterySuggestion(detail.Name) - case detail.loadpoint != nil: - s = site.loadpointSuggestion(*detail.loadpoint) + key := detail.key() + if key == "" { + continue } + s := site.suggestion(key, detail.currentAction(site)) if s == nil { continue } - key, ev := suggestionEvent(detail, *s) - pending[key] = pendingSuggestion{suggestion: *s, event: ev} + pending[key] = pendingSuggestion{suggestion: *s, event: suggestionEvent(detail, *s)} } return pending @@ -346,19 +330,41 @@ type requestDetails struct { BatteryDetails []batteryDetail `json:"batteryDetails"` } +// optimizerBattery pairs a battery request entry with its device detail +type optimizerBattery struct { + cfg optimizer.BatteryConfig + detail batteryDetail +} + +func optimizerURI() string { + return cmp.Or(os.Getenv("OPTIMIZER_URI"), OPTIMIZER_URI) +} + 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() { +// optimizerUpdateAsync runs the optimizer unless the last run is younger than +// minAge. Pass 0 to force a run, e.g. when a changed setting should take effect +// without waiting for the next slot. It is a no-op when the optimizer is not +// active or a run is already in progress; the running update reflects the +// change on its next slot. +func (site *Site) optimizerUpdateAsync(minAge time.Duration) { + if !sponsor.IsAuthorized() || !optimizerEnabled() { return } - defer mu.Unlock() - if time.Since(optimizerUpdated) < 2*time.Minute { + if !site.optimizerMu.TryLock() { + return + } + defer site.optimizerMu.Unlock() + + if minAge == 0 { + // keep the gate open so a not-ready run is retried on the next cycle + site.optimizerUpdated = time.Time{} + } else if time.Since(site.optimizerUpdated) < minAge { return } @@ -374,7 +380,7 @@ func (site *Site) optimizerUpdateAsync() { return } - optimizerUpdated = time.Now() + site.optimizerUpdated = time.Now() if err != nil { site.log.ERROR.Println("optimizer:", err) @@ -387,7 +393,12 @@ func (site *Site) optimizerUpdateAsync() { err = site.optimizerUpdate(site.battery.Devices) } -func (site *Site) optimizerUpdate(battery []types.Measurement) error { +// optimizerRequest assembles the optimizer request and the matching device +// details from tariffs, home profile, loadpoints and battery meters +func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.OptimizationInput, requestDetails, error) { + var req optimizer.OptimizationInput + var details requestDetails + solarTariff := site.GetTariff(api.TariffUsageSolar) solar := currentRates(solarTariff) @@ -400,17 +411,16 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { minLen = min(minLen, len(solar)) } - uri := cmp.Or(os.Getenv("OPTIMIZER_URI"), OPTIMIZER_URI) - if uri == OPTIMIZER_URI { + if optimizerURI() == OPTIMIZER_URI { // limit to 2 days for sake of performance minLen = min(2*96, minLen) } if expectedSlots := 8; minLen < expectedSlots { if solarTariff != nil { - return fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (grid=%d, feedIn=%d, solar=%d)", minLen, expectedSlots, len(grid), len(feedIn), len(solar)) + return req, details, fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (grid=%d, feedIn=%d, solar=%d)", minLen, expectedSlots, len(grid), len(feedIn), len(solar)) } - return fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (grid=%d, feedIn=%d)", minLen, expectedSlots, len(grid), len(feedIn)) + return req, details, fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (grid=%d, feedIn=%d)", minLen, expectedSlots, len(grid), len(feedIn)) } now := time.Now() @@ -426,7 +436,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { gt, err := site.homeProfile(minLen) if err != nil { - return err + return req, details, err } // blend measured energy of the last metrics slot into the first slots @@ -441,7 +451,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { if solarTariff != nil && len(solar) > 0 { solarEnergy, err := solarRatesToEnergy(solar) if err != nil { - return err + return req, details, err } scale := site.effectiveSolarScale() @@ -456,7 +466,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { ft = prorate(ftSlots, firstSlotDuration) } - req := optimizer.OptimizationInput{ + req = optimizer.OptimizationInput{ Strategy: optimizer.OptimizerStrategy{ ChargingStrategy: optimizer.OptimizerStrategyChargingStrategy(site.GetOptimizerChargingStrategy()), DischargingStrategy: optimizer.OptimizerStrategyDischargingStrategyDischargeBeforeImport, @@ -475,7 +485,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { // end of horizon Wh value pa := lo.Min(req.TimeSeries.PN) * eta * 0.99 - details := requestDetails{ + details = requestDetails{ Timestamps: asTimestamps(dt, now), } @@ -500,11 +510,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { } } - add := func(battery optimizer.BatteryConfig, detail batteryDetail) { - battery.PA = pa - req.Batteries = append(req.Batteries, battery) - details.BatteryDetails = append(details.BatteryDetails, detail) - } + var batteries []optimizerBattery for id, lp := range site.Loadpoints() { // ignore disconnected loadpoints, including StatusNone @@ -517,9 +523,9 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { } // skip disabled loadpoints - if req, detail := site.loadpointRequest(lp, minLen, firstSlotDuration, grid); req.CMax > 0 { + if cfg, detail := site.loadpointRequest(lp, minLen, firstSlotDuration, grid); cfg.CMax > 0 { detail.loadpoint = &id - add(req, detail) + batteries = append(batteries, optimizerBattery{cfg, detail}) } } @@ -534,7 +540,23 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { continue } - add(site.batteryRequest(dev, b, grid, minLen, firstSlotDuration)) + cfg, detail := site.batteryRequest(dev, b, grid, minLen, firstSlotDuration) + batteries = append(batteries, optimizerBattery{cfg, detail}) + } + + for _, b := range batteries { + b.cfg.PA = pa + req.Batteries = append(req.Batteries, b.cfg) + details.BatteryDetails = append(details.BatteryDetails, b.detail) + } + + return req, details, nil +} + +func (site *Site) optimizerUpdate(battery []types.Measurement) error { + req, details, err := site.optimizerRequest(battery) + if err != nil { + return err } if len(req.Batteries) == 0 { @@ -549,7 +571,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { httpClient := request.NewClient(site.log) httpClient.Timeout = 90 * time.Second - apiClient, err := optimizer.NewClientWithResponses(uri, optimizer.WithHTTPClient(httpClient)) + apiClient, err := optimizer.NewClientWithResponses(optimizerURI(), optimizer.WithHTTPClient(httpClient)) if err != nil { return err } @@ -581,51 +603,50 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { return errors.New(string(resp.JSON200.Status)) } - slotHours := firstSlotDuration.Hours() - gridImporting := len(resp.JSON200.GridImport) > 0 && resp.JSON200.GridImport[0] > 0 - gridExporting := len(resp.JSON200.GridExport) > 0 && resp.JSON200.GridExport[0] > 0 + site.applyOptimizerResult(req, details.BatteryDetails, *resp.JSON200) + + return nil +} + +// applyOptimizerResult maps the optimizer response onto suggestions, battery +// forecast and notifications +func (site *Site) applyOptimizerResult(req optimizer.OptimizationInput, details []batteryDetail, res optimizer.OptimizationResult) { + slotHours := (time.Duration(req.TimeSeries.Dt[0]) * time.Second).Hours() + gridImporting := len(res.GridImport) > 0 && res.GridImport[0] > 0 + gridExporting := len(res.GridExport) > 0 && res.GridExport[0] > 0 var batteries []batteryResult suggestions := make(map[string]types.Suggestion, len(req.Batteries)) - lpSuggestions := make(map[int]types.Suggestion) for i, batReq := range req.Batteries { - batResp := resp.JSON200.Batteries[i] - detail := details.BatteryDetails[i] + batRes := res.Batteries[i] + detail := details[i] - batResult := batteryResult{ + batteries = append(batteries, batteryResult{ batteryDetail: detail, - Full: matchSoc(batResp.StateOfCharge, func(soc float32) bool { + Full: matchSoc(batRes.StateOfCharge, func(soc float32) bool { return soc >= batReq.SMax }), - Empty: matchSoc(batResp.StateOfCharge, func(soc float32) bool { + Empty: matchSoc(batRes.StateOfCharge, func(soc float32) bool { return soc <= batReq.SMin }), - } + }) - batteries = append(batteries, batResult) - - suggestion := currentSlotSuggestion(detail, batResp, gridImporting, gridExporting, slotHours) + suggestion := currentSlotSuggestion(detail, batRes, gridImporting, gridExporting, slotHours) if suggestion.Action == "" { continue } - switch { - case detail.Type == batteryTypeBattery: - // uncontrollable batteries can't act on a suggestion - if !detail.controllable { - continue - } - suggestions[detail.Name] = suggestion - case detail.loadpoint != nil: - lpSuggestions[*detail.loadpoint] = suggestion + // uncontrollable devices can't act on a suggestion + if key := detail.key(); key != "" && detail.controllable { + suggestions[key] = suggestion } } site.publish("evopt-batteries", batteries) - site.setSuggestions(suggestions, lpSuggestions) - site.battery.Forecast = site.addBatteryForecastTotals(req.Batteries, resp.JSON200.Batteries) + site.setSuggestions(suggestions) + site.battery.Forecast = site.addBatteryForecastTotals(req.Batteries, res.Batteries) site.publishBattery() @@ -633,11 +654,9 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { site.publishSuggestions() // notify on actionable suggestion changes (advisory only, see #31903) - for _, ev := range site.diffSuggestions(site.pendingSuggestions(details.BatteryDetails)) { + for _, ev := range site.diffSuggestions(site.pendingSuggestions(details)) { site.pushEvent(ev) } - - return nil } func (site *Site) addBatteryForecastTotals(req []optimizer.BatteryConfig, resp []optimizer.BatteryResult) *types.BatteryForecast { @@ -741,8 +760,9 @@ func (site *Site) loadpointRequest(lp loadpoint.API, minLen int, firstSlotDurati } detail := batteryDetail{ - Type: batteryTypeLoadpoint, - Title: lp.GetTitle(), + Type: batteryTypeLoadpoint, + Title: lp.GetTitle(), + controllable: true, } // vehicle diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index 2d5133ba9..9e8d5bbb6 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -355,40 +355,49 @@ func TestSuggestionActionable(t *testing.T) { batteryMode: api.BatteryNormal, loadpoints: []*Loadpoint{lp}, } - site.setSuggestions( - map[string]types.Suggestion{"bat": {Action: api.BatteryCharge.String()}}, - map[int]types.Suggestion{0: {Action: actionCharge}}, - ) + site.setSuggestions(map[string]types.Suggestion{ + batteryKey("bat"): {Action: api.BatteryCharge.String()}, + loadpointKey(0): {Action: actionCharge}, + }) + + batterySuggestion := func(name string) *types.Suggestion { + return site.suggestion(batteryKey(name), site.GetBatteryMode().String()) + } + loadpointSuggestion := func(id int) *types.Suggestion { + return site.suggestion(loadpointKey(id), loadpointCurrentAction(lp)) + } // battery mode differs from suggestion - s := site.batterySuggestion("bat") + s := batterySuggestion("bat") require.NotNil(t, s) assert.True(t, s.Actionable) site.batteryMode = api.BatteryCharge - assert.False(t, site.batterySuggestion("bat").Actionable) + assert.False(t, batterySuggestion("bat").Actionable) - assert.Nil(t, site.batterySuggestion("unknown")) + assert.Nil(t, batterySuggestion("unknown")) // loadpoint stopped, suggestion is to charge - s = site.loadpointSuggestion(0) + s = loadpointSuggestion(0) require.NotNil(t, s) assert.True(t, s.Actionable) // loadpoint charging matches the suggestion lp.enabled = true lp.status = api.StatusC - assert.False(t, site.loadpointSuggestion(0).Actionable) + assert.False(t, loadpointSuggestion(0).Actionable) - assert.Nil(t, site.loadpointSuggestion(1)) + assert.Nil(t, loadpointSuggestion(1)) } func TestSuggestionEvent(t *testing.T) { id := 2 // battery: no loadpoint id, carries name - key, ev := suggestionEvent(batteryDetail{Type: batteryTypeBattery, Name: "home", Title: "Home"}, types.Suggestion{Action: api.BatteryCharge.String()}) - assert.Equal(t, "battery:home", key) + detail := batteryDetail{Type: batteryTypeBattery, Name: "home", Title: "Home"} + assert.Equal(t, "battery:home", detail.key()) + + ev := suggestionEvent(detail, types.Suggestion{Action: api.BatteryCharge.String()}) assert.Nil(t, ev.Loadpoint) assert.Equal(t, evSuggestion, ev.Event) assert.Equal(t, api.BatteryCharge.String(), ev.Attributes["suggestionAction"]) @@ -396,18 +405,23 @@ func TestSuggestionEvent(t *testing.T) { assert.Equal(t, "Home", ev.Attributes["suggestionTitle"]) // loadpoint: carries id, no name - key, ev = suggestionEvent(batteryDetail{Type: batteryTypeVehicle, loadpoint: &id, Title: "Garage"}, types.Suggestion{Action: actionCharge}) - assert.Equal(t, "loadpoint:2", key) + detail = batteryDetail{Type: batteryTypeVehicle, loadpoint: &id, Title: "Garage"} + assert.Equal(t, "loadpoint:2", detail.key()) + + ev = suggestionEvent(detail, types.Suggestion{Action: actionCharge}) require.NotNil(t, ev.Loadpoint) assert.Equal(t, id, *ev.Loadpoint) assert.NotContains(t, ev.Attributes, "suggestionName") + + // vehicle without loadpoint can't act on a suggestion + assert.Empty(t, batteryDetail{Type: batteryTypeVehicle}.key()) } func TestDiffSuggestions(t *testing.T) { site := &Site{} pending := func(s types.Suggestion) map[string]pendingSuggestion { - _, ev := suggestionEvent(batteryDetail{loadpoint: new(int)}, s) + ev := suggestionEvent(batteryDetail{loadpoint: new(int)}, s) return map[string]pendingSuggestion{"loadpoint:0": {suggestion: s, event: ev}} } From d09a952cbabb8e3f14532adcf670f8cf7d53a4d1 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 9 Aug 2026 10:37:22 +0200 Subject: [PATCH 1480/1700] Optimizer: add feasible status (#32632) --- .../js/components/Config/StatusIndicator.vue | 4 ++- .../Optimize/OptimizeHeader.stories.ts | 4 +++ .../js/components/Optimize/OptimizeHeader.vue | 35 +++++++++++-------- assets/js/types/evcc.ts | 1 + core/site_optimizer.go | 5 +-- 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/assets/js/components/Config/StatusIndicator.vue b/assets/js/components/Config/StatusIndicator.vue index 17c92d037..d27f33fb2 100644 --- a/assets/js/components/Config/StatusIndicator.vue +++ b/assets/js/components/Config/StatusIndicator.vue @@ -16,7 +16,7 @@ import { defineComponent, type PropType } from "vue"; import Tooltip from "bootstrap/js/dist/tooltip"; -type Variant = "success" | "warning" | "muted"; +type Variant = "success" | "warning" | "danger" | "muted"; export default defineComponent({ name: "StatusIndicator", @@ -34,6 +34,8 @@ export default defineComponent({ return "bg-success"; case "warning": return "bg-warning"; + case "danger": + return "bg-danger"; default: return "border border-secondary"; } diff --git a/assets/js/components/Optimize/OptimizeHeader.stories.ts b/assets/js/components/Optimize/OptimizeHeader.stories.ts index f443ac6a5..601eb24a2 100644 --- a/assets/js/components/Optimize/OptimizeHeader.stories.ts +++ b/assets/js/components/Optimize/OptimizeHeader.stories.ts @@ -41,6 +41,10 @@ Payment.args = { ...base, status: OptimizationStatus.OPTIMAL, netCost: 12.4 }; export const Credit = Template.bind({}); Credit.args = { ...base, status: OptimizationStatus.OPTIMAL, netCost: -3.8 }; +// usable but not proven optimal plan +export const Feasible = Template.bind({}); +Feasible.args = { ...base, status: OptimizationStatus.FEASIBLE, netCost: 12.4 }; + // solver could not produce a plan export const Infeasible = Template.bind({}); Infeasible.args = { ...base, status: OptimizationStatus.INFEASIBLE, netCost: 0 }; diff --git a/assets/js/components/Optimize/OptimizeHeader.vue b/assets/js/components/Optimize/OptimizeHeader.vue index a7cd81b65..5736ecd75 100644 --- a/assets/js/components/Optimize/OptimizeHeader.vue +++ b/assets/js/components/Optimize/OptimizeHeader.vue @@ -11,7 +11,9 @@
always, fixed
- Lowest cost + Lowest cost
@@ -32,7 +34,7 @@ @change="onStrategyChange" > {{ secondaryGoalLabel }} @@ -60,7 +62,7 @@
- {{ + {{ status }} @@ -88,7 +90,7 @@
{{ netCostDisplay }} @@ -132,6 +134,7 @@ const STRATEGY_LABELS: Record = { const STATUS_TOOLTIP = "The optimizer result:

" + "Optimal: the best plan was found.
" + + "Feasible: a usable plan was found, but not proven optimal.
" + "Infeasible: the constraints cannot all be met.
" + "Not solved: optimization hasn't finished yet."; @@ -180,13 +183,15 @@ export default defineComponent({ if (Math.abs(elapsed) < 60 * 1000) return "just now"; return this.fmtTimeAgo(elapsed); }, - statusVariant(): "success" | "warning" | "muted" { + statusVariant(): "success" | "warning" | "danger" | "muted" { switch (this.status) { case OptimizationStatus.OPTIMAL: return "success"; + case OptimizationStatus.FEASIBLE: + return "warning"; case OptimizationStatus.INFEASIBLE: case OptimizationStatus.UNBOUNDED: - return "warning"; + return "danger"; default: return "muted"; } @@ -276,20 +281,22 @@ export default defineComponent({ .info-icon { cursor: help; } +/* body size on small screens, larger from md up; bold carries the emphasis */ +.value-text { + font-size: 1rem; + line-height: 1.2; +} +@media (min-width: 768px) { + .value-text { + font-size: 1.25rem; + } +} .secondary-goal { text-decoration: underline; text-decoration-color: var(--evcc-gray); text-underline-offset: 3px; } -/* small: slightly reduced value size, rows are tighter */ -@media (max-width: 767.98px) { - .field-value .large, - .field-value .fs-4 { - font-size: 1.125rem !important; - } -} - /* md and up: each field a vertical stack (label, value, caption); columns via Bootstrap grid */ @media (min-width: 768px) { .field { diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 30a2bb32a..97cd268e5 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -1429,6 +1429,7 @@ export interface TimeSeries { // Solver status enum export enum OptimizationStatus { OPTIMAL = "Optimal", + FEASIBLE = "Feasible", INFEASIBLE = "Infeasible", UNBOUNDED = "Unbounded", UNDEFINED = "Undefined", diff --git a/core/site_optimizer.go b/core/site_optimizer.go index bdd34a44b..ef1a7b983 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -599,8 +599,9 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { Details: details, }) - if resp.JSON200.Status != optimizer.Optimal { - return errors.New(string(resp.JSON200.Status)) + // feasible results are usable, they are just not proven optimal + if status := resp.JSON200.Status; status != optimizer.Optimal && status != optimizer.Feasible { + return errors.New(string(status)) } site.applyOptimizerResult(req, details.BatteryDetails, *resp.JSON200) From 27aca97bab947c7a782a1e116398812c027c3a50 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sun, 9 Aug 2026 11:22:04 +0200 Subject: [PATCH 1481/1700] Tasmota: mask password in error messages (#32659) --- meter/tasmota/connection.go | 39 ++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/meter/tasmota/connection.go b/meter/tasmota/connection.go index 3442d3932..9f1b65fb9 100644 --- a/meter/tasmota/connection.go +++ b/meter/tasmota/connection.go @@ -43,7 +43,7 @@ func NewConnection(uri, user, password string, channels []int, cache time.Durati used[c] = true } - log := util.NewLogger("tasmota") + log := util.NewLogger("tasmota").Redact(user, password) c := &Connection{ Helper: request.NewHelper(log), uri: util.DefaultScheme(strings.TrimRight(uri, "/"), "http"), @@ -55,30 +55,35 @@ func NewConnection(uri, user, password string, channels []int, cache time.Durati c.Client.Transport = request.NewTripper(log, transport.Insecure()) c.statusSnsG = util.ResettableCached(func() (StatusSNSResponse, error) { - parameters := url.Values{ - "user": {c.user}, - "password": {c.password}, - "cmnd": {"Status 8"}, - } var res StatusSNSResponse - err := c.GetJSON(fmt.Sprintf("%s/cm?%s", c.uri, parameters.Encode()), &res) + err := c.cmd("Status 8", &res) return res, err }, cache) c.statusStsG = util.ResettableCached(func() (StatusSTSResponse, error) { - parameters := url.Values{ - "user": {c.user}, - "password": {c.password}, - "cmnd": {"Status 0"}, - } var res StatusSTSResponse - err := c.GetJSON(fmt.Sprintf("%s/cm?%s", c.uri, parameters.Encode()), &res) + err := c.cmd("Status 0", &res) return res, err }, cache) return c, nil } +// cmd executes a Tasmota command, masking the password in returned errors +func (c *Connection) cmd(cmnd string, res any) error { + parameters := url.Values{ + "user": {c.user}, + "password": {c.password}, + "cmnd": {cmnd}, + } + + err := c.GetJSON(fmt.Sprintf("%s/cm?%s", c.uri, parameters.Encode()), res) + if err != nil && c.password != "" { + return errors.New(strings.ReplaceAll(err.Error(), url.QueryEscape(c.password), "***")) + } + return err +} + // channelExists checks the existence of the configured relay channel interface func (c *Connection) RelayExists() error { res, err := c.statusStsG.Get() @@ -123,14 +128,8 @@ func (c *Connection) Enable(enable bool) error { cmd = fmt.Sprintf("Power%d on", channel) } - parameters := url.Values{ - "user": {c.user}, - "password": {c.password}, - "cmnd": {cmd}, - } - var res PowerResponse - if err := c.GetJSON(fmt.Sprintf("%s/cm?%s", c.uri, parameters.Encode()), &res); err != nil { + if err := c.cmd(cmd, &res); err != nil { return err } From 11cc118002a32dca1b3c7f29713427274b1ad669 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sun, 9 Aug 2026 11:29:39 +0200 Subject: [PATCH 1482/1700] chore: remove prettier editor leftovers, align oxfmt config (#32657) --- .vscode/extensions.json | 2 -- oxfmt.config.ts | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index ec190d867..4978ed1ba 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,9 +1,7 @@ { "recommendations": [ "Vue.volar", - "vitest.explorer", "golang.go", - "esbenp.prettier-vscode", "yoavbls.pretty-ts-errors", "VoidZero.vite-plus-extension-pack" ] diff --git a/oxfmt.config.ts b/oxfmt.config.ts index a304002f7..d3b80482c 100644 --- a/oxfmt.config.ts +++ b/oxfmt.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from "oxfmt"; export default defineConfig({ + printWidth: 100, + trailingComma: "es5", ignorePatterns: ["tests/custom-css.css"], overrides: [{ files: ["**/*.vue"], options: { tabWidth: 4 } }], }); From 7d4bb61636f13e4cffb42348624b1adf15979b3d Mon Sep 17 00:00:00 2001 From: mfuchs1984 <57141790+mfuchs1984@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:36:51 +0200 Subject: [PATCH 1483/1700] Hoymiles AhoyDTU: add cache (#32652) --- templates/definition/meter/hoymiles-ahoydtu.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/templates/definition/meter/hoymiles-ahoydtu.yaml b/templates/definition/meter/hoymiles-ahoydtu.yaml index 85a3c9111..8a7369351 100644 --- a/templates/definition/meter/hoymiles-ahoydtu.yaml +++ b/templates/definition/meter/hoymiles-ahoydtu.yaml @@ -16,13 +16,17 @@ params: de: "Falls mehrere vorhanden. Die Nummerierung beginnt bei 0. Siehe AhoyDTU Webinterface -> Inverter #[ID]" en: "If multiple exist. The numbering starts at 0. See AhoyDTU webinterface -> Inverter #[ID]" default: 0 + - name: cache + default: 1s render: | type: custom power: source: http uri: http://{{ .host }}/api/inverter/id/{{ .id }} jq: .ch[0][2] + cache: {{ .cache }} energy: source: http uri: http://{{ .host }}/api/inverter/id/{{ .id }} jq: .ch[0][6] + cache: {{ .cache }} From 99a9c889480faad431ed0df695218ed2e5e6802f Mon Sep 17 00:00:00 2001 From: CiNcH83 Date: Sun, 9 Aug 2026 12:19:03 +0200 Subject: [PATCH 1484/1700] Huawei SmartLogger: add returnenergy to grid (#32650) --- templates/definition/meter/huawei-smartlogger.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/templates/definition/meter/huawei-smartlogger.yaml b/templates/definition/meter/huawei-smartlogger.yaml index 85b69967c..cf40df6a1 100644 --- a/templates/definition/meter/huawei-smartlogger.yaml +++ b/templates/definition/meter/huawei-smartlogger.yaml @@ -34,6 +34,14 @@ render: | type: holding decode: int64 scale: 0.01 + returnenergy: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 32357 # Positive active electricity + type: holding + decode: int64 + scale: 0.01 currents: - source: modbus {{- include "modbus" . | indent 2 }} From 28e41bdd09f59cbbe36ee9d094c5fbb7e4c95c0b Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sun, 9 Aug 2026 13:01:19 +0200 Subject: [PATCH 1485/1700] chore: fix lint:format skipping assets and tests (#32662) --- assets/js/components/BottomTabs/Bar.vue | 9 +-- .../components/ChargingPlans/PlanStrategy.vue | 10 ++- .../js/components/Config/PropertyZoneForm.vue | 1 - i18n/check.ts | 72 ++++++++++--------- package.json | 2 +- tests/hems.spec.ts | 5 +- tests/plan.spec.ts | 4 +- 7 files changed, 52 insertions(+), 51 deletions(-) diff --git a/assets/js/components/BottomTabs/Bar.vue b/assets/js/components/BottomTabs/Bar.vue index a4ae2ae74..3628df075 100644 --- a/assets/js/components/BottomTabs/Bar.vue +++ b/assets/js/components/BottomTabs/Bar.vue @@ -52,14 +52,7 @@ import BatteryIcon from "../Energyflow/BatteryIcon.vue"; import Item from "./Item.vue"; import MoreItem from "./MoreItem.vue"; import { defineComponent, type PropType } from "vue"; -import type { - FatalError, - Sponsor, - EvOpt, - AuthProviders, - Battery, - Vehicle, -} from "@/types/evcc"; +import type { FatalError, Sponsor, EvOpt, AuthProviders, Battery, Vehicle } from "@/types/evcc"; export default defineComponent({ name: "BottomTabBar", diff --git a/assets/js/components/ChargingPlans/PlanStrategy.vue b/assets/js/components/ChargingPlans/PlanStrategy.vue index 7e38251e0..13bb6a8c9 100644 --- a/assets/js/components/ChargingPlans/PlanStrategy.vue +++ b/assets/js/components/ChargingPlans/PlanStrategy.vue @@ -31,7 +31,10 @@
-