From a2dc5ba479c06d5d0e3f1010c0254e3108818292 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 7 Apr 2026 08:26:01 +0200 Subject: [PATCH 0001/1316] Config UI: add curtailed device info (#28889) --- server/http_config_helper.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/http_config_helper.go b/server/http_config_helper.go index e8f90dad5..3930942a1 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -340,6 +340,11 @@ func testInstance(instance any) map[string]testResult { makeResult("dimmed", val, err) } + if dev, ok := api.Cap[api.Curtailer](instance); ok { + val, err := dev.Curtailed() + makeResult("curtailed", val, err) + } + if dev, ok := api.Cap[api.Identifier](instance); ok { val, err := dev.Identify() makeResult("identifier", val, err) From b51891b24beaf871acd56ba74167894b89d681ef Mon Sep 17 00:00:00 2001 From: pirndi <30297631+pirndi@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:26:52 +0200 Subject: [PATCH 0002/1316] Add MTEC heat pump (#28880) --- templates/definition/charger/mtec.yaml | 49 ++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 templates/definition/charger/mtec.yaml diff --git a/templates/definition/charger/mtec.yaml b/templates/definition/charger/mtec.yaml new file mode 100644 index 000000000..ce04e3580 --- /dev/null +++ b/templates/definition/charger/mtec.yaml @@ -0,0 +1,49 @@ +template: MTec +products: + - brand: M-Tec +group: heating +requirements: + # evcc: ["sponsorship"] +params: + - name: modbus + choice: ["tcpip"] + - name: tempsource + type: choice + choice: ["warmwater_top", "buffer"] + +render: | + type: heatpump + setmaxpower: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 1000 # PV Überschussleistung in Watt + type: writeholdings + decode: int16 + scale: 1 + power: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 707 # aktuelle Aufnahmeleistung der WP + type: holding + decode: int16 + scale: 1 + energy: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: 706 # aktuelle Wärmemenge der WP + type: holding + decode: int16 + scale: 1 + {{- if .tempsource }} + temp: + source: modbus + {{- include "modbus" . | indent 2 }} + register: + address: {{ if eq .tempsource "warmwater_top" -}} 401 {{ else if eq .tempsource "buffer" -}} 601 {{- end }} # + type: holding + decode: int16 + scale: 0.1 + {{- end }} From cebe55dfdb1faec59e2706075b1a037ce0f99a9f Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 7 Apr 2026 08:27:23 +0200 Subject: [PATCH 0003/1316] Templates: support IPv6 (#28872) --- .../definition/charger/daheimladen-pro.yaml | 2 +- templates/definition/charger/daheimladen.yaml | 2 +- templates/definition/charger/e3dc-rscp.yaml | 2 +- templates/definition/charger/etrel-duo.yaml | 2 +- templates/definition/charger/etrel.yaml | 2 +- templates/definition/charger/homematic.yaml | 2 +- templates/definition/charger/kermi.yaml | 12 ++-- .../definition/charger/lambda-zewotherm.yaml | 10 ++-- .../definition/charger/ochsner-bwwp.yaml | 8 +-- .../definition/charger/wallbe-meter.yaml | 2 +- .../charger/wallbe-pre2019-meter.yaml | 2 +- .../definition/charger/wallbe-pre2019.yaml | 2 +- templates/definition/charger/wallbe.yaml | 2 +- .../definition/charger/webasto-next.yaml | 2 +- templates/definition/charger/weidmüller.yaml | 2 +- templates/definition/meter/dsmr.yaml | 2 +- templates/definition/meter/e3dc-modbus.yaml | 10 ++-- templates/definition/meter/e3dc-rscp.yaml | 2 +- templates/definition/meter/fronius-gen24.yaml | 52 ++++++++--------- .../definition/meter/fronius-vertoplus.yaml | 56 +++++++++---------- .../definition/meter/hager-flow-modbus.yaml | 14 ++--- templates/definition/meter/huawei-emma.yaml | 20 +++---- .../definition/meter/huawei-smartlogger.yaml | 4 +- templates/definition/meter/rct-power.yaml | 2 +- templates/definition/meter/sigenergy.yaml | 20 +++---- templates/definition/meter/solarlog.yaml | 8 +-- templates/definition/meter/sunspec-meter.yaml | 26 ++++----- templates/definition/meter/varta.yaml | 8 +-- .../definition/meter/victron-energy.yaml | 32 +++++------ util/templates/includes/mqtt.tpl | 2 +- util/templates/modbus.tpl | 6 +- util/templates/utils.go | 8 +++ 32 files changed, 167 insertions(+), 159 deletions(-) diff --git a/templates/definition/charger/daheimladen-pro.yaml b/templates/definition/charger/daheimladen-pro.yaml index 8b8b7e7b0..56b4e2a25 100644 --- a/templates/definition/charger/daheimladen-pro.yaml +++ b/templates/definition/charger/daheimladen-pro.yaml @@ -13,5 +13,5 @@ params: default: 502 render: | type: daheimladen - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} phases1p3p: true diff --git a/templates/definition/charger/daheimladen.yaml b/templates/definition/charger/daheimladen.yaml index c009f10e9..461478990 100644 --- a/templates/definition/charger/daheimladen.yaml +++ b/templates/definition/charger/daheimladen.yaml @@ -15,4 +15,4 @@ params: default: 502 render: | type: daheimladen - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} diff --git a/templates/definition/charger/e3dc-rscp.yaml b/templates/definition/charger/e3dc-rscp.yaml index a62ccbe70..b25fbf799 100644 --- a/templates/definition/charger/e3dc-rscp.yaml +++ b/templates/definition/charger/e3dc-rscp.yaml @@ -47,7 +47,7 @@ params: advanced: true render: | type: e3dc-rscp - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} user: {{ .user }} password: {{ .password }} key: {{ .key }} diff --git a/templates/definition/charger/etrel-duo.yaml b/templates/definition/charger/etrel-duo.yaml index bfbc80341..38730a5a9 100644 --- a/templates/definition/charger/etrel-duo.yaml +++ b/templates/definition/charger/etrel-duo.yaml @@ -17,4 +17,4 @@ params: render: | type: etrel connector: {{ .connector }} - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} diff --git a/templates/definition/charger/etrel.yaml b/templates/definition/charger/etrel.yaml index 56201b9d9..24e4091f2 100644 --- a/templates/definition/charger/etrel.yaml +++ b/templates/definition/charger/etrel.yaml @@ -18,4 +18,4 @@ params: default: 502 render: | type: etrel - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} diff --git a/templates/definition/charger/homematic.yaml b/templates/definition/charger/homematic.yaml index 13223c8f8..9e0cdb4a7 100644 --- a/templates/definition/charger/homematic.yaml +++ b/templates/definition/charger/homematic.yaml @@ -58,7 +58,7 @@ params: - preset: switchsocket render: | type: homematic - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} device: {{ .device }} user: {{ .user }} password: {{ .password }} diff --git a/templates/definition/charger/kermi.yaml b/templates/definition/charger/kermi.yaml index 0580f60a6..f6634c5c3 100644 --- a/templates/definition/charger/kermi.yaml +++ b/templates/definition/charger/kermi.yaml @@ -25,7 +25,7 @@ render: | timeout: {{ .watchdog }} # re-write at timeout/2 set: source: modbus - uri: {{ .host }}:502 + uri: {{ joinHostPort .host "502" }} id: 40 # WP register: address: 301 # PV Überschussleistung @@ -34,7 +34,7 @@ render: | scale: 10.0 power: source: modbus - uri: {{ .host }}:502 + uri: {{ joinHostPort .host "502" }} id: 40 register: address: 108 @@ -44,7 +44,7 @@ render: | {{- if eq .tempsource "warmwater" }} temp: source: modbus - uri: {{ .host }}:502 + uri: {{ joinHostPort .host "502" }} id: 51 # TWE register: address: 100 @@ -53,7 +53,7 @@ render: | scale: 0.1 limittemp: source: modbus - uri: {{ .host }}:502 + uri: {{ joinHostPort .host "502" }} id: 51 # TWE register: address: 101 @@ -64,7 +64,7 @@ render: | {{- if eq .tempsource "buffer" }} temp: source: modbus - uri: {{ .host }}:502 + uri: {{ joinHostPort .host "502" }} id: 50 # Speicher register: address: 1 @@ -73,7 +73,7 @@ render: | scale: 0.1 limittemp: source: modbus - uri: {{ .host }}:502 + uri: {{ joinHostPort .host "502" }} id: 50 # Speicher register: address: 2 diff --git a/templates/definition/charger/lambda-zewotherm.yaml b/templates/definition/charger/lambda-zewotherm.yaml index eb3d7d5ab..7a9b23627 100644 --- a/templates/definition/charger/lambda-zewotherm.yaml +++ b/templates/definition/charger/lambda-zewotherm.yaml @@ -58,7 +58,7 @@ render: | initial: 0 set: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: 102 # PV Überschussleistung @@ -67,7 +67,7 @@ render: | scale: {{ if eq .excess "plus" }}1{{ else }}-1{{ end }} power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: 103 # aktuelle Aufnahmeleistung [E] der WP @@ -75,7 +75,7 @@ render: | decode: int16 energy: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: 1020 # kumulierter Stromverbrauch (Energieaufnahme) [E / Wh] seit dem letzten Reset @@ -85,7 +85,7 @@ render: | {{- if .tempsource }} temp: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: {{ if eq .tempsource "warmwater_top" -}} 2002 {{ else if eq .tempsource "warmwater_bottom" -}} 2003 {{ else if eq .tempsource "buffer_top" -}} 3002 {{ else }} 3003 {{- end }} # 2002 Trinkwasser Oben, 2003 Trinkwasser Unten, 3002 Wärmespeicher Oben, 3003 Wärmespeicher Unten @@ -94,7 +94,7 @@ render: | scale: 0.1 limittemp: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: {{ if eq .tempsource "warmwater_top" -}} 2050 {{ else if eq .tempsource "warmwater_bottom" -}} 2050 {{ else if eq .tempsource "buffer_top" -}} 3050 {{ else }} 3050 {{- end }} # 2050 Trinkwasser Speicher, 3050 Wärmespeicher diff --git a/templates/definition/charger/ochsner-bwwp.yaml b/templates/definition/charger/ochsner-bwwp.yaml index cd0126dc6..c1b8c3621 100644 --- a/templates/definition/charger/ochsner-bwwp.yaml +++ b/templates/definition/charger/ochsner-bwwp.yaml @@ -20,7 +20,7 @@ render: | source: delta # nur Differenz zu der von WP berechneten Überschussleistung (Reg. 2012) get: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 2012 # totale von der WP berechnete Überschussleistung Auflösung 1 W @@ -28,7 +28,7 @@ render: | decode: int16 set: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 2201 # SUR Überschussleistung Auflösung 1 W @@ -36,7 +36,7 @@ render: | decode: int16 temp: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 2000 # angezeigte Temperatur Auflösung 0,1°C @@ -45,7 +45,7 @@ render: | scale: 0.1 limittemp: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 2203 # Legionellen/Überschuss Solltemperatur Auflösung 0,1°C diff --git a/templates/definition/charger/wallbe-meter.yaml b/templates/definition/charger/wallbe-meter.yaml index 6561fc936..292bb5667 100644 --- a/templates/definition/charger/wallbe-meter.yaml +++ b/templates/definition/charger/wallbe-meter.yaml @@ -19,7 +19,7 @@ params: default: 502 render: | type: wallbe - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} meter: # only if a charge meter is connected to the controller power: true energy: true diff --git a/templates/definition/charger/wallbe-pre2019-meter.yaml b/templates/definition/charger/wallbe-pre2019-meter.yaml index 4f9694bc2..db7ddfaac 100644 --- a/templates/definition/charger/wallbe-pre2019-meter.yaml +++ b/templates/definition/charger/wallbe-pre2019-meter.yaml @@ -19,7 +19,7 @@ params: default: 502 render: | type: wallbe - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} legacy: true # set only for older Wallbe devices (pre ~2019, old controller firmware) meter: # only if a charge meter is connected to the controller power: true diff --git a/templates/definition/charger/wallbe-pre2019.yaml b/templates/definition/charger/wallbe-pre2019.yaml index 872731861..4004b3e68 100644 --- a/templates/definition/charger/wallbe-pre2019.yaml +++ b/templates/definition/charger/wallbe-pre2019.yaml @@ -19,5 +19,5 @@ params: default: 502 render: | type: wallbe - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} legacy: true # set only for older Wallbe devices (pre ~2019, old controller firmware) diff --git a/templates/definition/charger/wallbe.yaml b/templates/definition/charger/wallbe.yaml index 7888d8e88..5bd293daa 100644 --- a/templates/definition/charger/wallbe.yaml +++ b/templates/definition/charger/wallbe.yaml @@ -17,4 +17,4 @@ params: default: 502 render: | type: wallbe - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} diff --git a/templates/definition/charger/webasto-next.yaml b/templates/definition/charger/webasto-next.yaml index df7e64e9e..fc8fecbf4 100644 --- a/templates/definition/charger/webasto-next.yaml +++ b/templates/definition/charger/webasto-next.yaml @@ -18,4 +18,4 @@ params: default: 502 render: | type: webasto-next - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} diff --git a/templates/definition/charger/weidmüller.yaml b/templates/definition/charger/weidmüller.yaml index e2e9d84c4..64ebad857 100644 --- a/templates/definition/charger/weidmüller.yaml +++ b/templates/definition/charger/weidmüller.yaml @@ -10,4 +10,4 @@ params: - name: host render: | type: weidmüller - uri: {{ .host }}:502 + uri: {{ joinHostPort .host "502" }} diff --git a/templates/definition/meter/dsmr.yaml b/templates/definition/meter/dsmr.yaml index ff60c547b..d3803b656 100644 --- a/templates/definition/meter/dsmr.yaml +++ b/templates/definition/meter/dsmr.yaml @@ -18,7 +18,7 @@ params: type: string render: | type: dsmr - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} {{- if .energy }} energy: {{ .energy }} {{- end }} diff --git a/templates/definition/meter/e3dc-modbus.yaml b/templates/definition/meter/e3dc-modbus.yaml index dbf566017..189361cea 100644 --- a/templates/definition/meter/e3dc-modbus.yaml +++ b/templates/definition/meter/e3dc-modbus.yaml @@ -14,7 +14,7 @@ render: | {{- if eq .usage "grid" }} power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: type: holding decode: int32s @@ -25,13 +25,13 @@ render: | source: calc add: - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: type: holding decode: int32s address: 40067 # Photovoltaikleistung in Watt - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: type: holding decode: int32s @@ -41,7 +41,7 @@ render: | {{- if eq .usage "battery" }} power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: type: holding decode: int32s @@ -49,7 +49,7 @@ render: | scale: -1 # reverse direction soc: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 40082 # Batterie-SOC in % type: holding diff --git a/templates/definition/meter/e3dc-rscp.yaml b/templates/definition/meter/e3dc-rscp.yaml index 8163716cc..1c242eec2 100644 --- a/templates/definition/meter/e3dc-rscp.yaml +++ b/templates/definition/meter/e3dc-rscp.yaml @@ -67,7 +67,7 @@ params: render: | type: e3dc-rscp usage: {{ .usage }} - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} user: {{ .user }} password: {{ .password }} key: {{ .key }} diff --git a/templates/definition/meter/fronius-gen24.yaml b/templates/definition/meter/fronius-gen24.yaml index 6a97859c1..1bec978f0 100644 --- a/templates/definition/meter/fronius-gen24.yaml +++ b/templates/definition/meter/fronius-gen24.yaml @@ -40,7 +40,7 @@ render: | {{- if eq .usage "grid" }} power: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:W @@ -49,7 +49,7 @@ render: | - 213:W energy: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:TotWhImp @@ -59,7 +59,7 @@ render: | scale: 0.001 currents: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphA @@ -67,7 +67,7 @@ render: | - 203:AphA - 213:AphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphB @@ -75,7 +75,7 @@ render: | - 203:AphB - 213:AphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphC @@ -84,7 +84,7 @@ render: | - 213:AphC voltages: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphA @@ -92,7 +92,7 @@ render: | - 203:PhVphA - 213:PhVphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphB @@ -100,7 +100,7 @@ render: | - 203:PhVphB - 213:PhVphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphC @@ -109,7 +109,7 @@ render: | - 213:PhVphC powers: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphA @@ -117,7 +117,7 @@ render: | - 203:WphA - 213:WphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphB @@ -125,7 +125,7 @@ render: | - 203:WphB - 213:WphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphC @@ -138,23 +138,23 @@ render: | source: calc add: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:1:DCW # mppt 1 - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:2:DCW # mppt 2 energy: source: calc add: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:1:DCWH # mppt 1 scale: 0.001 - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:2:DCWH # mppt 2 scale: 0.001 @@ -165,23 +165,23 @@ render: | source: calc add: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:3:DCW # mppt 3 charge scale: -1 - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:4:DCW # mppt 4 discharge energy: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:4:DCWH # mppt 4 (discharge) scale: 0.001 soc: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:ChaState batterymode: # model 124 @@ -195,14 +195,14 @@ render: | value: 0 set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:StorCtl_Mod - source: const value: 100 # % set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:OutWRte - case: 2 # hold @@ -213,14 +213,14 @@ render: | value: 2 set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:StorCtl_Mod - source: const value: 0 # % set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:OutWRte - case: 3 # charge @@ -231,21 +231,21 @@ render: | value: 1 # off set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:ChaGriSet - source: const value: 2 set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:StorCtl_Mod - source: const value: {{ if .maxchargerate }}-{{ .maxchargerate }}{{ end }} # % set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:OutWRte {{- include "battery-params" . }} diff --git a/templates/definition/meter/fronius-vertoplus.yaml b/templates/definition/meter/fronius-vertoplus.yaml index 77cca6b3e..b85653940 100644 --- a/templates/definition/meter/fronius-vertoplus.yaml +++ b/templates/definition/meter/fronius-vertoplus.yaml @@ -27,7 +27,7 @@ render: | {{- if eq .usage "grid" }} power: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:W @@ -36,7 +36,7 @@ render: | - 213:W energy: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:TotWhImp @@ -46,7 +46,7 @@ render: | scale: 0.001 currents: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphA @@ -54,7 +54,7 @@ render: | - 203:AphA - 213:AphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphB @@ -62,7 +62,7 @@ render: | - 203:AphB - 213:AphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphC @@ -71,7 +71,7 @@ render: | - 213:AphC voltages: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphA @@ -79,7 +79,7 @@ render: | - 203:PhVphA - 213:PhVphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphB @@ -87,7 +87,7 @@ render: | - 203:PhVphB - 213:PhVphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphC @@ -96,7 +96,7 @@ render: | - 213:PhVphC powers: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphA @@ -104,7 +104,7 @@ render: | - 203:WphA - 213:WphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphB @@ -112,7 +112,7 @@ render: | - 203:WphB - 213:WphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphC @@ -125,32 +125,32 @@ render: | source: calc add: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:1:DCW # mppt 1 - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:2:DCW # mppt 2 - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:3:DCW # mppt 3 energy: source: calc add: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:1:DCWH # mppt 1 scale: 0.001 - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:2:DCWH # mppt 2 scale: 0.001 - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:3:DCWH # mppt 3 scale: 0.001 @@ -161,23 +161,23 @@ render: | source: calc add: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:4:DCW # mppt 4 charge scale: -1 - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:5:DCW # mppt 5 discharge energy: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 160:5:DCWH # mppt 5 (discharge) scale: 0.001 soc: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:ChaState batterymode: # model 124 @@ -191,14 +191,14 @@ render: | value: 0 set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:StorCtl_Mod - source: const value: 100 # % set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:OutWRte - case: 2 # hold @@ -209,14 +209,14 @@ render: | value: 2 set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:StorCtl_Mod - source: const value: 0 # % set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:OutWRte - case: 3 # charge @@ -227,21 +227,21 @@ render: | value: 1 # off set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:ChaGriSet - source: const value: 2 set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:StorCtl_Mod - source: const value: -{{ .maxchargerate }} # % set: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 value: 124:0:OutWRte {{- include "battery-params" . }} diff --git a/templates/definition/meter/hager-flow-modbus.yaml b/templates/definition/meter/hager-flow-modbus.yaml index 903613276..9b2989566 100644 --- a/templates/definition/meter/hager-flow-modbus.yaml +++ b/templates/definition/meter/hager-flow-modbus.yaml @@ -29,21 +29,21 @@ render: | currents: - source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 4099 # Netz Ampere L1 | Grid amps L1 type: holding decode: uint16 - source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 4100 # Netz Ampere L2 | Grid amps L2 type: holding decode: uint16 - source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 4101 # Netz Ampere L3 | Grid amps L3 type: holding @@ -51,7 +51,7 @@ render: | power: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: type: holding decode: int32 @@ -63,7 +63,7 @@ render: | add: - source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: type: holding decode: uint32 @@ -73,7 +73,7 @@ render: | power: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: type: holding decode: int32 @@ -82,7 +82,7 @@ render: | soc: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 4146 # Batterie-SOC in % type: holding diff --git a/templates/definition/meter/huawei-emma.yaml b/templates/definition/meter/huawei-emma.yaml index b4374ee93..9feef6c89 100644 --- a/templates/definition/meter/huawei-emma.yaml +++ b/templates/definition/meter/huawei-emma.yaml @@ -15,7 +15,7 @@ render: | power: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 31657 # Active power of built-in electric energy sensor type: holding @@ -23,7 +23,7 @@ render: | energy: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 31679 # Total negative active energy of built-in electric energy sensor type: holding @@ -32,7 +32,7 @@ render: | currents: - source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 31651 # Huawei phase A grid current type: holding @@ -40,7 +40,7 @@ render: | scale: 0.1 - source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 31653 # Huawei phase B grid current type: holding @@ -48,7 +48,7 @@ render: | scale: 0.1 - source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 31655 # Huawei phase C grid current type: holding @@ -59,7 +59,7 @@ render: | power: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 30354 # Active power type: holding @@ -67,7 +67,7 @@ render: | energy: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 30344 # E-Total type: holding @@ -78,7 +78,7 @@ render: | power: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 30360 type: holding @@ -87,7 +87,7 @@ render: | energy: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 30312 # Total discharge type: holding @@ -96,7 +96,7 @@ render: | soc: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 30368 type: holding diff --git a/templates/definition/meter/huawei-smartlogger.yaml b/templates/definition/meter/huawei-smartlogger.yaml index 40001ae51..e18e2f7d2 100644 --- a/templates/definition/meter/huawei-smartlogger.yaml +++ b/templates/definition/meter/huawei-smartlogger.yaml @@ -61,7 +61,7 @@ render: | power: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 40521 # Active power type: holding @@ -69,7 +69,7 @@ render: | energy: source: modbus id: 0 - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} register: address: 40560 # E-Total type: holding diff --git a/templates/definition/meter/rct-power.yaml b/templates/definition/meter/rct-power.yaml index dcf6405df..c53b05b1b 100644 --- a/templates/definition/meter/rct-power.yaml +++ b/templates/definition/meter/rct-power.yaml @@ -41,7 +41,7 @@ params: default: 30s render: | type: rct - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} usage: {{ .usage }} cache: {{ .cache }} {{- include "battery-params" . }} diff --git a/templates/definition/meter/sigenergy.yaml b/templates/definition/meter/sigenergy.yaml index bfa52d1eb..cfa5441f2 100644 --- a/templates/definition/meter/sigenergy.yaml +++ b/templates/definition/meter/sigenergy.yaml @@ -29,7 +29,7 @@ render: | {{- if eq .usage "grid" }} power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 247 register: address: 30005 # [Grid sensor] Active power @@ -37,7 +37,7 @@ render: | decode: int32 energy: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 30562 # Accumulated import energy @@ -46,7 +46,7 @@ render: | scale: 0.01 currents: - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 31017 # Phase A current @@ -54,7 +54,7 @@ render: | decode: int32 scale: 0.01 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 31019 # Phase B current @@ -62,7 +62,7 @@ render: | decode: int32 scale: 0.01 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 31021 # Phase C current @@ -73,7 +73,7 @@ render: | {{- if eq .usage "pv" }} power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 247 register: type: holding @@ -84,7 +84,7 @@ render: | {{- if eq .usage "battery" }} power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 247 register: type: holding @@ -93,7 +93,7 @@ render: | scale: -1 energy: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} register: address: 30574 # Battery accumulated discharge energy @@ -102,7 +102,7 @@ render: | scale: 0.01 soc: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 247 register: address: 30014 # Energy storage system SOC @@ -114,7 +114,7 @@ render: | convert: float2int set: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 247 register: address: 40048 # Discharge Cut-Off SoC diff --git a/templates/definition/meter/solarlog.yaml b/templates/definition/meter/solarlog.yaml index cae9462f6..1093a0258 100644 --- a/templates/definition/meter/solarlog.yaml +++ b/templates/definition/meter/solarlog.yaml @@ -23,7 +23,7 @@ render: | source: calc add: - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: 3502 # Pac @@ -31,7 +31,7 @@ render: | decode: uint32s scale: -1 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: 3518 # Pac consumption @@ -41,7 +41,7 @@ render: | {{- if eq .usage "pv" }} power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: 3502 # Pac @@ -49,7 +49,7 @@ render: | decode: uint32s energy: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 1 register: address: 3516 # total yield diff --git a/templates/definition/meter/sunspec-meter.yaml b/templates/definition/meter/sunspec-meter.yaml index 0cc7fac26..f3af86bb5 100644 --- a/templates/definition/meter/sunspec-meter.yaml +++ b/templates/definition/meter/sunspec-meter.yaml @@ -18,7 +18,7 @@ render: | {{- if or (eq .usage "grid") (eq .usage "charge") }} power: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:W @@ -29,7 +29,7 @@ render: | - 213:W energy: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:TotWhImp @@ -41,7 +41,7 @@ render: | scale: 0.001 currents: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphA @@ -51,7 +51,7 @@ render: | - 203:AphA - 213:AphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphB @@ -61,7 +61,7 @@ render: | - 203:AphB - 213:AphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:AphC @@ -72,7 +72,7 @@ render: | - 213:AphC voltages: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphA @@ -82,7 +82,7 @@ render: | - 203:PhVphA - 213:PhVphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphB @@ -92,7 +92,7 @@ render: | - 203:PhVphB - 213:PhVphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:PhVphC @@ -103,7 +103,7 @@ render: | - 213:PhVphC powers: - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphA @@ -113,7 +113,7 @@ render: | - 203:WphA - 213:WphA - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphB @@ -123,7 +123,7 @@ render: | - 203:WphB - 213:WphB - source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:WphC @@ -136,7 +136,7 @@ render: | {{- if eq .usage "pv" }} power: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:W @@ -147,7 +147,7 @@ render: | - 213:W energy: source: sunspec - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .id }} value: - 201:TotWhExp diff --git a/templates/definition/meter/varta.yaml b/templates/definition/meter/varta.yaml index 420cc2a6f..c010a49f7 100644 --- a/templates/definition/meter/varta.yaml +++ b/templates/definition/meter/varta.yaml @@ -35,7 +35,7 @@ render: | type: custom power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} {{- if eq .usage "grid" }} id: 255 register: @@ -60,7 +60,7 @@ render: | scale: -1 soc: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 255 register: address: 1068 # SOC @@ -81,7 +81,7 @@ render: | value: -{{- .maxdischargepower }} # value must be negative set: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 255 register: address: 1074 # max discharge power @@ -95,7 +95,7 @@ render: | value: 0 set: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 255 register: address: 1074 # max discharge power diff --git a/templates/definition/meter/victron-energy.yaml b/templates/definition/meter/victron-energy.yaml index 5142acb4a..4234c1be0 100644 --- a/templates/definition/meter/victron-energy.yaml +++ b/templates/definition/meter/victron-energy.yaml @@ -32,21 +32,21 @@ render: | source: calc add: - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 820 # L1 grid power type: input decode: int16 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 821 # L2 grid power type: input decode: int16 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 822 # L3 grid power @@ -55,7 +55,7 @@ render: | {{- if .meterid }} currents: - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .meterid }} register: address: 2617 # L1 grid current @@ -63,7 +63,7 @@ render: | decode: int16 scale: 0.1 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .meterid }} register: address: 2619 # L2 grid current @@ -71,7 +71,7 @@ render: | decode: int16 scale: 0.1 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: {{ .meterid }} register: address: 2621 # L3 grid current @@ -85,49 +85,49 @@ render: | source: calc add: - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 808 # ACout pv power L1 type: input decode: uint16 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 809 # ACout pv power L2 type: input decode: uint16 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 810 # ACout pv power L3 type: input decode: uint16 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 811 # ACin pv power L1 type: input decode: uint16 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 812 # ACin pv power L2 type: input decode: uint16 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 813 # ACin pv power L3 type: input decode: uint16 - source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 850 # DC pv power @@ -138,7 +138,7 @@ render: | {{- if eq .usage "battery" }} power: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 842 # active DC power @@ -147,7 +147,7 @@ render: | scale: -1 soc: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 843 # Soc @@ -158,7 +158,7 @@ render: | convert: float2int set: source: modbus - uri: {{ .host }}:{{ .port }} + uri: {{ joinHostPort .host .port }} id: 100 # com.victronenergy.system register: address: 2901 # limit soc diff --git a/util/templates/includes/mqtt.tpl b/util/templates/includes/mqtt.tpl index 4200a544b..f4472655c 100644 --- a/util/templates/includes/mqtt.tpl +++ b/util/templates/includes/mqtt.tpl @@ -1,5 +1,5 @@ {{ define "mqtt" }} -broker: {{ .host }}:{{ .port }} +broker: {{ joinHostPort .host .port }} {{- if .user }} user: {{ .user }} {{- end }} diff --git a/util/templates/modbus.tpl b/util/templates/modbus.tpl index 5ce1a4eb2..b46136e26 100644 --- a/util/templates/modbus.tpl +++ b/util/templates/modbus.tpl @@ -7,15 +7,15 @@ baudrate: {{ .baudrate }} comset: {{ .comset }} {{- else if or (eq .modbus "rs485tcpip") .rs485tcpip }} # RS485 via TCP/IP (Modbus RTU) -uri: {{ .host }}:{{ .port }} +uri: {{ joinHostPort .host .port }} rtu: true {{- else if or (eq .modbus "tcpip") .tcpip }} # Modbus TCP -uri: {{ .host }}:{{ .port }} +uri: {{ joinHostPort .host .port }} rtu: false {{- else if or (eq .modbus "udp") .udp }} # Modbus UDP -uri: {{ .host }}:{{ if (ne .port "502") }}{{ .port }}{{ else }}8899{{ end }} +uri: {{ joinHostPort .host .port }} udp: true rtu: true {{- else }} diff --git a/util/templates/utils.go b/util/templates/utils.go index 8aa5a5fbe..429fdfc4b 100644 --- a/util/templates/utils.go +++ b/util/templates/utils.go @@ -3,6 +3,7 @@ package templates import ( "bytes" "fmt" + "net" "net/url" "strings" "text/template" @@ -70,6 +71,13 @@ func FuncMap(tmpl *template.Template) *template.Template { "timeRound": func(t time.Time, d time.Duration) time.Time { return t.Round(d) }, + "joinHostPort": func(host, port string) string { + res := net.JoinHostPort(host, port) + if strings.Contains(host, ":") { + return `"` + res + `"` + } + return res + }, "urlEncode": url.QueryEscape, "unquote": unquote, "quote": yamlQuote, From 807fe1a90309fcd45c8046b56895c51211207131 Mon Sep 17 00:00:00 2001 From: Filipe Alves Date: Tue, 7 Apr 2026 09:14:08 +0100 Subject: [PATCH 0004/1316] Add OMIE day-ahead tariff for Portugal and Spain (#28768) --- templates/definition/tariff/omie.yaml | 165 ++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 templates/definition/tariff/omie.yaml diff --git a/templates/definition/tariff/omie.yaml b/templates/definition/tariff/omie.yaml new file mode 100644 index 000000000..49e04f099 --- /dev/null +++ b/templates/definition/tariff/omie.yaml @@ -0,0 +1,165 @@ +template: omie +products: + - brand: OMIE + description: + en: Iberian day-ahead market prices + de: Iberische Day-Ahead-Marktpreise +requirements: + evcc: ["skiptest"] + description: + en: "Day-ahead electricity prices from OMIE for Portugal and Spain. No API key is required." + de: "Day-Ahead-Strompreise von OMIE fuer Portugal und Spanien. Es ist kein API-Schluessel erforderlich." +group: price +countries: ["PT", "ES"] +params: + - name: country + description: + en: Country + de: Land + help: + en: Select whether to use the Portuguese or Spanish market price published by OMIE. + de: Waehlt aus, ob der von OMIE veroeffentlichte portugiesische oder spanische Marktpreis verwendet werden soll. + type: choice + choice: ["PT", "ES"] + default: PT + required: true + - preset: tariff-base + - preset: tariff-features +render: | + type: custom + {{ include "tariff-base" . }} + {{ include "tariff-features" . }} + forecast: + source: http + uri: https://www.omie.es/sites/default/files/dados/NUEVA_SECCION/INT_PBC_EV_H_ACUM.TXT + cache: 1h + regex: '(?ms)((?:^[0-9]{2}/[0-9]{2}/[0-9]{4};[^\r\n]*(?:\r?\n|$))+)' + default: "" + quote: true + jq: | + def market: + {{ if eq .country "PT" }} + { code: "PT", priceIndex: 3 } + {{ else }} + { code: "ES", priceIndex: 2 } + {{ end }}; + + def quarterHourSeconds: 900; + def dstStartMonth: 3; + def dstEndMonth: 10; + def standardOffsetSeconds: 3600; + def daylightOffsetSeconds: 7200; + def shortDayPeriodCount: 92; + def normalDayPeriodCount: 96; + def longDayPeriodCount: 100; + def minimumRequiredColumnCount: market.priceIndex + 1; + + def pad2: + tostring | if length == 1 then "0" + . else . end; + + def isoDate($y; $m; $d): + "\($y)-\($m | pad2)-\($d | pad2)"; + + def lastSunday($y; $m): + first([31, 30, 29, 28, 27, 26, 25][] | select(((isoDate($y; $m; .) + "T00:00:00Z") | fromdateiso8601 | gmtime | .[6]) == 0)); + + def daylightSavingSwitchDay($y; $m): + lastSunday($y; $m); + + def isStandardOffsetMonth($m): + $m < dstStartMonth or $m > dstEndMonth; + + def isDaylightOffsetMonth($m): + $m > dstStartMonth and $m < dstEndMonth; + + def marketStartOffsetSeconds($y; $m; $d): + if isStandardOffsetMonth($m) then + standardOffsetSeconds + elif isDaylightOffsetMonth($m) then + daylightOffsetSeconds + elif $m == dstStartMonth then + if $d <= daylightSavingSwitchDay($y; dstStartMonth) then standardOffsetSeconds else daylightOffsetSeconds end + else + if $d <= daylightSavingSwitchDay($y; dstEndMonth) then daylightOffsetSeconds else standardOffsetSeconds end + end; + + def marketStartUtc($y; $m; $d): + ((isoDate($y; $m; $d) + "T00:00:00Z") | fromdateiso8601) - marketStartOffsetSeconds($y; $m; $d); + + def isQuarterHourDay($day): + ([ $day[].period ] | length) as $count + | select($count == shortDayPeriodCount or $count == normalDayPeriodCount or $count == longDayPeriodCount); + + def parseDateParts: + split("/") | map(tonumber) | { d: .[0], m: .[1], y: .[2] }; + + def cutoffUtc: + ((now / quarterHourSeconds) | floor) * quarterHourSeconds; + + split("\n") + | map(gsub("\r"; "")) + | map(select(length > 0)) as $lines + | if ($lines | length) == 0 then + error("OMIE data rows not found") + elif (($lines[0] | split(";") | length) < minimumRequiredColumnCount) then + error("unexpected OMIE row layout") + else + ($lines + | map(split(";")) + | map({ + date: .[0], + dateParts: (.[0] | parseDateParts), + period: (.[1] | tonumber), + value: (.[(market.priceIndex)] | gsub(","; ".") | tonumber / 1000) + } + | . + .dateParts + | del(.dateParts) + ) + ) as $entries + | if ($entries | length) == 0 then + error("no OMIE rows parsed") + else + $entries + end + | ($entries + | sort_by(.date, .period) + | group_by(.date) + | map(isQuarterHourDay(.)) + ) as $quarterHourDays + | if ($quarterHourDays | length) == 0 then + error("no OMIE quarter-hour days parsed") + else + $quarterHourDays + end + | ($quarterHourDays + | all( + ([.[].period] | sort) as $periods + | ($periods | length) as $count + | ($periods == [range(1; $count + 1)]) + ) + ) as $validPeriods + | if ($validPeriods | not) then + error("unexpected OMIE period sequence") + else + ($quarterHourDays + | add + | sort_by(.y, .m, .d, .period) + | map( + (marketStartUtc(.y; .m; .d) + ((.period - 1) * quarterHourSeconds)) as $start + | ($start + quarterHourSeconds) as $end + | select($end > cutoffUtc) + | { + start: ($start | todateiso8601), + end: ($end | todateiso8601), + value: .value + } + ) + ) + end + | if length == 0 then + error("no OMIE \(market.code) rates parsed") + else + . + end + | tostring + end From 8144589d346ba4486b0156a7d442780ddf39033f Mon Sep 17 00:00:00 2001 From: tukutt Date: Tue, 7 Apr 2026 10:33:32 +0200 Subject: [PATCH 0005/1316] Add Lektrico charger (#28195) --- charger/lektrico.go | 290 +++++++++++++++++++++ templates/definition/charger/lektrico.yaml | 14 + 2 files changed, 304 insertions(+) create mode 100644 charger/lektrico.go create mode 100644 templates/definition/charger/lektrico.yaml diff --git a/charger/lektrico.go b/charger/lektrico.go new file mode 100644 index 000000000..84878c7e0 --- /dev/null +++ b/charger/lektrico.go @@ -0,0 +1,290 @@ +package charger + +// LICENSE + +// Copyright (c) evcc.io (andig, naltatis, premultiply) + +// This module is NOT covered by the MIT license. All rights reserved. + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// https://github.com/Lektrico/lektricowifi +// +// Lektrico charger protocol (discovered from lektricowifi source + real device testing): +// +// GET http:///rpc/ -> direct JSON response +// POST http:///rpc -> JSON-RPC body, response wrapped in "result" field +// +// Main endpoint: charger_info.get returns all data in a single request. +// +// Example response from charger_info.get: +// { +// "charger_state": "B", // raw IEC state: A/B/C/D/E/F/B_AUTH/B_PAUSE/OTA/LOCKED +// "extended_charger_state": "B_AUTH", +// "session_energy": 38.48, // Wh +// "instant_power": 0.0, // W +// "currents": [0.0, 0.0, 0.0], // A, array [L1, L2, L3] +// "voltages": [237.65, 0.0, 0.0], // V, array [L1, L2, L3] +// "total_charged_energy": 9683.844, // kWh +// "dynamic_current": 32, // allowed current (0=pause, 6-32=active) +// "relay_mode": 0, // phase mode +// "has_active_errors": false, +// "charger_is_paused": false, +// "current_limit_reason": 2, // int: 0=no_limit, 1=installation_current, 2=user_limit, +// // 3=dynamic_limit, 4=schedule, 5=em_offline, 6=em, +// // 7=ocpp, 8=overtemperature, 9=switching_phases, +// // 10=user_limit, 11=1p_charging_disabled, 12+=unknown +// "temperature": 18.8, +// "fw_version": "1.51", +// "headless": true, // true = no authentication required +// "install_current": 32, +// } + +import ( + "fmt" + "net/http" + "strings" + "sync/atomic" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/sponsor" +) + +// lektricoStateBAUTH is the extended state indicating waiting for RFID authentication +const lektricoStateBAUTH = "B_AUTH" + +// lektricoInfo maps the JSON response from charger_info.get +type lektricoInfo struct { + ChargerState string `json:"charger_state"` + ExtendedChargerState string `json:"extended_charger_state"` + HasActiveErrors bool `json:"has_active_errors"` + InstantPower float64 `json:"instant_power"` + SessionEnergy float64 `json:"session_energy"` + TotalChargedEnergy float64 `json:"total_charged_energy"` + Currents []float64 `json:"currents"` + Voltages []float64 `json:"voltages"` + DynamicCurrent int `json:"dynamic_current"` + FwVersion string `json:"fw_version"` +} + +// lektricoRPCRequest is the POST JSON-RPC request format +type lektricoRPCRequest struct { + Src string `json:"src"` + ID int `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params,omitempty"` +} + +// lektricoRPCResponse wraps the POST response +type lektricoRPCResponse struct { + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// Lektrico implements api.Charger for Lektrico 1P7K / 3P22K charging stations +type Lektrico struct { + *request.Helper + rpcID atomic.Uint32 + uri string + current int64 + statusG util.Cacheable[lektricoInfo] +} + +var _ api.Charger = (*Lektrico)(nil) + +func init() { + registry.Add("lektrico", NewLektricoFromConfig) +} + +// NewLektricoFromConfig creates a Lektrico charger from evcc configuration +func NewLektricoFromConfig(other map[string]any) (api.Charger, error) { + cc := struct { + Host string + Cache time.Duration + }{ + Cache: time.Second, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if cc.Host == "" { + return nil, fmt.Errorf("missing host") + } + + if !sponsor.IsAuthorized() { + return nil, api.ErrSponsorRequired + } + + return NewLektrico(cc.Host, cc.Cache) +} + +// NewLektrico creates a Lektrico charger and verifies connectivity +func NewLektrico(host string, cache time.Duration) (*Lektrico, error) { + uri := fmt.Sprintf("http://%s/rpc", strings.TrimSuffix(host, "/")) + + wb := &Lektrico{ + Helper: request.NewHelper(util.NewLogger("lektrico")), + uri: uri, + current: 6, + } + + wb.statusG = util.ResettableCached(func() (lektricoInfo, error) { + var res lektricoInfo + err := wb.GetJSON(uri+"/charger_info.get", &res) + return res, err + }, cache) + + return wb, nil +} + +// post sends a JSON-RPC command to the charger +func (wb *Lektrico) post(method string, params map[string]any) error { + payload := lektricoRPCRequest{ + Src: "evcc", + ID: int(wb.rpcID.Add(1)), + Method: method, + Params: params, + } + + req, _ := request.New(http.MethodPost, wb.uri, request.MarshalJSON(payload), request.JSONEncoding) + + var res lektricoRPCResponse + if err := wb.DoJSON(req, &res); err != nil { + return err + } + if res.Error != nil { + return fmt.Errorf("%d: %s", res.Error.Code, res.Error.Message) + } + + wb.statusG.Reset() + return nil +} + +// Status implements the api.Charger interface +func (wb *Lektrico) Status() (api.ChargeStatus, error) { + res, err := wb.statusG.Get() + if err != nil { + return api.StatusNone, err + } + + switch res.ChargerState { + case "A": + return api.StatusA, nil + case "B": + return api.StatusB, nil + case "C": + return api.StatusC, nil + default: + return api.StatusNone, fmt.Errorf("invalid state: %s", res.ChargerState) + } +} + +var _ api.StatusReasoner = (*Lektrico)(nil) + +// StatusReason implements the api.StatusReasoner interface +func (wb *Lektrico) StatusReason() (api.Reason, error) { + res, err := wb.statusG.Get() + if err == nil && res.ExtendedChargerState == lektricoStateBAUTH { + return api.ReasonWaitingForAuthorization, nil + } + + return api.ReasonUnknown, err +} + +// Enabled implements the api.Charger interface +func (wb *Lektrico) Enabled() (bool, error) { + res, err := wb.statusG.Get() + if err != nil { + return false, err + } + + return res.DynamicCurrent > 0, nil +} + +// sendCurrent sets the dynamic_current on the charger. +func (wb *Lektrico) setCurrent(value int64) error { + return wb.post("dynamic_current.set", map[string]any{ + "dynamic_current": value, + }) +} + +// Enable implements the api.Charger interface +func (wb *Lektrico) Enable(enable bool) error { + var curr int64 + if enable { + curr = wb.current + } + + return wb.setCurrent(curr) +} + +// MaxCurrent implements the api.Charger interface +func (wb *Lektrico) MaxCurrent(current int64) error { + err := wb.setCurrent(current) + if err == nil { + wb.current = current + } + return err +} + +var _ api.Meter = (*Lektrico)(nil) + +// CurrentPower implements the api.Meter interface +func (wb *Lektrico) CurrentPower() (float64, error) { + res, err := wb.statusG.Get() + return res.InstantPower, err +} + +var _ api.MeterEnergy = (*Lektrico)(nil) + +// TotalEnergy implements the api.MeterEnergy interface +func (wb *Lektrico) TotalEnergy() (float64, error) { + res, err := wb.statusG.Get() + return res.TotalChargedEnergy, err +} + +var _ api.ChargeRater = (*Lektrico)(nil) + +// ChargedEnergy implements the api.ChargeRater interface +func (wb *Lektrico) ChargedEnergy() (float64, error) { + res, err := wb.statusG.Get() + return res.SessionEnergy / 1000.0, err +} + +var _ api.PhaseCurrents = (*Lektrico)(nil) + +// Currents implements the api.PhaseCurrents interface +func (wb *Lektrico) Currents() (float64, float64, float64, error) { + res, err := wb.statusG.Get() + if err != nil { + return 0, 0, 0, err + } + return res.Currents[0], res.Currents[1], res.Currents[2], nil +} + +var _ api.PhaseVoltages = (*Lektrico)(nil) + +// Voltages implements the api.PhaseVoltages interface +func (wb *Lektrico) Voltages() (float64, float64, float64, error) { + res, err := wb.statusG.Get() + if err != nil { + return 0, 0, 0, err + } + return res.Voltages[0], res.Voltages[1], res.Voltages[2], nil +} diff --git a/templates/definition/charger/lektrico.yaml b/templates/definition/charger/lektrico.yaml new file mode 100644 index 000000000..d02da441a --- /dev/null +++ b/templates/definition/charger/lektrico.yaml @@ -0,0 +1,14 @@ +template: lektrico +products: + - brand: Lektrico + description: + generic: 1P7K / 3P22K Charging Station +requirements: + evcc: ["sponsorship"] +params: + - name: host + - name: cache +render: | + type: lektrico + host: {{ .host }} + cache: {{ .cache }} From 690873570feac796b443cbe2bfde815f41771544 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 7 Apr 2026 12:17:19 +0200 Subject: [PATCH 0006/1316] chore: simplify --- templates/definition/meter/ecoflow-powerocean.yaml | 2 +- templates/definition/meter/ecoflow-stream.yaml | 2 +- templates/definition/meter/goodwe-wifi-es.yaml | 8 ++++---- templates/definition/meter/goodwe-wifi.yaml | 2 +- templates/definition/meter/sonnenbatterie_eco56.yaml | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/templates/definition/meter/ecoflow-powerocean.yaml b/templates/definition/meter/ecoflow-powerocean.yaml index d914bb6a6..1fa102169 100644 --- a/templates/definition/meter/ecoflow-powerocean.yaml +++ b/templates/definition/meter/ecoflow-powerocean.yaml @@ -56,7 +56,7 @@ render: | type: ecoflow accesskey: {{ .accesskey }} secretkey: {{ .secretkey }} - serial: {{ if .serial }}{{ .serial }}{{ else }}{{ .deviceid }}{{ end }} + serial: {{ or .serial .deviceid }} usage: {{ .usage }} region: {{ .region }} cache: {{ .cache }} diff --git a/templates/definition/meter/ecoflow-stream.yaml b/templates/definition/meter/ecoflow-stream.yaml index f6056c30e..65b886318 100644 --- a/templates/definition/meter/ecoflow-stream.yaml +++ b/templates/definition/meter/ecoflow-stream.yaml @@ -57,7 +57,7 @@ render: | type: ecoflow accesskey: {{ .accesskey }} secretkey: {{ .secretkey }} - serial: {{ if .serial }}{{ .serial }}{{ else }}{{ .deviceid }}{{ end }} + serial: {{ or .serial .deviceid }} usage: {{ .usage }} cache: {{ .cache }} region: {{ .region }} diff --git a/templates/definition/meter/goodwe-wifi-es.yaml b/templates/definition/meter/goodwe-wifi-es.yaml index 056b36a9c..e7eb69805 100644 --- a/templates/definition/meter/goodwe-wifi-es.yaml +++ b/templates/definition/meter/goodwe-wifi-es.yaml @@ -16,7 +16,7 @@ render: | {{- if eq .usage "grid" }} power: source: aa55udp - host: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }} + host: {{ or .host .uri }} register: 29964 count: 2 decode: int32be @@ -24,7 +24,7 @@ render: | {{- if eq .usage "pv" }} power: source: aa55udp - host: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }} + host: {{ or .host .uri }} register: 29958 count: 2 decode: int32be @@ -32,13 +32,13 @@ render: | {{- if eq .usage "battery" }} power: source: aa55udp - host: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }} + host: {{ or .host .uri }} register: 29970 count: 2 decode: int32be soc: source: aa55udp - host: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }} + host: {{ or .host .uri }} register: 29966 count: 1 decode: uint16be diff --git a/templates/definition/meter/goodwe-wifi.yaml b/templates/definition/meter/goodwe-wifi.yaml index 3bd8eab9b..b2d4f0c66 100644 --- a/templates/definition/meter/goodwe-wifi.yaml +++ b/templates/definition/meter/goodwe-wifi.yaml @@ -16,4 +16,4 @@ params: render: | type: goodwe-wifi usage: {{ .usage }} - uri: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }} + uri: {{ or .host .uri }} diff --git a/templates/definition/meter/sonnenbatterie_eco56.yaml b/templates/definition/meter/sonnenbatterie_eco56.yaml index a3829cbfb..03033abd4 100644 --- a/templates/definition/meter/sonnenbatterie_eco56.yaml +++ b/templates/definition/meter/sonnenbatterie_eco56.yaml @@ -115,6 +115,6 @@ render: | - source: http method: PUT uri: http://{{ .host }}:{{ .port }}/rest/devices/battery/C24 - body: {{ if .maxchargepower }}{{ .maxchargepower }}{{ else }}99000{{ end }} + body: {{ or .maxchargepower 99000 }} {{- include "battery-params" . }} {{- end }} From ed3f44fe1925fde7b06fe70001ab6842db7287ac Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 7 Apr 2026 13:32:38 +0200 Subject: [PATCH 0007/1316] Ecoflow: add battery params (#28903) --- meter/ecoflow.go | 86 ++++++++----------- .../definition/meter/ecoflow-powerocean.yaml | 16 +++- .../definition/meter/ecoflow-stream.yaml | 16 +++- 3 files changed, 60 insertions(+), 58 deletions(-) diff --git a/meter/ecoflow.go b/meter/ecoflow.go index af468a265..acc014055 100644 --- a/meter/ecoflow.go +++ b/meter/ecoflow.go @@ -21,7 +21,7 @@ type EcoFlow struct { client *ecoflow.Client dataG func() (*ecoflow.GetCmdResponse, error) - gridPower, pvPower, batteryPower, batterySoc string + power, batterySoc string } func init() { @@ -31,10 +31,13 @@ func init() { // NewEcoFlowFromConfig creates an EcoFlow meter from generic config func NewEcoFlowFromConfig(ctx context.Context, other map[string]any) (api.Meter, error) { cc := struct { - Usage string - AccessKey, SecretKey, Serial, Region string - GridPower, PvPower, BatteryPower, BatterySoc string - Cache time.Duration + batteryCapacity `mapstructure:",squash"` + batteryPowerLimits `mapstructure:",squash"` + batterySocLimits `mapstructure:",squash"` + Usage string + AccessKey, SecretKey, Serial, Region string + Power, Soc string + Cache time.Duration }{ Cache: 30 * time.Second, } @@ -67,14 +70,16 @@ func NewEcoFlowFromConfig(ctx context.Context, other map[string]any) (api.Meter, return nil, fmt.Errorf("invalid region: %s", cc.Region) } - m, err := NewEcoFlow(ctx, cc.AccessKey, cc.SecretKey, cc.Serial, cc.Usage, uri, - cc.GridPower, cc.PvPower, cc.BatteryPower, cc.BatterySoc, cc.Cache) + m, err := NewEcoFlow(ctx, cc.AccessKey, cc.SecretKey, cc.Serial, cc.Usage, uri, cc.Power, cc.Soc, cc.Cache) if err != nil { return nil, err } if cc.Usage == "battery" { - return &EcoFlowBattery{m}, nil + return decorateMeterBattery( + m, nil, m.soc, cc.batteryCapacity.Decorator(), + cc.batterySocLimits.Decorator(), cc.batteryPowerLimits.Decorator(), nil, + ), nil } return m, nil @@ -82,17 +87,15 @@ func NewEcoFlowFromConfig(ctx context.Context, other map[string]any) (api.Meter, // NewEcoFlow constructs the EcoFlow struct func NewEcoFlow(ctx context.Context, accessKey, secretKey, serial, usage, uri string, - gridPower, pvPower, batteryPower, batterySoc string, cache time.Duration) (*EcoFlow, error) { + power, soc string, cache time.Duration) (*EcoFlow, error) { m := &EcoFlow{ - ctx: ctx, - serial: serial, - usage: usage, - cache: cache, - client: ecoflow.NewEcoflowClient(accessKey, secretKey, ecoflow.WithBaseUrl(uri)), - gridPower: gridPower, - pvPower: pvPower, - batteryPower: batteryPower, - batterySoc: batterySoc, + ctx: ctx, + serial: serial, + usage: usage, + cache: cache, + client: ecoflow.NewEcoflowClient(accessKey, secretKey, ecoflow.WithBaseUrl(uri)), + power: power, + batterySoc: soc, } m.dataG = util.Cached(m.getData, cache) @@ -105,14 +108,10 @@ func (m *EcoFlow) getData() (*ecoflow.GetCmdResponse, error) { ctx, cancel := context.WithTimeout(m.ctx, 30*time.Second) defer cancel() - var params []string - switch m.usage { - case "grid": - params = []string{m.gridPower} - case "pv": - params = []string{m.pvPower} - case "battery": - params = []string{m.batteryPower, m.batterySoc} + params := []string{m.power} + + if m.usage == "battery" { + params = append(params, m.batterySoc) } return m.client.GetDeviceParameters(ctx, m.serial, params) @@ -127,24 +126,16 @@ func (m *EcoFlow) CurrentPower() (float64, error) { return 0, err } - // sysGridPwr responds with int - // mpptPwr responds with an array of floats - // bpPwr responds with int - // bpSoc responds with int - switch m.usage { - case "grid": - return ecoflowValue(response.Data, m.gridPower) - case "pv": - return ecoflowValue(response.Data, m.pvPower) - case "battery": - pwr, err := ecoflowValue(response.Data, m.batteryPower) - if err != nil { - return 0, err - } - return -pwr, nil // invert battery power: ecoflow returns negative when discharging and positive when charging. - default: - return 0, fmt.Errorf("invalid usage: %s", m.usage) + pwr, err := ecoflowValue(response.Data, m.power) + if err != nil { + return 0, err } + + if m.usage == "battery" { + pwr = -pwr // invert battery power: ecoflow returns negative when discharging and positive when charging. + } + + return pwr, nil } // extractFloat extracts a float64 or int value from a map by key. @@ -157,13 +148,8 @@ func ecoflowValue(data map[string]any, key string) (float64, error) { return 0, api.ErrNotAvailable } -// EcoFlowBattery represents the EcoFlow battery decorator -type EcoFlowBattery struct { - *EcoFlow -} - -// Soc implements the api.Battery interface for battery usage -func (m *EcoFlowBattery) Soc() (float64, error) { +// soc returns the battery state of charge +func (m *EcoFlow) soc() (float64, error) { response, err := m.dataG() if err != nil { return 0, err diff --git a/templates/definition/meter/ecoflow-powerocean.yaml b/templates/definition/meter/ecoflow-powerocean.yaml index 1fa102169..5468e9f74 100644 --- a/templates/definition/meter/ecoflow-powerocean.yaml +++ b/templates/definition/meter/ecoflow-powerocean.yaml @@ -52,6 +52,7 @@ params: - name: cache default: 30s advanced: true + - preset: battery-params render: | type: ecoflow accesskey: {{ .accesskey }} @@ -60,7 +61,14 @@ render: | usage: {{ .usage }} region: {{ .region }} cache: {{ .cache }} - gridpower: sysGridPwr - pvpower: mpptPwr - batterypower: bpPwr - batterysoc: bpSoc + {{- if eq .usage "grid" }} + power: sysGridPwr + {{- end }} + {{- if eq .usage "pv" }} + power: mpptPwr + {{- end }} + {{- if eq .usage "battery" }} + power: bpPwr + soc: bpSoc + {{- include "battery-params" . }} + {{- end }} diff --git a/templates/definition/meter/ecoflow-stream.yaml b/templates/definition/meter/ecoflow-stream.yaml index 65b886318..95040b692 100644 --- a/templates/definition/meter/ecoflow-stream.yaml +++ b/templates/definition/meter/ecoflow-stream.yaml @@ -53,6 +53,7 @@ params: - name: cache default: 30s advanced: true + - preset: battery-params render: | type: ecoflow accesskey: {{ .accesskey }} @@ -61,7 +62,14 @@ render: | usage: {{ .usage }} cache: {{ .cache }} region: {{ .region }} - gridpower: powGetSysGrid - pvpower: powGetPvSum - batterypower: powGetBpCms - batterysoc: cmsBattSoc + {{- if eq .usage "grid" }} + power: powGetSysGrid + {{- end }} + {{- if eq .usage "pv" }} + power: powGetPvSum + {{- end }} + {{- if eq .usage "battery" }} + power: powGetBpCms + soc: cmsBattSoc + {{- include "battery-params" . }} + {{- end }} From c375354f08dded981e5694e94e5dbb953a7b61b5 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 7 Apr 2026 13:41:02 +0200 Subject: [PATCH 0008/1316] Goodwe-wifi: deprecate (#28898) --- templates/definition/meter/goodwe-wifi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/definition/meter/goodwe-wifi.yaml b/templates/definition/meter/goodwe-wifi.yaml index b2d4f0c66..73dd6b3b4 100644 --- a/templates/definition/meter/goodwe-wifi.yaml +++ b/templates/definition/meter/goodwe-wifi.yaml @@ -1,6 +1,6 @@ template: goodwe-wifi # UDP implementation is broken -# deprecated: true +deprecated: true products: - brand: GoodWe description: From e75b5e91a07f4b7ad477efd14e89141f5cdf2a23 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 7 Apr 2026 16:51:28 +0200 Subject: [PATCH 0009/1316] Log UI: progress indicator (#28914) --- .../components/MaterialIcon/ProgressRing.vue | 52 +++++++++++++++++++ assets/js/views/Log.vue | 25 +++------ 2 files changed, 60 insertions(+), 17 deletions(-) create mode 100644 assets/js/components/MaterialIcon/ProgressRing.vue diff --git a/assets/js/components/MaterialIcon/ProgressRing.vue b/assets/js/components/MaterialIcon/ProgressRing.vue new file mode 100644 index 000000000..0a53ce737 --- /dev/null +++ b/assets/js/components/MaterialIcon/ProgressRing.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/assets/js/views/Log.vue b/assets/js/views/Log.vue index f6ff1d02e..1c2a6556d 100644 --- a/assets/js/views/Log.vue +++ b/assets/js/views/Log.vue @@ -15,11 +15,11 @@ {{ $t("log.update") }} - @@ -110,7 +110,7 @@ import "@h2d2/shopicons/es/regular/download"; import Header from "../components/Top/Header.vue"; import Play from "../components/MaterialIcon/Play.vue"; -import Record from "../components/MaterialIcon/Record.vue"; +import ProgressRing from "../components/MaterialIcon/ProgressRing.vue"; import MultiSelect from "../components/Helper/MultiSelect.vue"; import api from "../api"; import store from "../store"; @@ -126,7 +126,7 @@ export default defineComponent({ components: { TopHeader: Header, Play, - Record, + ProgressRing, MultiSelect, }, props: { @@ -141,6 +141,7 @@ export default defineComponent({ timeout: null as Timeout, levels: LOG_LEVELS, busy: false, + tick: 0, }; }, head() { @@ -221,6 +222,7 @@ export default defineComponent({ // prevent concurrent requests if (this.busy) return; + this.tick++; try { this.busy = true; const response = await api.get("/system/log", { @@ -327,17 +329,6 @@ export default defineComponent({ .play { transform: scale(1.2); } -.spin { - animation: rotation 3s infinite ease-in-out; -} -@keyframes rotation { - from { - transform: rotate(0deg) scale(1, -1); - } - to { - transform: rotate(1440deg) scale(1, -1); - } -} @keyframes fadeIn { from { opacity: 0.3; From 1e15854687af51e3cc829544f5122f22c9049981 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 7 Apr 2026 18:25:55 +0200 Subject: [PATCH 0010/1316] chore: add status.evcc.io badge --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 31564c2c0..0c66d6742 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # evcc 🚘☀️ [![Build](https://github.com/evcc-io/evcc/actions/workflows/nightly.yml/badge.svg)](https://github.com/evcc-io/evcc/actions/workflows/nightly.yml) +[![Statuspage](https://img.shields.io/badge/status-evcc.io-green?color=brightgreen&link=https%3A%2F%2Fstatus.evcc.io)](https://status.evcc.io/) [![Translation](https://hosted.weblate.org/widgets/evcc/-/evcc/svg-badge.svg)](https://hosted.weblate.org/engage/evcc/) ![Docker Pulls](https://img.shields.io/docker/pulls/evcc/evcc) [![OSS hosting by cloudsmith](https://img.shields.io/badge/OSS%20hosting%20by-cloudsmith-blue?logo=cloudsmith)](https://cloudsmith.io/~evcc/packages/) -[![Latest Version](https://img.shields.io/github/release/evcc-io/evcc.svg)](https://github.com/evcc-io/evcc/releases)
+[![Latest Version](https://img.shields.io/github/release/evcc-io/evcc.svg)](https://github.com/evcc-io/evcc/releases) +
[![Built with Depot](https://depot.dev/badges/built-with-depot.svg)](https://depot.dev/?utm_source=evcc) evcc is an extensible EV Charge Controller and home energy management system. From cf9ea4ce907fc809cadc55e4c8b3ec304bc2b2be Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 7 Apr 2026 18:29:15 +0200 Subject: [PATCH 0011/1316] UI: prevent multiselect bottomnav overlap (#28901) --- assets/js/components/Helper/MultiSelect.vue | 73 +++++++++++++-------- assets/js/views/Log.vue | 1 + 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/assets/js/components/Helper/MultiSelect.vue b/assets/js/components/Helper/MultiSelect.vue index e59f0da0f..e47133b00 100644 --- a/assets/js/components/Helper/MultiSelect.vue +++ b/assets/js/components/Helper/MultiSelect.vue @@ -11,40 +11,46 @@ > - + + @@ -61,6 +67,7 @@ export default defineComponent({ value: { type: Array as PropType, default: () => [] }, options: { type: Array as PropType[]>, default: () => [] }, selectAllLabel: String, + isTopLevel: Boolean, }, emits: ["open", "update:modelValue"], data() { @@ -116,3 +123,15 @@ export default defineComponent({ }, }); + diff --git a/assets/js/views/Log.vue b/assets/js/views/Log.vue index 1c2a6556d..601be30af 100644 --- a/assets/js/views/Log.vue +++ b/assets/js/views/Log.vue @@ -60,6 +60,7 @@
Date: Tue, 7 Apr 2026 19:41:13 +0200 Subject: [PATCH 0012/1316] Revert "Goodwe-wifi: deprecate (#28898)" This reverts commit c375354f08dded981e5694e94e5dbb953a7b61b5. --- templates/definition/meter/goodwe-wifi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/definition/meter/goodwe-wifi.yaml b/templates/definition/meter/goodwe-wifi.yaml index 73dd6b3b4..b2d4f0c66 100644 --- a/templates/definition/meter/goodwe-wifi.yaml +++ b/templates/definition/meter/goodwe-wifi.yaml @@ -1,6 +1,6 @@ template: goodwe-wifi # UDP implementation is broken -deprecated: true +# deprecated: true products: - brand: GoodWe description: From f1408ad10fa5cfb7ac0e3a3438f96bf90b2b56f1 Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 8 Apr 2026 09:07:19 +0200 Subject: [PATCH 0013/1316] Fix chargeMeter capability lookup for decorated chargers (#28920) --- api/capable_test.go | 46 +++++++++++++++++++++++++++++++++++++++++++++ core/loadpoint.go | 17 ++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/api/capable_test.go b/api/capable_test.go index a365299ea..565dd596e 100644 --- a/api/capable_test.go +++ b/api/capable_test.go @@ -66,6 +66,52 @@ func TestCap_CapableRegistryLookup(t *testing.T) { assert.False(t, ok) } +// decoratedCharger simulates a real decorated charger where Meter is NOT +// directly embedded but only available through the capability registry. +type decoratedCharger struct { + caps map[reflect.Type]any +} + +func (d *decoratedCharger) Capability(typ reflect.Type) (any, bool) { + c, ok := d.caps[typ] + return c, ok +} + +func TestCap_ExtractedCapabilityLosesRegistry(t *testing.T) { + // Reproduces https://github.com/evcc-io/evcc/issues/28915 + // When a Meter is extracted from a decorated charger via Cap[Meter], + // the extracted impl does NOT carry the Capable interface, so + // subsequent Cap[MeterEnergy] on the extracted value fails. + decorated := &decoratedCharger{ + caps: map[reflect.Type]any{ + reflect.TypeFor[Meter](): &testMeterImpl{}, + reflect.TypeFor[MeterEnergy](): &testMeterEnergyImpl{}, + }, + } + + // extract Meter from decorated source (slow path: from caps registry) + mt, ok := Cap[Meter](decorated) + require.True(t, ok) + + // Bug: extracted meter cannot find MeterEnergy because it's a standalone impl + _, ok = Cap[MeterEnergy](mt) + assert.False(t, ok, "extracted meter should NOT have MeterEnergy capability") + + // Fix: wrapping extracted meter with source's Capable preserves registry + type capableMeter struct { + Meter + Capable + } + wrapped := &capableMeter{Meter: mt, Capable: decorated} + + me, ok := Cap[MeterEnergy](wrapped) + require.True(t, ok, "wrapped meter should find MeterEnergy via Capable") + + energy, err := me.TotalEnergy() + assert.NoError(t, err) + assert.Equal(t, 99.0, energy) +} + func TestCap_NilValue(t *testing.T) { _, ok := Cap[MeterEnergy](nil) assert.False(t, ok) diff --git a/core/loadpoint.go b/core/loadpoint.go index 00a51a489..4177e6a34 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -395,6 +395,14 @@ func (lp *Loadpoint) requestUpdate() { } } +// capableMeter wraps a meter with capability lookup from its source. +// This preserves capability checks (like MeterEnergy, PhaseCurrents, PhaseVoltages) when +// the meter was extracted from a decorated charger's capability registry. +type capableMeter struct { + api.Meter + api.Capable +} + // configureChargerType ensures that chargeMeter, Rate and Timer can use charger capabilities func (lp *Loadpoint) configureChargerType(charger api.Charger) { var integrated bool @@ -404,7 +412,14 @@ func (lp *Loadpoint) configureChargerType(charger api.Charger) { integrated = true if mt, ok := api.Cap[api.Meter](charger); ok { - lp.chargeMeter = mt + // preserve charger's capability registry so that subsequent + // capability checks on chargeMeter (e.g. MeterEnergy, PhaseCurrents) + // still work for decorated chargers (https://github.com/evcc-io/evcc/issues/28915) + if c, ok := charger.(api.Capable); ok { + lp.chargeMeter = &capableMeter{Meter: mt, Capable: c} + } else { + lp.chargeMeter = mt + } } else { mt := new(wrapper.ChargeMeter) _ = lp.bus.Subscribe(evChargeCurrent, lp.evChargeCurrentWrappedMeterHandler) From e20d76534493fef2d2c907d2612bc08cc930fb37 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 8 Apr 2026 09:08:29 +0200 Subject: [PATCH 0014/1316] Forecast UI: compacter, small fixes (#28917) --- assets/js/components/Forecast/Co2Chart.vue | 7 ------- assets/js/components/Forecast/PriceChart.vue | 11 ++--------- assets/js/components/Forecast/SolarChart.vue | 7 ------- assets/js/components/Forecast/chartMixin.ts | 7 +++++++ assets/js/components/Forecast/chartStyles.css | 7 +++++++ assets/js/components/Forecast/echarts.ts | 14 ++++++++------ assets/js/views/Forecast.vue | 9 ++++++--- 7 files changed, 30 insertions(+), 32 deletions(-) create mode 100644 assets/js/components/Forecast/chartStyles.css diff --git a/assets/js/components/Forecast/Co2Chart.vue b/assets/js/components/Forecast/Co2Chart.vue index 438098327..ea688deea 100644 --- a/assets/js/components/Forecast/Co2Chart.vue +++ b/assets/js/components/Forecast/Co2Chart.vue @@ -118,10 +118,3 @@ export default defineComponent({ }, }); - - diff --git a/assets/js/components/Forecast/PriceChart.vue b/assets/js/components/Forecast/PriceChart.vue index 14a99622e..00298fa81 100644 --- a/assets/js/components/Forecast/PriceChart.vue +++ b/assets/js/components/Forecast/PriceChart.vue @@ -131,8 +131,8 @@ export default defineComponent({ ? { opacity: 0.33 } : undefined, })), - barMaxWidth: 4, - barMinWidth: 4, + barMaxWidth: 2, + barMinWidth: 2, itemStyle: { color: priceColor, borderRadius: 2, @@ -164,10 +164,3 @@ export default defineComponent({ }, }); - - diff --git a/assets/js/components/Forecast/SolarChart.vue b/assets/js/components/Forecast/SolarChart.vue index bd13a76d9..528279bef 100644 --- a/assets/js/components/Forecast/SolarChart.vue +++ b/assets/js/components/Forecast/SolarChart.vue @@ -129,10 +129,3 @@ export default defineComponent({ }, }); - - diff --git a/assets/js/components/Forecast/chartMixin.ts b/assets/js/components/Forecast/chartMixin.ts index 4df452935..d8c9cecf3 100644 --- a/assets/js/components/Forecast/chartMixin.ts +++ b/assets/js/components/Forecast/chartMixin.ts @@ -1,5 +1,6 @@ import { defineComponent, markRaw } from "vue"; import { echarts } from "./echarts"; +import "./chartStyles.css"; // chartOption is provided by each consuming component's computed type WithChartOption = { chartOption: Record }; @@ -62,6 +63,12 @@ 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); }, }, }); diff --git a/assets/js/components/Forecast/chartStyles.css b/assets/js/components/Forecast/chartStyles.css new file mode 100644 index 000000000..5b9f3e23e --- /dev/null +++ b/assets/js/components/Forecast/chartStyles.css @@ -0,0 +1,7 @@ +.forecast-chart-scroll { + overflow-x: auto; + padding-bottom: 4px; + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; +} diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts index d8f61a122..d56556b54 100644 --- a/assets/js/components/Forecast/echarts.ts +++ b/assets/js/components/Forecast/echarts.ts @@ -51,7 +51,7 @@ export function markPointLabel( return { animation: true, - clip: false, + clip: true, symbol: "rect", symbolSize: 1, label: { @@ -76,11 +76,12 @@ export function tooltipStyle( getChart?: () => { convertToPixel: echarts.ECharts["convertToPixel"] } | null ) { return { + confine: true, backgroundColor: color, borderColor: color, borderWidth: 0, padding: [5, 10], - extraCssText: "box-shadow: none; border-radius: 4px; text-align: center;", + extraCssText: "box-shadow: none; border-radius: 4px; text-align: center; z-index: 1000;", position( point: [number, number], params: { value: [string, number] }[] | { value: [string, number] }, @@ -110,7 +111,7 @@ export function tooltipStyle( } export function forecastGrid() { - return { top: 36, right: 16, bottom: 4, left: 48, borderWidth: 0 }; + return { top: 36, right: 16, bottom: 4, left: 34, borderWidth: 0 }; } export function forecastXAxes(startDate: Date, endDate: Date, weekdayShort: (d: Date) => string) { @@ -129,7 +130,7 @@ export function forecastXAxes(startDate: Date, endDate: Date, weekdayShort: (d: formatter: (value: number) => { const date = new Date(value); const h = date.getHours(); - if (h % 2 !== 0) return ""; + if (h % 4 !== 0) return ""; if (h === 0) return `${h}\n${weekdayShort(date)}`; return `${h}`; }, @@ -171,7 +172,8 @@ export function forecastYAxis(overrides: Record = {}) { lineStyle: { color: colors.border || "" }, }, axisLabel: { - fontSize: 14, + fontSize: 10, + opacity: 0.5, ...(axisLabel as Record), }, ...rest, @@ -188,7 +190,7 @@ export function filterForecastSlots( endDate: Date ): ForecastSlot[] { if (!Array.isArray(slots)) return []; - return slots.filter((s) => new Date(s.end) >= startDate && new Date(s.start) <= endDate); + return slots.filter((s) => new Date(s.end) > startDate && new Date(s.start) <= endDate); } export function minSlotIndex(slots: ForecastSlot[]): number { diff --git a/assets/js/views/Forecast.vue b/assets/js/views/Forecast.vue index 65d4f6d65..2410839d4 100644 --- a/assets/js/views/Forecast.vue +++ b/assets/js/views/Forecast.vue @@ -157,7 +157,7 @@ import store from "../store"; import { adjustedSolar, ForecastType } from "@/utils/forecast"; import type { ForecastSlot } from "../components/Forecast/types"; -const MIN_HOURS = 38; +const MIN_HOURS = 76; const MAX_HOURS = 96; const SLOTS_PER_HOUR = 4; @@ -219,7 +219,7 @@ export default defineComponent({ chartWidth(): number { const ms = this.chartEndDate.getTime() - this.startDate.getTime(); const slots = Math.ceil(ms / (15 * 60 * 1000)); - return slots * 8 + 56; + return slots * 4 + 56; }, currency() { return store.state?.currency; @@ -242,7 +242,10 @@ export default defineComponent({ : this.forecast.solar; }, solarAdjustText() { - return this.$t("forecast.solarAdjustShort"); + const text = this.$t("forecast.solarAdjustShort"); + const scale = this.forecast.solar?.scale || 1; + const percentDiff = scale * 100 - 100; + return `${text} (${this.fmtPercentage(percentDiff, 0, true)})`; }, solarSubtitle(): string { const s = this.solar; From 3001cfbf41e6aa933d12cbc2e10b81dc428e0277 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 8 Apr 2026 09:09:01 +0200 Subject: [PATCH 0015/1316] Energyflow: forecast/battery navigation (#28918) --- assets/js/components/Energyflow/Energyflow.vue | 8 ++++++++ assets/js/components/Top/Navigation.vue | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index a6918d5cb..70335a9f1 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -652,12 +652,20 @@ export default defineComponent({ this.detailsCompleteHeight = this.$refs["detailsInner"]?.offsetHeight ?? 0; }, openBatterySettingsModal() { + if (this.experimental) { + this.$router.push("/battery"); + return; + } const modal = Modal.getOrCreateInstance( document.getElementById("batterySettingsModal") as HTMLElement ); modal.show(); }, openForecastModal() { + if (this.experimental) { + this.$router.push("/forecast"); + return; + } const modal = Modal.getOrCreateInstance( document.getElementById("forecastModal") as HTMLElement ); diff --git a/assets/js/components/Top/Navigation.vue b/assets/js/components/Top/Navigation.vue index 5ba73e434..3efc6d1d9 100644 --- a/assets/js/components/Top/Navigation.vue +++ b/assets/js/components/Top/Navigation.vue @@ -249,12 +249,20 @@ export default defineComponent({ modal.show(); }, openBatterySettingsModal() { + if (this.experimental) { + this.$router.push("/battery"); + return; + } const modal = Modal.getOrCreateInstance( document.getElementById("batterySettingsModal") as HTMLElement ); modal.show(); }, openForecastModal() { + if (this.experimental) { + this.$router.push("/forecast"); + return; + } const modal = Modal.getOrCreateInstance( document.getElementById("forecastModal") as HTMLElement ); From 411916982d4a164cd317d56c1d9a91317da3cfaf Mon Sep 17 00:00:00 2001 From: Martin Porsch Date: Wed, 8 Apr 2026 09:10:09 +0200 Subject: [PATCH 0016/1316] Fix: site soc calc was wrong if some batteries had no capacity (#28910) --- core/site.go | 47 +++++++++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/core/site.go b/core/site.go index 0d9ceb0eb..b4596dd8b 100644 --- a/core/site.go +++ b/core/site.go @@ -631,27 +631,19 @@ func (site *Site) updateBatteryMeters() { mm[i].Controllable = new(controllable) } - batterySocAcc := lo.SumBy(mm, func(m types.Measurement) float64 { - if m.Soc == nil { - return 0 - } - // weigh soc by capacity - if m.Capacity != nil && *m.Capacity > 0 { - return *m.Soc * *m.Capacity - } - return *m.Soc - }) - totalCapacity := lo.SumBy(mm, func(m types.Measurement) float64 { - if m.Capacity == nil { - return 0 - } - return *m.Capacity - }) + var batterySocAcc float64 + var totalCapacity float64 - // convert weighed socs to total soc - if totalCapacity == 0 { + if lo.SomeBy(mm, func(m types.Measurement) bool { return m.Capacity == nil || *m.Capacity <= 0 }) { + // any capacity is missing + batterySocAcc = sumOfSocs(mm) totalCapacity = float64(len(site.batteryMeters)) + } else { + // all capacities available - weigh soc by capacity + batterySocAcc = weightedSumOfSocs(mm) + totalCapacity = lo.SumBy(mm, func(m types.Measurement) float64 { return *m.Capacity }) } + site.battery.Soc = batterySocAcc / totalCapacity site.battery.Capacity = totalCapacity @@ -672,6 +664,25 @@ func (site *Site) updateBatteryMeters() { site.publish(keys.Battery, site.battery) } +func sumOfSocs(mm []types.Measurement) float64 { + return lo.SumBy(mm, func(m types.Measurement) float64 { + if m.Soc == nil { + return 0 + } + return *m.Soc + }) +} + +func weightedSumOfSocs(mm []types.Measurement) float64 { + return lo.SumBy(mm, func(m types.Measurement) float64 { + if m.Soc == nil { + return 0 + } + // weigh soc by capacity + return *m.Soc * *m.Capacity + }) +} + // updateAuxMeters updates aux meters func (site *Site) updateAuxMeters() { if len(site.auxMeters) == 0 { From 09d319b136566fa0e9367c75c18b54964b7428a5 Mon Sep 17 00:00:00 2001 From: Rene Nulsch <33263735+ReneNulschDE@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:20:56 +0200 Subject: [PATCH 0017/1316] Mercedes: update application and SDK versions (#28938) --- vehicle/mercedes/helper.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/vehicle/mercedes/helper.go b/vehicle/mercedes/helper.go index d91ea8b6a..a53a670f9 100644 --- a/vehicle/mercedes/helper.go +++ b/vehicle/mercedes/helper.go @@ -35,12 +35,12 @@ const ( BffUriNORAM = "https://bff.amap-prod.mobilesdk.mercedes-benz.com" WidgetUriNORAM = "https://widget.amap-prod.mobilesdk.mercedes-benz.com" IdUri = "https://id.mercedes-benz.com" - RisApplicationVersionEMEA = "1.63.0 (3044)" - RisSdkVersionEMEA = "3.26.2" - RisApplicationVersionAPAC = "1.63.0" - RisSdkVersionAPAC = "3.150.1" - RisApplicationVersionNORAM = "3.63.0" - RisSdkVersionNORAM = "3.150.1" + RisApplicationVersionEMEA = "1.65.1 (3174)" + RisSdkVersionEMEA = "4.4.2" + RisApplicationVersionAPAC = "1.65.0" + RisSdkVersionAPAC = "4.4.2" + RisApplicationVersionNORAM = "3.65.0" + RisSdkVersionNORAM = "4.4.2" RisOsVersion = "26.3" RisOsName = "ios" XApplicationNameEMEA = "mycar-store-ece" From f79bddd116636cd319d095bee39d4286920a73f8 Mon Sep 17 00:00:00 2001 From: Hobby-Student <6012744+Hobby-Student@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:22:11 +0200 Subject: [PATCH 0018/1316] Add Askoma ASKOHEAT+ (#28827) --- templates/definition/charger/askoheat.yaml | 84 ++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 templates/definition/charger/askoheat.yaml diff --git a/templates/definition/charger/askoheat.yaml b/templates/definition/charger/askoheat.yaml new file mode 100644 index 000000000..d5af7d6fc --- /dev/null +++ b/templates/definition/charger/askoheat.yaml @@ -0,0 +1,84 @@ +template: askoheat +products: + - brand: Askoma + description: + generic: ASKOHEAT+ +group: heating +requirements: + description: + de: | + Der ASKOHEAT+ muss über Modbus TCP erreichbar sein (Port 502). + Über das Webinterface sicherstellen, dass "Load Setpoint" im Input Setting aktiviert ist. + + **Wichtig:** Im Loadpoint `mincurrent` und `maxcurrent` passend zum Gerät setzen, + sowie `phases: 1` für einphasigen Betrieb konfigurieren. + Beispiel AHIR-BI-plus-1.75: `mincurrent: 1.1`, `maxcurrent: 7.6` (bei 230V, 1 Phase). + en: | + The ASKOHEAT+ must be reachable via Modbus TCP (port 502). + Ensure "Load Setpoint" is enabled in Input Settings via the web interface. + + **Important:** In the loadpoint configuration, set `mincurrent` and `maxcurrent` to match + your device, and configure `phases: 1` for single-phase operation. + Example AHIR-BI-plus-1.75: `mincurrent: 1.1`, `maxcurrent: 7.6` (at 230V, 1 phase). +params: + - name: host + - name: port + default: 502 + - name: id + default: 1 + - name: tempsensor + type: choice + choice: ["0", "1", "2", "3", "4"] + default: "0" + description: + de: Temperatursensor + en: Temperature sensor +render: | + type: heatpump + setmaxpower: + source: watchdog + timeout: 30s + set: + source: modbus + uri: {{ .host }}:{{ .port }} + id: {{ .id }} + register: + address: 319 + type: writesingle + encoding: int16 + getmaxpower: + source: modbus + uri: {{ .host }}:{{ .port }} + id: {{ .id }} + register: + address: 319 + type: input + encoding: int16 + power: + source: modbus + uri: {{ .host }}:{{ .port }} + id: {{ .id }} + register: + address: 317 + type: input + encoding: uint16 + temp: + source: modbus + uri: {{ .host }}:{{ .port }} + id: {{ .id }} + register: + address: {{ add 325 (mul (int .tempsensor) 2) }} + type: input + encoding: float32 + limittemp: + source: modbus + uri: {{ .host }}:{{ .port }} + id: {{ .id }} + register: + address: 640 + type: input + encoding: uint16 + icon: waterheater + features: + - heating + - integrateddevice From dec0e7d5b22530cedb038c8f647e677df72505a7 Mon Sep 17 00:00:00 2001 From: RenatusRo <126881912+RenatusRo@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:35:17 +0200 Subject: [PATCH 0019/1316] Optimizer: improve error message for solar forecast (#28934) --- core/site_optimizer.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index b351a9596..8ce7eb9b0 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -136,12 +136,16 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { feedIn := currentRates(site.GetTariff(api.TariffUsageFeedIn)) minLen := lo.Min([]int{len(grid), len(feedIn)}) - if solarTariff != nil { - // allow empty solar forecast + // exclude empty solar forecast from minLen + if solarTariff != nil && len(solar) > 0 { minLen = min(minLen, len(solar)) } - if minLen < 8 { - return fmt.Errorf("not enough slots for optimization: %d (grid=%d, feedIn=%d, solar=%d)", minLen, len(grid), len(feedIn), len(solar)) + const expectedSlots = 8 + if 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 fmt.Errorf("not enough forecast slots for meaningful optimization: %d < %d (grid=%d, feedIn=%d)", minLen, expectedSlots, len(grid), len(feedIn)) } now := time.Now() From 80900fd64846e46a2867a9b9e31cd95492fd9ccb Mon Sep 17 00:00:00 2001 From: Rishabh Vaish Date: Wed, 8 Apr 2026 03:36:38 -0700 Subject: [PATCH 0020/1316] chore: fix response body leak on 404 and hoist LoadLocation (#28612) --- tariff/pun.go | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tariff/pun.go b/tariff/pun.go index fa86988e2..a60f0fd88 100644 --- a/tariff/pun.go +++ b/tariff/pun.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "encoding/xml" + "errors" "fmt" "net/http" "net/http/cookiejar" @@ -19,6 +20,12 @@ import ( "github.com/evcc-io/evcc/util/request" ) +// ErrPunDataNotAvailable indicates that GME has not yet published prices for the requested day. +var ErrPunDataNotAvailable = errors.New("PUN data not available") + +// romeLocation is resolved once at package init to avoid repeated filesystem lookups. +var romeLocation *time.Location + type Pun struct { *embed log *util.Logger @@ -46,6 +53,7 @@ var _ api.Tariff = (*Pun)(nil) func init() { registry.Add("pun", NewPunFromConfig) + romeLocation, _ = time.LoadLocation("Europe/Rome") } func NewPunFromConfig(other map[string]any) (api.Tariff, error) { @@ -137,9 +145,14 @@ func (t *Pun) getData(day time.Time) (api.Rates, error) { } resp, err := client.Do(req) - if err != nil || resp.StatusCode == http.StatusNotFound { + if err != nil { return nil, err } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("%w: %s", ErrPunDataNotAvailable, day.Format("2006-01-02")) + } body, err := request.ReadBody(resp) if err != nil { @@ -193,17 +206,12 @@ func (t *Pun) getData(day time.Time) (api.Rates, error) { date = date.AddDate(0, 0, -1) } - location, err := time.LoadLocation("Europe/Rome") - if err != nil { - return nil, fmt.Errorf("load location: %w", err) - } - price, err := strconv.ParseFloat(strings.ReplaceAll(p.PUN, ",", "."), 64) if err != nil { return nil, fmt.Errorf("parse price: %w", err) } - ts := time.Date(date.Year(), date.Month(), date.Day(), hour-1, 0, 0, 0, location) + ts := time.Date(date.Year(), date.Month(), date.Day(), hour-1, 0, 0, 0, romeLocation) ar := api.Rate{ Start: ts, End: ts.Add(time.Hour), From 462dbd0def95f86cea51041579437adf8a9f2854 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 8 Apr 2026 17:24:08 +0200 Subject: [PATCH 0021/1316] Config UI: fix default for required choice params (#28935) --- assets/js/components/Config/DeviceModal/index.ts | 1 + assets/js/components/Config/LoadpointModal.vue | 6 +----- assets/js/components/Config/PropertyField.vue | 8 ++++---- assets/js/components/Config/VehicleModal.vue | 3 ++- tests/boost.spec.ts | 1 + tests/config-loadpoint.spec.ts | 4 ++-- 6 files changed, 11 insertions(+), 12 deletions(-) diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts index f1304d289..afa1123d8 100644 --- a/assets/js/components/Config/DeviceModal/index.ts +++ b/assets/js/components/Config/DeviceModal/index.ts @@ -68,6 +68,7 @@ export type ApiData = { icon?: string; usage?: MeterTemplateUsage; title?: string; + priority?: number; identifiers?: string[]; [key: string]: any; }; diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue index 7f5defd2f..96739b376 100644 --- a/assets/js/components/Config/LoadpointModal.vue +++ b/assets/js/components/Config/LoadpointModal.vue @@ -114,7 +114,6 @@ v-model="values.defaultMode" type="Choice" class="w-100" - required :choice="[ { key: '', name: '---' }, { key: 'off', name: $t('main.mode.off') }, @@ -302,8 +301,8 @@ type="Choice" size="w-100" class="me-2" - :choice="priorityOptions" required + :choice="priorityOptions" /> @@ -435,7 +434,6 @@ type="Choice" class="me-2" :choice="circuitOptions" - required />
@@ -465,7 +463,6 @@ type="Choice" class="me-2" :choice="allVehicleOptions" - required /> @@ -744,7 +741,6 @@ export default { name: string; }[]; result[0]!.name = "0 (default)"; - result[0]!.key = undefined; result[10]!.name = "10 (highest)"; return result; }, diff --git a/assets/js/components/Config/PropertyField.vue b/assets/js/components/Config/PropertyField.vue index 6c1ee9d48..d6d823747 100644 --- a/assets/js/components/Config/PropertyField.vue +++ b/assets/js/components/Config/PropertyField.vue @@ -51,9 +51,10 @@ v-model="value" class="form-select" :class="inputClasses" + :required="required" :disabled="disabled" > - + + + + + + @@ -473,6 +487,9 @@ import ModbusProxyIcon from "../components/MaterialIcon/ModbusProxy.vue"; import ModbusProxyModal from "../components/Config/ModbusProxyModal.vue"; import MqttIcon from "../components/MaterialIcon/Mqtt.vue"; import MqttModal from "../components/Config/MqttModal.vue"; +import RemoteAccessIcon from "../components/MaterialIcon/RemoteAccess.vue"; +import RemoteModal from "../components/Config/Remote/RemoteModal.vue"; +import { isRemoteClientActive } from "@/utils/remote"; import NetworkModal from "../components/Config/NetworkModal.vue"; import NotificationIcon from "../components/MaterialIcon/Notification.vue"; import OptimizerIcon from "../components/MaterialIcon/Optimizer.vue"; @@ -504,6 +521,7 @@ import type { SiteConfig, DeviceType, Notification, + Remote, } from "@/types/evcc"; import { CURRENCY, GRID_CONTROL } from "@/types/evcc"; import { circuitTree } from "@/utils/circuits"; @@ -555,6 +573,8 @@ export default defineComponent({ ModbusProxyModal, MqttIcon, MqttModal, + RemoteAccessIcon, + RemoteModal, NetworkModal, NotificationIcon, OptimizerIcon, @@ -763,6 +783,30 @@ export default defineComponent({ return result; }, + remote(): Remote | undefined { + return store.state?.remote; + }, + remoteTags(): DeviceTags { + const remote = this.remote; + if (!remote?.status?.url) { + return { configured: { value: false } }; + } + const tags: DeviceTags = { + enabled: { value: remote.config?.enabled }, + connected: { value: remote.status?.connected }, + }; + if (remote.status?.loginBlocked) { + tags["loginBlocked"] = { value: true, error: true }; + } + if (remote.status?.connected) { + const lastSeen = remote.status?.lastSeen; + const count = lastSeen + ? Object.keys(lastSeen).filter((u) => isRemoteClientActive(lastSeen, u)).length + : 0; + tags["activeClients"] = { value: count }; + } + return tags; + }, sponsor() { return store.state?.sponsor; }, diff --git a/cmd/root.go b/cmd/root.go index a1d10adab..be00a6c64 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,6 +22,7 @@ import ( "github.com/evcc-io/evcc/server/eebus" "github.com/evcc-io/evcc/server/mcp" "github.com/evcc-io/evcc/server/network" + "github.com/evcc-io/evcc/server/remote" "github.com/evcc-io/evcc/server/updater" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/auth" @@ -237,6 +238,12 @@ func runRoot(cmd *cobra.Command, args []string) { // publish to UI go socketHub.Run(pipe.NewDropper(ignoreEmpty).Pipe(tee.Attach()), cache) + // remote access tunnel + var remoteAccess *remote.Remote + if remoteHost := os.Getenv("EVCC_REMOTE_ACCESS"); remoteHost != "" { + remoteAccess = remote.New(remoteHost, httpd.Router(), valueChan) + } + // signal ui listening valueChan <- util.Param{Key: keys.StartupCompleted, Val: false} @@ -385,6 +392,11 @@ func runRoot(cmd *cobra.Command, args []string) { YamlSource: yamlSource.tariffs, }} + // publish remote access status + if remoteAccess != nil { + valueChan <- util.Param{Key: keys.Remote, Val: remoteAccess.ConfigStatus()} + } + // publish system infos valueChan <- util.Param{Key: keys.Version, Val: util.FormattedVersion()} valueChan <- util.Param{Key: keys.Config, Val: viper.ConfigFileUsed()} @@ -429,7 +441,7 @@ func runRoot(cmd *cobra.Command, args []string) { log.INFO.Println("evcc was stopped by user. OS should restart the service. Or restart manually.") err = errors.New("restart required") // https://gokrazy.org/development/process-interface/ once.Do(func() { close(stopC) }) // signal loop to end - }, viper.ConfigFileUsed()) + }, viper.ConfigFileUsed(), remoteAccess) // show and check version, reduce api load during development if util.Version != util.DevVersion { diff --git a/core/keys/global.go b/core/keys/global.go index f8fa5eba1..354ad27bc 100644 --- a/core/keys/global.go +++ b/core/keys/global.go @@ -30,6 +30,9 @@ const ( Telemetry = "telemetry" Optimizer = "optimizer" DemoMode = "demoMode" + Remote = "remote" + RemoteClients = "remoteClients" + RemoteLastSeen = "remoteLastSeen" AuthDisabled = "authDisabled" AuthProviders = "authProviders" ) diff --git a/go.mod b/go.mod index c597feb3e..27a5a776b 100644 --- a/go.mod +++ b/go.mod @@ -54,6 +54,7 @@ require ( github.com/gregdel/pushover v1.4.0 github.com/grid-x/modbus v0.0.0-20260325140807-cf9e1b9daae0 github.com/hashicorp/go-version v1.9.0 + github.com/hashicorp/yamux v0.1.2 github.com/hasura/go-graphql-client v0.16.0 github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 github.com/influxdata/influxdb-client-go/v2 v2.14.0 @@ -85,6 +86,7 @@ require ( github.com/robertkrimen/otto v0.5.1 github.com/samber/lo v1.53.0 github.com/sandrolain/httpcache v1.4.0 + github.com/sethvargo/go-password v0.3.1 github.com/sirupsen/logrus v1.9.4 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/smallnest/chanx v1.2.0 diff --git a/go.sum b/go.sum index 405e56c3c..427b45073 100644 --- a/go.sum +++ b/go.sum @@ -391,6 +391,8 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hasura/go-graphql-client v0.16.0 h1:DQLfp+djj4j5NPdJkGYym8J55hpm5etML1zqgco78Qc= github.com/hasura/go-graphql-client v0.16.0/go.mod h1:z/sO2T0zI+HnPNIevQcs+7xA6/gDOc8hgHMrNBzfL2c= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= @@ -686,6 +688,8 @@ github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/sethvargo/go-password v0.3.1 h1:WqrLTjo7X6AcVYfC6R7GtSyuUQR9hGyAj/f1PYQZCJU= +github.com/sethvargo/go-password v0.3.1/go.mod h1:rXofC1zT54N7R8K/h1WDUdkf9BOx5OptoxrMBcrXzvs= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= diff --git a/i18n/de.json b/i18n/de.json index 4e34cb620..3e24344f7 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -113,6 +113,7 @@ "title": "Währung" }, "deviceValue": { + "activeClients": "Aktive Clients", "amount": "Anzahl", "broker": "Broker", "bucket": "Bucket", @@ -144,6 +145,7 @@ "hemsActiveLimit": "Aktives Limit", "hemsType": "Kommunikation", "identifier": "RFID-Kennung", + "loginBlocked": "Login-Limit erreicht", "max": "max", "messengers": "Dienste", "no": "Nein", @@ -631,6 +633,38 @@ "titleAdd": "PV-Anlage hinzufügen", "titleEdit": "PV-Zähler bearbeiten" }, + "remote": { + "active": "Aktiv", + "addClient": "Client hinzufügen", + "addClientDescription": "Zugangsdaten werden ausschließlich lokal auf deiner evcc-Instanz gespeichert und überprüft.", + "addClientTitle": "Remote-Client hinzufügen", + "clientCreated": "Client erstellt", + "clients": "Clients", + "confirmDelete": "Client löschen?", + "connected": "Verbunden", + "createClient": "Client erstellen", + "description": "Externer Zugriff auf die evcc-Installation über die evcc-App. Ohne Portfreigabe oder VPN.", + "deviceName": "Gerätename", + "disconnected": "Nicht verbunden", + "done": "Fertig", + "enableLabel": "Remotezugriff aktivieren", + "expiration": "Ablauf", + "expirationNone": "Nie", + "expired": "abgelaufen", + "expiresIn": "läuft {time} ab", + "lastActive": "aktiv {time}", + "loginBlocked": "Remote-Logins sind wegen zu vieler fehlgeschlagener Anmeldeversuche für eine Minute gesperrt.", + "manualLogin": "Oder melde dich manuell unter {url} in deinem Browser mit diesen Zugangsdaten an:", + "noClients": "Noch keine Clients. Niemand kann sich verbinden.", + "password": "Passwort", + "passwordOnce": "Dieses Passwort wird nur einmal angezeigt. QR-Code scannen oder jetzt kopieren. Es wird später nicht mehr angezeigt.", + "qrInstall": "Installiere die evcc-App für {ios} oder {android}.", + "qrScan": "Scanne den Code mit der Kamera deines Smartphones, um dich zu verbinden. Klicke ihn an, wenn du bereits dein Handy nutzt.", + "removeClient": "Client entfernen", + "title": "Remotezugriff", + "url": "Öffentliche URL", + "username": "Benutzername" + }, "section": { "additionalMeter": "Zusätzliche Zähler", "general": "Allgemein", diff --git a/i18n/en.json b/i18n/en.json index f87439e35..b17667740 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -113,6 +113,7 @@ "title": "Currency" }, "deviceValue": { + "activeClients": "Active clients", "amount": "Amount", "broker": "Broker", "bucket": "Bucket", @@ -126,6 +127,7 @@ "chargedEnergy": "Charged", "co2": "Grid CO₂", "configured": "Configured", + "connected": "Connected", "connections": "Connections", "controllable": "Controllable", "currency": "Currency", @@ -144,6 +146,7 @@ "hemsActiveLimit": "Active limit", "hemsType": "Communication", "identifier": "RFID-Identifier", + "loginBlocked": "Login limit reached", "max": "max", "messengers": "Services", "no": "no", @@ -631,6 +634,38 @@ "titleAdd": "Add Solar Meter", "titleEdit": "Edit Solar Meter" }, + "remote": { + "active": "Active", + "addClient": "Add client", + "addClientDescription": "Credentials are stored and verified only locally on your evcc instance.", + "addClientTitle": "Add Remote Client", + "clientCreated": "Client created", + "clients": "Clients", + "confirmDelete": "Delete client?", + "connected": "Connected", + "createClient": "Create client", + "description": "Access your evcc installation from anywhere using the evcc mobile app. No port forwarding or VPN required.", + "deviceName": "Device name", + "disconnected": "Disconnected", + "done": "Done", + "enableLabel": "Enable remote access", + "expiration": "Expiration", + "expirationNone": "Never", + "expired": "expired", + "expiresIn": "expires {time}", + "lastActive": "active {time}", + "loginBlocked": "Remote logins are blocked for one minute due to too many failed login attempts.", + "manualLogin": "Or sign in manually at {url} in your browser using these credentials:", + "noClients": "No clients yet. No one can connect yet.", + "password": "Password", + "passwordOnce": "This password is shown only once. Scan the QR code or copy it now. You won't be able to see it again.", + "qrInstall": "Install the evcc app for {ios} or {android}.", + "qrScan": "Scan the code with your phone's camera to connect. Click it, if you're already using your phone.", + "removeClient": "Remove client", + "title": "Remote Access", + "url": "Public URL", + "username": "Username" + }, "section": { "additionalMeter": "Additional meters", "general": "General", diff --git a/package-lock.json b/package-lock.json index 829f0c83a..55d5578b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "dayjs": "^1.11.20", "echarts": "^6.0.0", "monaco-editor": "0.52.2", + "qrcode": "^1.5.4", "smoothscroll-polyfill": "^0.4.4", "snarkdown": "^2.0.0", "vue": "^3.5.31", @@ -40,6 +41,7 @@ "@types/bootstrap": "^5.2.10", "@types/canvas-confetti": "^1.9.0", "@types/kill-port": "^2.0.3", + "@types/qrcode": "^1.5.6", "@types/smoothscroll-polyfill": "^0.3.4", "@types/wait-on": "^5.3.4", "@types/ws": "^8.18.1", @@ -3435,6 +3437,16 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/readable-stream": { "version": "4.0.23", "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz", @@ -4439,7 +4451,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4449,7 +4460,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5115,6 +5125,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001780", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", @@ -5324,7 +5343,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5337,7 +5355,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -5655,6 +5672,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -5790,6 +5816,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -6993,7 +7025,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -7686,7 +7717,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9165,6 +9195,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -9215,7 +9254,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9346,6 +9384,15 @@ "node": ">=18" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -9702,6 +9749,173 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -9997,6 +10211,21 @@ "regjsparser": "bin/parser" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -10298,6 +10527,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -12470,6 +12705,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", diff --git a/package.json b/package.json index 17d725fde..ee0fe7d6f 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dayjs": "^1.11.20", "echarts": "^6.0.0", "monaco-editor": "0.52.2", + "qrcode": "^1.5.4", "smoothscroll-polyfill": "^0.4.4", "snarkdown": "^2.0.0", "vue": "^3.5.31", @@ -62,6 +63,7 @@ "@types/bootstrap": "^5.2.10", "@types/canvas-confetti": "^1.9.0", "@types/kill-port": "^2.0.3", + "@types/qrcode": "^1.5.6", "@types/smoothscroll-polyfill": "^0.3.4", "@types/wait-on": "^5.3.4", "@types/ws": "^8.18.1", diff --git a/server/http.go b/server/http.go index a0c4cfe4f..a39d2d45a 100644 --- a/server/http.go +++ b/server/http.go @@ -15,6 +15,7 @@ import ( "github.com/evcc-io/evcc/hems/shm" "github.com/evcc-io/evcc/server/assets" "github.com/evcc-io/evcc/server/eebus" + "github.com/evcc-io/evcc/server/remote" "github.com/evcc-io/evcc/server/service" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/auth" @@ -225,7 +226,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) { } // RegisterSystemHandler provides system level handlers -func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *util.ParamCache, auth auth.Auth, shutdown func(), configFile string) { +func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *util.ParamCache, auth auth.Auth, shutdown func(), configFile string, remoteAccess *remote.Remote) { router := s.Server.Handler.(*mux.Router) // api @@ -299,6 +300,13 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti "optimizer": {"POST", "/optimizer/{value:[01truefalse]+}", boolHandler(setOptimizer(pub), getOptimizer)}, } + if remoteAccess != nil { + routes["remote"] = route{"POST", "/remote/{value:[01truefalse]+}", boolHandler(remoteAccess.Enable, remoteAccess.Enabled)} + routes["remoteclients"] = route{"GET", "/remote/clients", remoteClientsHandler(remoteAccess)} + routes["createremoteclient"] = route{"POST", "/remote/clients", createRemoteClientHandler(remoteAccess)} + routes["deleteremoteclient"] = route{"DELETE", "/remote/clients", deleteRemoteClientHandler(remoteAccess)} + } + // yaml handlers for key, fun := range map[string]func() (any, any){ keys.Hems: func() (any, any) { return map[string]any{}, config.Typed{} }, diff --git a/server/http_remote_handler.go b/server/http_remote_handler.go new file mode 100644 index 000000000..5b7b417a2 --- /dev/null +++ b/server/http_remote_handler.go @@ -0,0 +1,62 @@ +package server + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/evcc-io/evcc/server/remote" +) + +// remoteClientsHandler returns the list of remote tunnel clients. +func remoteClientsHandler(r *remote.Remote) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + jsonWrite(w, r.Clients()) + } +} + +// createRemoteClientHandler creates a new tunnel client and returns the +// cleartext password (shown to the user only once). +func createRemoteClientHandler(r *remote.Remote) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + var body struct { + Username string `json:"username"` + ExpiresIn int64 `json:"expiresIn"` // seconds; 0 = never + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + client, password, err := r.CreateClient(body.Username, time.Duration(body.ExpiresIn)*time.Second) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + jsonWrite(w, struct { + Username string `json:"username"` + Password string `json:"password"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + }{ + Username: client.Username, + Password: password, + CreatedAt: client.CreatedAt, + ExpiresAt: client.ExpiresAt, + }) + } +} + +// deleteRemoteClientHandler removes a tunnel client by username. +// Username is passed as a query parameter to allow arbitrary characters. +func deleteRemoteClientHandler(r *remote.Remote) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + username := req.URL.Query().Get("username") + if err := r.DeleteClient(username); err != nil { + jsonError(w, http.StatusNotFound, err) + return + } + jsonWrite(w, true) + } +} diff --git a/server/remote/clients.go b/server/remote/clients.go new file mode 100644 index 000000000..f4907aec6 --- /dev/null +++ b/server/remote/clients.go @@ -0,0 +1,166 @@ +package remote + +import ( + "crypto/rand" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/server/db/settings" + "github.com/samber/lo" + "github.com/sethvargo/go-password/password" + "golang.org/x/crypto/bcrypt" +) + +// dummyHash is a bcrypt hash of a random value, used to make the +// "unknown user" path take the same time as a real password check and +// prevent username enumeration via timing side channels. +var dummyHash []byte + +func init() { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + panic(err) + } + h, err := bcrypt.GenerateFromPassword(buf, bcrypt.DefaultCost) + if err != nil { + panic(err) + } + dummyHash = h +} + +// Client is a single tunnel basic-auth credential used by a remote client. +type Client struct { + Username string `json:"username"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` +} + +type persistedClient struct { + Client + Hash string `json:"hash"` +} + +// loadClients reads the persisted client list. +func loadClients() []persistedClient { + var res []persistedClient + _ = settings.Json(keys.RemoteClients, &res) + return res +} + +// saveClients persists the given client list. +func saveClients(list []persistedClient) error { + return settings.SetJson(keys.RemoteClients, list) +} + +// generatePassword returns a crypto-random alphanumeric password +// with 20 characters including 4 digits (~96 bits of entropy). +func generatePassword() (string, error) { + return password.Generate(20, 4, 0, false, false) +} + +// Clients returns the list of configured clients (without password hashes). +func (r *Remote) Clients() []Client { + return lo.Map(loadClients(), func(c persistedClient, _ int) Client { + return c.Client + }) +} + +// CreateClient creates a new client with an auto-generated password. +// expiresIn <= 0 means the client never expires. +// Returns the cleartext password (shown to the user only once). +func (r *Remote) CreateClient(username string, expiresIn time.Duration) (Client, string, error) { + username = strings.TrimSpace(username) + if username == "" { + return Client{}, "", errors.New("username required") + } + // RFC 7617: ":" is the basic-auth separator; reject control chars too. + for _, r := range username { + if r == ':' || r < 0x20 || r == 0x7f { + return Client{}, "", errors.New("username contains invalid characters") + } + } + + var expires *time.Time + if expiresIn > 0 { + expires = new(time.Now().Add(expiresIn)) + } + + r.mu.Lock() + defer r.mu.Unlock() + + list := loadClients() + for _, c := range list { + if c.Username == username { + return Client{}, "", fmt.Errorf("client %q already exists", username) + } + } + + password, err := generatePassword() + if err != nil { + return Client{}, "", err + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return Client{}, "", err + } + + c := persistedClient{ + Client: Client{ + Username: username, + CreatedAt: time.Now(), + ExpiresAt: expires, + }, + Hash: string(hash), + } + list = append(list, c) + if err := saveClients(list); err != nil { + return Client{}, "", err + } + + return c.Client, password, nil +} + +// DeleteClient removes a client by username. +func (r *Remote) DeleteClient(username string) error { + r.mu.Lock() + defer r.mu.Unlock() + + list := loadClients() + idx := slices.IndexFunc(list, func(c persistedClient) bool { + return c.Username == username + }) + + if idx == -1 { + return fmt.Errorf("client %s not found", username) + } + + return saveClients(slices.Delete(list, idx, idx+1)) +} + +// Authenticate validates basic-auth credentials. Always runs bcrypt +// (against a dummy hash on miss) to prevent username enumeration via timing. +func (r *Remote) Authenticate(username, password string) bool { + hash := dummyHash + var found *persistedClient + for _, c := range loadClients() { + if c.Username == username { + found = &c + hash = []byte(c.Hash) + break + } + } + + valid := bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil + if !valid || found == nil { + return false + } + if found.ExpiresAt != nil && time.Now().After(*found.ExpiresAt) { + return false + } + return true +} diff --git a/server/remote/ratelimit.go b/server/remote/ratelimit.go new file mode 100644 index 000000000..76c43930b --- /dev/null +++ b/server/remote/ratelimit.go @@ -0,0 +1,51 @@ +package remote + +import ( + "sync" + "time" +) + +// authRateLimiter tracks failed authentication attempts in a sliding window. +// When the failure count exceeds the threshold, further attempts are blocked +// to prevent brute-force attacks. +type authRateLimiter struct { + mu sync.Mutex + failures []time.Time + window time.Duration + max int + now func() time.Time +} + +func newAuthRateLimiter() *authRateLimiter { + return &authRateLimiter{ + window: time.Minute, + max: 10, + now: time.Now, + } +} + +// allow checks whether an authentication attempt should proceed. +func (rl *authRateLimiter) allow() bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + cutoff := rl.now().Add(-rl.window) + + // prune old entries + valid := rl.failures[:0] + for _, t := range rl.failures { + if t.After(cutoff) { + valid = append(valid, t) + } + } + rl.failures = valid + + return len(rl.failures) < rl.max +} + +// fail records a failed authentication attempt. +func (rl *authRateLimiter) fail() { + rl.mu.Lock() + defer rl.mu.Unlock() + rl.failures = append(rl.failures, rl.now()) +} diff --git a/server/remote/ratelimit_test.go b/server/remote/ratelimit_test.go new file mode 100644 index 000000000..f0f1a7077 --- /dev/null +++ b/server/remote/ratelimit_test.go @@ -0,0 +1,70 @@ +package remote + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAuthRateLimiter(t *testing.T) { + t.Run("allows requests under threshold", func(t *testing.T) { + rl := newAuthRateLimiter() + + for range rl.max { + assert.True(t, rl.allow()) + rl.fail() + } + }) + + t.Run("blocks after threshold", func(t *testing.T) { + rl := newAuthRateLimiter() + + for range rl.max { + rl.fail() + } + + assert.False(t, rl.allow()) + }) + + t.Run("recovers after window expires", func(t *testing.T) { + now := time.Now() + var mu sync.Mutex + + rl := newAuthRateLimiter() + rl.now = func() time.Time { + mu.Lock() + defer mu.Unlock() + return now + } + + for range rl.max { + rl.fail() + } + + assert.False(t, rl.allow()) + + // advance past window + mu.Lock() + now = now.Add(rl.window + time.Second) + mu.Unlock() + + assert.True(t, rl.allow()) + }) + + t.Run("successful auth does not count as failure", func(t *testing.T) { + rl := newAuthRateLimiter() + + // fill up to max-1 failures + for range rl.max - 1 { + rl.fail() + } + + // allow should still work (no fail() call = successful auth) + assert.True(t, rl.allow()) + + // still under threshold + assert.True(t, rl.allow()) + }) +} diff --git a/server/remote/remote.go b/server/remote/remote.go new file mode 100644 index 000000000..80158dc10 --- /dev/null +++ b/server/remote/remote.go @@ -0,0 +1,228 @@ +package remote + +import ( + "fmt" + "net/http" + "sync" + "time" + + "github.com/evcc-io/evcc/api/globalconfig" + "github.com/evcc-io/evcc/cmd/shutdown" + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/server/db/settings" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/sponsor" +) + +// Settings is the persisted remote access configuration. +type Settings struct { + Enabled bool `json:"enabled"` + URL string `json:"url,omitempty"` + Token string `json:"token,omitempty"` + TunnelURL string `json:"tunnelUrl,omitempty"` +} + +// Remote manages the remote access tunnel lifecycle. +type Remote struct { + mu sync.Mutex + cloudHost string + settings Settings + tunnel *Tunnel + httpHandler http.Handler + log *util.Logger + publisher chan<- util.Param + lastSeen map[string]time.Time // persisted: username → last activity + connected map[string]int // in-memory: active connection count per user +} + +// New creates a new Remote manager, loads persisted settings, and connects if enabled. +func New(cloudHost string, httpHandler http.Handler, valueChan chan<- util.Param) *Remote { + r := &Remote{ + cloudHost: cloudHost, + httpHandler: httpHandler, + log: util.NewLogger("remote"), + publisher: valueChan, + lastSeen: make(map[string]time.Time), + connected: make(map[string]int), + } + + // load saved settings + _ = settings.Json(keys.Remote, &r.settings) + _ = settings.Json(keys.RemoteLastSeen, &r.lastSeen) + + if r.settings.Enabled && r.settings.Token != "" { + go r.connect() + } + + shutdown.Register(r.disconnect) + + go func() { + for range time.Tick(time.Minute) { + r.publish() + } + }() + + return r +} + +// Enable enables or disables remote access. When enabling for the first time, +// it registers with the cloud to obtain a URL and token. +func (r *Remote) Enable(enable bool) error { + r.mu.Lock() + r.settings.Enabled = enable + r.saveSettings() + r.mu.Unlock() + + if enable { + // TODO why do we need a go routine for this? + go r.connect() + } else { + r.disconnect() + } + + r.publish() + return nil +} + +// Enabled returns whether remote access is enabled. +func (r *Remote) Enabled() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.settings.Enabled +} + +func (r *Remote) connect() { + r.mu.Lock() + token := r.settings.Token + r.mu.Unlock() + + if token == "" { + if err := r.register(); err != nil { + r.log.ERROR.Printf("registration failed: %v", err) + return + } + } + + r.log.INFO.Printf("remote access via %s", r.settings.URL) + + tunnel := NewTunnel(r.settings.TunnelURL, r.settings.Token, r.httpHandler, r.Authenticate, r.TrackActivity, r.log, r.publish) + + r.mu.Lock() + r.tunnel = tunnel + r.mu.Unlock() + + // blocks until disconnected + tunnel.run() +} + +func (r *Remote) disconnect() { + r.mu.Lock() + defer r.mu.Unlock() + + if r.tunnel != nil { + r.tunnel.Close() + r.tunnel = nil + } +} + +type registerRequest struct { + SponsorToken string `json:"sponsorToken"` +} + +type registerResponse struct { + URL string `json:"url"` + Token string `json:"token"` + TunnelURL string `json:"tunnelUrl"` +} + +// register calls the cloud registration endpoint and persists the result. +func (r *Remote) register() error { + uri := fmt.Sprintf("https://%s/api/register", r.cloudHost) + data := registerRequest{SponsorToken: sponsor.Token} + req, _ := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding) + + var res registerResponse + + client := request.NewHelper(r.log) + if err := client.DoJSON(req, &res); err != nil { + return err + } + + r.mu.Lock() + r.settings.URL = res.URL + r.settings.Token = res.Token + r.settings.TunnelURL = res.TunnelURL + r.saveSettings() + r.mu.Unlock() + + r.log.INFO.Printf("registered as %s", res.URL) + return nil +} + +// TrackActivity tracks remote client connections and disconnections. +func (r *Remote) TrackActivity(username string, active bool) { + r.mu.Lock() + defer r.mu.Unlock() + if active { + r.lastSeen[username] = time.Now() + r.connected[username]++ + } else if r.connected[username] > 0 { + r.connected[username]-- + } +} + +// saveSettings persists the current settings. Must be called with mu held. +func (r *Remote) saveSettings() { + if err := settings.SetJson(keys.Remote, r.settings); err != nil { + r.log.ERROR.Println(err) + } +} + +// ConfigStatus returns the current remote access config and status. +func (r *Remote) ConfigStatus() globalconfig.ConfigStatus { + r.mu.Lock() + defer r.mu.Unlock() + + connected := r.tunnel != nil && r.tunnel.IsConnected() + loginBlocked := r.tunnel != nil && r.tunnel.LoginBlocked() + + return globalconfig.ConfigStatus{ + Config: struct { + Enabled bool `json:"enabled"` + }{ + Enabled: r.settings.Enabled, + }, + Status: struct { + Connected bool `json:"connected"` + URL string `json:"url,omitempty"` + LoginBlocked bool `json:"loginBlocked"` + LastSeen map[string]time.Time `json:"lastSeen,omitempty"` + }{ + Connected: connected, + URL: r.settings.URL, + LoginBlocked: loginBlocked, + LastSeen: r.lastSeen, + }, + } +} + +// publish sends the current status to the UI via the value channel. +func (r *Remote) publish() { + if r.publisher == nil { + return + } + + // refresh lastSeen for open connections (auth only fires once) + r.mu.Lock() + now := time.Now() + for user, count := range r.connected { + if count > 0 { + r.lastSeen[user] = now + } + } + _ = settings.SetJson(keys.RemoteLastSeen, r.lastSeen) + r.mu.Unlock() + + r.publisher <- util.Param{Key: keys.Remote, Val: r.ConfigStatus()} +} diff --git a/server/remote/tunnel.go b/server/remote/tunnel.go new file mode 100644 index 000000000..303acf92b --- /dev/null +++ b/server/remote/tunnel.go @@ -0,0 +1,199 @@ +package remote + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/coder/websocket" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/sponsor" + "github.com/hashicorp/yamux" +) + +// Tunnel manages a WebSocket+yamux tunnel to the cloud proxy. +type Tunnel struct { + tunnelURL string + token string + httpHandler http.Handler + authenticate func(user, pass string) bool + trackActivity func(username string, active bool) + log *util.Logger + cancel func() + onStateChange func() + rateLimiter *authRateLimiter + + mu sync.Mutex + session *yamux.Session +} + +// NewTunnel creates a new tunnel client. +func NewTunnel(tunnelURL, token string, httpHandler http.Handler, authenticate func(user, pass string) bool, trackActivity func(string, bool), log *util.Logger, onStateChange func()) *Tunnel { + return &Tunnel{ + tunnelURL: tunnelURL, + token: token, + httpHandler: httpHandler, + authenticate: authenticate, + trackActivity: trackActivity, + log: log, + onStateChange: onStateChange, + rateLimiter: newAuthRateLimiter(), + } +} + +// run establishes the tunnel and reconnects on failure. +func (t *Tunnel) run() { + bo := backoff.NewExponentialBackOff( + backoff.WithInitialInterval(time.Second), + backoff.WithMaxInterval(60*time.Second), + backoff.WithMaxElapsedTime(0), + ) + + ctx, cancel := context.WithCancel(context.Background()) + t.cancel = cancel + + for { + ok, err := t.connect(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + t.log.ERROR.Printf("tunnel: %v", err) + } + + // reset backoff after successful connection + if ok { + bo.Reset() + } + + select { + case <-ctx.Done(): + return + case <-time.After(bo.NextBackOff()): + } + } +} + +func (t *Tunnel) connect(ctx context.Context) (bool, error) { + conn, _, err := websocket.Dial(ctx, t.tunnelURL, &websocket.DialOptions{ + HTTPHeader: http.Header{ + "Authorization": []string{"Bearer " + t.token}, + "X-Sponsor-Token": []string{sponsor.Token}, + }, + }) + if err != nil { + return false, fmt.Errorf("websocket dial: %w", err) + } + + netConn := websocket.NetConn(ctx, conn, websocket.MessageBinary) + + config := yamux.DefaultConfig() + config.LogOutput = t.log.TRACE.Writer() + + session, err := yamux.Client(netConn, config) + if err != nil { + netConn.Close() // closes the underlying socket connection + return false, fmt.Errorf("yamux client: %w", err) + } + + t.changeState(session, nil) + + // accept streams from the proxy + srv := &http.Server{ + Handler: t.basicAuthMiddleware(t.httpHandler), + } + + if err := srv.Serve(session); err != nil { + t.changeState(nil, err) + } + + return true, nil +} + +func (t *Tunnel) changeState(session *yamux.Session, err error) { + t.mu.Lock() + t.session = session + t.mu.Unlock() + + if t.onStateChange != nil { + t.onStateChange() + } + + if session != nil { + t.log.INFO.Println("tunnel connected") + } else { + if errors.Is(err, context.Canceled) || errors.Is(err, io.EOF) { + t.log.INFO.Println("tunnel disconnected") + } else { + t.log.INFO.Println("tunnel disconnected:", err) + } + } +} + +// IsConnected returns whether the tunnel is currently connected. +func (t *Tunnel) IsConnected() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.session != nil +} + +// LoginBlocked returns whether login attempts are currently blocked by the rate limiter. +func (t *Tunnel) LoginBlocked() bool { + return !t.rateLimiter.allow() +} + +// Close tears down the tunnel. +func (t *Tunnel) Close() { + t.mu.Lock() + defer t.mu.Unlock() + + // close websocket; produces io.EOF in yamux which it handles silently + if t.session != nil { + t.session.Close() // closes the underlying socket connection + } + + if t.cancel != nil { + t.cancel() + t.cancel = nil + } +} + +// basicAuthMiddleware wraps a handler with HTTP basic auth, validating +// credentials against the given authenticate function per request. +// It rate-limits failed attempts to prevent brute-force attacks. +func (t *Tunnel) basicAuthMiddleware(next http.Handler) http.Handler { + rejectAuth := func(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", `Basic realm="evcc"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok || t.authenticate == nil { + rejectAuth(w) + return + } + + if !t.rateLimiter.allow() { + t.log.INFO.Printf("login blocked for %q (rate limited)", user) + http.Error(w, "Too many failed login attempts. Try again in 1 minute.", http.StatusTooManyRequests) + return + } + + if !t.authenticate(user, pass) { + t.rateLimiter.fail() + t.log.INFO.Printf("failed login attempt for %q", user) + rejectAuth(w) + return + } + + if t.trackActivity != nil { + t.trackActivity(user, true) + defer t.trackActivity(user, false) // long-running requests (ws) + } + + next.ServeHTTP(w, r) + }) +} From 4c44ebc5d5e1fe07d9a35282e22b1f4aa9b372da Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 14 Apr 2026 10:51:30 +0200 Subject: [PATCH 0061/1316] chore: fix connected --- charger/smart-evse.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charger/smart-evse.go b/charger/smart-evse.go index 901d26985..ac01f3755 100644 --- a/charger/smart-evse.go +++ b/charger/smart-evse.go @@ -46,7 +46,7 @@ type smartEvseRestSettings struct { ModeID int `json:"mode_id"` CarConnected bool `json:"car_connected"` Evse struct { - Connected int `json:"connected"` + Connected bool `json:"connected"` Access int `json:"access"` Mode int `json:"mode"` ChargeTimer int `json:"charge_timer"` @@ -413,7 +413,7 @@ func (wb *SmartEVSE3) Diagnose() { fmt.Printf("\tMode: %s (%d)\n", res.Mode, res.ModeID) fmt.Printf("\tCar connected: %t\n", res.CarConnected) - fmt.Printf("\tEVSE connected: %d\n", res.Evse.Connected) + fmt.Printf("\tEVSE connected: %t\n", res.Evse.Connected) fmt.Printf("\tEVSE access: %d\n", res.Evse.Access) fmt.Printf("\tEVSE mode: %d\n", res.Evse.Mode) fmt.Printf("\tEVSE charge timer: %d\n", res.Evse.ChargeTimer) From b45edd218784482179e10fd685b37a0f31b42f9d Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 14 Apr 2026 10:58:23 +0200 Subject: [PATCH 0062/1316] Mqtt: reset full/empty times (#29050) --- core/site_optimizer.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 8ce7eb9b0..ab6da5d6d 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -70,8 +70,8 @@ type batteryDetail struct { type batteryResult struct { batteryDetail - Full *time.Time `json:"full"` - Empty *time.Time `json:"empty"` + Full time.Time `json:"full,omitzero"` + Empty time.Time `json:"empty,omitzero"` } // func (br batteryResult) MarshalJSON() ([]byte, error) { @@ -471,15 +471,15 @@ func (site *Site) batteryRequest(dev config.Device[api.Meter], b types.Measureme return bat, detail } -func matchSoc(ts []float32, fun func(float32) bool) *time.Time { +func matchSoc(ts []float32, fun func(float32) bool) time.Time { for i, soc := range ts { if fun(soc) { // TODO first slot - return new(time.Now().Add(time.Duration(i+1) * tariff.SlotDuration)) + return time.Now().Add(time.Duration(i+1) * tariff.SlotDuration) } } - return nil + return time.Time{} } // continuousDemand creates a slice of power demands depending on loadpoint mode From 4b528cd355ee7a4d32503382d8ce429d421ab2ac Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 14 Apr 2026 11:39:43 +0200 Subject: [PATCH 0063/1316] EEBus: handle negative production limits (#29104) --- hems/eebus/eebus.go | 29 ++++++++++++++++++----------- hems/eebus/events.go | 2 +- meter/eebus.go | 4 ++-- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/hems/eebus/eebus.go b/hems/eebus/eebus.go index a9ef56405..ccbe35fbf 100644 --- a/hems/eebus/eebus.go +++ b/hems/eebus/eebus.go @@ -38,7 +38,7 @@ type EEBus struct { smartgridProductionId uint productionLimit ucapi.LoadLimit productionLimitActivated time.Time - failsafeProductionLimit float64 + failsafeProductionLimit *float64 heartbeat *util.Value[struct{}] interval time.Duration @@ -49,7 +49,7 @@ type Limits struct { FailsafeConsumptionActivePowerLimit float64 ProductionNominalMax float64 - FailsafeProductionActivePowerLimit float64 + FailsafeProductionActivePowerLimit *float64 FailsafeDurationMinimum time.Duration } @@ -67,7 +67,7 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*E FailsafeConsumptionActivePowerLimit: 4200, ProductionNominalMax: 0, - FailsafeProductionActivePowerLimit: 0, + FailsafeProductionActivePowerLimit: nil, // 0 is a valid limit FailsafeDurationMinimum: 2 * time.Hour, }, @@ -144,8 +144,8 @@ func NewEEBus(ctx context.Context, ski string, limits Limits, passthrough func(b if err := c.cs.CsLPPInterface.SetProductionNominalMax(limits.ProductionNominalMax); err != nil { c.log.ERROR.Println("CS LPP SetProductionNominalMax:", err) } - if c.failsafeProductionLimit > 0 { - if err := c.cs.CsLPPInterface.SetFailsafeProductionActivePowerLimit(c.failsafeProductionLimit, true); err != nil { + if c.failsafeProductionLimit != nil && *c.failsafeProductionLimit >= 0 { + if err := c.cs.CsLPPInterface.SetFailsafeProductionActivePowerLimit(*c.failsafeProductionLimit, true); err != nil { c.log.ERROR.Println("CS LPP SetFailsafeProductionActivePowerLimit:", err) } } @@ -181,7 +181,14 @@ func (c *EEBus) run() error { if heartbeatErr != nil && c.status != StatusFailsafe { // LPC-914/2 c.log.WARN.Println("missing heartbeat- entering failsafe mode") - c.setStatusAndLimit(StatusFailsafe, c.failsafeConsumptionLimit, c.failsafeProductionLimit) + c.setStatus(StatusFailsafe) + + c.setConsumptionLimit(c.failsafeConsumptionLimit) + + if c.failsafeProductionLimit != nil { + // production limit is negative, failsafe limits are always positive + c.setProductionLimit(-*c.failsafeProductionLimit, true) + } return nil } @@ -193,7 +200,10 @@ func (c *EEBus) run() error { } c.log.DEBUG.Println("heartbeat returned or failsafe duration exceeded- leaving failsafe mode") - c.setStatusAndLimit(StatusNormal, 0, 0) + c.setStatus(StatusNormal) + + c.setConsumptionLimit(0) + c.setProductionLimit(0, false) } // LPC-914/1 @@ -227,12 +237,9 @@ func (c *EEBus) run() error { return nil } -func (c *EEBus) setStatusAndLimit(status status, consumption, production float64) { +func (c *EEBus) setStatus(status status) { c.status = status c.statusUpdated = time.Now() - - c.setConsumptionLimit(consumption) - c.setProductionLimit(production, true) } func (c *EEBus) setConsumptionLimit(limit float64) { diff --git a/hems/eebus/events.go b/hems/eebus/events.go index 3dbd6bfda..beca70ba8 100644 --- a/hems/eebus/events.go +++ b/hems/eebus/events.go @@ -183,7 +183,7 @@ func (c *EEBus) updateFailsafeProductionActivePowerLimit() { c.mux.Lock() defer c.mux.Unlock() - c.failsafeProductionLimit = limit + c.failsafeProductionLimit = new(limit) } func (c *EEBus) updateFailsafeConsumptionDurationMinimum() { diff --git a/meter/eebus.go b/meter/eebus.go index 6b2cfaf3c..f2e4310c3 100644 --- a/meter/eebus.go +++ b/meter/eebus.go @@ -249,8 +249,8 @@ func (c *EEBus) Curtailed() (bool, error) { return false, err } - // Check if limit is active and has a valid power value - return limit.IsActive && limit.Value > 0, nil + // Check if limit is active and has a valid power value (valid is zero or negative) + return limit.IsActive && limit.Value <= 0, nil } // Curtail implements the api.Curtailer interface From 794a26d9166ebb23fe25deaa96a7305e3325fb05 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 14 Apr 2026 13:10:42 +0200 Subject: [PATCH 0064/1316] chore: fix naming (#29106) --- i18n/de.json | 2 +- i18n/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/i18n/de.json b/i18n/de.json index 3e24344f7..bbf0a02a9 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -341,7 +341,7 @@ "defaultsHintLink": "Einstellungen anpassen", "delete": "Löschen", "electricalSubtitle": "Im Zweifelsfall Elektriker fragen.", - "electricalTitle": "Elektrisch", + "electricalTitle": "Elektrik", "energyMeterHelp": "Zusätzlicher Zähler, wenn die Wallbox keinen integrierten hat.", "energyMeterLabel": "Energiezähler", "estimateLabel": "Ladestand zwischen API-Updates interpolieren", diff --git a/i18n/en.json b/i18n/en.json index b17667740..e4e669582 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -342,7 +342,7 @@ "defaultsHintLink": "Adjust settings", "delete": "Delete", "electricalSubtitle": "When in doubt, ask your electrician.", - "electricalTitle": "Electrical", + "electricalTitle": "Electrics", "energyMeterHelp": "Additional meter if the charger doesn't have an integrated one.", "energyMeterLabel": "Energy meter", "estimateLabel": "Interpolate charge level between API updates", From 812ffa47b8d9a36a6d46aedf5edbaffab8bfd86a Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 14 Apr 2026 16:46:59 +0200 Subject: [PATCH 0065/1316] Forecast UI: add feed-in, edge-to-edge layout, design improvements (#29090) --- assets/css/app.css | 9 +- assets/js/colors.ts | 3 + assets/js/components/Forecast/Co2Chart.vue | 2 +- assets/js/components/Forecast/Co2Details.vue | 57 ++++++ assets/js/components/Forecast/GridDetails.vue | 107 ++++++++++ assets/js/components/Forecast/PriceChart.vue | 141 +++++++------ assets/js/components/Forecast/SolarChart.vue | 2 +- .../js/components/Forecast/SolarDetails.vue | 79 ++++++++ assets/js/components/Forecast/chartStyles.css | 1 + assets/js/components/Forecast/echarts.ts | 10 +- assets/js/mixins/formatter.ts | 5 + assets/js/settings.ts | 4 + assets/js/utils/forecast.test.ts | 30 ++- assets/js/utils/forecast.ts | 8 +- assets/js/views/Forecast.vue | 191 ++++++++---------- i18n/en.json | 11 +- tests/config-grid.spec.ts | 2 +- 17 files changed, 484 insertions(+), 178 deletions(-) create mode 100644 assets/js/components/Forecast/Co2Details.vue create mode 100644 assets/js/components/Forecast/GridDetails.vue create mode 100644 assets/js/components/Forecast/SolarDetails.vue diff --git a/assets/css/app.css b/assets/css/app.css index cc748436c..15171e613 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -21,7 +21,7 @@ --evcc-darkest-green: #076f20ff; --evcc-darkest-green-rgb: 7, 111, 32; --evcc-yellow: #faf000; - --evcc-dark-yellow: #bbb400; + --evcc-dark-yellow: #f6bb0f; --evcc-orange: #ff9000; --evcc-orange-rgb: 255, 144, 0; --evcc-red: #fc440f; @@ -39,6 +39,7 @@ --evcc-pv: var(--evcc-dark-green); --evcc-battery: var(--evcc-darker-green); --evcc-export: var(--evcc-yellow); + --evcc-export-contrast: var(--evcc-dark-yellow); --evcc-price: #ff912fff; --evcc-co2: #00916eff; --evcc-background: var(--bs-gray-bright); @@ -100,6 +101,8 @@ :root.dark { --evcc-grid: var(--bs-gray-medium); + --evcc-export: var(--evcc-yellow); + --evcc-export-contrast: var(--evcc-yellow); --evcc-background: var(--bs-gray-deep); --evcc-box: var(--bs-gray-dark); --evcc-box-border: var(--bs-gray-darker); @@ -186,6 +189,10 @@ h5 { color: var(--evcc-co2) !important; } +.text-export { + color: var(--evcc-export-contrast) !important; +} + a { color: var(--bs-primary); } diff --git a/assets/js/colors.ts b/assets/js/colors.ts index f47242ec5..a98765010 100644 --- a/assets/js/colors.ts +++ b/assets/js/colors.ts @@ -17,6 +17,7 @@ const colors: { pricePerKWh: string | null; price: string | null; co2: string | null; + export: string | null; background: string | null; light: string | null; selfPalette: string[]; @@ -31,6 +32,7 @@ const colors: { pricePerKWh: null, price: null, co2: null, + export: null, background: null, light: null, selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"], @@ -75,6 +77,7 @@ export function updateCssColors() { colors.grid = style.getPropertyValue("--evcc-grid"); colors.price = style.getPropertyValue("--evcc-price"); colors.co2 = style.getPropertyValue("--evcc-co2"); + colors.export = style.getPropertyValue("--evcc-export-contrast"); colors.background = style.getPropertyValue("--evcc-background"); colors.pricePerKWh = style.getPropertyValue("--bs-gray-medium"); colors.co2PerKWh = style.getPropertyValue("--bs-gray-medium"); diff --git a/assets/js/components/Forecast/Co2Chart.vue b/assets/js/components/Forecast/Co2Chart.vue index ea688deea..5a3d15d3d 100644 --- a/assets/js/components/Forecast/Co2Chart.vue +++ b/assets/js/components/Forecast/Co2Chart.vue @@ -95,7 +95,7 @@ export default defineComponent({ { type: "line", data: this.slots.map((s) => [s.start, s.value]), - smooth: 0.05, + smooth: true, symbol: "circle", symbolSize: 6, showSymbol: false, diff --git a/assets/js/components/Forecast/Co2Details.vue b/assets/js/components/Forecast/Co2Details.vue new file mode 100644 index 000000000..3358f0b5d --- /dev/null +++ b/assets/js/components/Forecast/Co2Details.vue @@ -0,0 +1,57 @@ + + + diff --git a/assets/js/components/Forecast/GridDetails.vue b/assets/js/components/Forecast/GridDetails.vue new file mode 100644 index 000000000..70d5cecb0 --- /dev/null +++ b/assets/js/components/Forecast/GridDetails.vue @@ -0,0 +1,107 @@ + + + diff --git a/assets/js/components/Forecast/PriceChart.vue b/assets/js/components/Forecast/PriceChart.vue index 00298fa81..6a57b9019 100644 --- a/assets/js/components/Forecast/PriceChart.vue +++ b/assets/js/components/Forecast/PriceChart.vue @@ -7,6 +7,7 @@ diff --git a/assets/js/components/Forecast/SolarChart.vue b/assets/js/components/Forecast/SolarChart.vue index 528279bef..ba97ee967 100644 --- a/assets/js/components/Forecast/SolarChart.vue +++ b/assets/js/components/Forecast/SolarChart.vue @@ -103,7 +103,7 @@ export default defineComponent({ { type: "line", data, - smooth: 0.05, + smooth: true, symbol: "circle", symbolSize: 6, showSymbol: false, diff --git a/assets/js/components/Forecast/SolarDetails.vue b/assets/js/components/Forecast/SolarDetails.vue new file mode 100644 index 000000000..32497c190 --- /dev/null +++ b/assets/js/components/Forecast/SolarDetails.vue @@ -0,0 +1,79 @@ + + + diff --git a/assets/js/components/Forecast/chartStyles.css b/assets/js/components/Forecast/chartStyles.css index 5b9f3e23e..10f4a53a4 100644 --- a/assets/js/components/Forecast/chartStyles.css +++ b/assets/js/components/Forecast/chartStyles.css @@ -1,6 +1,7 @@ .forecast-chart-scroll { overflow-x: auto; padding-bottom: 4px; + margin-bottom: 0.5rem; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts index d56556b54..9fde86ba1 100644 --- a/assets/js/components/Forecast/echarts.ts +++ b/assets/js/components/Forecast/echarts.ts @@ -53,7 +53,7 @@ export function markPointLabel( animation: true, clip: true, symbol: "rect", - symbolSize: 1, + symbolSize: 0, label: { show: true, position: "top", @@ -63,7 +63,7 @@ export function markPointLabel( color: colors.background, backgroundColor: color, borderRadius: 4, - padding: [5, 10], + padding: [5, 15], offset: [0, -2], formatter: (p: { data: { value: string } }) => p.data.value, }, @@ -111,7 +111,7 @@ export function tooltipStyle( } export function forecastGrid() { - return { top: 36, right: 16, bottom: 4, left: 34, borderWidth: 0 }; + return { top: 36, right: 16, bottom: 16, left: 24, borderWidth: 0 }; } export function forecastXAxes(startDate: Date, endDate: Date, weekdayShort: (d: Date) => string) { @@ -167,13 +167,13 @@ export function forecastYAxis(overrides: Record = {}) { axisLine: { show: false }, axisTick: { show: false }, splitLine: { - showMinLine: false, + showMinLine: true, showMaxLine: false, lineStyle: { color: colors.border || "" }, }, axisLabel: { fontSize: 10, - opacity: 0.5, + opacity: 0.75, ...(axisLabel as Record), }, ...rest, diff --git a/assets/js/mixins/formatter.ts b/assets/js/mixins/formatter.ts index 8562f5972..9489dd492 100644 --- a/assets/js/mixins/formatter.ts +++ b/assets/js/mixins/formatter.ts @@ -221,6 +221,11 @@ export default defineComponent({ weekday: "short", }).format(date); }, + weekdayLong(date: Date) { + return new Intl.DateTimeFormat(this.$i18n?.locale, { + weekday: "long", + }).format(date); + }, fmtAbsoluteDate(date: Date) { const weekday = this.weekdayPrefix(date); const hour = new Intl.DateTimeFormat(this.$i18n?.locale, { diff --git a/assets/js/settings.ts b/assets/js/settings.ts index fcd200779..9ea7fcff8 100644 --- a/assets/js/settings.ts +++ b/assets/js/settings.ts @@ -21,6 +21,7 @@ const SESSIONS_GROUP = "sessions_group"; const SESSIONS_TYPE = "sessions_type"; const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted"; const SETTINGS_PRICE_ZOOM = "settings_price_zoom"; +const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin"; const LAST_BATTERY_SMART_COST_LIMIT = "last_battery_smart_cost_limit"; const LAST_TARGET_TIME = "last_target_time"; const LAST_SOC_GOAL = "last_soc_goal"; @@ -124,6 +125,7 @@ export interface Settings { sessionsType: string; solarAdjusted: boolean; priceZoom: boolean; + hideFeedin: boolean; loadpoints: Record; lastBatterySmartCostLimit: number | undefined; lastTargetTime: string | null; @@ -151,6 +153,7 @@ const settings: Settings = reactive({ sessionsType: read(SESSIONS_TYPE), solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED), priceZoom: readBool(SETTINGS_PRICE_ZOOM), + hideFeedin: readBool(SETTINGS_HIDE_FEEDIN), loadpoints: readJSON(LOADPOINTS), lastBatterySmartCostLimit: readNumber(LAST_BATTERY_SMART_COST_LIMIT), lastTargetTime: read(LAST_TARGET_TIME), @@ -177,6 +180,7 @@ watch(() => settings.sessionsGroup, save(SESSIONS_GROUP)); watch(() => settings.sessionsType, save(SESSIONS_TYPE)); watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED)); watch(() => settings.priceZoom, saveBool(SETTINGS_PRICE_ZOOM)); +watch(() => settings.hideFeedin, saveBool(SETTINGS_HIDE_FEEDIN)); watch(() => settings.loadpoints, saveJSON(LOADPOINTS), { deep: true }); watch(() => settings.lastBatterySmartCostLimit, saveNumber(LAST_BATTERY_SMART_COST_LIMIT)); watch(() => settings.lastTargetTime, save(LAST_TARGET_TIME)); diff --git a/assets/js/utils/forecast.test.ts b/assets/js/utils/forecast.test.ts index 7a1ba0265..f6f1b6b74 100644 --- a/assets/js/utils/forecast.test.ts +++ b/assets/js/utils/forecast.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { findLowestSumSlotIndex } from "./forecast"; +import { findLowestSumSlotIndex, isStaticTariff } from "./forecast"; describe("findLowestSumSlotIndex", () => { test("finds lowest sum with span of 4", () => { @@ -55,3 +55,31 @@ describe("findLowestSumSlotIndex", () => { expect(findLowestSumSlotIndex(slots, 2)).toBe(0); }); }); + +describe("isStaticTariff", () => { + test("returns false for undefined", () => { + expect(isStaticTariff(undefined)).toBe(false); + }); + + test("returns false for empty array", () => { + expect(isStaticTariff([])).toBe(false); + }); + + test("returns true when all values are identical", () => { + const slots = [ + { start: "2025-01-01T00:00:00Z", end: "2025-01-01T00:15:00Z", value: 0.25 }, + { start: "2025-01-01T00:15:00Z", end: "2025-01-01T00:30:00Z", value: 0.25 }, + { start: "2025-01-01T00:30:00Z", end: "2025-01-01T00:45:00Z", value: 0.25 }, + ]; + expect(isStaticTariff(slots)).toBe(true); + }); + + test("returns false when values differ", () => { + const slots = [ + { start: "2025-01-01T00:00:00Z", end: "2025-01-01T00:15:00Z", value: 0.25 }, + { start: "2025-01-01T00:15:00Z", end: "2025-01-01T00:30:00Z", value: 0.3 }, + { start: "2025-01-01T00:30:00Z", end: "2025-01-01T00:45:00Z", value: 0.25 }, + ]; + expect(isStaticTariff(slots)).toBe(false); + }); +}); diff --git a/assets/js/utils/forecast.ts b/assets/js/utils/forecast.ts index 66314158e..e18719493 100644 --- a/assets/js/utils/forecast.ts +++ b/assets/js/utils/forecast.ts @@ -1,4 +1,4 @@ -import type { TimeseriesEntry, SolarDetails } from "../components/Forecast/types"; +import type { ForecastSlot, TimeseriesEntry, SolarDetails } from "../components/Forecast/types"; import deepCopy from "./deepClone"; export enum ForecastType { @@ -65,6 +65,12 @@ export function adjustedSolar(solar?: SolarDetails): SolarDetails | undefined { return result; } +export function isStaticTariff(slots?: ForecastSlot[]): boolean { + if (!Array.isArray(slots) || slots.length === 0) return false; + const firstValue = slots[0].value; + return slots.every((slot) => slot.value === firstValue); +} + export interface SlotWithValue { start: string | Date; value: number; diff --git a/assets/js/views/Forecast.vue b/assets/js/views/Forecast.vue index 2410839d4..c1cd0b682 100644 --- a/assets/js/views/Forecast.vue +++ b/assets/js/views/Forecast.vue @@ -41,19 +41,10 @@
-
+
-
-

- {{ - $t("forecast.type.solar") - }} - {{ - solarSubtitle - }} -

+
+

{{ $t("forecast.type.solar") }}

- +
+ +
+
-
-
-

- {{ - $t("forecast.type.price") - }} - {{ - priceSubtitle - }} -

+
+
+

{{ $t("forecast.type.price") }}

- + +
+
-
-

- {{ - $t("forecast.type.co2") - }} - {{ - co2Subtitle - }} -

- +
+

{{ $t("forecast.type.co2") }}

+
+ +
+
@@ -149,17 +143,18 @@ import { defineComponent } from "vue"; import Header from "../components/Top/Header.vue"; import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue"; import SolarChart from "../components/Forecast/SolarChart.vue"; +import SolarDetails from "../components/Forecast/SolarDetails.vue"; import PriceChart from "../components/Forecast/PriceChart.vue"; +import GridDetails from "../components/Forecast/GridDetails.vue"; import Co2Chart from "../components/Forecast/Co2Chart.vue"; -import formatter, { POWER_UNIT } from "@/mixins/formatter"; +import Co2Details from "../components/Forecast/Co2Details.vue"; +import formatter from "@/mixins/formatter"; import settings from "@/settings"; import store from "../store"; -import { adjustedSolar, ForecastType } from "@/utils/forecast"; -import type { ForecastSlot } from "../components/Forecast/types"; +import { adjustedSolar, ForecastType, isStaticTariff } from "@/utils/forecast"; const MIN_HOURS = 76; const MAX_HOURS = 96; -const SLOTS_PER_HOUR = 4; export default defineComponent({ name: "Forecast", @@ -167,8 +162,11 @@ export default defineComponent({ TopHeader: Header, DynamicPriceIcon, SolarChart, + SolarDetails, PriceChart, + GridDetails, Co2Chart, + Co2Details, }, mixins: [formatter], data() { @@ -233,6 +231,17 @@ export default defineComponent({ priceZoom() { return settings.priceZoom; }, + showFeedin() { + return !settings.hideFeedin; + }, + isGridStatic(): boolean { + if (!this.forecast.grid) return false; + if (!isStaticTariff(this.forecast.grid)) return false; + if (this.forecast.feedin?.length) { + return isStaticTariff(this.forecast.feedin); + } + return true; + }, showSolarAdjust() { return !!this.forecast.solar && this.experimental; }, @@ -241,48 +250,16 @@ export default defineComponent({ ? adjustedSolar(this.forecast.solar) : this.forecast.solar; }, - solarAdjustText() { - const text = this.$t("forecast.solarAdjustShort"); + solarAdjustPercent(): string { const scale = this.forecast.solar?.scale || 1; const percentDiff = scale * 100 - 100; - return `${text} (${this.fmtPercentage(percentDiff, 0, true)})`; + return this.fmtPercentage(percentDiff, 0, true); }, - solarSubtitle(): string { - const s = this.solar; - if (!s) return ""; - const today = s.today?.energy ? this.fmtWh(s.today.energy, POWER_UNIT.AUTO) : ""; - const tomorrow = s.tomorrow?.energy - ? this.fmtWh(s.tomorrow.energy, POWER_UNIT.AUTO) - : ""; - if (!today && !tomorrow) return ""; - const parts = []; - if (today) parts.push(`${today} ${this.$t("forecast.solar.remaining")}`); - if (tomorrow) parts.push(`${tomorrow} ${this.$t("forecast.solar.tomorrow")}`); - return parts.join(" ・ "); + solarAdjustTextShort(): string { + return `${this.$t("forecast.solarAdjustShort")} (${this.solarAdjustPercent})`; }, - priceSubtitle(): string { - const slots = this.upcomingSlots(this.forecast.grid); - if (slots.length === 0) return ""; - const values = slots.map((s) => s.value); - const min = Math.min(...values); - const max = Math.max(...values); - const avg = values.reduce((a, b) => a + b, 0) / values.length; - const fmtMin = this.fmtPricePerKWh(min, this.currency, false, false); - const fmtMax = this.fmtPricePerKWh(max, this.currency, false, true); - const fmtAvg = this.fmtPricePerKWh(avg, this.currency, false, true); - return `⌀ ${fmtAvg} ・ ${fmtMin} – ${fmtMax}`; - }, - co2Subtitle(): string { - const slots = this.upcomingSlots(this.forecast.co2); - if (slots.length === 0) return ""; - const values = slots.map((s) => s.value); - const min = Math.min(...values); - const max = Math.max(...values); - const avg = values.reduce((a, b) => a + b, 0) / values.length; - const fmtMin = this.fmtNumber(min, 0); - const fmtMax = this.fmtCo2Medium(max); - const fmtAvg = this.fmtCo2Medium(avg); - return `⌀ ${fmtAvg} ・ ${fmtMin} – ${fmtMax}`; + solarAdjustTextMedium(): string { + return `${this.$t("forecast.solarAdjustMedium")} (${this.solarAdjustPercent})`; }, }, methods: { @@ -292,6 +269,9 @@ export default defineComponent({ togglePriceZoom() { settings.priceZoom = !settings.priceZoom; }, + toggleFeedin() { + settings.hideFeedin = !settings.hideFeedin; + }, onChartScroll(val: number) { if (this.isScrolling) return; this.isScrolling = true; @@ -300,13 +280,6 @@ export default defineComponent({ this.isScrolling = false; }); }, - upcomingSlots(slots?: ForecastSlot[]): ForecastSlot[] { - if (!Array.isArray(slots)) return []; - const now = new Date(); - return slots - .filter((slot) => new Date(slot.end) > now) - .slice(0, MAX_HOURS * SLOTS_PER_HOUR); - }, }, }); @@ -321,4 +294,10 @@ export default defineComponent({ border-radius: 2rem; max-width: 480px; } +@media (max-width: 575.98px) { + .chart-edge { + margin-left: -1.5rem; + margin-right: -1.5rem; + } +} diff --git a/i18n/en.json b/i18n/en.json index e4e669582..deed0f3f9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -796,8 +796,8 @@ "titleChoice": "What Do You Want To Add?", "type": { "co2": "CO₂ Intensity", - "feedIn": "Feed-In Price", - "grid": "Grid Consumption Price", + "feedIn": "Grid Export Price", + "grid": "Grid Import Price", "planner": "Planner", "solar": "Solar" }, @@ -972,6 +972,7 @@ "setup": "Set up tariffs and forecasts", "solar": "See expected solar production for today and the coming days. Will also be used for automatic charging optimization in the future." }, + "hideLine": "hide line", "modalTitle": "Forecast", "price": { "average": "Average", @@ -980,6 +981,7 @@ "range": "Range" }, "priceZoom": "zoom in", + "showLine": "show line", "solar": { "dayAfterTomorrow": "Day after tomorrow", "partly": "partly", @@ -988,7 +990,8 @@ "tomorrow": "Tomorrow" }, "solarAdjust": "Adjust solar forecast based on real production data{percent}.", - "solarAdjustShort": "real data adjust", + "solarAdjustMedium": "real data adjust", + "solarAdjustShort": "adjust", "type": { "co2": "CO₂ Emissions", "price": "Grid Price", @@ -1153,7 +1156,7 @@ "batteryTooltip": "{energy} of {total} ({soc})", "forecast": "Forecast: ", "forecastTooltip": "forecast: remaining solar production today", - "gridImport": "Grid use", + "gridImport": "Grid import", "homePower": "Consumption", "loadpoints": "Charger| Charger | {count} chargers", "loadpointsLimit": "{limit} limit", diff --git a/tests/config-grid.spec.ts b/tests/config-grid.spec.ts index 924cbcc73..6ce902306 100644 --- a/tests/config-grid.spec.ts +++ b/tests/config-grid.spec.ts @@ -62,7 +62,7 @@ test.describe("grid meter", async () => { // check in main ui await page.goto("/"); await page.getByTestId("visualization").click(); - await expect(page.getByTestId("energyflow")).toContainText(["Grid use", "5.0 kW"].join("")); + await expect(page.getByTestId("energyflow")).toContainText(["Grid import", "5.0 kW"].join("")); // delete #1 await page.goto("/#/config"); From d50e6be4680028397b3fee9f48449a765089e565 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 14 Apr 2026 17:06:51 +0200 Subject: [PATCH 0066/1316] chore: remove duplicate serial_number (#29107) --- templates/definition/charger/volttime.yaml | 9 ++++----- util/templates/defaults.yaml | 5 ----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/templates/definition/charger/volttime.yaml b/templates/definition/charger/volttime.yaml index 03789fe6f..5dad5d255 100644 --- a/templates/definition/charger/volttime.yaml +++ b/templates/definition/charger/volttime.yaml @@ -20,13 +20,12 @@ params: help: de: API Token ([developer.volttime.com](https://developer.volttime.com/api-reference/authentication#personal-access-tokens)) en: API Token ([developer.volttime.com](https://developer.volttime.com/api-reference/authentication#personal-access-tokens)) - - name: serial_number + - name: serial required: true - help: - de: Seriennummer (z. B. 1008621) - en: Serial number (e.g. 1008621) + - name: serial_number + deprecated: true render: | type: plugchoice token: {{ .token }} - identity: {{ if .serial_number }}VT_{{ .serial_number }}{{ end }} + identity: {{ if (or .serial .serial_number) }}VT_{{ or .serial .serial_number }}{{ end }} connector: 1 diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml index 698cd85e5..c51caa320 100644 --- a/util/templates/defaults.yaml +++ b/util/templates/defaults.yaml @@ -407,11 +407,6 @@ params: mask: true description: generic: PIN - - name: serial_number - private: true - description: - en: Serial Number - de: Seriennummer - name: watchdog description: generic: Watchdog From 0f65fd960f937711353465e4a7bfadd871bb5c9c Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 14 Apr 2026 19:14:03 +0200 Subject: [PATCH 0067/1316] EEBus: fix deadlock on save after validate (#29119) --- server/eebus/eebus.go | 18 ++++++++----- server/eebus/eebus_test.go | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/server/eebus/eebus.go b/server/eebus/eebus.go index 8cf5696a2..38a66dfc0 100644 --- a/server/eebus/eebus.go +++ b/server/eebus/eebus.go @@ -237,17 +237,21 @@ func (c *EEBus) UnregisterDevice(ski string, device Device) { c.log.TRACE.Printf("unregistering ski: %s", ski) c.mux.Lock() - defer c.mux.Unlock() - if idx := slices.Index(c.clients[ski], device); idx != -1 { c.clients[ski] = slices.Delete(c.clients[ski], idx, idx+1) - } - // only tear down SHIP connection when no more clients need it - if len(c.clients[ski]) == 0 { - delete(c.clients, ski) - c.service.UnregisterRemoteSKI(ski) + if len(c.clients[ski]) == 0 { + delete(c.clients, ski) + + // Tear down the SHIP session after releasing the mutex: ship-go's CloseConnection + // on a non-Complete state synchronously invokes HandleConnectionClosed, + // which calls back into evcc's connect(ski, false) — and that needs to + // acquire c.mux. Holding c.mux across this cross-layer call would + // deadlock the same goroutine on its own non-reentrant mutex. See #28942. + defer c.service.UnregisterRemoteSKI(ski) + } } + c.mux.Unlock() } func (c *EEBus) CustomerEnergyManagement() *CustomerEnergyManagement { diff --git a/server/eebus/eebus_test.go b/server/eebus/eebus_test.go index 12ae581e1..c9d3a3957 100644 --- a/server/eebus/eebus_test.go +++ b/server/eebus/eebus_test.go @@ -2,7 +2,12 @@ package eebus import ( "testing" + "time" + eebusapi "github.com/enbility/eebus-go/api" + eebusmocks "github.com/enbility/eebus-go/mocks" + spineapi "github.com/enbility/spine-go/api" + "github.com/evcc-io/evcc/util" "github.com/stretchr/testify/require" "go.yaml.in/yaml/v4" ) @@ -23,3 +28,50 @@ certificate: var res Config require.NoError(t, yaml.Unmarshal([]byte(conf), &res)) } + +// mockDevice implements Device for testing +type mockDevice struct{} + +func (d *mockDevice) Connect(connected bool) {} +func (d *mockDevice) UseCaseEvent(_ spineapi.DeviceRemoteInterface, entity spineapi.EntityRemoteInterface, event eebusapi.EventType) { +} + +var _ Device = (*mockDevice)(nil) + +// TestUnregisterDevice_MutexNotHeldDuringShipCall is the regression guard +// for issue #28942. It asserts that c.mux is NOT held at the point +// UnregisterRemoteSKI is called. The pre-fix code held c.mux across that +// cross-layer call, and ship-go's synchronous HandleConnectionClosed +// callback chain re-entered connect(ski, false) on the same goroutine, +// which then deadlocked on c.mux.Lock() (Go mutexes are non-reentrant). +// +// The assertion uses a goroutine that tries to briefly acquire c.mux from +// inside the mock's UnregisterRemoteSKI implementation; if the lock is +// held, the acquisition times out and the test fails. +func TestUnregisterDevice_MutexNotHeldDuringShipCall(t *testing.T) { + dev := &mockDevice{} + c := &EEBus{ + log: util.NewLogger("test"), + clients: map[string][]Device{"aabbcc": {dev}}, + } + + service := eebusmocks.NewServiceInterface(t) + service.EXPECT().UnregisterRemoteSKI("aabbcc").Run(func(string) { + acquired := make(chan struct{}) + go func() { + c.mux.Lock() + defer c.mux.Unlock() + close(acquired) + }() + select { + case <-acquired: + // good — mutex was free + case <-time.After(100 * time.Millisecond): + t.Errorf("c.mux was held while UnregisterRemoteSKI was called — " + + "regression to the cross-layer lock hold that caused #28942") + } + }).Once() + c.service = service + + c.UnregisterDevice("aabbcc", dev) +} From 0b198ff0dab8a473190065ef0a5eb23e2635ac2e Mon Sep 17 00:00:00 2001 From: Matthias Fechner Date: Wed, 15 Apr 2026 10:55:05 +0200 Subject: [PATCH 0068/1316] chore: allow the TAG_NAME and SHA Makefile variable to be overridden (#29123) --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index fc1168092..ee3298c54 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # build vars -TAG_NAME := $(shell test -d .git && git describe --abbrev=0 --tags) -SHA := $(shell test -d .git && git rev-parse --short HEAD) +TAG_NAME ?= $(shell test -d .git && git describe --abbrev=0 --tags) +SHA ?= $(shell test -d .git && git rev-parse --short HEAD) COMMIT := $(SHA) # hide commit for releases ifeq ($(RELEASE),1) From b6fda3212838830c3923c900fc4601a2e2516725 Mon Sep 17 00:00:00 2001 From: Cornelis Stuurman <56003577+corstuur@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:05:07 +0200 Subject: [PATCH 0069/1316] Enever: fix time parsing (#29129) --- templates/definition/tariff/enever.yaml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/templates/definition/tariff/enever.yaml b/templates/definition/tariff/enever.yaml index 880900eb8..2b3829df5 100644 --- a/templates/definition/tariff/enever.yaml +++ b/templates/definition/tariff/enever.yaml @@ -37,6 +37,14 @@ params: "ZP", ] required: true + - name: resolution + description: + en: Price resolution + de: Preisauflösung + type: choice + choice: ["hourly", "quarterly"] + default: quarterly + required: true - preset: tariff-base - preset: tariff-features - name: interval @@ -56,7 +64,7 @@ render: | type: string config: source: http - uri: https://enever.nl/apiv3/stroomprijs_vandaag.php?token={{ .token }} + uri: https://enever.nl/apiv3/stroomprijs_vandaag.php?token={{ .token }}{{ if eq .resolution "quarterly" }}&resolution=15{{ end }} jq: | [ .data.[] | { @@ -64,7 +72,7 @@ render: | "end": ( (.datum[0:19]) as $dt | (.datum[19:]) as $tz | - ($dt | strptime("%FT%T") | mktime + 900 | strflocaltime("%FT%T")) + $tz + ($dt | strptime("%FT%T") | mktime + {{ if eq .resolution "quarterly" }}900{{ else }}3600{{ end }} | strftime("%FT%T")) + $tz ), "value": .prijs{{ .provider }} | tonumber } @@ -73,7 +81,7 @@ render: | type: string config: source: http - uri: https://enever.nl/apiv3/stroomprijs_morgen.php?token={{ .token }} + uri: https://enever.nl/apiv3/stroomprijs_morgen.php?token={{ .token }}{{ if eq .resolution "quarterly" }}&resolution=15{{ end }} jq: | [ .data.[] | { @@ -81,9 +89,9 @@ render: | "end": ( (.datum[0:19]) as $dt | (.datum[19:]) as $tz | - ($dt | strptime("%FT%T") | mktime + 900 | strflocaltime("%FT%T")) + $tz + ($dt | strptime("%FT%T") | mktime + {{ if eq .resolution "quarterly" }}900{{ else }}3600{{ end }} | strftime("%FT%T")) + $tz ), "value": .prijs{{ .provider }} | tonumber - } + } ] | tostring interval: {{ .interval }} From 81b78db887e67aab77ebbc5fece156c1657193bd Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 15 Apr 2026 13:34:28 +0200 Subject: [PATCH 0070/1316] Collect 15min energy metrics (#23185) --- assets/js/components/BottomTabs/MoreMenu.vue | 5 +- assets/js/components/History/Chart.vue | 177 ++++++++++++++ assets/js/components/History/EnergyChart.vue | 170 ++++++++++++++ assets/js/components/Top/Navigation.vue | 5 + assets/js/router.ts | 5 + assets/js/views/History.vue | 103 ++++++++ core/meterenergy.go | 60 ----- core/metrics/accumulator.go | 126 ++++++++++ .../accumulator_test.go} | 24 +- core/metrics/collector.go | 126 ++++++++++ core/metrics/collector_test.go | 132 +++++++++++ core/metrics/db.go | 220 ++++++++++++++++-- core/metrics/db_test.go | 102 ++++++++ core/site.go | 81 +++---- core/site_optimizer.go | 3 +- core/site_optimizer_test.go | 85 ------- core/site_tariffs.go | 16 +- core/site_test.go | 35 --- server/http.go | 1 + server/http_history_handler.go | 51 ++++ tests/energy-history.spec.ts | 72 ++++++ tests/energy-history.sql | 42 ++++ 22 files changed, 1375 insertions(+), 266 deletions(-) create mode 100644 assets/js/components/History/Chart.vue create mode 100644 assets/js/components/History/EnergyChart.vue create mode 100644 assets/js/views/History.vue delete mode 100644 core/meterenergy.go create mode 100644 core/metrics/accumulator.go rename core/{meterenergy_test.go => metrics/accumulator_test.go} (53%) create mode 100644 core/metrics/collector.go create mode 100644 core/metrics/collector_test.go create mode 100644 core/metrics/db_test.go create mode 100644 server/http_history_handler.go create mode 100644 tests/energy-history.spec.ts create mode 100644 tests/energy-history.sql diff --git a/assets/js/components/BottomTabs/MoreMenu.vue b/assets/js/components/BottomTabs/MoreMenu.vue index 53b374709..f27d2704d 100644 --- a/assets/js/components/BottomTabs/MoreMenu.vue +++ b/assets/js/components/BottomTabs/MoreMenu.vue @@ -55,7 +55,10 @@ to="/optimize" active-class="active" > - Optimize + Optimize 🧪 + + + History 🧪 diff --git a/assets/js/components/History/Chart.vue b/assets/js/components/History/Chart.vue new file mode 100644 index 000000000..874388cf4 --- /dev/null +++ b/assets/js/components/History/Chart.vue @@ -0,0 +1,177 @@ + + + diff --git a/assets/js/components/History/EnergyChart.vue b/assets/js/components/History/EnergyChart.vue new file mode 100644 index 000000000..59705dc45 --- /dev/null +++ b/assets/js/components/History/EnergyChart.vue @@ -0,0 +1,170 @@ + + + diff --git a/assets/js/components/Top/Navigation.vue b/assets/js/components/Top/Navigation.vue index 3db3845c3..11ddb133d 100644 --- a/assets/js/components/Top/Navigation.vue +++ b/assets/js/components/Top/Navigation.vue @@ -89,6 +89,11 @@ Optimize 🧪 +
  • + + History 🧪 + +
  • diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index 68fe825e5..e6fba841d 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -427,8 +427,8 @@ - - + + diff --git a/i18n/de.json b/i18n/de.json index 7af4256b2..bbf0a02a9 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -602,9 +602,8 @@ "urlHelp": "Kopiere diese URL in die Konfiguration deiner Wallbox. Details findest du im Handbuch des Herstellers. Die Wallbox sollte automatisch ihre eindeutige Kennung (Station-ID) an die URL anhängen. In seltenen Fällen musst du die Kennung manuell angeben. Beispiel: `{url}`" }, "optimizer": { - "description": "Analysiert Solarprognose, Strompreise und deinen typischen Verbrauch, um Batterie- und Ladestrategie zu optimieren. Daten werden zur Berechnung an den evcc Optimierungsdienst übertragen. Berechnet und visualisiert aktuell nur. Steuert noch keine Geräte.", + "description": "Analysiert Solarprognose, Strompreise und deinen typischen Verbrauch, um Batterie- und Ladestrategie zu optimieren. Daten werden zur Berechnung an den evcc Optimierungsdienst übertragen.", "enable": "Optimizer aktivieren", - "info": "Es kann einige Minuten dauern, bis der Optimizer-Menüeintrag sichtbar wird. Bei neuen Installationen kann es bis zu 24 Stunden dauern, bis evcc genügend Daten gesammelt hat.", "title": "Optimizer" }, "options": { diff --git a/i18n/en.json b/i18n/en.json index 8d852e51c..deed0f3f9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -603,9 +603,8 @@ "urlHelp": "Copy this URL into your charger's configuration. Check the manufacturer's manual for details. The charger is expected to automatically append its unique identifier (station ID) to the url. In rare cases, you may need to manually specify the identifier. Example: `{url}`" }, "optimizer": { - "description": "Analyzes solar forecast, electricity prices, and your consumption patterns to optimize battery and charging strategy. Data is sent to the evcc optimization service for processing. Currently only calculates and visualizes. Does not control devices yet.", + "description": "Analyzes solar forecast, electricity prices, and your consumption patterns to optimize battery and charging strategy. Data is sent to the evcc optimization service for processing.", "enable": "Enable Optimizer", - "info": "It may take a few minutes for the optimizer menu entry to become visible. For new installations, it may take up to 24 hours until evcc has collected enough data.", "title": "Optimizer" }, "options": { From 643f1dcb2eac361774995c667c81651580df5bb6 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 17 Apr 2026 14:22:27 +0200 Subject: [PATCH 0082/1316] chore: open source --- charger/smart-evse.go | 5 ----- templates/definition/charger/smart-evse.yaml | 1 - 2 files changed, 6 deletions(-) diff --git a/charger/smart-evse.go b/charger/smart-evse.go index ac01f3755..1f93831b5 100644 --- a/charger/smart-evse.go +++ b/charger/smart-evse.go @@ -26,7 +26,6 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" - "github.com/evcc-io/evcc/util/sponsor" ) // SmartEVSE-3.5 REST API charger implementation @@ -138,10 +137,6 @@ func NewSmartEVSE3(uri string, cache time.Duration) (api.Charger, error) { curr: 60, // 6 A in 1/10 A } - if !sponsor.IsAuthorized() { - return nil, api.ErrSponsorRequired - } - wb.apiG = util.ResettableCached(func() (smartEvseRestSettings, error) { var res smartEvseRestSettings err := wb.GetJSON(wb.uri+"/settings", &res) diff --git a/templates/definition/charger/smart-evse.yaml b/templates/definition/charger/smart-evse.yaml index c15db7708..60b52e2a2 100644 --- a/templates/definition/charger/smart-evse.yaml +++ b/templates/definition/charger/smart-evse.yaml @@ -5,7 +5,6 @@ products: generic: Smart Evse v3 / v3.5 capabilities: ["mA", "rfid", "1p3p"] requirements: - evcc: ["sponsorship"] description: de: | Anbindung über die REST API der Smart Evse Firmware (FW >= 3.6.0). From a0989c18b4001220abdd528c7d68ed2a9bf16129 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 17 Apr 2026 14:23:39 +0200 Subject: [PATCH 0083/1316] Reapply "Config UI: better explain optimizer (#29141)" This reverts commit f474245bd9b8b0c6a9cbc12b419ab365a28b4fd5. --- assets/js/components/Config/OptimizerModal.vue | 16 ++++++++++++++-- assets/js/components/Config/TelemetryModal.vue | 12 +++--------- assets/js/views/Config.vue | 4 ++-- i18n/de.json | 3 ++- i18n/en.json | 3 ++- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/assets/js/components/Config/OptimizerModal.vue b/assets/js/components/Config/OptimizerModal.vue index a5a84aa56..23b95bb6d 100644 --- a/assets/js/components/Config/OptimizerModal.vue +++ b/assets/js/components/Config/OptimizerModal.vue @@ -1,7 +1,7 @@ @@ -32,6 +37,7 @@ import { defineComponent } from "vue"; import GenericModal from "../Helper/GenericModal.vue"; import ErrorMessage from "../Helper/ErrorMessage.vue"; +import SponsorTokenRequired from "./DeviceModal/SponsorTokenRequired.vue"; import api from "@/api"; import store from "@/store"; import { docsPrefix } from "@/i18n"; @@ -39,7 +45,10 @@ import type { AxiosError } from "axios"; export default defineComponent({ name: "OptimizerModal", - components: { GenericModal, ErrorMessage }, + components: { GenericModal, ErrorMessage, SponsorTokenRequired }, + props: { + isSponsor: Boolean, + }, data() { return { error: null as string | null, @@ -49,6 +58,9 @@ export default defineComponent({ enabled(): boolean { return !!store.state?.optimizer; }, + hasEvopt(): boolean { + return !!store.state?.evopt; + }, docsLink(): string { return `${docsPrefix()}/docs/features/optimizer`; }, diff --git a/assets/js/components/Config/TelemetryModal.vue b/assets/js/components/Config/TelemetryModal.vue index c318bea75..0c1e20fa5 100644 --- a/assets/js/components/Config/TelemetryModal.vue +++ b/assets/js/components/Config/TelemetryModal.vue @@ -7,27 +7,21 @@ data-testid="telemetry-modal" >

    {{ $t("config.telemetry.description") }}

    - + diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index e6fba841d..68fe825e5 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -427,8 +427,8 @@ - - + + diff --git a/i18n/de.json b/i18n/de.json index bbf0a02a9..7af4256b2 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -602,8 +602,9 @@ "urlHelp": "Kopiere diese URL in die Konfiguration deiner Wallbox. Details findest du im Handbuch des Herstellers. Die Wallbox sollte automatisch ihre eindeutige Kennung (Station-ID) an die URL anhängen. In seltenen Fällen musst du die Kennung manuell angeben. Beispiel: `{url}`" }, "optimizer": { - "description": "Analysiert Solarprognose, Strompreise und deinen typischen Verbrauch, um Batterie- und Ladestrategie zu optimieren. Daten werden zur Berechnung an den evcc Optimierungsdienst übertragen.", + "description": "Analysiert Solarprognose, Strompreise und deinen typischen Verbrauch, um Batterie- und Ladestrategie zu optimieren. Daten werden zur Berechnung an den evcc Optimierungsdienst übertragen. Berechnet und visualisiert aktuell nur. Steuert noch keine Geräte.", "enable": "Optimizer aktivieren", + "info": "Es kann einige Minuten dauern, bis der Optimizer-Menüeintrag sichtbar wird. Bei neuen Installationen kann es bis zu 24 Stunden dauern, bis evcc genügend Daten gesammelt hat.", "title": "Optimizer" }, "options": { diff --git a/i18n/en.json b/i18n/en.json index deed0f3f9..8d852e51c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -603,8 +603,9 @@ "urlHelp": "Copy this URL into your charger's configuration. Check the manufacturer's manual for details. The charger is expected to automatically append its unique identifier (station ID) to the url. In rare cases, you may need to manually specify the identifier. Example: `{url}`" }, "optimizer": { - "description": "Analyzes solar forecast, electricity prices, and your consumption patterns to optimize battery and charging strategy. Data is sent to the evcc optimization service for processing.", + "description": "Analyzes solar forecast, electricity prices, and your consumption patterns to optimize battery and charging strategy. Data is sent to the evcc optimization service for processing. Currently only calculates and visualizes. Does not control devices yet.", "enable": "Enable Optimizer", + "info": "It may take a few minutes for the optimizer menu entry to become visible. For new installations, it may take up to 24 hours until evcc has collected enough data.", "title": "Optimizer" }, "options": { From bb93ebf98bcff1b197ff3cb467746a3ac9c85483 Mon Sep 17 00:00:00 2001 From: RenatusRo <126881912+RenatusRo@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:06:28 +0200 Subject: [PATCH 0084/1316] Optimizer: wording on debug view (#29179) --- assets/js/views/Optimize.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/views/Optimize.vue b/assets/js/views/Optimize.vue index ab82e483e..37990f5a8 100644 --- a/assets/js/views/Optimize.vue +++ b/assets/js/views/Optimize.vue @@ -34,7 +34,7 @@ true ) }} - saved + net cost Date: Sat, 18 Apr 2026 09:27:18 +0200 Subject: [PATCH 0085/1316] Optimizer: don't send empty requests --- core/site_optimizer.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 03d3c697d..f175fff9e 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -221,6 +221,11 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error { add(site.batteryRequest(dev, b, grid, minLen, firstSlotDuration)) } + // empty request- all loadpoints disabled + if len(req.Batteries) == 0 { + return nil + } + httpClient := request.NewClient(site.log) httpClient.Timeout = 30 * time.Second From 11ec83716d803824307642eaf6831643021ecf35 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 18 Apr 2026 09:37:29 +0200 Subject: [PATCH 0086/1316] Translations update from Hosted Weblate (#28648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Dominik Trzebiński Co-authored-by: Jonas Gate Co-authored-by: Frits van Proosdij Co-authored-by: noxaos Co-authored-by: amtssp Co-authored-by: RTTTC <94727758+RTTTC@users.noreply.github.com> Co-authored-by: uqlel Co-authored-by: Joni Lehtola Co-authored-by: MobilettoSoft Co-authored-by: Filipe Lima Co-authored-by: aerucu Co-authored-by: bittin1ddc447d824349b2 Co-authored-by: Jaap Broekhuizen Co-authored-by: Jan --- i18n/da.json | 100 ++++++++++++++++++++++++++++++++++++++++++++++----- i18n/de.json | 15 +++++--- i18n/fi.json | 49 +++++++++++++++++++++++-- i18n/fr.json | 62 ++++++++++++++++++++++++++++---- i18n/it.json | 45 ++++++++++++++++++++++- i18n/lb.json | 64 ++++++++++++++++++++++++++++----- i18n/lt.json | 54 +++++++++++++++++++++++++--- i18n/nl.json | 75 ++++++++++++++++++++++++++++++++++---- i18n/no.json | 2 +- i18n/pl.json | 4 ++- i18n/pt.json | 62 ++++++++++++++++++++++++++++---- i18n/sv.json | 79 +++++++++++++++++++++++++++++++++++----- i18n/tr.json | 57 +++++++++++++++++++++++++---- 13 files changed, 602 insertions(+), 66 deletions(-) diff --git a/i18n/da.json b/i18n/da.json index 5adbf533f..df6b04bc6 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -40,6 +40,7 @@ "legendTopName": "Batteri-understøttet opladning af køretøj", "legendTopSubline": "når husbatteriet er over {soc}.", "modalTitle": "Hus batteri", + "noBattery": "Ingen batterier er konfigurerede.", "usageTab": "Batteri anvendelse" }, "config": { @@ -112,6 +113,7 @@ "title": "Møntfod" }, "deviceValue": { + "activeClients": "Aktive klienter", "amount": "Mængde", "broker": "Broker", "bucket": "Bucket", @@ -125,6 +127,7 @@ "chargedEnergy": "Opladet", "co2": "Elnettet CO₂", "configured": "Konfigureret", + "connected": "Forbundet", "connections": "Tilslutninger", "controllable": "Kan styres", "currency": "Møntenhed", @@ -143,6 +146,7 @@ "hemsActiveLimit": "Aktiv begrænsning", "hemsType": "Kommunikation", "identifier": "RFID-identifikator", + "loginBlocked": "Grænsen for loginforsøg er nået", "max": "max", "messengers": "Tjenester", "no": "nej", @@ -601,6 +605,12 @@ "url": "Server URL", "urlHelp": "Kopier denne URL til din laders opsætning. Tjek producentens vejledning for detaljer. Laderen tilføjer normalt automatisk sit unikke ID (stations-id) til URL’en. I sjældne tilfælde skal ID’et angives manuelt. Eksempel: {url}" }, + "optimizer": { + "description": "Analyserer sol-prognose, el-priser, og forbrugsmønstre for at optimere batteri og opladning. Data sendes til evcc optimerings-service for at blive beregnet. Aktuelt beregnes og vises data kun, der er ingen styring af enheder endnu.", + "enable": "Aktiver Optimering", + "info": "Det kan tage et par minutter for optimerings-menuen er synlig. For nye installationer kan det tage op til 24 timer før der er opsamlet tilstrækkelig med data.", + "title": "Optimering" + }, "options": { "boolean": { "no": "nej", @@ -628,6 +638,38 @@ "titleAdd": "Tilføj solmåler", "titleEdit": "Rediger solmåler" }, + "remote": { + "active": "Aktiv", + "addClient": "Tilføj klient", + "addClientDescription": "Oplysningerne gemmes og verificeres kun lokalt på din evcc-enhed.", + "addClientTitle": "Tilføj fjernklient", + "clientCreated": "Klient oprettet", + "clients": "Klienter", + "confirmDelete": "Slet klient?", + "connected": "Forbundet", + "createClient": "Opret klient", + "description": "Få adgang til din evcc-installation hvor som helst via evcc-mobilappen. Ingen viderestilling af porte eller VPN er krævet.", + "deviceName": "Navn på enhed", + "disconnected": "Afbrudt", + "done": "Færdig", + "enableLabel": "Tillad fjernadgang", + "expiration": "Udløbet", + "expirationNone": "Aldrig", + "expired": "Udløbet", + "expiresIn": "udløber {time}", + "lastActive": "aktiv {time}", + "loginBlocked": "Fjernadgang blokeret i ét minut på grund af for mange loginforsøg.", + "manualLogin": "Eller login manuelt ved {url} i din browser med disse oplysninger:", + "noClients": "Ingen klient endnu. Ingen kan forbindes endnu.", + "password": "Kode", + "passwordOnce": "Denne kode vises kun én gang. Skan QR koden eller kopier den nu. Den kan ikke vises igen.", + "qrInstall": "Installer evcc appen for {ios} eller {android}.", + "qrScan": "Skan koden med telefonens kamera for at forbinde. Klik på det hvis du allerede bruger telefonen.", + "removeClient": "Fjern klient", + "title": "Fjernadgang", + "url": "Offentlig URL", + "username": "Brugernavn" + }, "section": { "additionalMeter": "Tilføj ekstra målere", "general": "Generelle", @@ -760,7 +802,7 @@ "type": { "co2": "CO₂ Intensitet", "feedIn": "Indfødningspris", - "grid": "Forbrugspris fra elnettet", + "grid": "Købspris fra elnettet", "planner": "Planlægning", "solar": "Sol" }, @@ -854,6 +896,15 @@ "configurePriceCo2": "Lær hvordan man indstiller pris og CO₂ data.", "footerLong": "{percent} Solenergi", "footerShort": "{percent} Solenergi", + "indicator": { + "co2": "CO₂ emission", + "co2saved": "CO₂ sparet", + "none": "ingen", + "price": "energipris", + "savings": "sparet", + "solar": "solenergi" + }, + "indicatorLabel": "Overskriftsinformation", "modalTitle": "Oversigt over ladeenergi", "moneySaved": "{value} sparet", "percentGrid": "{grid} kWh elnettet", @@ -865,10 +916,11 @@ "thisYear": "dette år", "total": "hele tiden" }, - "periodLabel": "Periode:", + "periodLabel": "Periode", "priceTitle": "Energipris", "referenceGrid": "elnet", - "referenceLabel": "Referencedata:", + "referenceLabel": "Referencedata", + "sessionInfo": "Baseret på gennemførte opladninger.", "tabTitle": "Mine data" }, "sponsor": { @@ -894,15 +946,26 @@ }, "version": { "availableLong": "ny version tilgængelig", + "community": "evcc-fællesskabet", + "labelRelease": "Udgivet", + "labelVersion": "Version", + "labelWebsite": "Hjemmeside", + "latestVersion": "nyeste version", + "madeByCommunity": "Lavet af {0}.", "modalCancel": "Annuller", "modalDownload": "Hent", "modalInstalledVersion": "Installeret version", + "modalLatest": "Du bruger den nyeste version.", + "modalNextRelease": "Hvad er i næste udgave", "modalNoReleaseNotes": "Der er ingen tilgængelige release notes. Mere info om den nye version:", "modalTitle": "Ny version tilgængelig", "modalUpdate": "Installere", "modalUpdateNow": "Installer nu", "modalUpdateStarted": "Starter den nye version af evcc…", - "modalUpdateStatusStart": "Installation startede:" + "modalUpdateStatusStart": "Installation startede:", + "modalViewOnGitHub": "Se på GitHub", + "openSource": "open source", + "poweredByOpenSource": "Drevet af {0}." } }, "forecast": { @@ -912,6 +975,13 @@ "lowestHour": "Reneste time", "range": "spændvidde" }, + "empty": { + "co2": "Se når strøm i din region er grøn. Opladningsplaner bliver optimeret til lav-emission og sparet CO₂ beregnes.", + "price": "Konfigurér din dynamiske eltarif for automatisk at optimere ladeplaner og beregne besparelser.", + "setup": "Indstil tariffer og prognoser", + "solar": "Vis forventet solenergi produceret i dag og de nærmeste dage. Bliver også brugt til automatisk optimering af opladninger i fremtiden." + }, + "hideLine": "skjul linjen", "modalTitle": "Prognose", "price": { "average": "Gennemsnit", @@ -919,6 +989,8 @@ "lowestHour": "Billigste time", "range": "Spændvidde" }, + "priceZoom": "zoom ind", + "showLine": "vis linjen", "solar": { "dayAfterTomorrow": "I overmorgen", "partly": "delvis", @@ -927,10 +999,12 @@ "tomorrow": "I morgen" }, "solarAdjust": "Juster solprognosen på baggrund af produktions data{percent}.", + "solarAdjustMedium": "Justering baseret på faktiske data", + "solarAdjustShort": "justering", "type": { - "co2": "CO₂", - "price": "Pris", - "solar": "Sol" + "co2": "CO₂ Emissioner", + "price": "Strømpris", + "solar": "Solenergiproduktion" } }, "general": { @@ -1099,7 +1173,7 @@ "batteryTooltip": "{energy} af {total} ({soc})", "forecast": "Prognose: ", "forecastTooltip": "prognose: resterende solenergi produktion i dag", - "gridImport": "Forbrug fra elnet", + "gridImport": "Import fra elnet", "homePower": "Forbrug", "loadpoints": "Oplader| Oplader | {count} opladere", "loadpointsLimit": "{limit} begrænsning", @@ -1144,6 +1218,7 @@ "once": "Boost er aktiveret for denne opladning.", "stateActive": "Batteriboost er aktiv", "stateBelowLimit": "Batteriniveau er for lav til boost", + "stateHold": "Batteriet er låst", "stateReady": "Batteriboost er klar" }, "batteryUsage": "Husbatteri", @@ -1253,6 +1328,7 @@ "batteryBoostBelowLimit": "Batteriniveau er for lav til boost.", "batteryBoostDisabled": "Batteriboost er deaktiveret.", "batteryBoostEnabled": "Boost indtil batteri er på {limit}.", + "batteryBoostHold": "Batteriet låst. Boost er ikke muligt.", "charging": "Oplader…", "cheapEnergyCharging": "Billig energi er tilgængelig.", "cheapEnergyNextStart": "Billig energi i {duration}.", @@ -1492,5 +1568,13 @@ "lineErrorLink": "linje {0}", "restartButton": "Genstart", "title": "Fejl ved opstart" + }, + "tabBar": { + "battery": "Batteri", + "charge": "Oplader", + "comingSoon": "Siden under opbygning.", + "forecast": "Prognose", + "more": "Mere", + "sessions": "Sessioner" } } diff --git a/i18n/de.json b/i18n/de.json index 7af4256b2..aec63eea6 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -127,6 +127,7 @@ "chargedEnergy": "Geladen", "co2": "Netz-CO₂", "configured": "Konfiguriert", + "connected": "Verbunden", "connections": "Verbindungen", "controllable": "Steuerbar", "currency": "Währung", @@ -946,6 +947,7 @@ "modalCancel": "Abbrechen", "modalDownload": "Download", "modalInstalledVersion": "Installierte Version", + "modalLatest": "Du verwendest bereits die aktuellste Version.", "modalNextRelease": "Was ist neu im nächsten Release", "modalNoReleaseNotes": "Keine Versionshinweise verfügbar. Mehr Informationen zur neuen Version:", "modalTitle": "Neue Version verfügbar", @@ -955,7 +957,7 @@ "modalUpdateStatusStart": "Installation gestartet:", "modalViewOnGitHub": "Auf GitHub ansehen", "openSource": "Open Source", - "poweredByOpenSource": "Powered by {0}." + "poweredByOpenSource": "Unterstützt von {0}." } }, "forecast": { @@ -971,6 +973,7 @@ "setup": "Tarife und Vorhersagen einrichten", "solar": "Erwartete Solarproduktion für heute und die nächsten Tage. Wird in Zukunft auch für die automatische Ladeoptimierung verwendet." }, + "hideLine": "Linie ausblenden", "modalTitle": "Vorhersage", "price": { "average": "Durchschnitt", @@ -978,6 +981,8 @@ "lowestHour": "Günstigste Stunde", "range": "Bereich" }, + "priceZoom": "vergrößern", + "showLine": "Linie anzeigen", "solar": { "dayAfterTomorrow": "Übermorgen", "partly": "teilweise", @@ -986,10 +991,12 @@ "tomorrow": "Morgen" }, "solarAdjust": "Solarprognose anhand der realen Produktionsdaten anpassen{percent}.", + "solarAdjustMedium": "an reale Produktionsdaten anpassen", + "solarAdjustShort": "anpassen", "type": { - "co2": "CO₂", - "price": "Preis", - "solar": "Solar" + "co2": "CO₂-Emissionen", + "price": "Netzpreis", + "solar": "Solarproduktion" } }, "general": { diff --git a/i18n/fi.json b/i18n/fi.json index dc602412f..9d76ab6c1 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -113,6 +113,7 @@ "title": "Valuutta" }, "deviceValue": { + "activeClients": "Aktiiviset asiakasyhteydet", "amount": "Määrä", "broker": "Välittäjä (Broker)", "bucket": "Tietosäiliö (Bucket)", @@ -126,6 +127,7 @@ "chargedEnergy": "Ladattu", "co2": "Verkon CO₂", "configured": "Määritetty", + "connected": "Yhdistetty", "connections": "Yhteydet", "controllable": "Hallittava", "currency": "Valuutta", @@ -144,6 +146,7 @@ "hemsActiveLimit": "Aktiivinen raja", "hemsType": "Kommunikaatiomenetelmä", "identifier": "RFID-tunniste", + "loginBlocked": "Sisäänkirjoitumisyritysten raja saavutettu", "max": "maksimi", "messengers": "Palvelut", "no": "ei", @@ -602,6 +605,11 @@ "url": "Palvelimen URL", "urlHelp": "Kopioi tämä URL laturisi asetuksiin. Tarkista laturivalmistajan ohjeista yksityiskohdat. Laturin odotetaan lisäävän URL-osoitteeseen yksilöllinen tunnus (aseman ID). Joissain harvoissa tapauksissa sinun täytyy käsin lisätä tämä tunnus. Esim: `{url}`" }, + "optimizer": { + "description": "Analysoi aurinkoennustetta, sähkönhintaa ja kulutustottumuksiasi optimoidakseen akun ja latauspisteiden käyttöä. Tiedot lähetetään käsiteltäväksi evcc:n optimointipalveluun.", + "enable": "Käytä optimointia", + "title": "Optimointi" + }, "options": { "boolean": { "no": "ei", @@ -629,6 +637,38 @@ "titleAdd": "Lisää aurinkovoimalan mittari", "titleEdit": "Muokkaa aurinkovoimalan mittaria" }, + "remote": { + "active": "Aktiivinen", + "addClient": "Lisää etäkäyttäjä", + "addClientDescription": "Käyttäjätunnustiedot tallennetaan ja varmennetaan vain paikallisesti sinun evcc-asennuksessasi.", + "addClientTitle": "Lisää etäkäyttäjäasiakas", + "clientCreated": "Etäkäyttäjä luotu", + "clients": "Etäkäyttäjät", + "confirmDelete": "Poista etäkäyttäjä?", + "connected": "Yhdistetty", + "createClient": "Luo etäkäyttäjäasiakas", + "description": "Pääse käsiksi evcc-asennukseesi mistä vain käyttäen evcc-kännykkäsovellusta. Ei vaadi VPN:ää tai reititinohjausasetuksia.", + "deviceName": "Laitteen nimi", + "disconnected": "Ei yhteydessä", + "done": "Valmis", + "enableLabel": "Salli etäkäyttö", + "expiration": "Umpeutuu", + "expirationNone": "Ei koskaan", + "expired": "umpeutunut", + "expiresIn": "umpeutuu {time}", + "lastActive": "aktiivinen {time}", + "loginBlocked": "Etäkirjautumiset on estetty minuutiksi, koska liian monta kirjautumisyritystä on epäonnistunut.", + "manualLogin": "tai kirjaudu {url} selaimessa käyttäen seuraavia tunnuksia:", + "noClients": "Ei vielä etäyhteysasiakkaita. Kukaan ei vielä voi kirjautua etänä.", + "password": "Salasana", + "passwordOnce": "Tämä salasana näytetään vain kerran. Skannaa QR-koodi tai kopioi se, sillä et voi nähdä sitä enää uudelleen.", + "qrInstall": "Asenna evcc-sovellus {ios}:sta tai {android}:sta.", + "qrScan": "Skannaa koodi puhelimesi kameralla yhdistääksesi. Klikkaa linkkiä, jos jo käytät puhelimesi kameraa.", + "removeClient": "Poista etäyhteysasiakas", + "title": "Etäyhteys", + "url": "Julkinen URL-osoite", + "username": "Käyttäjätunnus" + }, "section": { "additionalMeter": "Ylimääräiset mittarit", "general": "Yleinen", @@ -759,8 +799,8 @@ "titleChoice": "Mitä haluat lisätä?", "type": { "co2": "CO₂-intensiteetti", - "feedIn": "Syöttöhinta", - "grid": "Verkkosähkön hinta", + "feedIn": "Sähkön myyntihinta verkkoon", + "grid": "Verkkosähkön ostohinta", "planner": "Suunnitelma", "solar": "Aurinko" }, @@ -939,6 +979,7 @@ "setup": "Määritä hinnat ja ennusteet", "solar": "Katso aurinkotuotannon ennuste täksi ja tuleviksi päiviksi. Käytetään tulevaisuudessa myös latauksien optimoitiin." }, + "hideLine": "piilota viiva", "modalTitle": "Ennuste", "price": { "average": "Keskimääräinen", @@ -947,6 +988,7 @@ "range": "Toimintamatka" }, "priceZoom": "lähennä", + "showLine": "näytä viiva", "solar": { "dayAfterTomorrow": "Ylihuomenna", "partly": "osittain", @@ -955,7 +997,8 @@ "tomorrow": "Huomenna" }, "solarAdjust": "Mukauta aurinkosääennustettu todellisten tuotantotietojen perusteella{percent}.", - "solarAdjustShort": "mitatun datan korjaus", + "solarAdjustMedium": "mitatun datan korjaus", + "solarAdjustShort": "korjaus", "type": { "co2": "CO₂-päästöt", "price": "Sähkön hinta", diff --git a/i18n/fr.json b/i18n/fr.json index 0011cf86f..40649f246 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -40,6 +40,7 @@ "legendTopName": "Charge véhicule par la batterie", "legendTopSubline": "lorsque la batterie domestique est au-dessus de {soc}.", "modalTitle": "Batterie domestique", + "noBattery": "Aucune batterie n'est configurée.", "usageTab": "Utilisation batterie" }, "config": { @@ -130,8 +131,9 @@ "currency": "Devise", "current": "Courant", "currentRange": "Courant", + "curtailed": "Limitation de l'injection", "detected": "Détecté", - "dimmed": "Atténué", + "dimmed": "Consommation limitée", "enabled": "Activé", "energy": "Énergie", "events": "Evénements", @@ -600,6 +602,11 @@ "url": "URL du Serveur", "urlHelp": "Copiez cette URL dans la configuration de votre chargeur. Consultez le manuel du fabricant pour plus de détails. Le chargeur devrait ajouter automatiquement son identifiant unique (ID de station) à l'URL. Dans de rares cas, vous devrez peut-être spécifier manuellement l'identifiant. Exemple : `{url}`" }, + "optimizer": { + "description": "Analyse les prévisions d'ensoleillement, les prix de l'électricité et vos habitudes de consommation afin d'optimiser la stratégie de gestion de la batterie et de recharge. Les données sont transmises au service d'optimisation evcc pour y être traitées.", + "enable": "Activer l'optimiseur", + "title": "Optimiseur" + }, "options": { "boolean": { "no": "non", @@ -642,11 +649,13 @@ "cardTitle": "Sunny Home Manager", "description": "evcc est équipé d'une intégration pour le SMA Sunny Home Manager (SHM) via le protocole SEMP. S'il fonctionne sur le même réseau, après vous être connecté à votre compte Sunny Portal, vous devriez automatiquement avoir la possibilité d'ajouter tous les chargeurs configurés dans evcc en tant que nouveaux consommateurs détectés. Tout devrait être prêt à l'emploi immédiatement, sans qu'aucun réglage ci-dessous ne soit nécessaire.", "descriptionDeviceId": "Chaîne HEX de 12 caractères. Préfixe pour tous les appareils (point de charge, etc.).", + "descriptionDeviceSerial": "Chaîne de 12 caractères en hexadécimal. Numéro de série de base pour tous les appareils (borne de recharge, etc.). Par défaut, evcc le déduit de l'adresse MAC de l'hôte.", "descriptionIdPattern": "Schéma d'identification", "descriptionIds": "Dans Sunny Portal, chaque appareil consommateur doit disposer d'un identifiant unique. evcc génère un identifiant unique basé sur votre matériel. Si vous migrez evcc vers un autre matériel, ces identifiants peuvent changer. Si vous souhaitez conserver l'historique, vous pouvez remplacer les identifiants générés ici. Ouvrez l'URL SEMP (/semp) pour vérifier vos identifiants actuels.", "descriptionSempUrl": "URL SEMP", "descriptionVendorId": "Chaîne HEX de 8 caractères. Préfixe général de toutes les entités. Par défaut, evcc utilisera son propre identifiant fournisseur interne.", "labelDeviceId": "Identifiant de l'appareil", + "labelDeviceSerial": "Numéro de série de l'appareil", "labelVendorId": "Identifiant du fournisseur", "title": "SMA Sunny Home Manager" }, @@ -850,6 +859,15 @@ "configurePriceCo2": "Infos sur la configuration du prix et des données de CO₂.", "footerLong": "{percent} énergie solaire", "footerShort": "{percent} solaire", + "indicator": { + "co2": "Émissions de CO₂", + "co2saved": "Économies de CO₂", + "none": "aucun", + "price": "prix de l'énergie", + "savings": "économies", + "solar": "énergie solaire" + }, + "indicatorLabel": "Informations d'en-tête", "modalTitle": "Aperçu de l'énergie de charge", "moneySaved": "{value} économisés", "percentGrid": "{grid} kWh réseau", @@ -861,10 +879,11 @@ "thisYear": "cette année", "total": "depuis le début" }, - "periodLabel": "Période :", + "periodLabel": "Période", "priceTitle": "Prix de l'énergie", "referenceGrid": "réseau électrique", - "referenceLabel": "Données de référence :", + "referenceLabel": "Données de référence", + "sessionInfo": "Sur la base des sessions de recharge terminées.", "tabTitle": "Mes données" }, "sponsor": { @@ -890,15 +909,26 @@ }, "version": { "availableLong": "mise à jour disponible", + "community": "Communauté evcc", + "labelRelease": "Lancement", + "labelVersion": "Version", + "labelWebsite": "Site web", + "latestVersion": "dernière version", + "madeByCommunity": "Réalisé par {0}.", "modalCancel": "Annuler", "modalDownload": "Télécharger", "modalInstalledVersion": "Version installée", + "modalLatest": "Vous utilisez la dernière version.", + "modalNextRelease": "Nouveautés de la prochaine version", "modalNoReleaseNotes": "Aucune note de version disponible. Plus d'informations sur la nouvelle version disponibles ici :", "modalTitle": "Mise à jour disponible", "modalUpdate": "Installer", "modalUpdateNow": "Installer maintenant", "modalUpdateStarted": "Démarrage de la nouvelle version d’evcc…", - "modalUpdateStatusStart": "L'installation a commencé :" + "modalUpdateStatusStart": "L'installation a commencé :", + "modalViewOnGitHub": "Voir sur GitHub", + "openSource": "open source", + "poweredByOpenSource": "Propulsé par {0}." } }, "forecast": { @@ -908,6 +938,12 @@ "lowestHour": "Heure la plus propre", "range": "Amplitude" }, + "empty": { + "co2": "Découvrez quand l'électricité du réseau dans votre région est verte. Les plans de recharge seront optimisés pour réduire les émissions et calculeront les économies de CO₂ réalisées.", + "price": "Configurez votre tarif d'électricité dynamique pour optimiser automatiquement vos plans de recharge et calculer vos économies.", + "setup": "Définir les tarifs et les prévisions", + "solar": "Consultez les prévisions de production solaire pour aujourd'hui et les prochains jours. Ces données serviront également, à l'avenir, à optimiser automatiquement la recharge." + }, "modalTitle": "Prévisions", "price": { "average": "Moyenne", @@ -915,6 +951,7 @@ "lowestHour": "Heure la moins chère", "range": "Amplitude" }, + "priceZoom": "zoomer", "solar": { "dayAfterTomorrow": "Après-demain", "partly": "partiellement", @@ -923,10 +960,11 @@ "tomorrow": "Demain" }, "solarAdjust": "Ajuster la prévision solaire se basant sur la production réelle{percent}.", + "solarAdjustShort": "ajuster les données réelles", "type": { - "co2": "CO₂", - "price": "Prix", - "solar": "Solaire" + "co2": "Émissions de CO₂", + "price": "Prix du réseau", + "solar": "Production solaire" } }, "general": { @@ -1140,6 +1178,7 @@ "once": "Boost actif pour cette session de charge.", "stateActive": "Boost batterie activé", "stateBelowLimit": "Niveau de batterie trop bas pour le boost", + "stateHold": "Batterie verrouillée", "stateReady": "Boost de batterie prêt" }, "batteryUsage": "Batterie domestique", @@ -1249,6 +1288,7 @@ "batteryBoostBelowLimit": "Niveau de batterie trop bas pour boost.", "batteryBoostDisabled": "Boost batterie désactivé.", "batteryBoostEnabled": "Boost batterie jusqu'à {limit}.", + "batteryBoostHold": "Batterie verrouillée. Mode Boost indisponible.", "charging": "En charge…", "cheapEnergyCharging": "Énergie bon marché disponible.", "cheapEnergyNextStart": "Énergie bon marché dans {duration}.", @@ -1488,5 +1528,13 @@ "lineErrorLink": "Ligne {0}", "restartButton": "Redémarrer", "title": "Erreur au démarrage" + }, + "tabBar": { + "battery": "Batterie", + "charge": "Charger", + "comingSoon": "Cette page est en cours de construction.", + "forecast": "Prévisions", + "more": "Plus", + "sessions": "Sessions" } } diff --git a/i18n/it.json b/i18n/it.json index da6463a12..940edfa9a 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -40,6 +40,7 @@ "legendTopName": "Ricarica del veicolo assistita dalla batteria", "legendTopSubline": "quando la batteria supera il {soc}.", "modalTitle": "Batteria di casa", + "noBattery": "Nessuna batteria configurata.", "usageTab": "Uso della batteria" }, "config": { @@ -601,6 +602,11 @@ "url": "URL del server", "urlHelp": "Copia questo URL nella configurazione del caricabatterie. Per ulteriori dettagli, consulta il manuale del produttore. Il caricabatterie dovrebbe aggiungere automaticamente il proprio identificativo univoco (ID stazione) all'URL. In rari casi, potrebbe essere necessario specificare manualmente l'identificativo. Esempio: `{url}`" }, + "optimizer": { + "description": "Analizza le previsioni solari, i prezzi dell'elettricità e i tuoi pattern di consumo per ottimizzare l'uso della batteria e la strategia di carica. I dati sono inviati al servizio di ottimizzazione di evcc per l'elaborazione.", + "enable": "Abilita Ottimizzatore", + "title": "Ottimizzatore" + }, "options": { "boolean": { "no": "no", @@ -853,6 +859,14 @@ "configurePriceCo2": "Scopri come configurare i dati su prezzi e CO₂.", "footerLong": "{percent} di energia solare", "footerShort": "{percent} solare", + "indicator": { + "co2": "Emissioni CO₂", + "co2saved": "CO₂ risparmiata", + "none": "nessuno", + "price": "prezzo elettricità", + "savings": "risparmi", + "solar": "energia solare" + }, "modalTitle": "Riepilogo energia caricata", "moneySaved": "{value} di risparmio", "percentGrid": "{grid} kWh rete", @@ -868,6 +882,7 @@ "priceTitle": "Prezzo energia", "referenceGrid": "rete", "referenceLabel": "Dati di riferimento:", + "sessionInfo": "In base alle sessioni di ricarica completate.", "tabTitle": "I miei dati" }, "sponsor": { @@ -893,15 +908,25 @@ }, "version": { "availableLong": "aggiornamento disponibile", + "community": "community di evcc", + "labelRelease": "Release", + "labelVersion": "Versione", + "labelWebsite": "Sito web", + "latestVersion": "ultima versione", + "madeByCommunity": "Creato da {0}.", "modalCancel": "Annulla", "modalDownload": "Download", "modalInstalledVersion": "Versione correntemente installata", + "modalLatest": "Stai utilizzando la versione più recente.", + "modalNextRelease": "Cosa c'è nella prossima release", "modalNoReleaseNotes": "Non ci sono note di rilascio disponibili. Altre informazioni circa la nuova versione si trovano qui:", "modalTitle": "Aggiornamento disponibile", "modalUpdate": "Installare", "modalUpdateNow": "Installa ora", "modalUpdateStarted": "Evcc ripartirà dopo l'aggiornamento…", - "modalUpdateStatusStart": "Installazione avviata:" + "modalUpdateStatusStart": "Installazione avviata:", + "modalViewOnGitHub": "Vedi su GitHub", + "openSource": "open source" } }, "forecast": { @@ -911,6 +936,12 @@ "lowestHour": "Ora più ecologica", "range": "Intervallo" }, + "empty": { + "co2": "Controlla quando l'energia della rete nella tua regione è prodotta da fonti pulite. I piani di ricarica saranno ottimizzati per ridurre le emissioni e terranno conto del risparmio di CO₂.", + "price": "Configura la tua tariffa dinamica dell'elettricità in modo da ottimizzare i piani di ricarica e calcolare il risparmio economico.", + "setup": "Imposta le tariffe e le previsioni", + "solar": "Visualizza la produzione solare attesa per oggi e per i giorni seguenti. Sarà utilizzato anche per l'ottimizzazione automatica della ricarica futura." + }, "modalTitle": "Previsioni", "price": { "average": "Media", @@ -918,6 +949,7 @@ "lowestHour": "Ora più economica", "range": "Intervallo" }, + "priceZoom": "ingrandisci", "solar": { "dayAfterTomorrow": "Dopodomani", "partly": "parzialmente", @@ -926,6 +958,7 @@ "tomorrow": "Domani" }, "solarAdjust": "Modifica la previsione sulla base dei dati reali{percent}.", + "solarAdjustShort": "correzione con dati reali", "type": { "co2": "CO₂", "price": "Prezzo", @@ -1137,6 +1170,7 @@ "once": "Boost attivo per questa sessione di ricarica.", "stateActive": "Boost da batteria attivo", "stateBelowLimit": "Batteria troppo scarica per il boost", + "stateHold": "Batteria bloccata", "stateReady": "Battery boost pronto" }, "batteryUsage": "Batteria di casa", @@ -1246,6 +1280,7 @@ "batteryBoostBelowLimit": "Batteria troppo scarica per il boost", "batteryBoostDisabled": "Battery boost disabilitato.", "batteryBoostEnabled": "Boost fino al {limit}.", + "batteryBoostHold": "Batteria bloccata. Boost non disponibile.", "charging": "In carica…", "cheapEnergyCharging": "Corrente economica disponibile.", "cheapEnergyNextStart": "Corrente economica in {duration}.", @@ -1485,5 +1520,13 @@ "lineErrorLink": "linea {0}", "restartButton": "Ricomincia", "title": "Errore di avvio" + }, + "tabBar": { + "battery": "Batteria", + "charge": "Carica", + "comingSoon": "Questa pagina è in costruzione.", + "forecast": "Previsione", + "more": "Di più", + "sessions": "Sessioni" } } diff --git a/i18n/lb.json b/i18n/lb.json index 4f5acc722..27f3c437b 100644 --- a/i18n/lb.json +++ b/i18n/lb.json @@ -40,6 +40,7 @@ "legendTopName": "Batterie-ënnerstëtzt Opluede vum Gefier", "legendTopSubline": "wann d'Hausbatterie iwwer {soc} ass.", "modalTitle": "Hausbatterie", + "noBattery": "Keng Batterien konfiguréiert.", "usageTab": "Batterieverbrauch" }, "config": { @@ -130,8 +131,9 @@ "currency": "Wärung", "current": "Stroum", "currentRange": "Stroum", + "curtailed": "Einspeisung limitéiert", "detected": "Erkannt", - "dimmed": "Gedimmt", + "dimmed": "Verbrauch limitéiert", "enabled": "Ageschalt", "energy": "Energie", "events": "Evenementer", @@ -600,6 +602,11 @@ "url": "Server-URL", "urlHelp": "Kopéier dës URL fir d'Konfiguratioun vun denger Wallbox. Kuck am Handbuch vum Hiersteller no Detailer. Et gëtt erwaart, datt d'Wallbox automatesch säin eenzegaartegen Identifikator (Statiouns-ID) un d'URL bäifügt. A rare Fäll musst Dir den Identifikator eventuell manuell spezifizéieren. Beispill: `{url}`" }, + "optimizer": { + "description": "Analyséiert d'Solarprognos, d'Stroumpräisser an Är Verbrauchssmuster, fir d'Batterie- a Luedstrategie ze optiméieren. D'Donnéeë ginn un den evcc-Optimiséierungsservice fir d'Berechnen geschéckt.", + "enable": "Optimizer aktivéieren", + "title": "Optimizer" + }, "options": { "boolean": { "no": "nee", @@ -642,11 +649,13 @@ "cardTitle": "Sunny Home Manager", "description": "Den evcc ass mat enger Integratioun fir den SMA Sunny Home Manager (SHM) iwwert den SEMP-Protokoll ausgestatt. Wann den SHM am selwechte Netzwierk leeft, sollt Dir no der Aschreiwung an Ärem Sunny Portal-Kont automatesch d'Méiglechkeet kréien, all an evcc konfiguréiert Luedpunkten als nei detektéiert Laaschten derbäizesetzen. Alles sollt direkt gebrauchsfäeg sinn, ouni weider Upassungen.", "descriptionDeviceId": "12-stellegen HEX-String. Präfix fir all Geräter (Luedpunkt, ..).", + "descriptionDeviceSerial": "HEX-String mat 12-Zeechen. Basis-Seriennummer fir all Apparater (Luedpunkt, ...). Standardméisseg kritt den evcc dës aus der MAC-Adress vum Host.", "descriptionIdPattern": "Identifikatiounsmuster", "descriptionIds": "Am Sunny Portal brauch all Ladung (Luedpunkt, etc.) eng eenzegaarteg Identifikatioun. evcc generéiert eng eenzegaarteg Identifikatioun, déi op Ärer Hardware baséiert. Wann Dir evcc op aner Hardware migréiert, kann dësen Identifikator sech änneren. Fir den Historique ze späicheren, kënnt Dir déi generéiert Identifikatoren hei iwwerschreiwen. Maacht d'SEMP URL op (/semp) fir Är aktuell Identifikatoren ze kontrolléieren.", "descriptionSempUrl": "SEMP-URL", "descriptionVendorId": "8-stellegen HEX-String. Allgemenge Präfix fir all Entiéiten. Standardméisseg verwennt evcc seng eegen intern Hiersteller-ID.", "labelDeviceId": "Geräter-ID", + "labelDeviceSerial": "Apparat-Seriennummer", "labelVendorId": "Hiersteller-ID", "title": "SMA Sunny Home Manager" }, @@ -833,7 +842,7 @@ }, "footer": { "community": { - "greenEnergy": "Solarenergie", + "greenEnergy": "Solar", "greenEnergySub1": "opgelueden mat evcc", "greenEnergySub2": "zënter Oktober 2022", "greenShare": "Sonnenundeel", @@ -850,6 +859,15 @@ "configurePriceCo2": "Léiert wéi een de Präis an d'CO₂-Daten konfiguréiert.", "footerLong": "{percent} Solarenergie", "footerShort": "{percent} Solar", + "indicator": { + "co2": "CO₂-Emissiounen", + "co2saved": "CO₂ agespuert", + "none": "keng", + "price": "Energiepräis", + "savings": "Erspuernesser", + "solar": "Solarenergie" + }, + "indicatorLabel": "Header-Info", "modalTitle": "Iwwersiicht vum opgeluedene Stroum", "moneySaved": "{value} gespuert", "percentGrid": "{grid} kWh Stroumnetz", @@ -861,10 +879,11 @@ "thisYear": "dëst Joer", "total": "gesamt" }, - "periodLabel": "Zäitraum:", + "periodLabel": "Zäitraum", "priceTitle": "Energiepräis", "referenceGrid": "Netz", - "referenceLabel": "Referenzdaten:", + "referenceLabel": "Referenzdaten", + "sessionInfo": "Baséiert op ofgeschlossen Opluedsessiounen.", "tabTitle": "Meng Daten" }, "sponsor": { @@ -890,15 +909,26 @@ }, "version": { "availableLong": "nei Versioun verfügbar", + "community": "evcc Communautéit", + "labelRelease": "Verëffentlechung", + "labelVersion": "Versioun", + "labelWebsite": "Websäit", + "latestVersion": "lescht Versioun", + "madeByCommunity": "Entwéckelt vun der {0}.", "modalCancel": "Ofbriechen", "modalDownload": "Download", "modalInstalledVersion": "Aktuell installéiert Versioun", + "modalLatest": "Dir benotzt déi lescht Versioun.", + "modalNextRelease": "Wat gëtt et an der nächster Versioun", "modalNoReleaseNotes": "Keng Versiounshiweiser verfügbar. Méi Informatioun iwwert déi nei Versioun:", "modalTitle": "Aktualiséierung verfügbar", "modalUpdate": "Installéieren", "modalUpdateNow": "Elo installéieren", "modalUpdateStarted": "Déi nei Versioun vun evcc starten…", - "modalUpdateStatusStart": "Installatioun ugefaang:" + "modalUpdateStatusStart": "Installatioun ugefaang:", + "modalViewOnGitHub": "Op GitHub kucken", + "openSource": "Open Source", + "poweredByOpenSource": "Bedriwwen vun {0}." } }, "forecast": { @@ -908,6 +938,12 @@ "lowestHour": "Déi properst Stonn", "range": "Beräich" }, + "empty": { + "co2": "Iwwerpréiwt, wéini d'Netzenergie an Ärer Regioun propper ass. Luedpläng ginn optimiséiert fir d'Emissiounen ze reduzéieren an CO₂ anzespueren.", + "price": "Konfiguréiert Ären dynameschen Stromtarif, fir d'Luedpläng automatesch ze optiméieren an d'Erspuernësser ze berechnen.", + "setup": "Tariffer a Prognosen opstellen", + "solar": "Kuckt déi erwaart Solarproduktioun fir haut an déi nächst Deeg. Et gëtt och an der Zukunft fir d'automatiséiert Luedoptimiséierung benotzt." + }, "modalTitle": "Prognose", "price": { "average": "Duerchschnitt", @@ -915,6 +951,7 @@ "lowestHour": "Bëllegst Stonn", "range": "Beräich" }, + "priceZoom": "erazoomen", "solar": { "dayAfterTomorrow": "Iwwermuer", "partly": "deelweis", @@ -923,10 +960,11 @@ "tomorrow": "Muer" }, "solarAdjust": "Solarprognose unhand vun de reale Produktionsdaten upassen{percent}.", + "solarAdjustShort": "u reell Daten upassen", "type": { - "co2": "CO₂", - "price": "Präis", - "solar": "Solar" + "co2": "CO₂-Emissiounen", + "price": "Netzpräis", + "solar": "Solar-Productioun" } }, "general": { @@ -1139,6 +1177,7 @@ "once": "Boost ass dir dës Luedsessioun aktivéiert.", "stateActive": "Batterieboost aktiv", "stateBelowLimit": "Batterie ze niddereg fir de Boost", + "stateHold": "Batterie gespaart", "stateReady": "Batterieboost bereet" }, "batteryUsage": "Hausbatterie", @@ -1248,6 +1287,7 @@ "batteryBoostBelowLimit": "Batterie ze niddereg fir de Boost.", "batteryBoostDisabled": "Batterieboost deaktivéiert.", "batteryBoostEnabled": "Boost bis d'Batterie bei {limit} ass.", + "batteryBoostHold": "Batterie gespaart. Boost net verfügbar.", "charging": "Luet…", "cheapEnergyCharging": "Gënschteg Energie verfügbar.", "cheapEnergyNextStart": "Gënschteg Energie an {duration}.", @@ -1487,5 +1527,13 @@ "lineErrorLink": "Linn {0}", "restartButton": "Neistart", "title": "Startup Feeler" + }, + "tabBar": { + "battery": "Batterie", + "charge": "Lueden", + "comingSoon": "Dës Säit ass amgaang gebaut ze ginn.", + "forecast": "Viraussoen", + "more": "Méi", + "sessions": "Sessiounen" } } diff --git a/i18n/lt.json b/i18n/lt.json index 674207c0f..09adec291 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -113,6 +113,7 @@ "title": "Valiuta" }, "deviceValue": { + "activeClients": "Aktyvūs vartotojai", "amount": "Kiekis", "broker": "Broker sistema", "bucket": "Bucket", @@ -126,6 +127,7 @@ "chargedEnergy": "Įkrauta", "co2": "Tinklo CO₂", "configured": "Sukonfigūruota", + "connected": "Prisijungta", "connections": "Jungtys", "controllable": "Valdomas", "currency": "Valiuta", @@ -144,6 +146,7 @@ "hemsActiveLimit": "Aktyvus limitas", "hemsType": "Komunikacija", "identifier": "RFID-Identifikatorius", + "loginBlocked": "Pasiektas prisijungimų limitas", "max": "max", "messengers": "Paslaugos", "no": "ne", @@ -206,7 +209,7 @@ }, "experimental": { "description": "Suteikia išankstinę prieigą prie funkcijų, kurios vis dar testuojamos. Jos gali būti nestabilios bei gali pasikeisti arba būti pašalintos būsimose versijose.", - "title": "Eksperimentinis" + "title": "Eksperimentinės" }, "ext": { "description": "Įrašo nekontroliuojamų įrenginių (pvz., šaldytuvo, skalbimo mašinos ir kt.) energijos vertes statistikos tikslais. Šie skaitikliai taip pat gali būti naudojami apkrovos valdymui (pvz., paskirstymui).", @@ -238,7 +241,7 @@ "dragItem": "Galima vilkti: {title}", "dragList": "Galima sukeisti vietomis", "error": "Klaida", - "experimental": "Eksperimentinis", + "experimental": "Eksperimentinės", "forceSave": "Vistiek įrašyti", "fromYamlHint": "Pastaba: Sukonfigūruota naudojant evcc.yaml. Pašalinkite įrašą iš failo, kad būtų galima redaguoti čia.", "hideAdvancedSettings": "Slėpti išplėstinius nustatymus", @@ -602,6 +605,12 @@ "url": "Serverio URL", "urlHelp": "Nukopijuokite šį URL į savo įkroviklio konfigūraciją. Išsamesnės informacijos ieškokite gamintojo vadove. Įkroviklis turėtų automatiškai pridėti savo unikalų identifikatorių (stotelės ID) prie URL. Retais atvejais gali tekti rankiniu būdu nurodyti identifikatorių. Pavyzdys: `{url}`" }, + "optimizer": { + "description": "Analizuoja saulės energijos prognozę, elektros energijos kainas ir jūsų vartojimo įpročius, kad optimizuotų kaupiklį ir įkrovimo strategiją. Duomenys siunčiami apdorojimui į evcc optimizavimo paslaugą. Šiuo metu tik apskaičiuoja ir vizualizuoja, įrenginių kol kas nevaldo.", + "enable": "Įjungti Optimizavimą", + "info": "Gali užtrukti kelias minutes, kol meniu pasirodys optimizatoriaus eilutė. Naujoms evcc instaliacijoms gali užtrukti iki 24 valandų, kol evcc turės pakankamai duomenų veikimui.", + "title": "Optimizavimas" + }, "options": { "boolean": { "no": "ne", @@ -629,6 +638,38 @@ "titleAdd": "Pridėti saulės skaitiklį", "titleEdit": "Redaguoti saulės skaitiklį" }, + "remote": { + "active": "Aktyvus", + "addClient": "Pridėti vartotoją", + "addClientDescription": "Įgaliojimai saugomi ir tikrinami tik lokaliai, jūsų evcc egzemplioriuje.", + "addClientTitle": "Pridėti nutolusį vartotoją", + "clientCreated": "Vartotojas sukurtas", + "clients": "Vartotojai", + "confirmDelete": "Ištrinti vartotoją?", + "connected": "Prijungta", + "createClient": "Sukurti vartotoją", + "description": "Pasiekite savo evcc sistemą iš bet kurios vietos, naudodami evcc mobiliąją programėlę. Nereikia jokio prievado peradresavimo ar VPN.", + "deviceName": "Įrenginio pavadinimas", + "disconnected": "Atjungta", + "done": "Atlikta", + "enableLabel": "Įgalinti nuotolinę prieigą", + "expiration": "Galiojimo laikas", + "expirationNone": "Niekada", + "expired": "pasibaigęs", + "expiresIn": "pasibaigs {time}", + "lastActive": "aktyvus {time}", + "loginBlocked": "Nuotoliniai prisijungimai blokuojami vienai minutei dėl per daug nepavykusių prisijungimo bandymų.", + "manualLogin": "Arba prisijunkite rankiniu būdu adresu {url} naršyklėje naudodami šiuos prisijungimo duomenis:", + "noClients": "Kol kas nėra vartotojų. Niekas dar negali prisijungti.", + "password": "Slaptažodis", + "passwordOnce": "Šis slaptažodis rodomas tik vieną kartą. Nuskaitykite QR kodą arba nukopijuokite jį dabar. Daugiau jo nebegalėsite pamatyti.", + "qrInstall": "Įdiekite evcc programėlę, skirtą {ios} arba {android}.", + "qrScan": "Nuskaitykite kodą telefono kamera, kad prisijungtumėte. Bakstelėkite ant jo, jei jau naudojate telefoną.", + "removeClient": "Pašalinti vartotoją", + "title": "Nuotolinė Prieiga", + "url": "Viešas URL", + "username": "Vartotojo vardas" + }, "section": { "additionalMeter": "Papildomi skaitikliai", "general": "Pagrindiniai", @@ -904,7 +945,7 @@ "optInSponsorship": "Gali tik rėmėjai." }, "version": { - "availableLong": "yra naujesnė versija", + "availableLong": "(yra naujesnė versija)", "community": "evcc bendruomenė", "labelRelease": "Leidimas", "labelVersion": "Versija", @@ -940,6 +981,7 @@ "setup": "Sukonfigūruoti tarifus ir prognozes", "solar": "Prognozuojama saulės energijos gamyba šiandienai ir ateinančiomis dienomis. Ateityje taip pat bus naudojama automatiniam įkrovimo optimizavimui." }, + "hideLine": "paslėpti liniją", "modalTitle": "Prognozė", "price": { "average": "Vidurkis", @@ -948,6 +990,7 @@ "range": "Diapazonas" }, "priceZoom": "priartinti", + "showLine": "rodyti liniją", "solar": { "dayAfterTomorrow": "Poryt", "partly": "dalinai", @@ -956,7 +999,8 @@ "tomorrow": "Rytoj" }, "solarAdjust": "Pakoreguoti saulės prognozę remiantis faktiškais gamybos duomenimis{percent}.", - "solarAdjustShort": "koreguoti pagal faktinius duomenis", + "solarAdjustMedium": "koreguoti pagal faktinius duomenis", + "solarAdjustShort": "koreguoti", "type": { "co2": "CO₂ Išmetimai", "price": "Pirkimo Kaina", @@ -1001,7 +1045,7 @@ }, "restartButton": "Restartuoti", "restartDescription": "Ar pabandėte išjungti ir vėl įjungti?", - "secondaryActions": "Vis dar nepavyksta išspręsti problemos? Čia rasite drastiškesnes opcijas." + "secondaryActions": "Vis dar nepavyksta išspręsti problemos? Čia rasite sudėtingesnes opcijas." }, "issue": { "additional": { diff --git a/i18n/nl.json b/i18n/nl.json index d6a7cbd57..4b9d5bd1c 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -126,6 +126,7 @@ "chargedEnergy": "Geladen", "co2": "Stroomnet-CO₂", "configured": "Geconfigureerd", + "connected": "Verbonden", "connections": "Verbindingen", "controllable": "Stuurbaar", "currency": "Valuta", @@ -144,6 +145,7 @@ "hemsActiveLimit": "Actieve limiet", "hemsType": "Communicatie", "identifier": "RFID-Nummer", + "loginBlocked": "Inloglimiet bereikt", "max": "max", "messengers": "Diensten", "no": "nee", @@ -602,6 +604,11 @@ "url": "Server URL", "urlHelp": "Kopieer deze URL naar de configuratie van uw lader. Raadpleeg de handleiding van de fabrikant voor meer informatie. De lader voegt naar verwachting automatisch zijn unieke identificatiecode (station-ID) toe aan de URL. In zeldzame gevallen moet u de identificatiecode mogelijk handmatig ingeven. Voorbeeld: `{url}`" }, + "optimizer": { + "description": "Analyseert zonne-energieprognoses, elektriciteitsprijzen en je verbruikspatronen om de batterij- en laadstrategie te optimaliseren. Gegevens worden naar de evcc-optimalisatiedienst gestuurd voor verwerking.", + "enable": "Activeer optimizer", + "title": "Optimizer" + }, "options": { "boolean": { "no": "nee", @@ -629,6 +636,29 @@ "titleAdd": "Meter Zonnepanelen Toevoegen", "titleEdit": "Meter Zonnepanelen Bewerken" }, + "remote": { + "active": "Actief", + "addClient": "Client toevoegen", + "addClientTitle": "Remote client toevoegen", + "clientCreated": "Client toegevoegd", + "clients": "Clients", + "confirmDelete": "Client verwijderen?", + "connected": "Verbonden", + "createClient": "Client aanmaken", + "description": "Toegang tot uw evcc installatie via de evcc mobile app. Zonder port-forwarding en zonder VPN.", + "deviceName": "Apparaat naam", + "disconnected": "Niet verbonden", + "done": "Klaar", + "enableLabel": "Toegang op afstand inschakelen", + "expirationNone": "Nooit", + "expired": "verlopen", + "expiresIn": "verloopt {time}", + "lastActive": "actief {time}", + "loginBlocked": "Inloggen op afstand is voor een minuut geblokkeerd, omdat er te veel mislukte inlogpogingen waren.", + "manualLogin": "Of log handmatig in via uw browser op {url}, met de volgende credentials:", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, "section": { "additionalMeter": "Extra meters", "general": "Algemeen", @@ -862,6 +892,7 @@ "savings": "Besparing", "solar": "Zonne-Energie" }, + "indicatorLabel": "Header info", "modalTitle": "Oplaadenergie-overzicht", "moneySaved": "{value} bespaard", "percentGrid": "{grid} kWh net", @@ -873,10 +904,11 @@ "thisYear": "dit jaar", "total": "aller tijden" }, - "periodLabel": "Periode:", + "periodLabel": "Periode", "priceTitle": "Energieprijs", "referenceGrid": "net", - "referenceLabel": "Referentiedata:", + "referenceLabel": "Referentiedata", + "sessionInfo": "Op basis van voltooide laadsessies.", "tabTitle": "Mijn data" }, "sponsor": { @@ -902,15 +934,26 @@ }, "version": { "availableLong": "nieuwe versie beschikbaar", + "community": "evcc coomunity", + "labelRelease": "Release", + "labelVersion": "Versie", + "labelWebsite": "Website", + "latestVersion": "Nieuwste versie", + "madeByCommunity": "Gemaakt door de {0}.", "modalCancel": "Annuleer", "modalDownload": "Download", "modalInstalledVersion": "Geïnstalleerde versie", + "modalLatest": "U heeft de nieuwste versie.", + "modalNextRelease": "Wat zit er in de volgende release", "modalNoReleaseNotes": "Geen release notes beschikbaar. Meer info over de nieuwe versie:", "modalTitle": "Nieuwe versie beschikbaar", "modalUpdate": "Installeren", "modalUpdateNow": "Nu installeren", "modalUpdateStarted": "Starten van de nieuwe versie van evcc…", - "modalUpdateStatusStart": "Installatie gestart:" + "modalUpdateStatusStart": "Installatie gestart:", + "modalViewOnGitHub": "Op GitHub bekijken", + "openSource": "open source", + "poweredByOpenSource": "Powered by {0}." } }, "forecast": { @@ -920,6 +963,13 @@ "lowestHour": "Schoonste uur", "range": "Bereik" }, + "empty": { + "co2": "Kijk wanneer de netstroom in uw regio schoon is. Laadplanning wordt geoptimaliseerd voor minimale emissie en CO₂ besparing wordt berekend.", + "price": "Stel uw dynamische stroomprijzen in om laadplannen te optimaliseren en om uw besparingen mee te berekenen.", + "setup": "Tarieven en voorspellingen instellen", + "solar": "Bekijk de verwachtte zonne opbrengt voor vandaag en de komende dagen. Wordt ook gebruikt voor automatische laadplan optimalisatie in de toekomst." + }, + "hideLine": "regel verbergen", "modalTitle": "Voorspelling", "price": { "average": "Gemiddeld", @@ -927,6 +977,8 @@ "lowestHour": "Goedkoopste uur", "range": "Bereik" }, + "priceZoom": "zoom in", + "showLine": "regel tonen", "solar": { "dayAfterTomorrow": "Overmorgen", "partly": "gedeeltelijk", @@ -935,10 +987,11 @@ "tomorrow": "Morgen" }, "solarAdjust": "Pas de zonne-energie voorspelling aan op basis van echte productiegegevens{percent}.", + "solarAdjustShort": "aanpassen", "type": { - "co2": "CO₂", - "price": "Prijs", - "solar": "Zonne-energie" + "co2": "CO₂ Uitstoot", + "price": "Net-Prijs", + "solar": "Zonne-energie opbrengst" } }, "general": { @@ -1152,6 +1205,7 @@ "once": "Boost geactiveerd voor deze laadsessie.", "stateActive": "Batterijboost actief", "stateBelowLimit": "Batterij te laag voor boost", + "stateHold": "Batterij vergrendeld", "stateReady": "Batterijboost klaar" }, "batteryUsage": "Thuisbatterij", @@ -1261,6 +1315,7 @@ "batteryBoostBelowLimit": "Batterij te laag voor boost.", "batteryBoostDisabled": "Batterijboost uitgeschakeld.", "batteryBoostEnabled": "Boost de batterij tot {limit}.", + "batteryBoostHold": "Batterij vergrendeld. Boost niet beschikbaar.", "charging": "Opladen…", "cheapEnergyCharging": "Goedkope energie beschikbaar.", "cheapEnergyNextStart": "Goedkope energie in {duration}.", @@ -1500,5 +1555,13 @@ "lineErrorLink": "lijn {0}", "restartButton": "Herstart", "title": "Opstart fout" + }, + "tabBar": { + "battery": "Batterij", + "charge": "Laden", + "comingSoon": "Deze pagina is in ontwikkeling.", + "forecast": "Voorspelling", + "more": "Meer", + "sessions": "Sessies" } } diff --git a/i18n/no.json b/i18n/no.json index 41f73b942..92658c4e5 100644 --- a/i18n/no.json +++ b/i18n/no.json @@ -28,7 +28,7 @@ }, "capacity": "{energy} av {total}", "control": "Batterikontroll", - "discharge": "Forhindre utladning i hurtigmodus og planlagt lading", + "discharge": "Forhindre utladning i hurtigmodus og planlagt lading.", "disclaimerHint": "Merk:", "disclaimerText": "Kun relevant i PV-modus. Ladeadferden justeres deretter.", "gridChargeTab": "Nettlading", diff --git a/i18n/pl.json b/i18n/pl.json index ad3e1e7f9..fc1db3a70 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -40,6 +40,7 @@ "legendTopName": "Ładowanie pojazdu z wykorzystaniem domowego magazynu energii", "legendTopSubline": "gdy poziom naładowania domowego magazynu energii jest wyższy niż {soc}.", "modalTitle": "Domowy magazyn energii", + "noBattery": "Brak skonfigurowanego magazynu energii.", "usageTab": "Użycie magazynu" }, "config": { @@ -446,7 +447,7 @@ "guest": { "messageDefault": "Nieznany pojazd, połączony gość?", "title": "Kiedy podłączy się nieznany samochód", - "titleDefault": "Nieznany samochód" + "titleDefault": "Nieznany pojazd" }, "planoverrun": { "messageDefault": "{vehicleTitle}: Plan zostanie przekroczony.", @@ -861,6 +862,7 @@ "priceTitle": "Cena energii", "referenceGrid": "sieć", "referenceLabel": "Dane referencyjne:", + "sessionInfo": "Oparte na zakończonych sesjach ładowania.", "tabTitle": "Moje dane" }, "sponsor": { diff --git a/i18n/pt.json b/i18n/pt.json index fc18636f4..7f654842a 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -40,6 +40,7 @@ "legendTopName": "Carregamento de veículos com a bateria", "legendTopSubline": "quando a bateria da casa está acima de {soc}.", "modalTitle": "Bateria da Casa", + "noBattery": "Não há baterias configuradas.", "usageTab": "Gestão da bateria" }, "config": { @@ -130,8 +131,9 @@ "currency": "Moeda", "current": "Corrente", "currentRange": "Corrente", + "curtailed": "Limitação da injeção na rede", "detected": "Detectado", - "dimmed": "Escurecido", + "dimmed": "Consumo limitado", "enabled": "Ativado", "energy": "Energia", "events": "Eventos", @@ -600,6 +602,11 @@ "url": "URL do Servidor", "urlHelp": "Copie este URL para a configuração do seu carregador. Consulte o manual do fabricante para obter mais detalhes. O carregador deve anexar automaticamente o seu identificador único (ID da estação) ao URL. Em casos raros, pode ser necessário especificar manualmente o identificador. Exemplo: `{url}`" }, + "optimizer": { + "description": "Analisa as previsões de energia solar, os preços da eletricidade e os seus padrões de consumo para otimizar a estratégia de utilização da bateria e de carregamento. Os dados são enviados para o serviço de otimização evcc para serem processados.", + "enable": "Ativar o Otimizador", + "title": "Otimizador" + }, "options": { "boolean": { "no": "não", @@ -642,11 +649,13 @@ "cardTitle": "Sunny Home Manager", "description": "evcc tem integração com SMA Sunny Manager (SHM) via protocolo SEMP. Se estiver a ser executado na mesma rede, depois de aceder à sua conta no Sunny Portal, deverá aparecer automaticamente a opção de adicionar todos os Carregadores configurados no evcc como novos clientes. Tudo deverá ficar pronto a usar imediatamente sem necessidade de quaisquer ajustes.", "descriptionDeviceId": "String 12 caracteres HEX. Prefixo para todos os dispositivos (ponto de carregamento, ..).", + "descriptionDeviceSerial": "Cadeia HEX de 12 caracteres. Número de série padrão para todos os dispositivos (ponto de carregamento, etc.). Por predefinição, o evcc obtém este valor a partir do endereço MAC do host.", "descriptionIdPattern": "Padrão de identidade", "descriptionIds": "No Sunny Portal cada dispositivo necessita duma identidade única. O evcc gera uma identidade única com base no seu hardware. Se migrar o evcc para outro hardware estas identidades podem mudar. Se quiser manter o histórico pode substituir as identidades geradas aqui. Abra o SEMP URL(/semp) para ver as identidade atuais.", "descriptionSempUrl": "SEMP URL", "descriptionVendorId": "string 8 caratecteres HEX. Prefixo geral de todas as entidades. O evcc usa o seu próprio vendor ID interno Por defeito.", "labelDeviceId": "Device ID", + "labelDeviceSerial": "Número de série do equipamento", "labelVendorId": "Vendor ID", "title": "SMA Sunny Home Manager" }, @@ -850,6 +859,15 @@ "configurePriceCo2": "Saiba como configurar os dados de preço e CO₂.", "footerLong": "{percent} energia solar", "footerShort": "{percent} Sol", + "indicator": { + "co2": "Emissões de CO₂", + "co2saved": "CO₂ poupado", + "none": "nenhum", + "price": "preço da energia", + "savings": "poupança", + "solar": "energia solar" + }, + "indicatorLabel": "Informações do cabeçalho", "modalTitle": "Dados da energia carregada", "moneySaved": "{value} poupado", "percentGrid": "{grid} kWh Rede", @@ -861,10 +879,11 @@ "thisYear": "este ano", "total": "total" }, - "periodLabel": "Período:", + "periodLabel": "Período", "priceTitle": "Preço da energia", "referenceGrid": "Rede", - "referenceLabel": "Dados de referência:", + "referenceLabel": "Dados de referência", + "sessionInfo": "Com base nas sessões de carregamento concluídas.", "tabTitle": "Os meus dados" }, "sponsor": { @@ -890,15 +909,26 @@ }, "version": { "availableLong": "nova versão disponível", + "community": "comunidade evcc", + "labelRelease": "Lançamento", + "labelVersion": "Versão", + "labelWebsite": "Website", + "latestVersion": "versão mais recente", + "madeByCommunity": "Realizado por {0}.", "modalCancel": "Cancelar", "modalDownload": "Download", "modalInstalledVersion": "Versão instalada", + "modalLatest": "Está a utilizar a versão mais recente.", + "modalNextRelease": "O que está previsto para a próxima versão", "modalNoReleaseNotes": "Sem notas da versão disponível. Mais informações sobre a nova versão:", "modalTitle": "Nova versão disponível", "modalUpdate": "Instalar", "modalUpdateNow": "Instalar agora", "modalUpdateStarted": "Iniciando a nova versão do evcc…", - "modalUpdateStatusStart": "Instalação iniciada:" + "modalUpdateStatusStart": "Instalação iniciada:", + "modalViewOnGitHub": "Ver no GitHub", + "openSource": "código aberto", + "poweredByOpenSource": "Desenvolvido por {0}." } }, "forecast": { @@ -908,6 +938,12 @@ "lowestHour": "Horário mais \"Verde\"", "range": "Intervalo" }, + "empty": { + "co2": "Veja quando a energia da rede na sua região é verde. Os planos de carregamento serão otimizados para reduzir as emissões e calcular a redução de CO₂.", + "price": "Configure a sua tarifa de eletricidade dinâmica para otimizar automaticamente os planos de carregamento e calcular as poupanças.", + "setup": "Definir tarifas e previsões", + "solar": "Veja a produção solar prevista para hoje e para os próximos dias. Esta informação será também utilizada para a otimização automática do carregamento no futuro." + }, "modalTitle": "Previsões", "price": { "average": "Média", @@ -915,6 +951,7 @@ "lowestHour": "Horário mais barato", "range": "Intervalo" }, + "priceZoom": "ampliar", "solar": { "dayAfterTomorrow": "Depois de amanhã", "partly": "parcialmente", @@ -923,10 +960,11 @@ "tomorrow": "Amanhã" }, "solarAdjust": "Ajustar a previsão solar com base em dados reais de produção{percent}.", + "solarAdjustShort": "ajuste com dados reais", "type": { - "co2": "CO₂", - "price": "Preço", - "solar": "Solar" + "co2": "Emissões de CO₂", + "price": "Preço da rede", + "solar": "Produção solar" } }, "general": { @@ -1139,6 +1177,7 @@ "once": "Boost ativo para esta sessão de carga.", "stateActive": "Boost de bateria ativo", "stateBelowLimit": "Bateria com carga insuficiente para o boost", + "stateHold": "Bateria bloqueada", "stateReady": "Boost da bateria pronto" }, "batteryUsage": "Bateria de casa", @@ -1248,6 +1287,7 @@ "batteryBoostBelowLimit": "Bateria com carga insuficiente para boost.", "batteryBoostDisabled": "Boost da bateria desativado.", "batteryBoostEnabled": "Boost até a bateria atingir {limit}.", + "batteryBoostHold": "Bateria bloqueada. Função Boost não disponível.", "charging": "A carregar…", "cheapEnergyCharging": "Energia barata disponível.", "cheapEnergyNextStart": "Energia barata em {duration}.", @@ -1487,5 +1527,13 @@ "lineErrorLink": "linha {0}", "restartButton": "Reiniciar", "title": "Erro ao iniciar" + }, + "tabBar": { + "battery": "Bateria", + "charge": "Carga", + "comingSoon": "Esta página está em construção.", + "forecast": "Previsão", + "more": "Mais", + "sessions": "Sessões" } } diff --git a/i18n/sv.json b/i18n/sv.json index 628d98a07..3ed0c7dc9 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -113,6 +113,7 @@ "title": "Valuta" }, "deviceValue": { + "activeClients": "Aktiva klienter", "amount": "Belopp", "broker": "Broker", "bucket": "Bucket", @@ -126,6 +127,7 @@ "chargedEnergy": "Laddad", "co2": "Elnät CO₂", "configured": "Konfigurerad", + "connected": "Ansluten", "connections": "Anslutningar", "controllable": "Kontrollerbar", "currency": "Valuta", @@ -144,6 +146,7 @@ "hemsActiveLimit": "Aktiv begränsning", "hemsType": "Kommunikation", "identifier": "RFID-identifierare", + "loginBlocked": "Inloggningsgräns nådd", "max": "max", "messengers": "Tjänster", "no": "nej", @@ -339,7 +342,7 @@ "defaultsHintLink": "Ändra inställningar", "delete": "Radera", "electricalSubtitle": "Om du är osäker, kontakta elektriker.", - "electricalTitle": "Elektrisk", + "electricalTitle": "Elektricitet", "energyMeterHelp": "Extern mätare (Om laddaren inte har en integrerad mätare).", "energyMeterLabel": "Energimätare", "estimateLabel": "Interpolera laddningsnivå mellan API uppdateringar", @@ -602,6 +605,12 @@ "url": "Server URL", "urlHelp": "Kopiera denna URL till laddarens konfiguration. Se tillverkarens manual för mer information. Laddaren ska automatiskt lägga till sin unika identifierare (stations-ID) till URL:en. I sällsynta fall kan du behöva ange identifieraren manuellt. Exempel: `{url}`Kopiera denna URL till laddarens konfiguration. Se tillverkarens manual för mer information. Laddaren ska automatiskt lägga till sin unika identifierare (stations-ID) till URL:en. I sällsynta fall kan du behöva ange identifieraren manuellt. Exempel: `{url}`" }, + "optimizer": { + "description": "Analyserar solprognoser, elpriser och din typiska förbrukning för att optimera batteri- och laddningsstrategi. Data överförs till evcc optimeringstjänst för beräkning.", + "enable": "Aktivera optimerare", + "info": "Det kan ta några minuter innan optimeringsmenyn blir synlig. För nya installationer kan det ta upp till 24 timmar innan evcc har samlat in tillräckligt med data.", + "title": "Optimerare" + }, "options": { "boolean": { "no": "nej", @@ -629,6 +638,38 @@ "titleAdd": "Lägg till solcellsmätare", "titleEdit": "Ändra solcellsmätare" }, + "remote": { + "active": "Aktiv", + "addClient": "Lägg till klient", + "addClientDescription": "Inloggningsuppgifter lagras och verifieras endast lokalt på din evcc-instans.", + "addClientTitle": "Lägg till fjärklient", + "clientCreated": "Klient skapad", + "clients": "Klienter", + "confirmDelete": "Ta bort klient?", + "connected": "Ansluten", + "createClient": "Skapa klient", + "description": "Nå din evcc-installation från var som helst med evcc-mobilappen. Du behöver varken portvidarebefordran eller VPN.", + "deviceName": "Enhetsnamn", + "disconnected": "Frånkopplad", + "done": "Klar", + "enableLabel": "Aktivera fjärråtkomst", + "expiration": "Upphör", + "expirationNone": "Aldrig", + "expired": "utgången", + "expiresIn": "upphör {time}", + "lastActive": "activ {time}", + "loginBlocked": "Fjärrinloggningar blockeras i en minut efter för många misslyckade inloggningsförsök.", + "manualLogin": "Eller logga in manuellt på {url} i din webbläsare med dessa inloggningsuppgifter:", + "noClients": "Det finns inga klienter än. Ingen kan ansluta just nu.", + "password": "Lösenord", + "passwordOnce": "Det här lösenordet visas bara en gång. Skanna QR-koden eller kopiera den nu. Du kommer inte att kunna se den igen.", + "qrInstall": "Installera evcc-appen för {ios} eller {android}.", + "qrScan": "Skanna koden med din telefons kamera för att ansluta. Klicka på den om du redan använder din telefon.", + "removeClient": "Ta bort klient", + "title": "Fjärråtkomst", + "url": "Publik URL", + "username": "Användarnamn" + }, "section": { "additionalMeter": "Ytterligare mätare", "general": "Allmänna inställningar", @@ -875,10 +916,10 @@ "thisYear": "detta år", "total": "totalt" }, - "periodLabel": "Period:", + "periodLabel": "Period", "priceTitle": "Energipris", "referenceGrid": "nät", - "referenceLabel": "Referensdata:", + "referenceLabel": "Referensdata", "sessionInfo": "Baserat på avslutade laddsessioner.", "tabTitle": "Mina uppgifter" }, @@ -922,7 +963,9 @@ "modalUpdateNow": "Installera nu", "modalUpdateStarted": "Starta den nya versionen av evcc…", "modalUpdateStatusStart": "Installationen har börjat:", - "openSource": "open source" + "modalViewOnGitHub": "Se på GitHub", + "openSource": "open source", + "poweredByOpenSource": "Powered by {0}." } }, "forecast": { @@ -934,8 +977,12 @@ }, "empty": { "co2": "Se när elen i ditt område är ren. Laddningsscheman optimeras för låga utsläpp och CO₂-besparingar beräknas.", - "price": "Konfigurera rörligt elpris för att automatiskt optimera laddningsscheman och beräkna besparingar." + "price": "Konfigurera rörligt elpris för att automatiskt optimera laddningsscheman och beräkna besparingar.", + "setup": "Lägg till tariffer och prognoser", + "solar": "Förväntat solproduktion idag och imorgon. Kommer i framtiden även att användas för automatisk laddningsoptimering.", + "title": "Tariffer & prognoser" }, + "hideLine": "göm linje", "modalTitle": "Prognos", "price": { "average": "Genomsnitt", @@ -943,18 +990,22 @@ "lowestHour": "Billigaste timmen", "range": "Intervall" }, + "priceZoom": "zooma in", + "showLine": "visa linje", "solar": { "dayAfterTomorrow": "I övermorgon", "partly": "delvis", "remaining": "återstående", "today": "Idag", - "tomorrow": "I morgon" + "tomorrow": "i morgon" }, "solarAdjust": "Justera solprognosen baserat på verkliga produktionsvärden{percent}.", + "solarAdjustMedium": "Justera med verkliga data", + "solarAdjustShort": "justera", "type": { - "co2": "CO₂", - "price": "Pris", - "solar": "Sol" + "co2": "CO₂-utsläpp", + "price": "Nätpris", + "solar": "Solenergi produktion" } }, "general": { @@ -1168,6 +1219,7 @@ "once": "Boost är aktiverat för denna laddning.", "stateActive": "Batteriboost aktiv", "stateBelowLimit": "Batterinivå för låg för boost", + "stateHold": "Batteri låst", "stateReady": "Beredd för batteriboost" }, "batteryUsage": "Hembatteri", @@ -1277,6 +1329,7 @@ "batteryBoostBelowLimit": "Batterinivå för låg för boost.", "batteryBoostDisabled": "Batteriboost avaktiverad.", "batteryBoostEnabled": "Boost tills batteriet är på {limit}.", + "batteryBoostHold": "Batteri låst. Boost ej tillgänglig.", "charging": "Laddar…", "cheapEnergyCharging": "Billig energi är tillgänglig.", "cheapEnergyNextStart": "Billig energi om {duration}.", @@ -1516,5 +1569,13 @@ "lineErrorLink": "linje {0}", "restartButton": "Starta om", "title": "Startfel" + }, + "tabBar": { + "battery": "Batteri", + "charge": "Ladda", + "comingSoon": "Sida under uppbyggnad.", + "forecast": "Prognos", + "more": "Mer", + "sessions": "Laddningar" } } diff --git a/i18n/tr.json b/i18n/tr.json index a9bbc9d00..52f20a9c8 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -40,6 +40,7 @@ "legendTopName": "Batarya destekli araç doldurma", "legendTopSubline": "ev bataryası {soc} seviyesinin üzerindeyse.", "modalTitle": "Ev Bataryası", + "noBattery": "Batarya yapılandırılmamış.", "usageTab": "Batarya kullanımı" }, "config": { @@ -601,6 +602,11 @@ "url": "Sunucu URL'si", "urlHelp": "Bu URL'yi doldrma cihazının yapılandırmasına kopyala. Ayrıntılar için üreticinin kılavuzuna bak. Doldurma cihazının, URL'ye benzersiz tanımlayıcısını (istasyon kimliği) otomatik olarak eklemesi beklenir. Nadir durumlarda, tanımlayıcıyı elle belirtmen gerekebilir. Örnek: `{url}`" }, + "optimizer": { + "description": "Güneş enerjisi tahminlerini, elektrik fiyatlarını ve tüketim alışkanlıklarınızı analiz ederek batarya ve doldurma stratejisini iyileştirir. Veriler, işlenmek üzere evcc iyileştirme hizmetine gönderilir.", + "enable": "İyileştiriciyi Etkinleştir", + "title": "İyileştirici" + }, "options": { "boolean": { "no": "“hayır”", @@ -853,6 +859,15 @@ "configurePriceCo2": "Fiyat ve CO₂ emisyonlarını yapılandır.", "footerLong": "{percent} güneş enerjisi", "footerShort": "{percent} güneş", + "indicator": { + "co2": "CO₂ emisyonları", + "co2saved": "Azaltılan CO₂ miktarı", + "none": "yok", + "price": "enerji fiyatı", + "savings": "tasarruf", + "solar": "güneş enerjisi" + }, + "indicatorLabel": "Başlık bilgisi", "modalTitle": "Doldurma Enerjisi Genel Bakışı", "moneySaved": "{value} tasarruf edildi", "percentGrid": "{grid} kWh şebekeden", @@ -864,10 +879,11 @@ "thisYear": "“bu yıl”", "total": "tüm zaman" }, - "periodLabel": "Zaman aralığı:", + "periodLabel": "Zaman aralığı", "priceTitle": "Enerji fiyatı", "referenceGrid": "şebeke", - "referenceLabel": "Referans verileri:", + "referenceLabel": "Kaynak verileri", + "sessionInfo": "Tamamlanmış doldurma işlemlerine göre.", "tabTitle": "Verilerim" }, "sponsor": { @@ -893,15 +909,26 @@ }, "version": { "availableLong": "yeni sürüm mevcut", + "community": "evcc topluluğu", + "labelRelease": "Yayın", + "labelVersion": "Sürüm", + "labelWebsite": "Web sitesi", + "latestVersion": "en son sürüm", + "madeByCommunity": "{0} tarafından hazırlandı.", "modalCancel": "İptal", "modalDownload": "İndir", "modalInstalledVersion": "Kurulu sürüm", + "modalLatest": "En son sürümü kullanıyorsun.", + "modalNextRelease": "Bir sonraki yayında neler var", "modalNoReleaseNotes": "Sürüm bilgileri mevcut değil. Yeni sürüm hakkında bilgiler:", "modalTitle": "Yeni sürüm mevcut", "modalUpdate": "Kur", "modalUpdateNow": "Şimdi kur", "modalUpdateStarted": "evcc'nin yeni sürümü başlatılıyor…", - "modalUpdateStatusStart": "Kurulum başladı:" + "modalUpdateStatusStart": "Kurulum başladı:", + "modalViewOnGitHub": "GitHub'da görüntüle", + "openSource": "açık kaynak", + "poweredByOpenSource": "{0} tarafından desteklenmekte." } }, "forecast": { @@ -911,6 +938,12 @@ "lowestHour": "“En temiz saat”", "range": "“Aralık”" }, + "empty": { + "co2": "Bölgendeki şebeke enerjisinin ne zaman temiz olduğunu gör. Doldurma planları, düşük emisyonlara göre iyileştirilecek ve CO₂ tasarrufu hesaplanacak.", + "price": "Dinamik elektrik tarifeni yapılandırarak doldurma planlarını otomatik olarak iyileştir ve tasarrufları hesapla.", + "setup": "Fiyatlar ve tahminler oluştur", + "solar": "Bugün ve önümüzdeki günler için beklenen güneş enerjisi üretimini gör. Bu veriler gelecekte otomatik doldurma iyileştirmesi için de kullanılacak." + }, "modalTitle": "“Öngörü”", "price": { "average": "“Ortalama”", @@ -918,6 +951,7 @@ "lowestHour": "“En ucuz saat”", "range": "“Aralık”" }, + "priceZoom": "büyüt", "solar": { "dayAfterTomorrow": "“Yarından sonra”", "partly": "“kısmen”", @@ -926,10 +960,11 @@ "tomorrow": "“Yarın”" }, "solarAdjust": "Güneş enerjisi tahminlerini gerçek üretim verilerine göre ayarla{percent}.", + "solarAdjustShort": "gerçek verilere göre ayarlama", "type": { - "co2": "CO₂", - "price": "“Fiyat”", - "solar": "“Güneş”" + "co2": "CO₂ salımları", + "price": "Şebeke Fiyatı", + "solar": "Güneş Enerjisi Üretimi" } }, "general": { @@ -1142,6 +1177,7 @@ "once": "Bu doldurma oturumu için takviye etkin.", "stateActive": "Batarya desteği etkin", "stateBelowLimit": "Desdek için batarya çok düşük", + "stateHold": "Batarya kilitli", "stateReady": "Batarya desteği hazır" }, "batteryUsage": "“Ev Bataryası”", @@ -1251,6 +1287,7 @@ "batteryBoostBelowLimit": "Desdek için batarya çok düşük.", "batteryBoostDisabled": "Batarya desteği devre dışı bırakıldı.", "batteryBoostEnabled": "Batarya {limit} seviyesine kadar destek.", + "batteryBoostHold": "Batarya kilitli. Hızlandırma kullanılamıyor.", "charging": "doluyor…", "cheapEnergyCharging": "Ucuz enerji mevcut.", "cheapEnergyNextStart": "{duration} içinde ucuz enerji.", @@ -1490,5 +1527,13 @@ "lineErrorLink": "{0}. satır", "restartButton": "Yeniden Başlat", "title": "Başlama Hatası" + }, + "tabBar": { + "battery": "Batarya", + "charge": "Doldurma", + "comingSoon": "Bu sayfa yapım aşamasında.", + "forecast": "Tahmin", + "more": "Daha fazla", + "sessions": "Oturumlar" } } From 2f106579e78e38c2f88e3c031c50dc942255bfaa Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 19 Apr 2026 13:55:54 +0200 Subject: [PATCH 0087/1316] Add Fritz smarthome REST API support (FritzOS 8.2+) (#29013) --- charger/fritzdect.go | 65 ++--- .../fritzdect.go => fritz/aha/aha.go} | 108 +++----- meter/fritz/aha/types.go | 15 + meter/fritz/api.go | 16 ++ meter/fritz/smarthome/smarthome.go | 258 ++++++++++++++++++ meter/fritz/smarthome/types.go | 58 ++++ meter/fritz/types.go | 71 +++++ meter/fritzdect.go | 14 +- templates/definition/charger/fritzdect.yaml | 10 + templates/definition/meter/fritzdect.yaml | 10 + templates/definition/meter/fritzgrid.yaml | 1 + 11 files changed, 516 insertions(+), 110 deletions(-) rename meter/{fritzdect/fritzdect.go => fritz/aha/aha.go} (54%) create mode 100644 meter/fritz/aha/types.go create mode 100644 meter/fritz/api.go create mode 100644 meter/fritz/smarthome/smarthome.go create mode 100644 meter/fritz/smarthome/types.go create mode 100644 meter/fritz/types.go diff --git a/charger/fritzdect.go b/charger/fritzdect.go index 1b68bbc2c..7bef93c02 100644 --- a/charger/fritzdect.go +++ b/charger/fritzdect.go @@ -1,20 +1,20 @@ package charger import ( - "errors" - "strconv" - "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/meter/fritzdect" + "github.com/evcc-io/evcc/meter/fritz" + "github.com/evcc-io/evcc/meter/fritz/aha" + "github.com/evcc-io/evcc/meter/fritz/smarthome" "github.com/evcc-io/evcc/util" ) // FRITZ! FritzBox AHA interface specifications: // https://fritz.com/fileadmin/user_upload/Global/Service/Schnittstellen/AHA-HTTP-Interface.pdf +// https://fritz.support/resources/SmarthomeRestApiFRITZOS82.html (REST API for FritzOS 8.2+) // FritzDECT charger implementation type FritzDECT struct { - conn *fritzdect.Connection + conn fritz.Switch *switchSocket } @@ -25,9 +25,9 @@ func init() { // NewFritzDECTFromConfig creates a fritzdect charger from generic config func NewFritzDECTFromConfig(other map[string]any) (api.Charger, error) { var cc struct { - embed `mapstructure:",squash"` - fritzdect.Settings `mapstructure:",squash"` - StandbyPower float64 + embed `mapstructure:",squash"` + fritz.Settings `mapstructure:",squash"` + StandbyPower float64 } if err := util.DecodeOther(other, &cc); err != nil { @@ -38,12 +38,20 @@ func NewFritzDECTFromConfig(other map[string]any) (api.Charger, error) { return nil, api.ErrMissingCredentials } - return NewFritzDECT(cc.embed, cc.URI, cc.AIN, cc.User, cc.Password, cc.StandbyPower) + return NewFritzDECT(cc.embed, cc.URI, cc.AIN, cc.User, cc.Password, cc.StandbyPower, cc.Firmware82) } // NewFritzDECT creates a new connection with standbypower for charger -func NewFritzDECT(embed embed, uri, ain, user, password string, standbypower float64) (*FritzDECT, error) { - conn, err := fritzdect.NewConnection(uri, ain, user, password) +func NewFritzDECT(embed embed, uri, ain, user, password string, standbypower float64, firmware82 bool) (*FritzDECT, error) { + var conn fritz.Switch + var err error + + // Use new REST API if firmware82 is set, otherwise use legacy LUA API + if firmware82 { + conn, err = smarthome.NewConnection(uri, ain, user, password) + } else { + conn, err = aha.NewConnection(uri, ain, user, password) + } if err != nil { return nil, err } @@ -59,18 +67,13 @@ func NewFritzDECT(embed embed, uri, ain, user, password string, standbypower flo // Status implements the api.Charger interface func (c *FritzDECT) Status() (api.ChargeStatus, error) { - resp, err := c.conn.ExecCmd("getswitchpresent") + present, err := c.conn.SwitchPresent() if err != nil { return api.StatusNone, err } - present, err := strconv.ParseBool(resp) - if err == nil && !present { - err = api.ErrNotAvailable - } - - if err != nil { - return api.StatusNone, err + if !present { + return api.StatusNone, api.ErrNotAvailable } return c.switchSocket.Status() @@ -78,33 +81,15 @@ func (c *FritzDECT) Status() (api.ChargeStatus, error) { // Enabled implements the api.Charger interface func (c *FritzDECT) Enabled() (bool, error) { - resp, err := c.conn.ExecCmd("getswitchstate") - if err != nil { - return false, err - } - - return strconv.ParseBool(resp) + return c.conn.SwitchState() } // Enable implements the api.Charger interface func (c *FritzDECT) Enable(enable bool) error { - cmd := "setswitchoff" if enable { - cmd = "setswitchon" + return c.conn.SwitchOn() } - - // on 0/1 - DECT Switch state off/on (empty if unknown or error) - resp, err := c.conn.ExecCmd(cmd) - if err != nil { - return err - } - - on, err := strconv.ParseBool(resp) - if err == nil && enable != on { - err = errors.New("switch failed") - } - - return err + return c.conn.SwitchOff() } var _ api.MeterEnergy = (*FritzDECT)(nil) diff --git a/meter/fritzdect/fritzdect.go b/meter/fritz/aha/aha.go similarity index 54% rename from meter/fritzdect/fritzdect.go rename to meter/fritz/aha/aha.go index ef28e850d..e98542f47 100644 --- a/meter/fritzdect/fritzdect.go +++ b/meter/fritz/aha/aha.go @@ -1,9 +1,6 @@ -package fritzdect +package aha import ( - "crypto/md5" - "encoding/hex" - "encoding/xml" "errors" "fmt" "net/url" @@ -12,44 +9,24 @@ import ( "time" "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/meter/fritz" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" "github.com/evcc-io/evcc/util/transport" - "golang.org/x/text/encoding/unicode" ) // FRITZ! FritzBox AHA interface and authentication specifications: // https://fritz.com/fileadmin/user_upload/Global/Service/Schnittstellen/AHA-HTTP-Interface.pdf // https://fritz.com/fileadmin/user_upload/Global/Service/Schnittstellen/AVM_Technical_Note_-_Session_ID.pdf -// FritzDECT settings -type Settings struct { - URI, AIN, User, Password string -} - // FritzDECT connection type Connection struct { *request.Helper - *Settings + *fritz.Settings SID string updated time.Time } -// https://fritz.com/fileadmin/user_upload/Global/Service/Schnittstellen/AVM_Technical_Note_-_Session_ID_english_2021-05-03.pdf -const sessionTimeout = 15 * time.Minute - -// Devicestats structures getbasicdevicesstats command response (AHA-HTTP-Interface) -type Devicestats struct { - XMLName xml.Name `xml:"devicestats"` - Energy Energy `xml:"energy"` -} - -// Energy structures getbasicdevicesstats command energy response (AHA-HTTP-Interface) -type Energy struct { - XMLName xml.Name `xml:"energy"` - Values []string `xml:"stats"` -} - // NewConnection creates FritzDECT connection func NewConnection(uri, ain, user, password string) (*Connection, error) { if uri == "" { @@ -60,7 +37,7 @@ func NewConnection(uri, ain, user, password string) (*Connection, error) { return nil, errors.New("missing ain") } - settings := &Settings{ + settings := &fritz.Settings{ URI: strings.TrimRight(uri, "/"), AIN: ain, User: user, @@ -82,11 +59,13 @@ func NewConnection(uri, ain, user, password string) (*Connection, error) { // ExecCmd execautes an FritzDECT AHA-HTTP-Interface command func (c *Connection) ExecCmd(function string) (string, error) { // refresh Fritzbox session id - if time.Since(c.updated) >= sessionTimeout { - if err := c.getSessionID(); err != nil { + if time.Since(c.updated) >= fritz.SessionTimeout { + sid, err := c.GetSessionID(c.Helper) + if err != nil { return "", err } // update session timestamp + c.SID = sid c.updated = time.Now() } @@ -123,7 +102,7 @@ func (c *Connection) CurrentPower() (float64, error) { var _ api.MeterEnergy = (*Connection)(nil) -// CurrentPower implements the api.MeterEnergy interface +// TotalEnergy implements the api.MeterEnergy interface func (c *Connection) TotalEnergy() (float64, error) { // Energy value in Wh (total switch energy, refresh approximately every 2 minutes) resp, err := c.ExecCmd("getswitchenergy") @@ -136,53 +115,48 @@ func (c *Connection) TotalEnergy() (float64, error) { return energy / 1000, err // Wh ==> KWh } -// Fritzbox helpers (credits to https://github.com/rsdk/ahago) +// SwitchPresent checks if the device is connected +func (c *Connection) SwitchPresent() (bool, error) { + resp, err := c.ExecCmd("getswitchpresent") + if err != nil { + return false, err + } + return strconv.ParseBool(resp) +} -// getSessionID fetches a session-id based on the username and password in the connection struct -func (c *Connection) getSessionID() error { - uri := fmt.Sprintf("%s/login_sid.lua", c.URI) - body, err := c.GetBody(uri) +// SwitchState returns the current switch state +func (c *Connection) SwitchState() (bool, error) { + resp, err := c.ExecCmd("getswitchstate") + if err != nil { + return false, err + } + return strconv.ParseBool(resp) +} + +// SwitchOn turns the switch on +func (c *Connection) SwitchOn() error { + resp, err := c.ExecCmd("setswitchon") if err != nil { return err } - var v struct { - SID string - Challenge string - BlockTime string + on, err := strconv.ParseBool(resp) + if err == nil && !on { + err = errors.New("switch on failed") } - - if err = xml.Unmarshal(body, &v); err == nil && v.SID == "0000000000000000" { - var challresp string - if challresp, err = createChallengeResponse(v.Challenge, c.Password); err == nil { - params := url.Values{ - "username": {c.User}, - "response": {challresp}, - } - - if body, err = c.GetBody(uri + "?" + params.Encode()); err == nil { - err = xml.Unmarshal(body, &v) - if v.SID == "0000000000000000" { - return errors.New("invalid user or password") - } - c.SID = v.SID - } - } - } - return err } -// createChallengeResponse creates the Fritzbox challenge response string -func createChallengeResponse(challenge, pass string) (string, error) { - encoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder() - utf16le, err := encoder.String(challenge + "-" + pass) +// SwitchOff turns the switch off +func (c *Connection) SwitchOff() error { + resp, err := c.ExecCmd("setswitchoff") if err != nil { - return "", err + return err } - hash := md5.Sum([]byte(utf16le)) - md5hash := hex.EncodeToString(hash[:]) - - return challenge + "-" + md5hash, nil + off, err := strconv.ParseBool(resp) + if err == nil && off { + err = errors.New("switch off failed") + } + return err } diff --git a/meter/fritz/aha/types.go b/meter/fritz/aha/types.go new file mode 100644 index 000000000..0af22c3d6 --- /dev/null +++ b/meter/fritz/aha/types.go @@ -0,0 +1,15 @@ +package aha + +import "encoding/xml" + +// Devicestats structures getbasicdevicesstats command response (AHA-HTTP-Interface) +type Devicestats struct { + XMLName xml.Name `xml:"devicestats"` + Energy Energy `xml:"energy"` +} + +// Energy structures getbasicdevicesstats command energy response (AHA-HTTP-Interface) +type Energy struct { + XMLName xml.Name `xml:"energy"` + Values []string `xml:"stats"` +} diff --git a/meter/fritz/api.go b/meter/fritz/api.go new file mode 100644 index 000000000..371f241ac --- /dev/null +++ b/meter/fritz/api.go @@ -0,0 +1,16 @@ +package fritz + +// Meter defines the interface for Fritz connections (both legacy LUA and REST) +type Meter interface { + CurrentPower() (float64, error) + TotalEnergy() (float64, error) +} + +// Switch extends Meter with switch control capabilities +type Switch interface { + Meter + SwitchPresent() (bool, error) + SwitchState() (bool, error) + SwitchOn() error + SwitchOff() error +} diff --git a/meter/fritz/smarthome/smarthome.go b/meter/fritz/smarthome/smarthome.go new file mode 100644 index 000000000..624af3daf --- /dev/null +++ b/meter/fritz/smarthome/smarthome.go @@ -0,0 +1,258 @@ +package smarthome + +import ( + "errors" + "fmt" + "net/url" + "strings" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/meter/fritz" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/transport" +) + +// FRITZ! Smarthome REST API (FritzOS 8.2+) +// https://fritz.support/resources/SmarthomeRestApiFRITZOS82.html + +// Connection implements the new REST API for Fritz smarthome devices +type Connection struct { + *request.Helper + *fritz.Settings + SID string + UID string // device UID (AIN with space) + updated time.Time + unitG util.Cacheable[Unit] +} + +// NewConnection creates a new REST API connection +func NewConnection(uri, ain, user, password string) (*Connection, error) { + if uri == "" { + uri = "https://fritz.box" + } + + if ain == "" { + return nil, errors.New("missing ain") + } + + settings := &fritz.Settings{ + URI: strings.TrimRight(uri, "/"), + AIN: ain, + User: user, + Password: password, + } + + log := util.NewLogger("fritzsmarthome").Redact(password) + + conn := &Connection{ + Helper: request.NewHelper(log), + Settings: settings, + UID: ainToUID(ain), + } + + conn.Client.Transport = request.NewTripper(log, transport.Insecure()) + + // cache unit data for 2 seconds to avoid excessive API calls + conn.unitG = util.ResettableCached(func() (Unit, error) { + return conn.getUnit() + }, 2*time.Second) + + return conn, nil +} + +// ainToUID converts AIN format to UID format by adding space +// AIN: "116300015376" -> UID: "11630 0015376" +func ainToUID(ain string) string { + // Remove any existing spaces first + ain = strings.ReplaceAll(ain, " ", "") + if len(ain) >= 5 { + return ain[:5] + " " + ain[5:] + } + return ain +} + +// getUnit fetches unit data from REST API +func (c *Connection) getUnit() (Unit, error) { + if err := c.refreshSession(); err != nil { + return Unit{}, err + } + + // Try to get the specific unit first + uri := fmt.Sprintf("%s/api/v0/smarthome/overview/units/%s", c.URI, url.PathEscape(c.UID)) + + req, _ := request.New("GET", uri, nil, map[string]string{ + "Authorization": "AVM-SID " + c.SID, + }, request.AcceptJSON) + + var unit Unit + if err := c.DoJSON(req, &unit); err != nil { + // Fall back to getting all units and finding ours + return c.findUnit() + } + + if !unit.IsConnected { + return unit, api.ErrNotAvailable + } + + return unit, nil +} + +// findUnit searches for our unit in the list of all units +func (c *Connection) findUnit() (Unit, error) { + uri := fmt.Sprintf("%s/api/v0/smarthome/overview/units", c.URI) + + req, err := request.New("GET", uri, nil, map[string]string{ + "Authorization": "AVM-SID " + c.SID, + }, request.AcceptJSON) + if err != nil { + return Unit{}, err + } + + var units []Unit + if err := c.DoJSON(req, &units); err != nil { + return Unit{}, err + } + + // Search for matching unit by UID or AIN + for _, unit := range units { + unitAIN := strings.ReplaceAll(unit.UID, " ", "") + if unit.UID == c.UID || unitAIN == c.AIN { + if !unit.IsConnected { + return unit, api.ErrNotAvailable + } + return unit, nil + } + } + + return Unit{}, fmt.Errorf("unit not found: %s", c.AIN) +} + +// CurrentPower implements the api.Meter interface +func (c *Connection) CurrentPower() (float64, error) { + unit, err := c.unitG.Get() + if err != nil { + return 0, err + } + + if unit.Statistics != nil && len(unit.Statistics.Powers) > 0 { + if stats := unit.Statistics.Powers[0].Values; len(stats) > 0 { + return (float64)(stats[0]) / 1000, nil + } + } + + return 0, api.ErrNotAvailable +} + +var _ api.MeterEnergy = (*Connection)(nil) + +// TotalEnergy implements the api.MeterEnergy interface +func (c *Connection) TotalEnergy() (float64, error) { + unit, err := c.unitG.Get() + if err != nil { + return 0, err + } + + if unit.Statistics != nil && len(unit.Statistics.Energies) > 0 { + if stats := unit.Statistics.Energies[len(unit.Statistics.Energies)-1].Values; len(stats) > 0 { + return (float64)(stats[0]) / 1000, nil + } + } + + return 0, api.ErrNotAvailable +} + +// SwitchPresent checks if the device is connected +func (c *Connection) SwitchPresent() (bool, error) { + unit, err := c.unitG.Get() + if err != nil { + if errors.Is(err, api.ErrNotAvailable) { + return false, nil + } + return false, err + } + return unit.IsConnected, nil +} + +// SwitchState returns the current switch state +func (c *Connection) SwitchState() (bool, error) { + unit, err := c.unitG.Get() + if err != nil { + return false, err + } + + if unit.Interfaces.OnOffInterface == nil { + return false, errors.New("device has no switch") + } + + return unit.Interfaces.OnOffInterface.State == "on", nil +} + +// SwitchOn turns the switch on +func (c *Connection) SwitchOn() error { + return c.setSwitch(true) +} + +// SwitchOff turns the switch off +func (c *Connection) SwitchOff() error { + return c.setSwitch(false) +} + +// setSwitch sets the switch state via REST API +func (c *Connection) setSwitch(on bool) error { + if err := c.refreshSession(); err != nil { + return err + } + + state := "off" + if on { + state = "on" + } + + uri := fmt.Sprintf("%s/api/v0/smarthome/overview/units/%s", c.URI, url.PathEscape(c.UID)) + + data := map[string]any{ + "onOffInterface": map[string]string{ + "state": state, + }, + } + + req, _ := request.New("PUT", uri, request.MarshalJSON(data), map[string]string{ + "Authorization": "AVM-SID " + c.SID, + }, request.JSONEncoding) + + var unit Unit + if err := c.DoJSON(req, &unit); err != nil { + return err + } + + // Reset cache after state change + c.unitG.Reset() + + // Verify state was changed + if unit.Interfaces.OnOffInterface != nil { + actualState := unit.Interfaces.OnOffInterface.State == "on" + if actualState != on { + return errors.New("switch state change failed") + } + } + + return nil +} + +// refreshSession ensures we have a valid session ID +func (c *Connection) refreshSession() error { + // refresh Fritzbox session id + if time.Since(c.updated) >= fritz.SessionTimeout { + sid, err := c.GetSessionID(c.Helper) + if err != nil { + return err + } + // update session timestamp + c.SID = sid + c.updated = time.Now() + } + + return nil +} diff --git a/meter/fritz/smarthome/types.go b/meter/fritz/smarthome/types.go new file mode 100644 index 000000000..9b74a48cc --- /dev/null +++ b/meter/fritz/smarthome/types.go @@ -0,0 +1,58 @@ +package smarthome + +// Unit represents a smarthome unit with its interfaces +type Unit struct { + GroupUID string `json:"groupUid,omitempty"` + UID string `json:"UID,omitempty"` + DeviceUID string `json:"deviceUid"` + UnitType string `json:"unitType"` + IsConnected bool `json:"isConnected"` + Statistics *Statistics `json:"statistics,omitempty"` + Interfaces *Interfaces `json:"interfaces,omitempty"` +} + +type Interfaces struct { + MultimeterInterface *MultimeterInterface `json:"multimeterInterface,omitempty"` + OnOffInterface *OnOffInterface `json:"onOffInterface,omitempty"` + TemperatureInterface *TemperatureInterface `json:"temperatureInterface,omitempty"` +} + +type Statistics struct { + Temperatures []ElementFloat `json:"temperatures,omitempty"` + Powers []Element `json:"powers,omitempty"` + Voltages []Element `json:"voltages,omitempty"` + Energies []Element `json:"energies,omitempty"` +} + +type ElementFloat struct { + Interval int64 `json:"interval"` + StasticsState string `json:"statisticsState"` + Period string `json:"period"` + Values []float64 `json:"values,omitempty"` +} + +type Element struct { + Interval int64 `json:"interval"` + StasticsState string `json:"statisticsState"` + Period string `json:"period"` + Values []int64 `json:"values,omitempty"` +} + +// MultimeterInterface contains power/energy measurements +type MultimeterInterface struct { + State string `json:"state"` + Power float64 `json:"power"` // W + Voltage float64 `json:"voltage"` // V + Current float64 `json:"current"` // A + Energy float64 `json:"energy"` // Wh +} + +// OnOffInterface contains switch state +type OnOffInterface struct { + State string `json:"state"` // "on" or "off" +} + +type TemperatureInterface struct { + State string `json:"state"` // "on" or "off" + Celsius float64 `json:"celsius"` +} diff --git a/meter/fritz/types.go b/meter/fritz/types.go new file mode 100644 index 000000000..7b5599517 --- /dev/null +++ b/meter/fritz/types.go @@ -0,0 +1,71 @@ +package fritz + +import ( + "crypto/md5" + "encoding/hex" + "encoding/xml" + "errors" + "fmt" + "net/url" + "time" + + "github.com/evcc-io/evcc/util/request" + "golang.org/x/text/encoding/unicode" +) + +// https://fritz.com/fileadmin/user_upload/Global/Service/Schnittstellen/AVM_Technical_Note_-_Session_ID_english_2021-05-03.pdf +const SessionTimeout = 15 * time.Minute + +// FritzDECT settings +type Settings struct { + URI, AIN, User, Password string + Firmware82 bool // use new REST API (FritzOS 8.2+) +} + +// Fritzbox helpers (credits to https://github.com/rsdk/ahago) + +// getSessionID fetches a session-id based on the username and password in the connection struct +func (s Settings) GetSessionID(c *request.Helper) (string, error) { + uri := fmt.Sprintf("%s/login_sid.lua", s.URI) + body, err := c.GetBody(uri) + if err != nil { + return "", err + } + + var v struct { + SID string + Challenge string + } + + if err = xml.Unmarshal(body, &v); err == nil && v.SID == "0000000000000000" { + var challresp string + if challresp, err = s.createChallengeResponse(v.Challenge); err == nil { + params := url.Values{ + "username": {s.User}, + "response": {challresp}, + } + + if body, err = c.GetBody(uri + "?" + params.Encode()); err == nil { + if err = xml.Unmarshal(body, &v); err == nil && v.SID == "0000000000000000" { + return "", errors.New("invalid user or password") + } + } + } + } + + return v.SID, err +} + +// createChallengeResponse creates the Fritzbox challenge response string +func (s Settings) createChallengeResponse(challenge string) (string, error) { + encoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder() + utf16le, err := encoder.String(challenge + "-" + s.Password) + if err != nil { + return "", err + } + + hash := md5.Sum([]byte(utf16le)) + md5hash := hex.EncodeToString(hash[:]) + + return challenge + "-" + md5hash, nil +} diff --git a/meter/fritzdect.go b/meter/fritzdect.go index 1a3f97d6c..f6e5a2627 100644 --- a/meter/fritzdect.go +++ b/meter/fritzdect.go @@ -2,12 +2,15 @@ package meter import ( "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/meter/fritzdect" + "github.com/evcc-io/evcc/meter/fritz" + "github.com/evcc-io/evcc/meter/fritz/aha" + "github.com/evcc-io/evcc/meter/fritz/smarthome" "github.com/evcc-io/evcc/util" ) // AVM FritzBox AHA interface specifications: // https://avm.de/fileadmin/user_upload/Global/Service/Schnittstellen/AHA-HTTP-Interface.pdf +// https://fritz.support/resources/SmarthomeRestApiFRITZOS82.html (REST API for FritzOS 8.2+) func init() { registry.Add("fritzdect", NewFritzDECTFromConfig) @@ -15,7 +18,7 @@ func init() { // NewFritzDECTFromConfig creates a fritzdect meter from generic config func NewFritzDECTFromConfig(other map[string]any) (api.Meter, error) { - var cc fritzdect.Settings + var cc fritz.Settings if err := util.DecodeOther(other, &cc); err != nil { return nil, err } @@ -24,5 +27,10 @@ func NewFritzDECTFromConfig(other map[string]any) (api.Meter, error) { return nil, api.ErrMissingCredentials } - return fritzdect.NewConnection(cc.URI, cc.AIN, cc.User, cc.Password) + // Use new REST API if firmware82 is set, otherwise use legacy LUA API + if cc.Firmware82 { + return smarthome.NewConnection(cc.URI, cc.AIN, cc.User, cc.Password) + } + + return aha.NewConnection(cc.URI, cc.AIN, cc.User, cc.Password) } diff --git a/templates/definition/charger/fritzdect.yaml b/templates/definition/charger/fritzdect.yaml index 8099513bf..922f4d127 100644 --- a/templates/definition/charger/fritzdect.yaml +++ b/templates/definition/charger/fritzdect.yaml @@ -25,6 +25,15 @@ params: required: true - name: ain required: true + - name: firmware82 + advanced: true + type: bool + description: + de: Neue REST-API verwenden (FritzOS 8.2+) + en: Use new REST API (FritzOS 8.2+) + help: + de: Verwende die neue REST-API für FritzOS ab Version 8.2 + en: Use the new REST API for FritzOS version 8.2 and later - preset: switchsocket render: | type: fritzdect @@ -32,4 +41,5 @@ render: | user: {{ .user }} password: {{ .password }} ain: {{ .ain }} # switch actor identification number without blanks (see AIN number on switch sticker) + firmware82: {{ .firmware82 }} {{ include "switchsocket" . }} diff --git a/templates/definition/meter/fritzdect.yaml b/templates/definition/meter/fritzdect.yaml index 98dbb6934..caab3a1b8 100644 --- a/templates/definition/meter/fritzdect.yaml +++ b/templates/definition/meter/fritzdect.yaml @@ -27,9 +27,19 @@ params: required: true - name: ain required: true + - name: firmware82 + advanced: true + type: bool + description: + de: Neue REST-API verwenden (FritzOS 8.2+) + en: Use new REST API (FritzOS 8.2+) + help: + de: Verwende die neue REST-API für FritzOS ab Version 8.2 + en: Use the new REST API for FritzOS version 8.2 and later render: | type: fritzdect uri: {{ .uri }} user: {{ .user }} password: {{ .password }} ain: {{ .ain }} # switch actor identification number without blanks (see AIN number on switch sticker) + firmware82: {{ .firmware82 }} diff --git a/templates/definition/meter/fritzgrid.yaml b/templates/definition/meter/fritzgrid.yaml index 0d11df4d1..f67125749 100644 --- a/templates/definition/meter/fritzgrid.yaml +++ b/templates/definition/meter/fritzgrid.yaml @@ -20,3 +20,4 @@ render: | user: {{ .user }} password: {{ .password }} ain: {{ .ain }} # switch actor identification number without blanks (see AIN number on switch sticker) + firmware82: true From cd176a43998e619861143014c9908e6c634e4b7b Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 19 Apr 2026 14:18:21 +0200 Subject: [PATCH 0088/1316] chore: migrate metrics columns either or, not both --- core/metrics/db.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/core/metrics/db.go b/core/metrics/db.go index e1ef96150..06809f30f 100644 --- a/core/metrics/db.go +++ b/core/metrics/db.go @@ -187,17 +187,17 @@ func SetupSchema() error { if err := m.RenameColumn(new(meter), old, "import"); err != nil { return err } - } - - // meter: split energy direction #2 - if old := "pos"; m.HasColumn(new(meter), old) { - if err := m.RenameColumn(new(meter), old, "import"); err != nil { - return err + } else { + // meter: split energy direction #2 + if old := "pos"; m.HasColumn(new(meter), old) { + if err := m.RenameColumn(new(meter), old, "import"); err != nil { + return err + } } - } - if old := "neg"; m.HasColumn(new(meter), old) { - if err := m.RenameColumn(new(meter), old, "export"); err != nil { - return err + if old := "neg"; m.HasColumn(new(meter), old) { + if err := m.RenameColumn(new(meter), old, "export"); err != nil { + return err + } } } From c968d68947e71f6b2368ca75afbe4972e97ade09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20He=C3=9F?= Date: Sun, 19 Apr 2026 14:26:07 +0200 Subject: [PATCH 0089/1316] Add domain knowledge docs for AI agents (#29228) --- AGENTS.md | 24 +++ docs/agents/core-domain.md | 147 +++++++++++++++ docs/agents/easee-architecture.md | 269 +++++++++++++++++++++++++++ docs/agents/hardware-integrations.md | 148 +++++++++++++++ docs/agents/plugin-system.md | 61 ++++++ docs/agents/web-ui-api.md | 87 +++++++++ 6 files changed, 736 insertions(+) create mode 100644 docs/agents/core-domain.md create mode 100644 docs/agents/easee-architecture.md create mode 100644 docs/agents/hardware-integrations.md create mode 100644 docs/agents/plugin-system.md create mode 100644 docs/agents/web-ui-api.md diff --git a/AGENTS.md b/AGENTS.md index 75476d4a0..f7a467b3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,30 @@ This file provides guidance to AI coding agents when working with code in this r - `evcc --template-type [type] --template [file]` - test device templates - `make docs` - generate template documentation +## Domain Knowledge + +Deep documentation on specific subsystems is available in `docs/agents/`. Load what you need based on the task: + +| File | When to load | +|------|-------------| +| [Core Domain](docs/agents/core-domain.md) | Control loop, loadpoint logic, PV surplus, charge modes, tariffs, interfaces | +| [Hardware Integrations](docs/agents/hardware-integrations.md) | Charger/meter/vehicle implementations, adding new devices | +| [Easee Architecture](docs/agents/easee-architecture.md) | Easee charger (REST+SignalR, async correlation, concurrency) | +| [Plugin System](docs/agents/plugin-system.md) | Plugin layer (HTTP, MQTT, Modbus, SunSpec, JS) | +| [Web UI & API](docs/agents/web-ui-api.md) | REST API, WebSocket, Vue frontend, authentication | + +### Loading guide by task type + +- **Charger implementation** — hardware-integrations + core-domain +- **Easee charger work** — easee-architecture + core-domain +- **Meter implementation** — hardware-integrations + plugin-system +- **Vehicle implementation** — hardware-integrations +- **UI/frontend work** — web-ui-api +- **API endpoint work** — web-ui-api + core-domain +- **Config/template work** — plugin-system +- **Control loop / charging logic** — core-domain +- **Bug in any area** — core-domain + relevant topic file(s) + ## Architecture Guidelines ### Core Components diff --git a/docs/agents/core-domain.md b/docs/agents/core-domain.md new file mode 100644 index 000000000..42ba9ac3f --- /dev/null +++ b/docs/agents/core-domain.md @@ -0,0 +1,147 @@ +# Core Domain: Site, Loadpoint, and the Control Loop + +## Object Hierarchy + +``` +Site (orchestrator — core/site.go) +├── Meters: Grid, PV[], Battery[], Auxiliary[], External[] +├── Tariffs: Grid, FeedIn, CO2, Solar +├── Coordinator (vehicle <-> loadpoint assignment) +├── Prioritizer (power allocation fairness) +└── Loadpoints[] (core/loadpoint.go) + ├── Charger (api.Charger — hardware controller) + ├── Vehicle (api.Vehicle — EV battery state via cloud API) + ├── ChargeMeter (api.Meter — AC power at charger) + └── Circuit (optional — electrical domain limits) +``` + +## Key Interfaces (api/api.go) + +### Meter +- `Meter` — `CurrentPower() (float64, error)` — watts +- `MeterEnergy` — `TotalEnergy() (float64, error)` — kWh +- `PhaseCurrents` / `PhaseVoltages` / `PhasePowers` — per-phase readings + +### Battery +- `Battery` — `Soc() (float64, error)` — 0-100% +- `BatteryCapacity` — kWh +- `BatteryController` — set charge/discharge/hold mode + +### Charger +- `Charger` — `Status()`, `Enabled()`, `Enable(bool)`, `MaxCurrent(int64)` +- `ChargerEx` — milliamp-precision current via `MaxCurrentMillis(float64)` +- `PhaseSwitcher` — `Phases1p3p(int) error` +- `ChargeRater` — `ChargedEnergy() (float64, error)` +- `ChargeTimer` — `ChargeDuration() (time.Duration, error)` + +### Vehicle +- `Vehicle` — `Soc()`, `Capacity()`, `Identifiers()`, `Phases()`, `OnIdentified()` +- `VehicleRange`, `VehicleOdometer`, `VehicleClimater`, `VehicleFinishTimer`, `VehiclePosition` +- `ChargeController` — remote start/stop on vehicle +- `CurrentLimiter` — `GetMinMaxCurrent()` for vehicle-side current limits +- `CurrentController` — some vehicles (Tesla, Fiat) also implement `MaxCurrent()` to set charge current from the vehicle side + +## Charge Modes + +| Mode | Behavior | +|------|----------| +| `OFF` | Disabled (unless welcome charge) | +| `NOW` | Max current immediately | +| `MINPV` | Min current when PV surplus; fast if cheap tariff | +| `PV` | Ramp current proportional to available solar | + +## Charge States (IEC 61851) + +- `A` — not connected +- `B` — connected, not charging +- `C` — connected, charging + +## The Control Loop (Site.update — runs every N seconds) + +``` +1. Update all meters (grid, PV, battery, aux) +2. For each loadpoint: UpdateChargePowerAndCurrents() +3. Calculate site power balance: + sitePower = gridPower + batteryPower + excessDCPower + + residualPower - auxPower - flexiblePower +4. Apply battery priority rules (prioritySoc, bufferSoc) +5. Get tariff rates +6. For EACH loadpoint: Update(sitePower, ...) + ├── Read charger status + ├── Detect/identify vehicle + ├── Check plan requirements (minSOC, target time) + ├── Check limits (limitSOC, limitEnergy) + ├── MODE switch -> calculate target current + ├── Cap at maxCurrent, respect circuit limits + ├── Send MaxCurrent() to charger + └── Record metrics +7. Push updates to WebSocket + metrics +``` + +The loop is stateless per cycle: always re-reads actual state, calculates +optimal current, sends single command. Resilient to restarts and missed updates. + +## PV Surplus Charging (pvMaxCurrent in core/loadpoint.go) + +``` +1. Read effective min/max current limits +2. Reduce sitePower by battery boost power +3. Consider phase switching (1p <-> 3p) if supported +4. deltaCurrent = powerToCurrent(-sitePower, activePhases) + targetCurrent = effectiveCurrent + deltaCurrent +5. Below minCurrent -> start disable timer (default 3 min) +6. Surplus returns -> start enable timer (default 1 min) +7. Cap at maxCurrent +``` + +## Battery Priority Rules + +| Setting | Effect | +|---------|--------| +| `prioritySoc` | Below this: battery charges first, EV gets 0 | +| `bufferSoc` | Above this: EV can draw from battery reserves | +| `bufferStartSoc` | Above this: EV charging can begin even if importing | + +## Effective Price Calculation + +``` +greenShare = (max(pvPower,0) + max(batteryPower,0)) / totalChargePower +effectivePrice = gridPrice * (1 - greenShare) + feedInPrice * greenShare +``` + +## Concurrency Model + +- **Site** owns `RWMutex` for its state (meters, battery, tariffs) +- **Loadpoint** owns `RWMutex` for its state (charger, vehicle, current) +- **Coordinator** owns `RWMutex` for vehicle <-> loadpoint tracking +- No global locks — ordering prevents deadlocks + +### Channels + +| Channel | Scope | Buffer | Purpose | +|---------|-------|--------|---------| +| `valueChan` | Site | Unbounded (`chanx.NewUnboundedChan`) | State changes -> DB + UI (ordering) | +| `lpUpdateChan` | Site | 1 | Early loadpoint update requests | +| `pushChan` | Loadpoint | Buffered | User notifications | + +## Tariff Integration + +Types: `TariffUsageGrid`, `TariffUsageFeedIn`, `TariffUsageCo2`, `TariffUsagePlanner`, `TariffUsageSolar` + +### Smart Features +- **Cheap-tariff override** — rate below threshold -> fast charge +- **Smart feed-in** — feed-in rate above threshold -> prioritize export +- **Planner** (`core/planner/planner.go`) — finds cheapest time slots for target SOC/energy by deadline + - `optimalPlan()` — cheapest non-contiguous slots + - `continuousPlan()` — cheapest continuous window (fallback) + +## Key File Locations + +- `api/api.go` — all core interfaces +- `core/site.go` — Site orchestrator + control loop +- `core/loadpoint.go` — Loadpoint state machine (pvMaxCurrent, mode switch) +- `core/site_battery.go` — battery priority logic +- `core/site_tariffs.go` — tariff integration +- `core/planner/planner.go` — charge time optimization +- `core/prioritizer/prioritizer.go` — power allocation across loadpoints +- `core/circuit/circuit.go` — electrical domain limits diff --git a/docs/agents/easee-architecture.md b/docs/agents/easee-architecture.md new file mode 100644 index 000000000..1890e5e24 --- /dev/null +++ b/docs/agents/easee-architecture.md @@ -0,0 +1,269 @@ +# Easee Charger Architecture + +The Easee integration communicates with the Easee cloud over two distinct channels: + +1. **REST API** (`https://api.easee.com/api`) — synchronous control commands and configuration. +2. **SignalR WebSocket** (`https://streams.easee.com/hubs/chargers`) — asynchronous real-time state updates and command confirmations. + +Commands are sent via REST; their acknowledgement and state changes are delivered asynchronously via SignalR. + +## Authentication + +### Flow + +Authentication uses username/password credentials against: +``` +POST https://api.easee.com/api/accounts/login +``` + +Returns a `Token` struct with `accessToken` (short-lived JWT), `refreshToken`, `expiresIn`, and `tokenType`. + +### Token Lifecycle + +Wrapped in `oauth2.TokenSource` via `oauth.RefreshTokenSource`. Near expiry, automatically calls: +``` +POST https://api.easee.com/api/accounts/refresh_token +``` +Falls back to full re-login if refresh fails. + +### Token Caching + +`TokenSource` is shared per user via `cache.New[oauth2.TokenSource]()`. Multiple `Easee` instances with the same user email share a single token source, preventing redundant re-authentication. + +## Initialization Sequence + +`NewEasee` performs these steps in order: + +1. **Charger Discovery** — If no serial provided, queries `GET /api/chargers` and expects exactly one charger. +2. **Site and Circuit Discovery** — `GET /api/chargers/{chargerID}/site`. Searches for a single-charger circuit for circuit-level phase control. +3. **SignalR Connection** — Creates client with `WithMaxElapsedTime(0)` to retry forever (default 15-min cap would silently stop updates). +4. **Subscription** — On every `ClientConnected`, sends `SubscribeWithCurrentState(chargerID, true)` to replay full current state before switching to push-on-change. +5. **Startup Gate** — Blocks until `CHARGER_OP_MODE` is received (one-shot `sync.OnceFunc`). +6. **Optional State Wait** — Waits up to 3s for `SESSION_ENERGY`, `LIFETIME_ENERGY`, `TOTAL_POWER`. WARN if missing but initialization succeeds. + +## SignalR Back-Channel + +### Why SignalR is Required + +1. **Commands are fire-and-forget at HTTP level.** HTTP response only confirms cloud received the request. Success/failure arrives via SignalR `CommandResponse`. +2. **State is event-driven, not pollable.** No REST endpoint streams charger state. +3. **Ticks correlation only works with a live connection.** If SignalR drops mid-command, the waiter times out. + +### Server -> Client Methods + +#### `ProductUpdate(json.RawMessage)` +Primary state channel. Carries a single `Observation` with `ID` (ObservationID), `Value`, `DataType`, and `Timestamp`. + +- **Timestamp deduplication**: older timestamps for the same ID are silently dropped. +- **Non-blocking fan-out**: observation sent on `obsC` via non-blocking select. + +#### `CommandResponse(json.RawMessage)` +Async acknowledgement for REST commands. Contains `Ticks` (correlation key), `WasAccepted`, `ResultCode`, and `ID` (ObservationID). + +Routes through three maps in order: +1. `pendingTicks[res.Ticks]` — primary correlation for async (HTTP 202) commands +2. `pendingByID[ObservationID(res.ID)]` — fallback when Ticks mismatch +3. `expectedOrphans[ObservationID(res.ID)]` — counter for sync (HTTP 200) endpoints that still produce a CommandResponse + +Unmatched responses are logged as WARN (rogue response from external system). + +#### `ChargerUpdate` / `SubscribeToMyProduct` +Logged at TRACE, not processed further. + +## Command Flow and Async Correlation + +### REST Command Endpoints + +``` +POST /api/chargers/{chargerID}/commands/{action} (start/stop/pause/resume) +POST /api/chargers/{chargerID}/settings (enable, DCC, PhaseMode, SmartCharging) +POST /api/sites/{siteID}/circuits/{circuitID}/settings (dynamic circuit currents) +``` + +### Response Handling + +| HTTP Status | Meaning | Behavior | +|-------------|---------|----------| +| `200` | Synchronous / already applied | Returns immediately | +| `202` | Asynchronous, Ticks provided | Waits for matching CommandResponse | +| other | Error | Returns error | + +### Ticks Correlation + +On 202, the body contains `RestCommandResponse` with a `Ticks` field (.NET DateTime.Ticks). If `Ticks == 0`, the command was a no-op. + +Each in-flight command creates a **buffered channel** (capacity 1), registered in both `pendingTicks` and `pendingByID`, cleaned up via `defer`. + +### The Sync/Async Mismatch + +Some endpoints return HTTP `200` but still fire a `CommandResponse` via SignalR. The observed case is circuit settings (`POST /api/sites/{siteID}/circuits/{circuitID}/settings`) which returns `200` but generates `CommandResponse` with `ID=22` (`CIRCUIT_MAX_CURRENT_P1`). + +Handled via the **expected-orphan counter**: +```go +expectedOrphans map[easee.ObservationID]int // protected by cmdMu +``` + +Before a POST to a known 200-returning endpoint, increment the counter. When `CommandResponse` arrives with no pending match, decrement and silently consume. Counter at 0 means genuinely rogue. + +## State Management + +### Internal State Fields (all protected by `sync.RWMutex`) + +| Field | Observation | Notes | +|-------|------------|-------| +| `opMode` | `CHARGER_OP_MODE` (109) | Central state machine | +| `chargerEnabled` | `IS_ENABLED` (31) | Hardware enable state | +| `smartCharging` | `SMART_CHARGING` (102) | LED color mode | +| `currentPower` | `TOTAL_POWER` (120) | Watts (API sends kW, multiplied by 1000) | +| `sessionEnergy` | `SESSION_ENERGY` (121) | kWh, special zero-handling | +| `totalEnergy` | `LIFETIME_ENERGY` (124) | kWh, updated ~hourly | +| `currentL1/L2/L3` | `IN_CURRENT_T3/T4/T5` (183/184/185) | Phase currents in A | +| `phaseMode` | `PHASE_MODE` (38) | 1=single, 2=auto, 3=locked 3-phase | +| `dynamicCircuitCurrent[3]` | `DYNAMIC_CIRCUIT_CURRENT_P1/P2/P3` (111/112/113) | Per-phase circuit limit | +| `maxChargerCurrent` | `MAX_CHARGER_CURRENT` (47) | Hardware max (non-volatile) | +| `dynamicChargerCurrent` | `DYNAMIC_CHARGER_CURRENT` (48) | Volatile current limit | +| `reasonForNoCurrent` | `REASON_FOR_NO_CURRENT` (96) | Debug enum | +| `pilotMode` | `PILOT_MODE` (100) | CP signal state A-F | +| `rfid` | `USER_IDTOKEN` (128) | Last scanned RFID token | + +### Session Energy Zero-value Protection + +`sessionEnergy` is never set to `0` from a `ProductUpdate` — the API sends spurious zeros erratically. Session reset is driven by op-mode transition: when `CHARGER_OP_MODE` transitions from disconnected to awaiting-start, `sessionEnergy` resets to `0` with a fresh timestamp. + +## Charger Operation Modes + +``` +0 = Offline — no cloud connection +1 = Disconnected — no car plugged in +2 = AwaitingStart — car plugged, waiting for authorization/start +3 = Charging — actively charging +4 = Completed — car full or finished, cable still plugged +5 = Error — fault condition +6 = ReadyToCharge — ready, current available +7 = AwaitingAuthentication — RFID auth required +8 = Deauthenticating — finishing authentication teardown +``` + +### Mapping to evcc Status + +| opMode | evcc Status | +|--------|------------| +| 1 (Disconnected) | A | +| 2, 4, 6, 7, 8 | B | +| 3 (Charging) | C | +| 0, 5 and others | error | + +## Enable/Disable Flow + +### Enable = true + +1. If `chargerEnabled == false`: POST settings `{ enabled: true }` and wait. +2. If `opMode == Disconnected`: return (no cable). +3. If `opMode == AwaitingAuthentication && authorize`: action = `start_charging`. +4. Otherwise: action = `resume_charging`. +5. POST `/commands/{action}` and wait. +6. Wait for `opMode` to reach enabled state. +7. Wait for `dynamicChargerCurrent` to reach `32` (Easee sets this on resume). +8. Call `MaxCurrent(c.current)` to restore previous setpoint. + +### Enable = false + +1. If disconnected or (awaiting auth && !authorize): return. +2. POST `/commands/pause_charging` and wait. +3. Wait for `opMode` to reach disabled state. +4. Wait for `dynamicChargerCurrent` to reach `0`. + +### State Waiting Pattern + +Both `waitForChargerEnabledState` and `waitForDynamicChargerCurrent` use: +1. Short-circuit check: if already in target state, return immediately. +2. Open a timer. +3. Loop on `obsC` channel. +4. On timer expiry: **one final check** before returning `api.ErrTimeout`. + +The final check handles the race where the state update arrived between the last channel read and the timer fire. + +## Phase Control + +### Circuit-Level (preferred, when circuit is known) + +Phase switching by zeroing dynamic circuit current on unused phases: +``` +POST /api/sites/{siteID}/circuits/{circuitID}/settings +``` + +For 1-phase: set P2=0, P3=0. For 3-phase: restore all three. + +This POST returns HTTP `200` but still fires a `CommandResponse` with `ID=22` (expected orphan). + +### Charger-Level (fallback) + +Uses `PhaseMode` setting: `1` for single-phase, `2` (auto) for 3-phase. +After changing PhaseMode, `Enable(false)` is called — the loadpoint then re-enables, because PhaseMode changes only take effect after a charging cycle restart. + +## Authorization Mode (`authorize`) + +When `authorize: true`, evcc sends `start_charging` to authorize sessions when the charger enters `ModeAwaitingAuthentication`. This enables fully unattended operation but is incompatible with RFID-based vehicle identification. + +When `authorize: false`, evcc does nothing in mode 7 — the charger waits for external authorization (RFID card or app). + +Setting `authorize: true` also prevents the charger from auto-starting at 32A on plug-in, giving evcc full control from the first amp. + +## Concurrency Model + +### Mutexes + +| Mutex | Type | Protects | +|-------|------|----------| +| `c.mux` | `sync.RWMutex` | All charger state fields | +| `dispatcher.mu` | `sync.Mutex` | `pendingTicks`, `pendingByID`, `expectedOrphans` maps (inside `CommandDispatcher`) | + +Command dispatch was extracted into `charger/easee/dispatcher.go` (`CommandDispatcher` struct). The two mutexes are intentionally separate to prevent the SignalR receive loop from blocking on command dispatch operations. + +### Observation Channel + +`obsC chan Observation` is unbuffered. `ProductUpdate` sends via non-blocking select — if no waiter is listening, the notification is dropped. The authoritative state is always in the struct fields; the channel is only a notification mechanism. + +**Design constraint**: any waiter on `obsC` must include a final state check after timer expiry before returning `api.ErrTimeout`. + +## Known Design Concerns + +1. **SESSION_ENERGY zero-value protection** — defensive measure based on field observations; root cause unverified. +2. **LIFETIME_ENERGY** — inaccurate by design, API pushes updates ~hourly. +3. **current vs dynamicChargerCurrent drift** — evcc's desired setpoint and charger's confirmed value can drift around pause/resume cycles. Resynced via `MaxCurrent(c.current)` after resume. +4. **Multi-charger circuits** — only circuit-level phase control when charger is alone on its circuit. Multi-charger circuits fall back to less precise charger-level control. +5. **Stale CommandResponses after reconnect** — if SignalR drops mid-command, the response may arrive after reconnect with no pending entry, triggering a false-positive rogue WARN. Acceptable trade-off. + +## API Endpoints Summary + +| Method | Endpoint | Used For | +|--------|----------|----------| +| `POST` | `/accounts/login` | Initial authentication | +| `POST` | `/accounts/refresh_token` | Token refresh | +| `GET` | `/chargers` | Auto-discover charger ID | +| `GET` | `/chargers/{id}/site` | Discover site and circuit | +| `POST` | `/chargers/{id}/settings` | Enable/disable, DCC, PhaseMode, SmartCharging | +| `POST` | `/chargers/{id}/commands/{action}` | start/stop/pause/resume charging | +| `GET` | `/sites/{siteId}/circuits/{circuitId}/settings` | Read max circuit currents | +| `POST` | `/sites/{siteId}/circuits/{circuitId}/settings` | Set dynamic circuit currents (phase switching) | + +### SignalR Hub + +| Endpoint | `https://streams.easee.com/hubs/chargers` | +|----------|------------------------------------------| +| Client -> Server | `SubscribeWithCurrentState(chargerID, true)` | +| Server -> Client | `ProductUpdate`, `ChargerUpdate`, `SubscribeToMyProduct`, `CommandResponse` | + +## Configuration + +| Parameter | Required | Default | Notes | +|-----------|----------|---------|-------| +| `user` | yes | | Easee account email | +| `password` | yes | | Easee account password | +| `charger` | no | | Charger serial; auto-detected if exactly one on account | +| `timeout` | no | `20s` | HTTP timeout for all API calls and command waits | +| `authorize` | no | `false` | If true, evcc sends `start_charging` to authorize sessions | + +Supported products: Easee Home, Easee Charge, Easee Charge Lite, Easee Charge Core. +Declared capabilities: `1p3p` (phase switching), `rfid` (RFID identification). +Requires evcc sponsorship. diff --git a/docs/agents/hardware-integrations.md b/docs/agents/hardware-integrations.md new file mode 100644 index 000000000..2fddf3fd5 --- /dev/null +++ b/docs/agents/hardware-integrations.md @@ -0,0 +1,148 @@ +# Hardware Integrations: Chargers, Meters, Vehicles + +## Integration Pattern + +All device types use a registry-based factory pattern: + +```go +// Self-registration in init() +func init() { + registry.AddCtx("typename", NewFromConfig) +} + +func NewFromConfig(ctx context.Context, other map[string]interface{}) (api.Charger, error) { + // Parse config, create client, return implementation +} +``` + +Optional interfaces are added via the decorator pattern: +```go +//go:generate decorate -f decorateXxx -b *Xxx -t "api.PhaseSwitcher,Phases1p3p,func(int) error" +``` + +## Charger Implementations + +### By Protocol + +| Protocol | Examples | +|----------|---------| +| HTTP/REST | Easee (REST+SignalR), Wallbox, go-e, OpenWB, Shelly | +| Modbus RTU/TCP | KEBA, Wallbe, CFOS, Bender, Delta, Mennekes | +| OCPP 1.6 | Generic charge point server | +| EEBus/ISO 15118 | EEBus SPINE protocol | +| UDP/Custom | KEBA UDP, OpenEVSE, Wattpilot, NRGKick | +| MQTT | OpenWB, Tasmota, Shelly | +| Smart Socket | Shelly, Tapo, TP-Link, FritzDECT | + +### Required Charger Interface + +```go +type Charger interface { + ChargeState + Enabled() (bool, error) + Enable(enable bool) error + CurrentController +} +``` + +Where `ChargeState` provides `Status() (ChargeStatus, error)` (A/B/C) and +`CurrentController` provides `MaxCurrent(current int64) error`. + +### Optional Charger Interfaces + +- `ChargerEx` — `MaxCurrentMillis(float64)` for milliamp precision +- `PhaseSwitcher` — `Phases1p3p(int)` to switch 1p/3p +- `Meter` / `MeterEnergy` — built-in power/energy measurement +- `PhaseCurrents` / `PhaseVoltages` — per-phase readings +- `ChargeRater` — `ChargedEnergy()` for session energy +- `ChargeTimer` — `ChargeDuration()` for session time +- `Identifier` — `Identify()` for RFID/vehicle identification + +### Key Implementations + +- **Easee** — REST + async SignalR; see `docs/agents/easee-architecture.md` for full detail +- **OCPP** (`charger/ocpp.go`) — Full 1.6 with charge point management +- **go-e** — Dual API (v1 local HTTP, v2 cloud); phase switching on v2 +- **EEBus** — Complex SPINE protocol with USE cases (CEM, EV, EVCC) +- **Generic configurable** (`charger/charger.go`) — plugin-driven via YAML template + +### Adding a New Charger + +1. Create `charger/xxx.go` (or YAML template in `templates/definition/charger/`) +2. Implement `NewXxxFromConfig()` returning `api.Charger` +3. Required: `Status()`, `Enabled()`, `Enable()`, `MaxCurrent()` +4. Register: `registry.AddCtx("xxx", factory)` in `init()` +5. Optional: add decorator for PhaseSwitcher, Meter, etc. +6. Add template: `templates/definition/charger/xxx.yaml` for UI metadata + +## Meter Implementations + +### By Category + +| Category | Examples | +|----------|---------| +| Modbus/SunSpec | SDM630, SMA, Fronius, Victron | +| HTTP/REST | Homewizard, Shelly Gen3, E3DC | +| Smart Home | HomeAssistant entities, Homematic | +| Battery/Storage | Tesla Powerwall, LG ESS, Zendure | + +### Required Meter Interface + +```go +type Meter interface { + CurrentPower() (float64, error) // watts +} +``` + +### Optional: `MeterEnergy`, `PhaseCurrents/Voltages/Powers`, `Battery`, `BatteryCapacity` + +### Key Implementations +- **mbmd** (`meter/mbmd.go`) — RS485 device library with auto-detection +- **SunSpec** (`plugin/sunspec.go`) — Modbus model-based point queries +- **Generic** — plugin-driven (HTTP, Modbus, MQTT sources) + +## Vehicle Integrations + +| Manufacturer | API Type | +|-------------|----------| +| Tesla | Fleet API + vehicle-command proxy | +| VW Group | WeConnect (VW, Audi, Skoda, Seat, Cupra) | +| Hyundai/Kia | BlueLink (regional variants) | +| BMW/Mini | ConnectedDrive v2 | +| Mercedes | Official API | +| Renault/Nissan | Renault API + Carwings | +| Ford | FordConnect (US/EU) | +| Porsche | Porsche Connect | +| PSA Group | Peugeot, Citroen, DS, Opel | +| Generic | OVMS, Tronity | + +### Required Vehicle Interface + +```go +type Vehicle interface { + Battery // Soc() (float64, error) + BatteryCapacity // Capacity() float64 + IconDescriber // Icon() string + FeatureDescriber // Features() []Feature + PhaseDescriber // Phases() int + TitleDescriber // GetTitle() string + SetTitle(string) + Identifiers() []string + OnIdentified() ActionConfig +} +``` + +### Optional: `SocLimiter`, `ChargeState`, `VehicleRange`, `VehicleOdometer`, `VehicleClimater`, `VehicleFinishTimer`, `VehiclePosition`, `CurrentLimiter`, `CurrentController`, `ChargeController`, `Resurrector` + +### Polling Strategy + +Configurable: always / while charging / while connected. Interval-based caching +to avoid excessive cloud API calls. OAuth2 token handling built into each provider. + +## Auto-Detection (`cmd/detect/`) + +Task-based parallel IP scanning: +`ping` -> `tcp_http` -> `tcp_modbus` -> `sunspec` -> device-specific probes + +Detects: OpenWB, SMA, KEBA, E3DC, Sonnen, Tesla Powerwall, Wallbe, Fronius, +Tasmota, Shelly, Phoenix, and many more. diff --git a/docs/agents/plugin-system.md b/docs/agents/plugin-system.md new file mode 100644 index 000000000..c37a76922 --- /dev/null +++ b/docs/agents/plugin-system.md @@ -0,0 +1,61 @@ +# Plugin System + +The plugin system (`plugin/`) provides protocol-level abstraction for device +communication. Plugins implement typed getter/setter interfaces and are composed +into charger, meter, or vehicle implementations via configuration. + +## Plugin Types + +| Plugin | Protocol | Key Config | +|--------|----------|------------| +| `http` | HTTP/REST | `uri`, `method`, `headers`, `auth`, `cache`, `timeout` | +| `mqtt` | MQTT | `topic`, `retained`, `payload` template, `timeout` | +| `modbus` | Modbus TCP/RTU | `uri`, `register`, `scale`, `baudrate`, `rtu` | +| `sunspec` | SunSpec/Modbus | Model-based point queries via device tree | +| `js` | JavaScript/WASM | Inline script evaluation | +| `go` | Go runtime | Dynamic Go code | +| `gpio` | Linux GPIO | Digital I/O for relays | + +## Getter/Setter Interfaces + +```go +type StringGetter func() (string, error) +type FloatGetter func() (float64, error) +type IntGetter func() (int64, error) +type BoolGetter func() (bool, error) +// + corresponding Setter types +``` + +## Pipeline Transforms + +Plugins support chained transforms: `scale`, `offset`, `lookup`, `regex`. + +## Template-Based Device Configuration + +Devices can be defined entirely via YAML templates using plugins: + +```yaml +# templates/definition/charger/example.yaml +status: + source: http + uri: http://{{ .host }}/status +enable: + source: http + uri: http://{{ .host }}/enable + method: POST +maxcurrent: + source: http + uri: http://{{ .host }}/current/{{ .maxcurrent }} +``` + +The generic configurable charger (`charger/charger.go`) wires these plugin +configs into the `api.Charger` interface at runtime. + +## Key Files + +- `plugin/config.go` — plugin registry and config types +- `plugin/http.go` — HTTP plugin +- `plugin/mqtt.go` — MQTT plugin +- `plugin/modbus.go` — Modbus plugin +- `plugin/sunspec.go` — SunSpec plugin +- `charger/charger.go` — generic configurable charger using plugins diff --git a/docs/agents/web-ui-api.md b/docs/agents/web-ui-api.md new file mode 100644 index 000000000..fed6d2331 --- /dev/null +++ b/docs/agents/web-ui-api.md @@ -0,0 +1,87 @@ +# Web UI & REST API + +## Server Architecture + +- **Router:** gorilla/mux, strict slash +- **Middleware:** GZIP, CORS (`*`), ETag caching, request logging, JSON headers, JWT auth +- **Timeouts:** Read 5s, Write 10s, Idle 120s +- **Static assets:** embedded in binary (`fs.FS`) +- **Default port:** 7070 + +## REST API (base `/api/`) + +### Site-level +- `POST /buffersoc/{value}`, `/prioritysoc/{value}`, `/residualpower/{value}` etc. +- `GET /tariff/{tariff}` — tariff rates +- `GET /sessions` — charging history +- `GET /state` — complete system state (supports jq filtering) + +### Per-loadpoint (`/loadpoints/{id}/...`) +- `POST mode/{value}` — off/now/minpv/pv +- `POST limitsoc/{value}`, `limitenergy/{value}` — charge limits +- `POST mincurrent/{value}`, `maxcurrent/{value}` — current limits +- `POST phases/{value}` — phase config +- `POST priority/{value}`, `batteryboost/{value}` +- `POST plan/energy/{value}/{time}` — schedule plan +- `POST vehicle/{name}` — select vehicle +- `POST smartcostlimit/{value}` — smart cost threshold + +### Configuration (`/config/...`, auth required) +- CRUD for devices (chargers, meters, vehicles, tariffs) +- Template browsing and testing +- Site, loadpoint, circuit, HEMS, messaging config +- `GET /config/evcc.yaml` — YAML export + +### System (`/system/...`, auth required) +- Log viewing, cache clear, DB backup/restore/reset, shutdown + +### Handler Pattern +Generic `handler[T]` with type conversion, setter, getter. +Specialized: `floatHandler`, `intHandler`, `boolHandler`, `durationHandler`. + +## WebSocket (`/ws`) + +- `coder/websocket` (RFC 6455) +- Pub/sub via `SocketHub` +- Buffered channels (1024 per subscriber) +- Welcome message with full state snapshot +- Incremental updates as JSON key-value pairs with dot-notation keys: + ```json + {"loadpoints.1.mode": "solar", "site.gridPower": 1234} + ``` +- Write timeout: 10s, compression (disabled for Safari) + +## State Flow + +1. WS connects -> receives welcome with full state +2. App emits `util.Param` on changes +3. Hub broadcasts to subscribers +4. Frontend `store.update(msg)` merges via dot-notation +5. Components reactively re-render + +## Authentication + +- JWT, 90-day lifetime +- HttpOnly cookie (`auth`) with `SameSite=Strict` +- Also accepts `Authorization: Bearer ` header +- Modes: Disabled, Locked (demo), Configured (password) +- Protects `/api/config` and `/api/system` + +## MQTT Integration + +- Publishes state changes to configurable broker +- Subscribes to control topics +- Retained messages for state persistence + +## Key Files + +- `server/http.go` — router setup +- `server/http_auth.go` — authentication +- `server/http_site_handler.go` — state + request handlers +- `server/http_config_*.go` — config endpoints +- `server/http_loadpoint_handler.go` — per-loadpoint endpoints +- `server/socket.go` — WebSocket pub/sub +- `assets/js/app.ts` — Vue app entry +- `assets/js/store.ts` — reactive state store +- `assets/js/api.ts` — Axios clients +- `assets/js/router.ts` — route definitions From 6414d8add33b2f5d08e18797917ff69fdda37e95 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 19 Apr 2026 14:42:03 +0200 Subject: [PATCH 0090/1316] Forecast: temporarily remove adjustment (#29244) --- assets/js/components/Forecast/ForecastModal.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/assets/js/components/Forecast/ForecastModal.vue b/assets/js/components/Forecast/ForecastModal.vue index ab5df04fc..c0769fd1b 100644 --- a/assets/js/components/Forecast/ForecastModal.vue +++ b/assets/js/components/Forecast/ForecastModal.vue @@ -95,7 +95,8 @@ export default defineComponent({ return settings.solarAdjusted; }, showSolarAdjust() { - return !!this.forecast.solar && this.experimental; + // TODO fix https://github.com/evcc-io/evcc/issues/29165 + return !!this.forecast.solar && this.experimental && false; }, solar() { if (this.showSolarAdjust && this.solarAdjusted) { From b751090b03828aa2ca346820e0b5684e68d0b7bd Mon Sep 17 00:00:00 2001 From: Mathias Walter Date: Sun, 19 Apr 2026 17:25:45 +0200 Subject: [PATCH 0091/1316] Solarprognose: deprecate forecast_days (#29249) --- .../definition/tariff/solarprognose.yaml | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/templates/definition/tariff/solarprognose.yaml b/templates/definition/tariff/solarprognose.yaml index 00103c9eb..85a07da51 100644 --- a/templates/definition/tariff/solarprognose.yaml +++ b/templates/definition/tariff/solarprognose.yaml @@ -39,13 +39,7 @@ params: de: Prognosealgorithmus (mosmix, own-v1 oder clearsky) advanced: true - name: forecast_days - description: - en: Number of additional days to forecast (1-6) - de: Anzahl zusätzlicher Tage für die Prognose (1-6) - help: - en: "Number of additional days to forecast (1-6), excluding today. Remember: evcc calculates energy for today, tomorrow, and the day after tomorrow, effectively handling up to three days of solar forecast data, but the UI shows up to 96 hours." - de: "Anzahl der zusätzlichen Tage für die Prognose (1-6), ohne den heutigen Tag. Bedenke: evcc berechnet die Energie für heute, morgen und übermorgen und verarbeitet dabei effektiv bis zu drei Tage Solarprognosedaten, zeigt jedoch bis zu 96 Stunden an." - default: 4 + deprecated: true advanced: true - name: interval default: 1h @@ -57,7 +51,7 @@ render: | forecast: source: http # Base URL with required params (format, type, access token), then optional item-based parameters - uri: https://www.solarprognose.de/web/solarprediction/api/v1?_format=json&type=hourly&project=https://evcc.io&access-token={{ .token }}{{ if .item }}&item={{ .item }}{{ if .id }}&id={{ .id }}{{ else }}&token={{ unquote .uniquetoken }}{{ end }}{{ end }}{{ if .algorithm }}&algorithm={{ .algorithm }}{{ end }}{{ if .forecast_days }}&end_day={{ .forecast_days }}{{ end }} + uri: https://www.solarprognose.de/web/solarprediction/api/v1?_format=json&type=hourly&project=https://evcc.io&access-token={{ .token }}{{ if .item }}&item={{ .item }}{{ if .id }}&id={{ .id }}{{ else }}&token={{ unquote .uniquetoken }}{{ end }}{{ end }}{{ if .algorithm }}&algorithm={{ .algorithm }}{{ end }} jq: | def step: 3600; # hourly interval @@ -65,7 +59,10 @@ render: | (.data // {}) | if type == "object" and length > 0 then to_entries - | map({key, value: (.value[0] * 1000)}) + | map( + select(.value | type == "array" and length > 0) + | {key, value: ((.value[0] // 0) * 1000)} + ) | from_entries else {} @@ -74,19 +71,21 @@ render: | . as $root | ($root | lookup) as $L | ($L | keys | map(tonumber) | sort) as $ts - | ($ts[0]) as $min - | ($ts[-1]) as $max - | reduce range($min; $max + step; step) as $t - ( { last: null, out: [] }; - ($L[($t|tostring)]?) as $v - | .last = (if $v == null then .last else $v end) - | .out += - [ { - start: ($t | strftime("%FT%TZ")), - end: (($t + step) | strftime("%FT%TZ")), - value: (.last // 0) - } ] - ) - | .out + | if ($ts | length) == 0 then + [] + else + reduce range($ts[0]; $ts[-1] + step; step) as $t + ( { last: null, out: [] }; + ($L[($t|tostring)]?) as $v + | .last = (if $v == null then .last else $v end) + | .out += + [ { + start: ($t | strftime("%FT%TZ")), + end: (($t + step) | strftime("%FT%TZ")), + value: (.last // 0) + } ] + ) + | .out + end | tostring interval: {{ .interval }} From 9138ef81f6c80dccba4b4ab2e952cfcbcabb2ae9 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 19 Apr 2026 17:35:45 +0200 Subject: [PATCH 0092/1316] Fix metrics table migration (#29250) --- core/metrics/db.go | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/core/metrics/db.go b/core/metrics/db.go index 06809f30f..d84fcdffa 100644 --- a/core/metrics/db.go +++ b/core/metrics/db.go @@ -182,23 +182,24 @@ func SetupSchema() error { } } + rename := func(from, to string) error { + if table := new(meter); m.HasColumn(table, from) && !m.HasColumn(table, to) { + return m.RenameColumn(table, from, to) + } + return nil + } + // meter: split energy direction - if old := "val"; m.HasColumn(new(meter), old) { - if err := m.RenameColumn(new(meter), old, "import"); err != nil { - return err - } - } else { - // meter: split energy direction #2 - if old := "pos"; m.HasColumn(new(meter), old) { - if err := m.RenameColumn(new(meter), old, "import"); err != nil { - return err - } - } - if old := "neg"; m.HasColumn(new(meter), old) { - if err := m.RenameColumn(new(meter), old, "export"); err != nil { - return err - } - } + if err := rename("val", "import"); err != nil { + return err + } + + // meter: split energy direction #2 + if err := rename("pos", "import"); err != nil { + return err + } + if err := rename("neg", "export"); err != nil { + return err } return db.Instance.AutoMigrate(new(meter)) From 1617f4ef544b767705b727f48af544f6c0b65cf0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:09:36 +0000 Subject: [PATCH 0093/1316] Remove YAML-specific device template input hints (#29259) --- templates/definition/charger/evbox-livo.yaml | 4 ++-- templates/definition/meter/solax-hybrid-cloud.yaml | 4 ++-- templates/definition/meter/solax-inverter-cloud.yaml | 4 ++-- util/templates/defaults.yaml | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/templates/definition/charger/evbox-livo.yaml b/templates/definition/charger/evbox-livo.yaml index cb6d119e7..5c5f35cc6 100644 --- a/templates/definition/charger/evbox-livo.yaml +++ b/templates/definition/charger/evbox-livo.yaml @@ -6,8 +6,8 @@ products: requirements: evcc: ["eebus"] description: - de: Das Gerät benötigt eine feste IP Adresse. Es ist wichtig, zuerst EEBus einzurichten. Danach erkennt das Ladegerät evcc als HEMS-Gerät im Netzwerk. Verwende das Installationstool, um evcc als HEMS auszuwählen. Kopiere anschließend den angegebenen SKI aus der Installations-App und fügen ihn zu Ihrem YAML hinzu. - en: The device requires a fixed IP addres. It's important to set up EEBus first. After setting up EEBus the charger will recognize evcc as a HEMS device on the network. Please use the installer tool to select evcc as HEMS. After this has been done, copy the given SKI from the Install app and add it to your yaml. + de: Das Gerät benötigt eine feste IP Adresse. Es ist wichtig, zuerst EEBus einzurichten. Danach erkennt das Ladegerät evcc als HEMS-Gerät im Netzwerk. Verwende das Installationstool, um evcc als HEMS auszuwählen. Kopiere anschließend den angegebenen SKI aus der Installations-App und füge ihn zur Konfiguration hinzu. + en: The device requires a fixed IP address. It's important to set up EEBus first. After setting up EEBus the charger will recognize evcc as a HEMS device on the network. Please use the installer tool to select evcc as HEMS. After this has been done, copy the given SKI from the Install app and add it to the configuration. params: - preset: eebus render: | diff --git a/templates/definition/meter/solax-hybrid-cloud.yaml b/templates/definition/meter/solax-hybrid-cloud.yaml index 680d2565c..693f13833 100644 --- a/templates/definition/meter/solax-hybrid-cloud.yaml +++ b/templates/definition/meter/solax-hybrid-cloud.yaml @@ -22,8 +22,8 @@ params: description: generic: SolaxCloud TokenID help: - de: "[solaxcloud.com](https://www.solaxcloud.com/) -> Support -> Drittanbieter-Ökosystem (alte Website) oder Dienst -> API (neue Website), den Wert von `tokenID` hier in Anführungszeichen eintragen (Beispiel: '20241028488283838')" - en: "[solaxcloud.com](https://www.solaxcloud.com/) -> Support -> Third-party Ecology (old site) or Service -> API (new site), put the value of `tokenID` here in single quotes (Example: '20241028488283838')" + de: "[solaxcloud.com](https://www.solaxcloud.com/) -> Support -> Drittanbieter-Ökosystem (alte Website) oder Dienst -> API (neue Website), den Wert von `tokenID` hier eintragen (Beispiel: 20241028488283838)" + en: "[solaxcloud.com](https://www.solaxcloud.com/) -> Support -> Third-party Ecology (old site) or Service -> API (new site), enter the value of `tokenID` here (Example: 20241028488283838)" - name: serial required: true description: diff --git a/templates/definition/meter/solax-inverter-cloud.yaml b/templates/definition/meter/solax-inverter-cloud.yaml index d43952b51..86d5c8b1d 100644 --- a/templates/definition/meter/solax-inverter-cloud.yaml +++ b/templates/definition/meter/solax-inverter-cloud.yaml @@ -22,8 +22,8 @@ params: description: generic: SolaxCloud TokenID help: - de: "[solaxcloud.com](https://www.solaxcloud.com/) -> Support -> Drittanbieter-Ökosystem (alte Website) oder Dienst -> API (neue Website), den Wert von `tokenID` hier in Anführungszeichen eintragen (Beispiel: '20241028488283838')" - en: "[solaxcloud.com](https://www.solaxcloud.com/) -> Support -> Third-party Ecology (old site) or Service -> API (new site), put the value of `tokenID` here in single quotes (Example: '20241028488283838')" + de: "[solaxcloud.com](https://www.solaxcloud.com/) -> Support -> Drittanbieter-Ökosystem (alte Website) oder Dienst -> API (neue Website), den Wert von `tokenID` hier eintragen (Beispiel: 20241028488283838)" + en: "[solaxcloud.com](https://www.solaxcloud.com/) -> Support -> Third-party Ecology (old site) or Service -> API (new site), enter the value of `tokenID` here (Example: 20241028488283838)" - name: serial required: true description: diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml index 576df08c3..3b98dec06 100644 --- a/util/templates/defaults.yaml +++ b/util/templates/defaults.yaml @@ -59,8 +59,8 @@ params: de: Passwort en: Password help: - de: Bei führenden Nullen bitte in einfache Hochkommata setzen - en: Use single quotes in case of leading zeros + de: Zugangspasswort des Dienstes + en: Service account password mask: true - name: capacity unit: kWh @@ -292,8 +292,8 @@ params: en: Actor Identification Number (AIN) de: Aktoridentifikationsnummer (AIN) help: - en: Printed on the type label on the back of the device. Embed it in double quotes in case of leading zeroes. - de: Ist auf dem Typenschild auf der Geräterückseite aufgedruckt. Bei führenden Nullen bitte in doppelte Hochkommata setzen. + en: Printed on the type label on the back of the device. + de: Ist auf dem Typenschild auf der Geräterückseite aufgedruckt. - name: coarsecurrent type: bool description: From 63459e18e99a16eac473b49625425d820cd318d8 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 20 Apr 2026 09:02:53 +0200 Subject: [PATCH 0094/1316] Forecast: temporarily remove adjustment (2) (#29260) --- assets/js/settings.ts | 2 +- assets/js/views/Forecast.vue | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/assets/js/settings.ts b/assets/js/settings.ts index 9ea7fcff8..1657c0bc1 100644 --- a/assets/js/settings.ts +++ b/assets/js/settings.ts @@ -151,7 +151,7 @@ const settings: Settings = reactive({ savingsIndicator: read(SAVINGS_INDICATOR), sessionsGroup: read(SESSIONS_GROUP), sessionsType: read(SESSIONS_TYPE), - solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED), + solarAdjusted: false, //readBool(SETTINGS_SOLAR_ADJUSTED), # temporarily disable, https://github.com/evcc-io/evcc/issues/29165 priceZoom: readBool(SETTINGS_PRICE_ZOOM), hideFeedin: readBool(SETTINGS_HIDE_FEEDIN), loadpoints: readJSON(LOADPOINTS), diff --git a/assets/js/views/Forecast.vue b/assets/js/views/Forecast.vue index c1cd0b682..d15863a4a 100644 --- a/assets/js/views/Forecast.vue +++ b/assets/js/views/Forecast.vue @@ -243,7 +243,8 @@ export default defineComponent({ return true; }, showSolarAdjust() { - return !!this.forecast.solar && this.experimental; + // TODO fix https://github.com/evcc-io/evcc/issues/29165 + return !!this.forecast.solar && this.experimental && false; }, solar() { return this.showSolarAdjust && this.solarAdjusted From 0918c62b44ecb7f698a5ae946f04af247ce7779a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:16:02 +0200 Subject: [PATCH 0095/1316] Sungrow iHomeManager: fix voltage and per-phase active power decoding (#27985) --- templates/definition/meter/sungrow-ihm.yaml | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/templates/definition/meter/sungrow-ihm.yaml b/templates/definition/meter/sungrow-ihm.yaml index 06cb6d664..d58e1f131 100644 --- a/templates/definition/meter/sungrow-ihm.yaml +++ b/templates/definition/meter/sungrow-ihm.yaml @@ -36,21 +36,21 @@ render: | - source: modbus {{- include "modbus" . | indent 2 }} register: - address: 8554 # Register 8555 (A-phase voltage) + address: 4356 # Register 4357 (Phase A voltage, phase-to-neutral) type: input decode: uint16 scale: 0.1 # Factor 0.1 V - source: modbus {{- include "modbus" . | indent 2 }} register: - address: 8555 # Register 8556 (B-phase voltage) + address: 4358 # Register 4359 (Phase B voltage, phase-to-neutral) type: input decode: uint16 scale: 0.1 - source: modbus {{- include "modbus" . | indent 2 }} register: - address: 8556 # Register 8557 (C-phase voltage) + address: 4360 # Register 4361 (Phase C voltage, phase-to-neutral) type: input decode: uint16 scale: 0.1 @@ -58,24 +58,21 @@ render: | - source: modbus {{- include "modbus" . | indent 2 }} register: - address: 8558 # Register 8559 (Phase A active power, U32) + address: 8558 # Register 8559 (Phase A active power, S32, signed import/export) type: input - decode: uint32s - scale: 1 # Factor 1 W + decode: int32s - source: modbus {{- include "modbus" . | indent 2 }} register: address: 8560 # Register 8561 (Phase B active power) type: input - decode: uint32s - scale: 1 + decode: int32s - source: modbus {{- include "modbus" . | indent 2 }} register: address: 8562 # Register 8563 (Phase C active power) type: input - decode: uint32s - scale: 1 + decode: int32s {{- end }} {{- if eq .usage "pv" }} type: custom From 4bfc1ba6e9b95b6be6546bcdcf25e0c437e9d6ea Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 20 Apr 2026 16:33:33 +0200 Subject: [PATCH 0096/1316] myPV AC Thor: support extra heater on relay (#29266) --- charger/mypv.go | 67 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/charger/mypv.go b/charger/mypv.go index cf6fc386b..67982419f 100644 --- a/charger/mypv.go +++ b/charger/mypv.go @@ -46,13 +46,18 @@ type MyPv struct { } const ( - elwaRegSetPower = 1000 - elwaRegTempLimit = 1002 - elwaRegStatus = 1003 - elwaRegLoadState = 1059 - elwaRegPower = 1000 // https://github.com/evcc-io/evcc/issues/18020#issuecomment-2585300804 - elwaRegOperationState = 1077 - elwaERegOperationState = elwaRegStatus // same register for elwa-e operation state + elwaRegSetPower = 1000 + elwaRegTempLimit = 1002 + elwaRegStatus = 1003 + elwaRegLoadState = 1059 + elwaRegPower = 1000 // https://github.com/evcc-io/evcc/issues/18020#issuecomment-2585300804 + elwaRegOperationState = 1077 + elwaERegOperationState = elwaRegStatus // same register for elwa-e operation state + elwaRegRelayState = 1058 + elwaRegVoltage = 1061 + elwaRegOperationMode = 1065 // https://github.com/evcc-io/evcc/discussions/23708 + elwaRegMaxControlledPower = 1014 // max. power for linear controlled output + elwaRegMaxCombinedPower = 1071 // (max. power for linear controlled output + configured relais power) * 1.10 ) var elwaTemp = []uint16{1001, 1030, 1031} @@ -266,6 +271,7 @@ func (wb *MyPv) MaxCurrentMillis(current float64) error { phases = p } } + power := uint16(voltage * current * float64(phases)) err := wb.setPower(power) @@ -285,7 +291,52 @@ func (wb *MyPv) CurrentPower() (float64, error) { return 0, err } - return float64(binary.BigEndian.Uint16(b)), nil + res := float64(binary.BigEndian.Uint16(b)) + if wb.name != "ac-thor" { + return res, nil + } + + c, err := wb.conn.ReadHoldingRegisters(elwaRegOperationMode, 1) + if err != nil { + return 0, err + } + wb.log.TRACE.Printf("operation mode %d", binary.BigEndian.Uint16(c)) + + // AC Thor operation mode != 3 + if binary.BigEndian.Uint16(c) != 3 { + return res, nil + } + + // AC Thor operation mode == 3 "Warm water 9 + 9kW" + // with extra heater on internal relay + // see https://github.com/evcc-io/evcc/discussions/23708 + f, err := wb.conn.ReadHoldingRegisters(elwaRegRelayState, 1) + if err != nil { + return 0, err + } + + // relay inactive + if binary.BigEndian.Uint16(f) != 1 { + return res, nil + } + + // get power of heater on relay as set in web interface + // (scale factor must be used for correct setting in web interface) + d, err := wb.conn.ReadHoldingRegisters(elwaRegMaxControlledPower, 1) + if err != nil { + return 0, err + } + + e, err := wb.conn.ReadHoldingRegisters(elwaRegMaxCombinedPower, 1) + if err != nil { + return 0, err + } + wb.log.TRACE.Printf("max. power: controlled %.0f W / combined %.0f W", float64(binary.BigEndian.Uint16(d)), float64(binary.BigEndian.Uint16(e))) + + // relay power = combined power - controlled power, finally corrected with 110% factor + res += float64(int(binary.BigEndian.Uint16(e))-int(binary.BigEndian.Uint16(d))) / wb.scale / 1.1 + + return res, nil } var _ api.Battery = (*MyPv)(nil) From 7d2f507f3a15647446a0270d406b680a222cce3c Mon Sep 17 00:00:00 2001 From: Mathias Walter Date: Tue, 21 Apr 2026 05:54:45 +0200 Subject: [PATCH 0097/1316] Log UI: sort the log area multiselect naturally (#29289) --- assets/js/views/Log.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/assets/js/views/Log.vue b/assets/js/views/Log.vue index 601be30af..436e8e0a7 100644 --- a/assets/js/views/Log.vue +++ b/assets/js/views/Log.vue @@ -171,7 +171,10 @@ export default defineComponent({ }); }, areaOptions() { - return this.availableAreas.map((area) => ({ name: area, value: area })); + return this.availableAreas + .slice() + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) + .map((area) => ({ name: area, value: area })); }, areasLabel() { if (this.areas.length === 0) { From 1d3a963dc82d3f8fd540daa147b7b8f4e71d90fb Mon Sep 17 00:00:00 2001 From: VallahDieWaldfee <164005630+VallahDieWaldfee@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:35:57 +0200 Subject: [PATCH 0098/1316] Nibe S-series: update docs (#29293) --- .../definition/charger/nibe-s-series.yaml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/templates/definition/charger/nibe-s-series.yaml b/templates/definition/charger/nibe-s-series.yaml index c9cae6d39..d943e48ce 100644 --- a/templates/definition/charger/nibe-s-series.yaml +++ b/templates/definition/charger/nibe-s-series.yaml @@ -7,16 +7,21 @@ group: heating requirements: description: de: | - Modbus Verbindung zur NIBE S Wärmepumpe. Firmware 4.7.5 oder neuer benötigt. - In der Inneneinheit muss im Menü 7.5.9 Modbus aktiviert sein. - Zur Verwendung der Leistungssteuerung muss im Menü 7.4 unter dem Punkt AUX von Modbus ext. Leistungsbegrenzung ausgewählt sein. - Es muss einmalig im Holding Register 3032 der Wert `1` (uint8) geschrieben werden. + Modbus Verbindung zur NIBE S Wärmepumpe. + Firmware 4.7.5 oder neuer benötigt. + Benötigte Einstellungen in der Steuereinheit: + - In der Inneneinheit muss im Menü 7.5.9 Modbus aktiviert sein. + - Zur Verwendung der Leistungssteuerung muss im Menü 7.4 unter dem Punkt AUX von Modbus ext. Leistungsbegrenzung ausgewählt sein. In den Ladepunkteinstellungen muss eine minimale Leistung von 1,2kW (1-phasig) eingestellt werden. Sollte der Normalverbrauch höher sein, muss dieser als minimale Leistung eingestellt werden. + - Im Menü 7.4 dürfen die Optionen SG-Ready A und SG-Ready B nicht vergeben sein. + - Es muss einmalig im Holding Register 3032 der Wert `1` (uint8) geschrieben werden. en: | Modbus connection to the NIBE S heat pump. Firmware version 4.7.5 or newer is required. - In the indoor unit, Modbus must be enabled in menu 7.5.9 - To use the power control function, external power limitation must be selected under Menu 7.4, item AUX from modbus. - The value `1` (uint8) must be written once to holding register 3032. + Required settings in the control unit: + - In the indoor unit, Modbus must be enabled in menu 7.5.9 + - To use power control, “external power limitation via Modbus” must be selected under AUX in menu 7.4. In the charging point settings, a minimum power of 1.2 kW (single-phase) must be configured. If the base load is higher, it must be set as the minimum power. + - In menu 7.4, the options “SG-Ready A” and “SG-Ready B” must not be assigned. + - The value `1` (uint8) must be written once to holding register 3032. params: - name: modbus choice: ["tcpip"] From 2fb900c8eae7dc06482384a64d7c3dd30c4d2390 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 21 Apr 2026 10:31:19 +0200 Subject: [PATCH 0099/1316] History UI: small fixes (#29138) --- assets/js/components/History/Chart.vue | 14 +++++++---- assets/js/views/History.vue | 4 ++++ core/metrics/db.go | 7 +++--- core/metrics/db_test.go | 33 ++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/assets/js/components/History/Chart.vue b/assets/js/components/History/Chart.vue index 874388cf4..4e71801fc 100644 --- a/assets/js/components/History/Chart.vue +++ b/assets/js/components/History/Chart.vue @@ -72,10 +72,14 @@ export default defineComponent({ const color = colors.palette[i % colors.palette.length]; const fill = dimColor(color); - const points = s.data.map((slot) => ({ - x: new Date(slot.start).getTime(), - y: slotPower(slot, "import") - slotPower(slot, "export"), - })); + const points = s.data.map((slot) => { + const start = new Date(slot.start).getTime(); + const end = new Date(slot.end).getTime(); + return { + x: (start + end) / 2, + y: slotPower(slot, "import") - slotPower(slot, "export"), + }; + }); return { label: s.name, @@ -85,7 +89,7 @@ export default defineComponent({ fill: true, pointRadius: 0, borderWidth: 1.5, - tension: 0.3, + tension: 0.05, }; }); diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue index 4d818ebca..a346f2d97 100644 --- a/assets/js/views/History.vue +++ b/assets/js/views/History.vue @@ -1,6 +1,10 @@ @@ -31,6 +32,8 @@ import Item from "./Item.vue"; import MoreIcon from "../MaterialIcon/More.vue"; import MoreMenu from "./MoreMenu.vue"; import { isUserConfigError } from "@/utils/fatal"; +import { isNewVersionAvailable, isNewVersionUnacknowledged } from "@/utils/version"; +import settings from "@/settings"; import type { FatalError, Sponsor, EvOpt, AuthProviders } from "@/types/evcc"; export default defineComponent({ @@ -46,6 +49,7 @@ export default defineComponent({ evopt: { type: Object as PropType, required: false }, installed: String, commit: String, + availableVersion: String, }, data() { return { open: false }; @@ -69,14 +73,27 @@ export default defineComponent({ showConfigBadge() { return this.sponsorExpires || isUserConfigError(this.fatal); }, + newVersionAvailable() { + return isNewVersionAvailable(this.installed, this.availableVersion); + }, + showVersionBadge() { + return isNewVersionUnacknowledged( + this.installed, + this.availableVersion, + settings.lastAcknowledgedVersion + ); + }, showRootBadge() { - return this.authorizationRequired || this.showConfigBadge; + return this.authorizationRequired || this.showConfigBadge || this.showVersionBadge; }, badgeClass() { if (this.fatal.length > 0) { return "bg-danger"; } - return "bg-warning"; + if (this.authorizationRequired || this.showConfigBadge) { + return "bg-warning"; + } + return "bg-darker-green"; }, }, methods: { diff --git a/assets/js/components/BottomTabs/MoreMenu.vue b/assets/js/components/BottomTabs/MoreMenu.vue index f27d2704d..1749ae2d3 100644 --- a/assets/js/components/BottomTabs/MoreMenu.vue +++ b/assets/js/components/BottomTabs/MoreMenu.vue @@ -11,8 +11,14 @@ class="dropdown-item d-flex align-items-center" @click="openAboutModal" > + evcc {{ versionLabel }} + +
    + + +
    @@ -68,11 +94,21 @@ import "@h2d2/shopicons/es/filled/lightning"; import Loadpoint from "./Loadpoint.vue"; import { defineComponent, type PropType } from "vue"; -import type { UiLoadpoint, SMART_COST_TYPE, Timeout, Vehicle, BATTERY_MODE } from "@/types/evcc"; +import type { + UiLoadpoint, + SMART_COST_TYPE, + Timeout, + Vehicle, + BATTERY_MODE, + Forecast, + CURRENCY, +} from "@/types/evcc"; +import ChargingPlanModal from "../ChargingPlans/ChargingPlanModal.vue"; +import SettingsModal from "../Loadpoints/SettingsModal.vue"; export default defineComponent({ name: "Loadpoints", - components: { Loadpoint }, + components: { Loadpoint, ChargingPlanModal, SettingsModal }, props: { loadpoints: { type: Array as PropType, default: () => [] }, vehicles: { type: Array as PropType }, @@ -82,14 +118,14 @@ export default defineComponent({ tariffGrid: Number, tariffCo2: Number, tariffFeedIn: Number, - currency: String, + currency: String as PropType, selectedId: String, gridConfigured: Boolean, pvConfigured: Boolean, batteryConfigured: Boolean, batterySoc: Number, batteryMode: String as PropType, - forecast: Object, // as PropType, + forecast: Object as PropType, }, emits: ["id-changed"], data() { @@ -188,6 +224,25 @@ export default defineComponent({ } }, 1000); }, + openChargingPlanModal(loadpointId: string, openArrivalTab = false) { + const modal = this.$refs["chargingPlanModal"] as + | InstanceType + | undefined; + + if (openArrivalTab) { + modal?.showArrivalTab(); + } else { + modal?.showDepartureTab(); + } + + modal?.open(loadpointId); + }, + openSettingsModal(loadpointId: string) { + const modal = this.$refs["settingsModal"] as + | InstanceType + | undefined; + modal?.open(loadpointId); + }, }, }); diff --git a/assets/js/components/Loadpoints/SettingsModal.vue b/assets/js/components/Loadpoints/SettingsModal.vue index 34d254b93..e3a11a253 100644 --- a/assets/js/components/Loadpoints/SettingsModal.vue +++ b/assets/js/components/Loadpoints/SettingsModal.vue @@ -1,30 +1,31 @@ + + diff --git a/assets/js/components/Sessions/EnergyGroupedChart.vue b/assets/js/components/Sessions/EnergyGroupedChart.vue index 18c39eb21..31e69bd9c 100644 --- a/assets/js/components/Sessions/EnergyGroupedChart.vue +++ b/assets/js/components/Sessions/EnergyGroupedChart.vue @@ -1,7 +1,7 @@