From d921a82be02631a16e36fd48dcecf78ebd1b14e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20He=C3=9F?= Date: Sun, 22 Mar 2026 10:51:23 +0100 Subject: [PATCH 0001/1413] Easee: disable phase switching on non TN grid installations (#28411) --- charger/easee.go | 52 ++++++++++++++++------ charger/easee/types.go | 7 +++ charger/easee_test.go | 98 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 13 deletions(-) diff --git a/charger/easee.go b/charger/easee.go index 4b0ca898b..3bb1ddd41 100644 --- a/charger/easee.go +++ b/charger/easee.go @@ -161,19 +161,8 @@ func NewEasee(ctx context.Context, user, password, charger string, timeout time. return nil, err } - // find single charger per circuit - for _, circuit := range site.Circuits { - if len(circuit.Chargers) > 1 { - continue - } - - for _, charger := range circuit.Chargers { - if charger.ID == c.charger { - c.site = site.ID - c.circuit = circuit.ID - break - } - } + if err := c.determineCircuit(site); err != nil { + return nil, err } client, err := signalr.NewClient(ctx, @@ -236,6 +225,43 @@ func (c *Easee) chargerSite(charger string) (easee.Site, error) { return res, err } +func isTNGrid(gridType int) bool { + switch gridType { + case easee.PowerGridTN3Phase, easee.PowerGridTN2PhasePin234, easee.PowerGridTN1Phase: + return true + } + return false +} + +func (c *Easee) chargerConfig(charger string) (res easee.ChargerConfig, err error) { + uri := fmt.Sprintf("%s/chargers/%s/config", easee.API, charger) + err = c.GetJSON(uri, &res) + return res, err +} + +func (c *Easee) determineCircuit(site easee.Site) error { + config, err := c.chargerConfig(c.charger) + if err != nil { + return fmt.Errorf("charger config unavailable: %w", err) + } + if !isTNGrid(config.DetectedPowerGridType) { + return nil + } + for _, circuit := range site.Circuits { + if len(circuit.Chargers) > 1 { + continue + } + for _, charger := range circuit.Chargers { + if charger.ID == c.charger { + c.site = site.ID + c.circuit = circuit.ID + return nil + } + } + } + return nil +} + // connect creates an HTTP connection to the signalR hub func (c *Easee) connect(ts oauth2.TokenSource) func() (signalr.Connection, error) { bo := backoff.NewExponentialBackOff(backoff.WithMaxInterval(time.Minute), backoff.WithMaxElapsedTime(0)) diff --git a/charger/easee/types.go b/charger/easee/types.go index 094acb92f..ee3115e41 100644 --- a/charger/easee/types.go +++ b/charger/easee/types.go @@ -10,6 +10,13 @@ const ( ChargeResume = "resume_charging" ) +// DetectedPowerGridType values +const ( + PowerGridTN3Phase = 1 + PowerGridTN2PhasePin234 = 2 + PowerGridTN1Phase = 3 +) + // charge mode definition const ( ModeOffline int = 0 diff --git a/charger/easee_test.go b/charger/easee_test.go index 6d1eafa18..a2a91162b 100644 --- a/charger/easee_test.go +++ b/charger/easee_test.go @@ -500,3 +500,101 @@ func TestLivenessCheck_freshObservations(t *testing.T) { assert.Equal(t, float64(16), l2) assert.Equal(t, float64(16), l3) } + +func TestIsTNGrid(t *testing.T) { + // TN grid types must return true + assert.True(t, isTNGrid(easee.PowerGridTN3Phase)) + assert.True(t, isTNGrid(easee.PowerGridTN2PhasePin234)) + assert.True(t, isTNGrid(easee.PowerGridTN1Phase)) + + // IT grid types, zero, and unknown values must return false + assert.False(t, isTNGrid(4)) // IT3Phase + assert.False(t, isTNGrid(5)) // IT1Phase + assert.False(t, isTNGrid(0)) // absent / unknown + assert.False(t, isTNGrid(99)) // arbitrary unknown +} + +// makeTestSite returns a Site with a single Circuit containing the given charger IDs. +// Site.ID = 111, Circuit.ID = 222. +func makeTestSite(chargerIDs ...string) easee.Site { + chargers := make([]easee.Charger, len(chargerIDs)) + for i, id := range chargerIDs { + chargers[i] = easee.Charger{ID: id} + } + return easee.Site{ + ID: 111, + Circuits: []easee.Circuit{ + {ID: 222, Chargers: chargers}, + }, + } +} + +func TestDetermineCircuit(t *testing.T) { + const chargerID = "TESTTEST" + configURI := fmt.Sprintf("%s/chargers/%s/config", easee.API, chargerID) + + tests := []struct { + name string + httpStatus int + gridType int + chargerIDs []string + wantCircuit int + wantErr bool + }{ + { + name: "TN grid, sole charger — circuit assigned", + httpStatus: 200, + gridType: easee.PowerGridTN3Phase, + chargerIDs: []string{chargerID}, + wantCircuit: 222, + }, + { + name: "IT grid, sole charger — circuit not assigned", + httpStatus: 200, + gridType: 4, // IT3Phase + chargerIDs: []string{chargerID}, + wantCircuit: 0, + }, + { + name: "config fetch fails — error returned", + httpStatus: 500, + chargerIDs: []string{chargerID}, + wantErr: true, + }, + { + name: "TN grid, multi-charger circuit — circuit not assigned", + httpStatus: 200, + gridType: easee.PowerGridTN3Phase, + chargerIDs: []string{chargerID, "OTHER"}, + wantCircuit: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + e := newEasee() + e.charger = chargerID + + httpmock.ActivateNonDefault(e.Client) + defer httpmock.DeactivateAndReset() + + if tc.httpStatus == 200 { + body, _ := json.Marshal(easee.ChargerConfig{DetectedPowerGridType: tc.gridType}) + httpmock.RegisterResponder(http.MethodGet, configURI, + httpmock.NewBytesResponder(200, body)) + } else { + httpmock.RegisterResponder(http.MethodGet, configURI, + httpmock.NewStringResponder(tc.httpStatus, "")) + } + + err := e.determineCircuit(makeTestSite(tc.chargerIDs...)) + + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.wantCircuit, e.circuit) + } + }) + } +} From 1369339de8543bf279ba8768bc691561a568d229 Mon Sep 17 00:00:00 2001 From: Hofy <68365998+Hofyyy@users.noreply.github.com> Date: Sun, 22 Mar 2026 10:57:24 +0100 Subject: [PATCH 0002/1413] Teslamate: add finishtime (#28405) --- templates/definition/vehicle/teslamate.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/templates/definition/vehicle/teslamate.yaml b/templates/definition/vehicle/teslamate.yaml index 93301b62f..35005d4d5 100644 --- a/templates/definition/vehicle/teslamate.yaml +++ b/templates/definition/vehicle/teslamate.yaml @@ -46,4 +46,9 @@ render: | source: mqtt topic: teslamate/cars/{{ .id }}/charge_limit_soc timeout: {{ .timeout }} + finishtime: + source: mqtt + topic: teslamate/cars/{{ .id }}/time_to_full_charge + timeout: {{ .timeout }} + jq: if (. == null) or (. == 0) or (type != "number") or (. < 0) then null else (now + (. * 3600) | strftime("%Y-%m-%dT%H:%M:%SZ")) end features: ["coarsecurrent"] From 4a41ad99d122ac404fa34a4b62714c6baea6d3c8 Mon Sep 17 00:00:00 2001 From: Timo Reimann Date: Sun, 22 Mar 2026 11:00:02 +0100 Subject: [PATCH 0003/1413] chore(hassio): Update CHANGELOG.md to overwrite content (#28444) --- .github/workflows/hassio-changelog.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/hassio-changelog.yml b/.github/workflows/hassio-changelog.yml index 9ae6d476e..d0bca0f1c 100644 --- a/.github/workflows/hassio-changelog.yml +++ b/.github/workflows/hassio-changelog.yml @@ -37,7 +37,7 @@ jobs: | gsub("(?m)^\\* [0-9a-f]{40} "; "* "))\n" ' -r > /tmp/changelog_entries.md - printf '%s\n\n' 'Full release details: https://github.com/evcc-io/evcc/releases' >> ./hassio/evcc/CHANGELOG.md + printf '%s\n\n' 'Full release details: https://github.com/evcc-io/evcc/releases' > ./hassio/evcc/CHANGELOG.md cat /tmp/changelog_entries.md >> ./hassio/evcc/CHANGELOG.md - name: Push if changed From 420a0e9f9b95bc64efdafa3ada098a3404ffb2cd Mon Sep 17 00:00:00 2001 From: Eric Nieuwenhuijsen Date: Sun, 22 Mar 2026 11:07:14 +0100 Subject: [PATCH 0004/1413] Victron: add phase switching (#28408) --- charger/victron.go | 85 ++++++++++++++----- charger/victron_decorators.go | 58 +++++++++++++ .../definition/charger/victron-evcs.yaml | 13 ++- 3 files changed, 135 insertions(+), 21 deletions(-) create mode 100644 charger/victron_decorators.go diff --git a/charger/victron.go b/charger/victron.go index 42090db7a..f331612bb 100644 --- a/charger/victron.go +++ b/charger/victron.go @@ -36,13 +36,15 @@ type Victron struct { } type victronRegs struct { - Mode uint16 - Energy uint16 - Power uint16 - Status uint16 - SetCurrent uint16 - Enabled uint16 - isGX bool + Mode uint16 + Energy uint16 + Power uint16 + Status uint16 + SetCurrent uint16 + Enabled uint16 + PhaseSwitch uint16 + ThreePhaseEnabled uint16 + isGX bool } var ( @@ -57,13 +59,15 @@ var ( } victronEVCS = victronRegs{ - Mode: 5009, - Energy: 5021, - Power: 5014, - Status: 5015, - SetCurrent: 5016, - Enabled: 5010, - isGX: false, + Mode: 5009, + Energy: 5021, + Power: 5014, + Status: 5015, + SetCurrent: 5016, + Enabled: 5010, + PhaseSwitch: 5055, + ThreePhaseEnabled: 5100, + isGX: false, } ) @@ -72,17 +76,19 @@ func init() { registry.AddCtx("victron-evcs", NewVictronEVCSFromConfig) } -// NewVictronGXFromConfig creates a ABB charger from generic config +//go:generate go tool decorate -f decorateVictron -b *Victron -r api.Charger -t api.PhaseSwitcher,api.PhaseGetter + +// NewVictronGXFromConfig creates a Victron charger from generic config func NewVictronGXFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) { return NewVictronFromConfig(ctx, other, victronGX) } -// NewVictronEVCSFromConfig creates a ABB charger from generic config +// NewVictronEVCSFromConfig creates a Victron charger from generic config func NewVictronEVCSFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) { return NewVictronFromConfig(ctx, other, victronEVCS) } -// NewVictronFromConfig creates a ABB charger from generic config +// NewVictronFromConfig creates a Victron charger from generic config func NewVictronFromConfig(ctx context.Context, other map[string]any, regs victronRegs) (api.Charger, error) { cc := modbus.TcpSettings{ ID: cast.ToUint8(regs.isGX) * 100, @@ -119,7 +125,23 @@ func NewVictron(ctx context.Context, uri string, slaveID uint8, regs victronRegs return nil, errors.New("charger must be in manual mode") } - return wb, nil + // phase switching (EVCS only, requires hardware modification with second contactor) + var phasesS func(int) error + var phasesG func() (int, error) + + if !regs.isGX { + b, err := wb.conn.ReadHoldingRegisters(wb.regs.ThreePhaseEnabled, 1) + if err != nil { + return nil, err + } + + if binary.BigEndian.Uint16(b) == 1 { + phasesS = wb.phases1p3p + phasesG = wb.getPhases + } + } + + return decorateVictron(wb, phasesS, phasesG), nil } // Status implements the api.Charger interface @@ -132,7 +154,7 @@ func (wb *Victron) Status() (api.ChargeStatus, error) { u := binary.BigEndian.Uint16(b) switch u { case 0, 1, 2, 3: - return api.ChargeStatusString(string('A' + rune(binary.BigEndian.Uint16(b)))) + return api.ChargeStatusString(string('A' + rune(u))) case 5, 6, 21: return api.StatusB, nil default: @@ -194,3 +216,28 @@ func (wb *Victron) ChargedEnergy() (float64, error) { return float64(binary.BigEndian.Uint16(b)) / 100, nil } + +// phases1p3p implements the api.PhaseSwitcher interface +func (wb *Victron) phases1p3p(phases int) error { + // register 5055: 1 = single phase, 0 = three phase + var u uint16 + if phases == 1 { + u = 1 + } + + _, err := wb.conn.WriteSingleRegister(wb.regs.PhaseSwitch, u) + return err +} + +// getPhases implements the api.PhaseGetter interface +func (wb *Victron) getPhases() (int, error) { + b, err := wb.conn.ReadHoldingRegisters(wb.regs.PhaseSwitch, 1) + if err != nil { + return 0, err + } + + if binary.BigEndian.Uint16(b) == 1 { + return 1, nil + } + return 3, nil +} diff --git a/charger/victron_decorators.go b/charger/victron_decorators.go new file mode 100644 index 000000000..7a3ea9b44 --- /dev/null +++ b/charger/victron_decorators.go @@ -0,0 +1,58 @@ +package charger + +// Code generated by github.com/evcc-io/evcc/cmd/tools/decorate.go. DO NOT EDIT. + +import ( + "github.com/evcc-io/evcc/api" +) + +func decorateVictron(base *Victron, phaseSwitcher func(int) error, phaseGetter func() (int, error)) api.Charger { + switch { + case phaseSwitcher == nil: + return base + + case phaseGetter == nil && phaseSwitcher != nil: + return &struct { + *Victron + api.PhaseSwitcher + }{ + Victron: base, + PhaseSwitcher: &decorateVictronPhaseSwitcherImpl{ + phaseSwitcher: phaseSwitcher, + }, + } + + case phaseGetter != nil && phaseSwitcher != nil: + return &struct { + *Victron + api.PhaseGetter + api.PhaseSwitcher + }{ + Victron: base, + PhaseGetter: &decorateVictronPhaseGetterImpl{ + phaseGetter: phaseGetter, + }, + PhaseSwitcher: &decorateVictronPhaseSwitcherImpl{ + phaseSwitcher: phaseSwitcher, + }, + } + } + + return nil +} + +type decorateVictronPhaseGetterImpl struct { + phaseGetter func() (int, error) +} + +func (impl *decorateVictronPhaseGetterImpl) GetPhases() (int, error) { + return impl.phaseGetter() +} + +type decorateVictronPhaseSwitcherImpl struct { + phaseSwitcher func(int) error +} + +func (impl *decorateVictronPhaseSwitcherImpl) Phases1p3p(p0 int) error { + return impl.phaseSwitcher(p0) +} diff --git a/templates/definition/charger/victron-evcs.yaml b/templates/definition/charger/victron-evcs.yaml index c1cc3b4f1..1fb951039 100644 --- a/templates/definition/charger/victron-evcs.yaml +++ b/templates/definition/charger/victron-evcs.yaml @@ -3,10 +3,19 @@ products: - brand: Victron description: generic: EV Charging Station +capabilities: ["1p3p"] requirements: description: - en: Enter the host of the charger (not the GX device) and ensure that the charger is in manual mode. - de: Trage den Host der Wallbox (nicht des GX-Geräts) ein und stelle sicher, dass die Wallbox sich im Modus "Manual" befindet. + en: | + Enter the host of the charger (not the GX device) and ensure that the charger is in manual mode. + + To enable 1P/3P phase switching a hardware modification is required: the 4P contactor must be replaced with 2×2P contactors. The first contactor switches L1+N, the second switches L2+L3 and is controlled by the second relay on the PCB. Register 5100 must be set to 1 to enable phase switching. + [More info](https://community.victronenergy.com/t/charging-an-ev-with-excess-solar-switching-between-1p-and-3p/21181) + de: | + Trage den Host der Wallbox (nicht des GX-Geräts) ein und stelle sicher, dass die Wallbox sich im Modus "Manual" befindet. + + Für die 1P/3P Phasenumschaltung ist eine Hardware-Modifikation erforderlich: Der 4P-Schütz muss durch 2×2P-Schütze ersetzt werden. Der erste Schütz schaltet L1+N, der zweite L2+L3 und wird über das zweite Relais auf der Platine gesteuert. Register 5100 muss auf 1 gesetzt werden, um die Phasenumschaltung zu aktivieren. + [Mehr Infos](https://community.victronenergy.com/t/charging-an-ev-with-excess-solar-switching-between-1p-and-3p/21181) params: - name: modbus choice: ["tcpip"] From f7b214df7503cdce4fbec66bcb1569d28c8bbfe1 Mon Sep 17 00:00:00 2001 From: Martin Porsch Date: Sun, 22 Mar 2026 11:10:37 +0100 Subject: [PATCH 0005/1413] Add EcoFlow Stream (#28158) --- meter/ecoflow-powerocean.go | 162 ---------------- meter/ecoflow.go | 173 ++++++++++++++++++ .../definition/meter/ecoflow-powerocean.yaml | 24 ++- .../definition/meter/ecoflow-stream.yaml | 62 +++++++ 4 files changed, 252 insertions(+), 169 deletions(-) delete mode 100644 meter/ecoflow-powerocean.go create mode 100644 meter/ecoflow.go create mode 100644 templates/definition/meter/ecoflow-stream.yaml diff --git a/meter/ecoflow-powerocean.go b/meter/ecoflow-powerocean.go deleted file mode 100644 index fc6a173d0..000000000 --- a/meter/ecoflow-powerocean.go +++ /dev/null @@ -1,162 +0,0 @@ -package meter - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/util" - "github.com/spf13/cast" - "github.com/tess1o/go-ecoflow" -) - -// EcoFlowPowerOcean represents the EcoFlow PowerOcean meter -type EcoFlowPowerOcean struct { - usage string - accessKey string - secretKey string - deviceId string - cache time.Duration - client *ecoflow.Client - dataG func() (*ecoflow.GetCmdResponse, error) -} - -func init() { - registry.Add("ecoflow-powerocean", NewEcoFlowPowerOceanFromConfig) -} - -// NewEcoFlowPowerOceanFromConfig creates an EcoFlow PowerOcean meter from generic config -func NewEcoFlowPowerOceanFromConfig(other map[string]any) (api.Meter, error) { - cc := struct { - AccessKey string - SecretKey string - DeviceId string - Usage string - Cache time.Duration - }{ - Cache: 30 * time.Second, - } - - if err := util.DecodeOther(other, &cc); err != nil { - return nil, err - } - if cc.AccessKey == "" { - return nil, errors.New("missing access key") - } - if cc.SecretKey == "" { - return nil, errors.New("missing secret key") - } - if cc.DeviceId == "" { - return nil, errors.New("missing device ID") - } - if cc.Usage == "" { - return nil, errors.New("missing usage") - } - - m, err := NewEcoFlowPowerOcean(cc.AccessKey, cc.SecretKey, cc.DeviceId, cc.Usage, cc.Cache) - if err != nil { - return nil, err - } - - if cc.Usage == "battery" { - return &EcoFlowPowerOceanBattery{m}, nil - } - - return m, nil -} - -// NewEcoFlowPowerOcean constructs the EcoFlowPowerOcean struct -func NewEcoFlowPowerOcean(accessKey, secretKey, deviceId, usage string, cache time.Duration) (*EcoFlowPowerOcean, error) { - client := ecoflow.NewEcoflowClient(accessKey, secretKey) - m := &EcoFlowPowerOcean{ - accessKey: accessKey, - secretKey: secretKey, - deviceId: deviceId, - usage: usage, - cache: cache, - client: client, - } - m.dataG = util.Cached(m.getData, cache) - return m, nil -} - -// getData retrieves device parameters from EcoFlow API -func (m *EcoFlowPowerOcean) getData() (*ecoflow.GetCmdResponse, error) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - var params []string - switch m.usage { - case "grid": - params = []string{"sysGridPwr"} - case "pv": - params = []string{"mpptPwr"} - case "battery": - params = []string{"bpPwr", "bpSoc"} - } - - return m.client.GetDeviceParameters(ctx, m.deviceId, params) -} - -var _ api.Meter = (*EcoFlowPowerOcean)(nil) - -// CurrentPower implements the api.Meter interface -func (m *EcoFlowPowerOcean) CurrentPower() (float64, error) { - response, err := m.dataG() - if err != nil { - 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 extractFloat(response.Data, "sysGridPwr") - case "pv": - return extractFloat(response.Data, "mpptPwr") - case "battery": - pwr, err := extractFloat(response.Data, "bpPwr") - 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) - } -} - -// extractFloat extracts a float64 or int value from a map by key. -func extractFloat(data map[string]any, key string) (float64, error) { - if data != nil { - if v, ok := data[key]; ok { - return cast.ToFloat64E(v) - } - } - return 0, fmt.Errorf("data not available for key: %s", key) -} - -// EcoFlowPowerOceanBattery represents the EcoFlow PowerOcean battery decorator -type EcoFlowPowerOceanBattery struct { - *EcoFlowPowerOcean -} - -// Soc implements the api.Battery interface for battery usage -func (m *EcoFlowPowerOceanBattery) Soc() (float64, error) { - response, err := m.dataG() - if err != nil { - return 0, err - } - - // Access the data from the GetCmdResponse - if response.Data != nil { - if soc, ok := response.Data["bpSoc"]; ok { - return cast.ToFloat64E(soc) - } - } - - return 0, api.ErrNotAvailable -} diff --git a/meter/ecoflow.go b/meter/ecoflow.go new file mode 100644 index 000000000..af468a265 --- /dev/null +++ b/meter/ecoflow.go @@ -0,0 +1,173 @@ +package meter + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/spf13/cast" + "github.com/tess1o/go-ecoflow" +) + +// EcoFlow represents the EcoFlow meter +type EcoFlow struct { + ctx context.Context + usage string + serial string + cache time.Duration + client *ecoflow.Client + dataG func() (*ecoflow.GetCmdResponse, error) + + gridPower, pvPower, batteryPower, batterySoc string +} + +func init() { + registry.AddCtx("ecoflow", NewEcoFlowFromConfig) +} + +// 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 + }{ + Cache: 30 * time.Second, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + if cc.AccessKey == "" { + return nil, errors.New("missing access key") + } + if cc.SecretKey == "" { + return nil, errors.New("missing secret key") + } + if cc.Serial == "" { + return nil, errors.New("missing serial") + } + if cc.Usage == "" { + return nil, errors.New("missing usage") + } + + var uri string + switch cc.Region { + case "auto": + uri = "https://api.ecoflow.com" + case "europe": + uri = "https://api-e.ecoflow.com" + case "america": + uri = "https://api-a.ecoflow.com" + default: + 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) + if err != nil { + return nil, err + } + + if cc.Usage == "battery" { + return &EcoFlowBattery{m}, nil + } + + return m, nil +} + +// 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) { + 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, + } + + m.dataG = util.Cached(m.getData, cache) + + return m, nil +} + +// getData retrieves device parameters from EcoFlow API +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} + } + + return m.client.GetDeviceParameters(ctx, m.serial, params) +} + +var _ api.Meter = (*EcoFlow)(nil) + +// CurrentPower implements the api.Meter interface +func (m *EcoFlow) CurrentPower() (float64, error) { + response, err := m.dataG() + if err != nil { + 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) + } +} + +// extractFloat extracts a float64 or int value from a map by key. +func ecoflowValue(data map[string]any, key string) (float64, error) { + if data != nil { + if v, ok := data[key]; ok { + return cast.ToFloat64E(v) + } + } + 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) { + response, err := m.dataG() + if err != nil { + return 0, err + } + + return ecoflowValue(response.Data, m.batterySoc) +} diff --git a/templates/definition/meter/ecoflow-powerocean.yaml b/templates/definition/meter/ecoflow-powerocean.yaml index f697be437..bd4361e95 100644 --- a/templates/definition/meter/ecoflow-powerocean.yaml +++ b/templates/definition/meter/ecoflow-powerocean.yaml @@ -9,14 +9,14 @@ requirements: Für die Nutzung des EcoFlow PowerOcean Meters benötigen Sie: - Einen gültigen Access Key von der EcoFlow Developer Console - Den entsprechenden Secret Key - - Die Device ID Ihres PowerOcean Systems + - Die Seriennummer Ihres PowerOcean Systems Diese Credentials erhalten Sie über die EcoFlow Developer Console nach der Registrierung Ihrer Anwendung. en: | To use the EcoFlow PowerOcean meter you need: - A valid Access Key from the EcoFlow Developer Console - The corresponding Secret Key - - The Device ID of your PowerOcean system + - The serial number of your PowerOcean system These credentials can be obtained through the EcoFlow Developer Console after registering your application. params: @@ -37,19 +37,29 @@ params: en: Secret Key from EcoFlow Developer Console de: Secret Key aus der EcoFlow Developer Console - name: deviceid + deprecated: true + - name: serial required: true + - name: region + choice: ["auto", "europe", "america"] + default: auto description: - generic: Device ID + generic: "API Region: auto (default), europe, or america." help: - en: Device ID of your EcoFlow PowerOcean system (e.g., HJ31ZD1AZH5G0342) - de: Geräte-ID Ihres EcoFlow PowerOcean Systems (z.B. HJ31ZD1AZH5G0342) + en: 'If you sometimes see an error "8513: accessKey is invalid", which for example Starlink users do, you can set API region explicitly. Passible values: auto (default), europe, or america.' + de: 'Manche Nutzer, z.B. mit Starlink-Internet, sehen in den Logs den "Fehler 8513: accessKey is invalid". Um das zu beheben, kann man die API Region manuell setzen. Mögliche Werte: auto (Standard), europe oder america.' - name: cache default: 30s advanced: true render: | - type: ecoflow-powerocean + type: ecoflow accesskey: {{ .accesskey }} secretkey: {{ .secretkey }} - deviceid: {{ .deviceid }} + serial: {{ if .serial }}{{ .serial }}{{ else }}{{ .deviceid }}{{ end }} usage: {{ .usage }} + region: {{ .region }} cache: {{ .cache }} + gridpower: sysGridPwr + pvpower: mpptPwr + batterypower: bpPwr + batterysoc: bpSoc diff --git a/templates/definition/meter/ecoflow-stream.yaml b/templates/definition/meter/ecoflow-stream.yaml new file mode 100644 index 000000000..56b1b32b9 --- /dev/null +++ b/templates/definition/meter/ecoflow-stream.yaml @@ -0,0 +1,62 @@ +template: ecoflow-stream +products: + - brand: EcoFlow + description: + generic: Stream +requirements: + description: + de: | + Für die Nutzung des EcoFlow Stream Meters benötigen Sie: + - Einen gültigen Access Key von der EcoFlow Developer Console + - Den entsprechenden Secret Key + - Die Seriennummer des Hauptgerätes Ihres Stream Systems + + Diese Credentials erhalten Sie über die EcoFlow Developer Console nach der Registrierung Ihrer Anwendung. + en: | + To use the EcoFlow Stream meter you need: + - A valid Access Key from the EcoFlow Developer Console + - The corresponding Secret Key + - The main device serial number of your Stream system + + These credentials can be obtained through the EcoFlow Developer Console after registering your application. +params: + - name: usage + choice: ["grid", "pv", "battery"] + - name: accesskey + required: true + description: + generic: Access Key + help: + en: Access Key from EcoFlow Developer Console + de: Access Key aus der EcoFlow Developer Console + - name: secretkey + required: true + description: + generic: Secret Key + help: + en: Secret Key from EcoFlow Developer Console + de: Secret Key aus der EcoFlow Developer Console + - name: serial + - name: region + choice: ["auto", "europe", "america"] + default: auto + description: + generic: "API Region: auto (default), europe, or america." + help: + en: 'If you sometimes see an error "8513: accessKey is invalid", which for example Starlink users do, you can set API region explicitly. Passible values: auto (default), europe, or america.' + de: 'Manche Nutzer, z.B. mit Starlink-Internet, sehen in den Logs den "Fehler 8513: accessKey is invalid". Um das zu beheben, kann man die API Region manuell setzen. Mögliche Werte: auto (Standard), europe oder america.' + - name: cache + default: 30s + advanced: true +render: | + type: ecoflow + accesskey: {{ .accesskey }} + secretkey: {{ .secretkey }} + serial: {{ if .serial }}{{ .serial }}{{ else }}{{ .deviceid }}{{ end }} + usage: {{ .usage }} + cache: {{ .cache }} + region: {{ .region }} + gridpower: powGetSysGrid + pvpower: powGetPvSum + batterypower: powGetBpCms + batterysoc: cmsBattSoc From 377d3d199c222f5dc49d280ff869ff9f080d445d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20He=C3=9F?= Date: Sun, 22 Mar 2026 17:32:37 +0100 Subject: [PATCH 0006/1413] fix: OCPP test race on logger and connector clock (#28460) --- charger/ocpp/connector.go | 2 ++ charger/ocpp_test.go | 10 ++++++++-- charger/ocpp_test_logger.go | 34 ++++++++++++++++++++-------------- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/charger/ocpp/connector.go b/charger/ocpp/connector.go index 4d45751d6..9e5622b51 100644 --- a/charger/ocpp/connector.go +++ b/charger/ocpp/connector.go @@ -80,6 +80,8 @@ func NewConnector(ctx context.Context, log *util.Logger, id int, cp *CP, idTag s } func (conn *Connector) TestClock(clock clock.Clock) { + conn.mu.Lock() + defer conn.mu.Unlock() conn.clock = clock } diff --git a/charger/ocpp_test.go b/charger/ocpp_test.go index 42a95703f..01789f9ee 100644 --- a/charger/ocpp_test.go +++ b/charger/ocpp_test.go @@ -33,7 +33,8 @@ func TestOcpp(t *testing.T) { type ocppTestSuite struct { suite.Suite - clock *clock.Mock + clock *clock.Mock + logger *ocppLogger } func (suite *ocppTestSuite) SetupSuite() { @@ -41,12 +42,17 @@ func (suite *ocppTestSuite) SetupSuite() { // setup cs so we can overwrite logger afterwards _ = ocpp.Instance() - ocppj.SetLogger(&ocppLogger{suite.T()}) + suite.logger = &ocppLogger{t: suite.T()} + ocppj.SetLogger(suite.logger) suite.clock = clock.NewMock() suite.NotNil(ocpp.Instance()) } +func (suite *ocppTestSuite) TearDownSuite() { + suite.logger.close() +} + func (suite *ocppTestSuite) startChargePoint(id string, connectorId int) (ocpp16.ChargePoint, *ocppj.Client) { // set a handler for all callback functions handler := &ChargePointHandler{ diff --git a/charger/ocpp_test_logger.go b/charger/ocpp_test_logger.go index b5fdc4c24..01cdaa845 100644 --- a/charger/ocpp_test_logger.go +++ b/charger/ocpp_test_logger.go @@ -2,27 +2,33 @@ package charger import ( "fmt" + "sync" "testing" "time" ) type ocppLogger struct { - t *testing.T + mu sync.Mutex + t *testing.T } -func print(t *testing.T, s string) { - t.Log((time.Now().Format(time.DateTime)), s) +func (l *ocppLogger) close() { + l.mu.Lock() + l.t = nil + l.mu.Unlock() } -func (l *ocppLogger) Debug(args ...any) { print(l.t, fmt.Sprint(args...)) } -func (l *ocppLogger) Debugf(format string, args ...any) { - print(l.t, fmt.Sprintf(format, args...)) -} -func (l *ocppLogger) Info(args ...any) { print(l.t, fmt.Sprint(args...)) } -func (l *ocppLogger) Infof(format string, args ...any) { - print(l.t, fmt.Sprintf(format, args...)) -} -func (l *ocppLogger) Error(args ...any) { print(l.t, fmt.Sprint(args...)) } -func (l *ocppLogger) Errorf(format string, args ...any) { - print(l.t, fmt.Sprintf(format, args...)) +func (l *ocppLogger) print(s string) { + l.mu.Lock() + defer l.mu.Unlock() + if l.t != nil { + l.t.Log(time.Now().Format(time.DateTime), s) + } } + +func (l *ocppLogger) Debug(args ...any) { l.print(fmt.Sprint(args...)) } +func (l *ocppLogger) Debugf(format string, args ...any) { l.print(fmt.Sprintf(format, args...)) } +func (l *ocppLogger) Info(args ...any) { l.print(fmt.Sprint(args...)) } +func (l *ocppLogger) Infof(format string, args ...any) { l.print(fmt.Sprintf(format, args...)) } +func (l *ocppLogger) Error(args ...any) { l.print(fmt.Sprint(args...)) } +func (l *ocppLogger) Errorf(format string, args ...any) { l.print(fmt.Sprintf(format, args...)) } From 0d027945a3d5bbebc79783b4438383c882681e34 Mon Sep 17 00:00:00 2001 From: Hofy <68365998+Hofyyy@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:30:43 +0100 Subject: [PATCH 0007/1413] Teslamate: add climater (#28463) --- templates/definition/vehicle/teslamate.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/templates/definition/vehicle/teslamate.yaml b/templates/definition/vehicle/teslamate.yaml index 35005d4d5..1ff42e4ac 100644 --- a/templates/definition/vehicle/teslamate.yaml +++ b/templates/definition/vehicle/teslamate.yaml @@ -51,4 +51,8 @@ render: | topic: teslamate/cars/{{ .id }}/time_to_full_charge timeout: {{ .timeout }} jq: if (. == null) or (. == 0) or (type != "number") or (. < 0) then null else (now + (. * 3600) | strftime("%Y-%m-%dT%H:%M:%SZ")) end + climater: + source: mqtt + topic: teslamate/cars/{{ .id }}/is_preconditioning + timeout: {{ .timeout }} features: ["coarsecurrent"] From 803cc3166ce6ec9674f870e92cdc911a99cc40bf Mon Sep 17 00:00:00 2001 From: Joe Auricchio Date: Mon, 23 Mar 2026 02:24:38 -0700 Subject: [PATCH 0008/1413] Openevse: fix api (#28479) --- charger/openevse.go | 8 ++++---- charger/openevse/types.go | 6 ++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/charger/openevse.go b/charger/openevse.go index 120136b94..c34b8cd4f 100644 --- a/charger/openevse.go +++ b/charger/openevse.go @@ -199,7 +199,7 @@ func (c *OpenEVSE) ChargedEnergy() (float64, error) { return 0, err } - return res.SessionEnergy / 1e3, err + return res.Wattsec / 3600 / 1e3, err } var _ api.ChargeTimer = (*OpenEVSE)(nil) @@ -210,7 +210,7 @@ func (c *OpenEVSE) ChargeDuration() (time.Duration, error) { return 0, err } - return time.Duration(res.SessionElapsed) * time.Second, err + return time.Duration(res.Elapsed) * time.Second, err } var _ api.MeterEnergy = (*OpenEVSE)(nil) @@ -222,7 +222,7 @@ func (c *OpenEVSE) TotalEnergy() (float64, error) { return 0, err } - return res.TotalEnergy, err + return res.Watthour / 1e3, err } var _ api.Meter = (*OpenEVSE)(nil) @@ -233,7 +233,7 @@ func (c *OpenEVSE) CurrentPower() (float64, error) { return 0, err } - return res.Power, err + return res.Amp * res.Voltage / 1e3, err } // phases1p3p implements the api.PhaseSwitcher interface diff --git a/charger/openevse/types.go b/charger/openevse/types.go index b471adc7d..a2a03503a 100644 --- a/charger/openevse/types.go +++ b/charger/openevse/types.go @@ -12,14 +12,12 @@ type Status struct { ManualOverride int `json:"manual_override,omitempty"` // 1 = active, 0 = default Mode string `json:"mode,omitempty"` // The current mode of the EVSE Pilot int `json:"pilot,omitempty"` // the pilot value, in amps - Power float64 `json:"power,omitempty"` // apparent power in watts - SessionEnergy float64 `json:"session_energy"` // The total amount of energy accumulated for current session (in wh) - SessionElapsed int `json:"session_elapsed"` // duration of this charging session in seconds State int `json:"state,omitempty"` // evse state 1=A 2=B 3=C 4=D 5-11=F 254=sleeping 255=disabled Status string `json:"status,omitempty"` // active, disabled, none, unknown - TotalEnergy float64 `json:"total_energy"` // The total amount of energy accumulated (in kwh) Vehicle int `json:"vehicle,omitempty"` // 0=not connected, 1=connected Voltage float64 `json:"voltage,omitempty"` // supplied via MQTT/Tesla/HTTP or assume a default + Watthour float64 `json:"watthour,omitempty"` // The total amount of energy accumulated (in watt-hours) + Wattsec float64 `json:"wattsec,omitempty"` // The total amount of energy accumulated for current session (in watt-seconds) } type Override struct { From 257d8cdd5f078ad5465e5ca35de84243584d658c Mon Sep 17 00:00:00 2001 From: Joe Auricchio Date: Mon, 23 Mar 2026 08:20:18 -0700 Subject: [PATCH 0009/1413] chore: fix api keys (#28497) --- charger/openevse.go | 4 ++-- charger/openevse/types.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/charger/openevse.go b/charger/openevse.go index c34b8cd4f..8318bbfbf 100644 --- a/charger/openevse.go +++ b/charger/openevse.go @@ -199,7 +199,7 @@ func (c *OpenEVSE) ChargedEnergy() (float64, error) { return 0, err } - return res.Wattsec / 3600 / 1e3, err + return res.SessionEnergy / 1e3, err } var _ api.ChargeTimer = (*OpenEVSE)(nil) @@ -222,7 +222,7 @@ func (c *OpenEVSE) TotalEnergy() (float64, error) { return 0, err } - return res.Watthour / 1e3, err + return res.TotalEnergy, err } var _ api.Meter = (*OpenEVSE)(nil) diff --git a/charger/openevse/types.go b/charger/openevse/types.go index a2a03503a..10926f850 100644 --- a/charger/openevse/types.go +++ b/charger/openevse/types.go @@ -12,12 +12,12 @@ type Status struct { ManualOverride int `json:"manual_override,omitempty"` // 1 = active, 0 = default Mode string `json:"mode,omitempty"` // The current mode of the EVSE Pilot int `json:"pilot,omitempty"` // the pilot value, in amps + SessionEnergy float64 `json:"session_energy"` // The total amount of energy accumulated for current session (in wh) State int `json:"state,omitempty"` // evse state 1=A 2=B 3=C 4=D 5-11=F 254=sleeping 255=disabled Status string `json:"status,omitempty"` // active, disabled, none, unknown + TotalEnergy float64 `json:"total_energy"` // The total amount of energy accumulated (in kwh) Vehicle int `json:"vehicle,omitempty"` // 0=not connected, 1=connected Voltage float64 `json:"voltage,omitempty"` // supplied via MQTT/Tesla/HTTP or assume a default - Watthour float64 `json:"watthour,omitempty"` // The total amount of energy accumulated (in watt-hours) - Wattsec float64 `json:"wattsec,omitempty"` // The total amount of energy accumulated for current session (in watt-seconds) } type Override struct { From 2d3e7ed78a412c6d35ac6e06a0909d42411203dc Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 23 Mar 2026 18:19:38 +0100 Subject: [PATCH 0010/1413] Fix invalid data panic --- server/socket.go | 8 ++++---- server/socket_helper.go | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/server/socket.go b/server/socket.go index 8b5a9c128..c30dcd655 100644 --- a/server/socket.go +++ b/server/socket.go @@ -126,7 +126,7 @@ func (h *SocketHub) welcome(subscriber *socketSubscriber, params []util.Param) { if sharder, ok := (p.Val).(util.Sharder); ok { sharders[k] = sharder } else { - msg[k] = json.RawMessage(socketEncode(p.Val)) + msg[k] = json.RawMessage(socketEncode(k, p.Val)) } } @@ -138,7 +138,7 @@ func (h *SocketHub) welcome(subscriber *socketSubscriber, params []util.Param) { for k, sharder := range sharders { for key, val := range sharder.AllShards() { if b, err := json.Marshal(map[string]json.RawMessage{ - k + "." + key: json.RawMessage(socketEncode(val)), + k + "." + key: json.RawMessage(socketEncode(k, val)), }); err == nil { subscriber.send <- b } @@ -164,10 +164,10 @@ func (h *SocketHub) broadcast(p util.Param) { // Sharder splits data into chunks if sp, ok := (p.Val).(util.Sharder); ok { for key, val := range sp.ModifiedShards() { - msg[k+"."+key] = json.RawMessage(socketEncode(val)) + msg[k+"."+key] = json.RawMessage(socketEncode(k, val)) } } else { - msg[k] = json.RawMessage(socketEncode(p.Val)) + msg[k] = json.RawMessage(socketEncode(k, p.Val)) } b, _ := json.Marshal(msg) diff --git a/server/socket_helper.go b/server/socket_helper.go index f59be6837..cd5e66cdd 100644 --- a/server/socket_helper.go +++ b/server/socket_helper.go @@ -30,7 +30,7 @@ func encodeSliceAsString(v any) (string, error) { return fmt.Sprintf("[%s]", strings.Join(res, ",")), nil } -func socketEncode(pval any) string { +func socketEncode(key string, pval any) string { var ( val string err error @@ -44,7 +44,8 @@ func socketEncode(pval any) string { } if err != nil { - panic(err) + log.ERROR.Printf("invalid data for key %s in socket encode: %v", key, pval) + return "null" } return val From a35135aa29aefb6472d5825c691df84e2baee904 Mon Sep 17 00:00:00 2001 From: VolkerK62 <99199865+VolkerK62@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:47:01 +0100 Subject: [PATCH 0011/1413] DaheimLaden Pro: update firmware requirements (#28499) --- templates/definition/charger/daheimladen-pro.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/definition/charger/daheimladen-pro.yaml b/templates/definition/charger/daheimladen-pro.yaml index 22644b155..8b8b7e7b0 100644 --- a/templates/definition/charger/daheimladen-pro.yaml +++ b/templates/definition/charger/daheimladen-pro.yaml @@ -4,8 +4,8 @@ products: - { brand: DaheimLaden, description: { generic: Business } } requirements: description: - de: Firmware-Anforderungen= Smart/Touch Pro ab "M3W_3.11STP", Business ab "M3W_3.09PTB". Während der Phasen-Umschaltung pausiert die Ladung für zwei Minuten. Informationen zur Ladefreigabe (AutoStart, App, RFID, Button), bitte der Wallbox Dokumentation von DaheimLaden entnehmen. - en: Firmware requirements= Smart/Touch Pro from "M3W_3.11STP", Business from "M3W_3.09PTB". Charging pauses for two minutes during phase switching. For information regarding charging authorization (AutoStart, App, RFID, Button), please refer to the DaheimLaden wallbox documentation. + de: Firmware-Anforderungen= Smart/Touch Pro ab "M3W_3.11STP", Business ab "M3W_4.03PTB". Während der Phasen-Umschaltung pausiert die Ladung für zwei Minuten. Informationen zur Ladefreigabe (AutoStart, App, RFID, Button), bitte der Wallbox Dokumentation von DaheimLaden entnehmen. + en: Firmware requirements= Smart/Touch Pro from "M3W_3.11STP", Business from "M3W_4.03PTB". Charging pauses for two minutes during phase switching. For information regarding charging authorization (AutoStart, App, RFID, Button), please refer to the DaheimLaden wallbox documentation. capabilities: ["1p3p", "mA", "rfid"] params: - name: host From a56d7f010b8f0bd4fb31b9ab0304b733c5eb1028 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 24 Mar 2026 09:31:49 +0100 Subject: [PATCH 0012/1413] chore: update AI-gen guidelines --- CONTRIBUTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff64f3890..a04cfe985 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -160,6 +160,7 @@ When submitting AI-assisted contributions, keep these principles in mind: - **Understanding**: Fully understand all changes and be prepared to answer questions about them. - **Human-written intent**: Write issue descriptions, PR explanations, and commit messages in your own words. Keep them clear and concise, not lengthy generated text. - **Value**: Ensure contributions justify the review effort required from maintainers. +- **Prior consensus**: Only open a PR if there is a related issue or discussion where the team has indicated a positive tendency toward the proposed change. Link to it in your PR description. Contributors remain responsible for their work regardless of which tools were used to create it. From 7c6f0691df43a29cd9e275c385fe7ac44e872271 Mon Sep 17 00:00:00 2001 From: Robbert Dol Date: Tue, 24 Mar 2026 17:34:59 +0100 Subject: [PATCH 0013/1413] Vaillant: support more countries (BC) (#28096) --- templates/definition/charger/vaillant.yaml | 48 ++++++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/templates/definition/charger/vaillant.yaml b/templates/definition/charger/vaillant.yaml index d114353cc..9732c508a 100644 --- a/templates/definition/charger/vaillant.yaml +++ b/templates/definition/charger/vaillant.yaml @@ -14,8 +14,50 @@ params: - name: password - name: realm type: choice - choice: [DE, AT] - default: DE + choice: + [ + "albania", + "austria", + "belgium", + "bosnia", + "bulgaria", + "croatia", + "cyprus", + "czechrepublic", + "denmark", + "estonia", + "finland", + "france", + "georgia", + "germany", + "greece", + "hungary", + "ireland", + "italy", + "kosovo", + "latvia", + "lithuania", + "luxembourg", + "moldavia", + "netherlands", + "new-zealand", + "north-macedonia", + "norway", + "poland", + "portugal", + "romania", + "serbia", + "slovakia", + "slovenia", + "spain", + "sweden", + "switzerland", + "turkiye", + "ukraine", + "unitedkingdom", + "uzbekistan", + ] + default: germany description: de: Region en: Region @@ -42,7 +84,7 @@ render: | type: vaillant user: {{ .user }} password: {{ .password }} - realm: {{ if eq .realm "AT" -}} vaillant-austria-b2c {{- end }} + realm: {{ if eq .realm "DE" -}} vaillant-germany-b2c {{ else if eq .realm "AT" -}} vaillant-austria-b2c {{ else }} vaillant-{{ .realm }}-b2c {{ end }} {{- if .system }} system: {{ .system }} {{- end }} From 5822750fe85476a40f83cf855e3b2e3c16b01613 Mon Sep 17 00:00:00 2001 From: Martin Porsch Date: Tue, 24 Mar 2026 18:27:27 +0100 Subject: [PATCH 0014/1413] chore: improve config (#28505) --- templates/definition/meter/ecoflow-powerocean.yaml | 7 ++++--- templates/definition/meter/ecoflow-stream.yaml | 11 ++++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/templates/definition/meter/ecoflow-powerocean.yaml b/templates/definition/meter/ecoflow-powerocean.yaml index bd4361e95..d914bb6a6 100644 --- a/templates/definition/meter/ecoflow-powerocean.yaml +++ b/templates/definition/meter/ecoflow-powerocean.yaml @@ -43,11 +43,12 @@ params: - name: region choice: ["auto", "europe", "america"] default: auto + advanced: true description: - generic: "API Region: auto (default), europe, or america." + generic: "API Region" help: - en: 'If you sometimes see an error "8513: accessKey is invalid", which for example Starlink users do, you can set API region explicitly. Passible values: auto (default), europe, or america.' - de: 'Manche Nutzer, z.B. mit Starlink-Internet, sehen in den Logs den "Fehler 8513: accessKey is invalid". Um das zu beheben, kann man die API Region manuell setzen. Mögliche Werte: auto (Standard), europe oder america.' + en: "If you sometimes see error code 8513, like for example Starlink users do, automatic region detection fails. Here, you can set the API region manually. Possible values: auto (default), europe, or america." + de: 'Manche Nutzer, z.B. mit Starlink-Internet, sehen gelegentlich in den Logs den "error code 8513". Das passiert, wenn die API Region nicht korrekt erkannt wird. Um das zu beheben, kann man die API Region manuell setzen. Mögliche Werte: auto (Standard), europe oder america.' - name: cache default: 30s advanced: true diff --git a/templates/definition/meter/ecoflow-stream.yaml b/templates/definition/meter/ecoflow-stream.yaml index 56b1b32b9..f6056c30e 100644 --- a/templates/definition/meter/ecoflow-stream.yaml +++ b/templates/definition/meter/ecoflow-stream.yaml @@ -37,14 +37,19 @@ params: en: Secret Key from EcoFlow Developer Console de: Secret Key aus der EcoFlow Developer Console - name: serial + required: true + help: + en: Serial number of the main device in your Stream system, normally the first device of your system + de: Seriennummer des Hauptgeräts (normalerweise das erste Gerät) Ihres Stream Systems - name: region choice: ["auto", "europe", "america"] default: auto + advanced: true description: - generic: "API Region: auto (default), europe, or america." + generic: "API Region" help: - en: 'If you sometimes see an error "8513: accessKey is invalid", which for example Starlink users do, you can set API region explicitly. Passible values: auto (default), europe, or america.' - de: 'Manche Nutzer, z.B. mit Starlink-Internet, sehen in den Logs den "Fehler 8513: accessKey is invalid". Um das zu beheben, kann man die API Region manuell setzen. Mögliche Werte: auto (Standard), europe oder america.' + en: "If you sometimes see error code 8513, like for example Starlink users do, automatic region detection fails. Here, you can set the API region manually. Possible values: auto (default), europe, or america." + de: 'Manche Nutzer, z.B. mit Starlink-Internet, sehen gelegentlich in den Logs den "error code 8513". Das passiert, wenn die API Region nicht korrekt erkannt wird. Um das zu beheben, kann man die API Region manuell setzen. Mögliche Werte: auto (Standard), europe oder america.' - name: cache default: 30s advanced: true From 3059749ece50a2e860dddfd612f846e33c221fd0 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 24 Mar 2026 18:42:04 +0100 Subject: [PATCH 0015/1413] OpenWB native: release GPIOs when closing (#28532) --- charger/openwb-native_linux.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/charger/openwb-native_linux.go b/charger/openwb-native_linux.go index 9455a9caa..6ffb6fa54 100644 --- a/charger/openwb-native_linux.go +++ b/charger/openwb-native_linux.go @@ -79,7 +79,6 @@ func NewOpenWbNativeFromConfig(ctx context.Context, other map[string]any) (api.C // NewOpenWbNative creates OpenWbNative charger func NewOpenWbNative(ctx context.Context, uri, device, comset string, baudrate int, proto modbus.Protocol, slaveID uint8, hasPhases1p3p bool, rfIdVidPid string, cpWait time.Duration, connector int, chip string) (api.Charger, error) { log := util.NewLogger("openwb-native") - log.DEBUG.Printf("Creating OpenWB native with 3 phases %t, rfid %s, cpwait %s, connector %d", hasPhases1p3p, rfIdVidPid, cpWait.String(), connector) evse, err := NewEvseDIN(ctx, uri, device, comset, baudrate, proto, slaveID) if err != nil { @@ -135,6 +134,13 @@ func NewOpenWbNative(ctx context.Context, uri, device, comset string, baudrate i *gpioConfig.dst = line } + go func() { + <-ctx.Done() + wb.gpio.cp.Close() + wb.gpio.ph1.Close() + wb.gpio.ph3.Close() + }() + return decorateOpenWbNative(wb, maxCurrentMillis, phases1p3p, identify), nil } From ed8f52e8f96c9bc8fd8c738947eb41369841b070 Mon Sep 17 00:00:00 2001 From: Klaas Gadeyne Date: Tue, 24 Mar 2026 21:29:40 +0100 Subject: [PATCH 0016/1413] Goodwe Wifi: support for DT series via aa55 pseudo-modbus protocol (BC) (#28396) --- plugin/aa55udp.go | 224 +++++++++ plugin/aa55udp_test.go | 431 ++++++++++++++++++ .../definition/meter/goodwe-wifi-dt.yaml | 33 ++ .../definition/meter/goodwe-wifi-es.yaml | 54 +++ .../definition/meter/goodwe-wifi-et.yaml | 71 +++ templates/definition/meter/goodwe-wifi.yaml | 20 - 6 files changed, 813 insertions(+), 20 deletions(-) create mode 100644 plugin/aa55udp.go create mode 100644 plugin/aa55udp_test.go create mode 100644 templates/definition/meter/goodwe-wifi-dt.yaml create mode 100644 templates/definition/meter/goodwe-wifi-es.yaml create mode 100644 templates/definition/meter/goodwe-wifi-et.yaml delete mode 100644 templates/definition/meter/goodwe-wifi.yaml diff --git a/plugin/aa55udp.go b/plugin/aa55udp.go new file mode 100644 index 000000000..fed3063b6 --- /dev/null +++ b/plugin/aa55udp.go @@ -0,0 +1,224 @@ +package plugin + +import ( + "context" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "net" + "strings" + "time" + + "github.com/evcc-io/evcc/util" +) + +// AA55UDP implements the GoodWe WiFi AA55-over-UDP wire protocol as a generic +// evcc source plugin. +// +// The inverter speaks a simple request/response protocol over UDP port 8899: +// +// Request: [6-byte Modbus PDU body] [Modbus CRC-16, little-endian] +// Response: AA 55 [src] 03 [byteCount] [payload…] [CRC] +// +// src varies by inverter family (0x7F for DT/DNS, 0xF7 for ET/EH/BT/BH); +// only the AA 55 magic bytes and function code 0x03 are validated. +// +// Each instance reads exactly one value from one register (or register pair +// for 32-bit values), matching how Modbus source plugins work. The PDU is +// constructed from register and count; the decoded value is always at offset 0 +// of the response payload. +type AA55UDP struct { + log *util.Logger + conn *net.UDPConn + pdu []byte // 6-byte PDU body, no CRC + decode string // int32be | uint32be | int16be | uint16be + scale float64 +} + +func init() { + registry.AddCtx("aa55udp", NewAA55UDPFromConfig) +} + +// NewAA55UDPFromConfig creates an AA55UDP plugin from a source block: +// +// source: aa55udp +// host: 192.168.1.26 # inverter IP; port 8899 is always used +// register: 30127 # Modbus register address (0-based, uint16) +// count: 2 # number of registers to read (1=U16, 2=S32/U32) +// decode: int32be # int32be | uint32be | int16be | uint16be +// scale: 1.0 # optional multiplier (default 1.0) +func NewAA55UDPFromConfig(_ context.Context, other map[string]interface{}) (Plugin, error) { + cc := struct { + Host string `mapstructure:"host"` + Register uint16 `mapstructure:"register"` + Count uint16 `mapstructure:"count"` + Decode string `mapstructure:"decode"` + Scale float64 `mapstructure:"scale"` + }{ + Count: 2, + Scale: 1.0, + } + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if cc.Count == 0 { + return nil, errors.New("aa55udp: count must be ≥ 1") + } + + switch cc.Decode { + case "int32be", "uint32be", "int16be", "uint16be": + default: + return nil, fmt.Errorf("aa55udp: unsupported decode %q (want int32be|uint32be|int16be|uint16be)", cc.Decode) + } + + pdu := buildPDU(cc.Register, cc.Count) + + addr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(cc.Host, "8899")) + if err != nil { + return nil, fmt.Errorf("aa55udp: resolve %s: %w", cc.Host, err) + } + + conn, err := net.DialUDP("udp4", nil, addr) + if err != nil { + return nil, fmt.Errorf("aa55udp: dial %s: %w", cc.Host, err) + } + + return &AA55UDP{ + log: util.NewLogger("aa55udp"), + conn: conn, + pdu: pdu, + decode: cc.Decode, + scale: cc.Scale, + }, nil +} + +// FloatGetter implements the evcc Plugin interface. +func (p *AA55UDP) FloatGetter() (func() (float64, error), error) { + return p.query, nil +} + +// query sends the PDU and returns the decoded, scaled value at offset 0 of the +// response payload. +func (p *AA55UDP) query() (float64, error) { + packet := append(p.pdu, modbusCRC16(p.pdu)...) + + if _, err := p.conn.Write(packet); err != nil { + return 0, fmt.Errorf("aa55udp write: %w", err) + } + + if err := p.conn.SetReadDeadline(time.Now().Add(4 * time.Second)); err != nil { + return 0, fmt.Errorf("aa55udp deadline: %w", err) + } + + buf := make([]byte, 512) + n, err := p.conn.Read(buf) + if err != nil { + return 0, fmt.Errorf("aa55udp read: %w", err) + } + + payload, err := stripAA55Header(buf[:n]) + if err != nil { + return 0, fmt.Errorf("aa55udp: %w", err) + } + + v, err := decodeAt(payload, 0, p.decode) + if err != nil { + return 0, fmt.Errorf("aa55udp: %w", err) + } + + return v * p.scale, nil +} + +// aa55InverterAddr is the target address byte used in all AA55 request PDUs. +// The GoodWe WiFi module always uses 0x7F as the inverter target address, +// regardless of inverter family. The family only affects the source byte in +// responses (0x7F for DT/DNS, 0xF7 for ET/EH/BT/BH). +const aa55InverterAddr = 0x7F + +// aa55ReadFunc is the Modbus function code for READ HOLDING REGISTERS. +const aa55ReadFunc = 0x03 + +// buildPDU constructs the 6-byte Modbus PDU body for a READ HOLDING REGISTERS +// request targeting the GoodWe WiFi module. +func buildPDU(register, count uint16) []byte { + return []byte{ + aa55InverterAddr, aa55ReadFunc, + byte(register >> 8), byte(register), + byte(count >> 8), byte(count), + } +} + +// parsePDUHex decodes a hex string (spaces allowed) into exactly 6 bytes. +// Kept for use in tests. +func parsePDUHex(s string) ([]byte, error) { + clean := strings.ReplaceAll(s, " ", "") + b, err := hex.DecodeString(clean) + if err != nil { + return nil, fmt.Errorf("aa55udp: invalid pdu %q: %w", s, err) + } + if len(b) != 6 { + return nil, fmt.Errorf("aa55udp: pdu must be 6 bytes, got %d", len(b)) + } + return b, nil +} + +// stripAA55Header validates the AA55 response frame and returns the bare +// payload (without the 5-byte header and trailing 2-byte CRC). +// buf[2] is the inverter source address and varies by family — only the +// AA 55 magic bytes and function code 0x03 are validated. +func stripAA55Header(buf []byte) ([]byte, error) { + if len(buf) < 6 || buf[0] != 0xAA || buf[1] != 0x55 || buf[3] != 0x03 { + return nil, errors.New("invalid response header") + } + byteCount := int(buf[4]) + if len(buf) < 5+byteCount+2 { + return nil, errors.New("short response") + } + return buf[5 : 5+byteCount], nil +} + +// decodeAt extracts an integer at the given byte offset of payload and +// interprets it according to decode. +func decodeAt(payload []byte, offset int, decode string) (float64, error) { + switch decode { + case "int32be": + if len(payload) < offset+4 { + return 0, fmt.Errorf("payload too short for int32be at offset %d (len=%d)", offset, len(payload)) + } + return float64(int32(binary.BigEndian.Uint32(payload[offset:]))), nil + case "uint32be": + if len(payload) < offset+4 { + return 0, fmt.Errorf("payload too short for uint32be at offset %d (len=%d)", offset, len(payload)) + } + return float64(binary.BigEndian.Uint32(payload[offset:])), nil + case "int16be": + if len(payload) < offset+2 { + return 0, fmt.Errorf("payload too short for int16be at offset %d (len=%d)", offset, len(payload)) + } + return float64(int16(binary.BigEndian.Uint16(payload[offset:]))), nil + case "uint16be": + if len(payload) < offset+2 { + return 0, fmt.Errorf("payload too short for uint16be at offset %d (len=%d)", offset, len(payload)) + } + return float64(binary.BigEndian.Uint16(payload[offset:])), nil + } + return 0, fmt.Errorf("unknown decode type: %s", decode) +} + +// modbusCRC16 computes the Modbus CRC-16 (little-endian byte order). +func modbusCRC16(data []byte) []byte { + crc := uint16(0xFFFF) + for _, b := range data { + crc ^= uint16(b) + for i := 0; i < 8; i++ { + if crc&0x0001 != 0 { + crc = (crc >> 1) ^ 0xA001 + } else { + crc >>= 1 + } + } + } + return []byte{byte(crc & 0xFF), byte(crc >> 8)} +} diff --git a/plugin/aa55udp_test.go b/plugin/aa55udp_test.go new file mode 100644 index 000000000..a617a6ffc --- /dev/null +++ b/plugin/aa55udp_test.go @@ -0,0 +1,431 @@ +package plugin + +import ( + "encoding/binary" + "encoding/hex" + "net" + "testing" + + "github.com/evcc-io/evcc/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// Real captured frames (marcelblijleven/goodwe tests/sample/ + discussion #27411) +// --------------------------------------------------------------------------- +// +// All frames are verbatim UDP datagrams received from real inverters. +// In the per-register protocol each PDU fetches exactly one value; the +// response payload starts at offset 0. +// +// Register map summary: +// +// Family Reading Register Count Decode Expected (captures below) +// DT power 0x75AF 2 int32be GW3000-DNS-30=1972W GW17K-DT=12470W +// DT energy 0x75C1 2 uint32be GW17K-DT=29984.4kWh GW6000-DT=13350.2kWh +// ES pv 0x7506 2 int32be +// ES grid 0x750C 2 int32be +// ES battery 0x7512 2 int32be +// ES soc 0x750E 1 uint16be +// ET pv 0x8941 2 int32be GW10K-ET=831W +// ET grid 0x8943 2 int32be GW10K-ET=-3W GW25K-ET=1511W GW29K9-ET=-5403W +// ET battery 0x896E 2 int32be GW10K-ET=-2512W (charging) +// ET energy 0x8977 2 uint32be GW10K-ET=6085.3kWh GW25K-ET=160.3kWh +// ET soc 0x908F 1 uint16be GW10K-ET=68% GW25K-ET=100% +// +// Note: these captures are full block-read responses used to verify the +// per-register values at offset 0 of what the inverter would return for +// a targeted single-register read. The payload bytes at the register's +// offset within the block are identical to what a per-register read returns. + +const ( + // DT family (source byte 0x7F, block PDU READ 73 @ 0x7594) + capGW3000DNS30 = `aa557f03921a020e0e301007cf005f053b000b00000000ffffffffffffffffffffffffffffffffffff08eeffffffff0056ffffffff1387ffffffff000007b40001000000000000000007a600000002ffffffff03e7ffff011bffffffff00140000a9f9000013ff0006ffffffffffffffffffffffffffffffffffffffffffffffffffff0e05ffffffffffff013e000000030cdaffff00393eb0` + capGW17kDT = `aa557f03921805140a23371518006912930094ffffffffffffffffffffffffffffffff102210130fff093f094f094500b000af00af138a138a138a000030b600010000000000000000000000000000000000000000ffff01c9ffffffff012500049344000020a500010000000000000000ffffffffffffffffffffffffffffffff0222184a0c4600000004000003a300f7000400000064b2f2` + capGW20kAUDT = `aa557f0392160a1513172a0f4100440dbc0047ffffffffffffffffffffffffffffffff0f2d0f4d0f6908d508bc08eb0048004a00471384138413850000135d000100000000000000000000000000cd0000000003e7ffff016cffffffff00c60000a8280000047300200000000000000000ffffffffffffffffffffffffffffffff0000174b0bad000000040000044b00000004000000696b04` + capGW6000DT = `aa557f039215081f0c03020c88001f0ca90020ffffffffffffffffffffffffffffffffffffffffffff08d008f90906001b001a001b1386138613860000072b0001000000000000ffffffffffffffffffffffff0000ffff019dffffffff003c0002097e0000210300140000ffff0000ffff0000ffff0000ffffffffffffffffffff0000177c0beeffffffff00cf016302f00000000000649f03` + + // ET family (source byte 0xF7, block PDU READ 125 @ 0x891C) + capGW10kET = `aa55f703fa1508160b0b0c0cfe00330000069f0cfe0035000006e100000000000000000000000000000000000002020959000f138700000150096f000d13870000011f096b000b1387000000ce00010000033ffffffffd000000000000000009560006138600010000006b096d000913880001000000bd096c00021387000100000000000000e000000050000000e9000001380000020a000401fe0000024b00001f640fb209eeff9efffff63000030000002000010000000000000000edb50000007d0000b8520000241e00620000024400000001588a007400006bbd003500005f65001d0005000000010000000000000000000107000800000209ee000055ae` + capGW25kET = `aa55f703fa170c030e07071cd3000e000004091cd30000000003d51d82000d000000001d82000000000000000002020905001d13830000024d0906001b1385000002290900002a138500000323000100000799000005e7000004d7000008d308f7001e138300000000003408fc0012138500000000000f08f6002013850000000001580000002c0000001000000153000001980000001a000701ce000001ae00001e350f1a0868000000000000000200000020000100000000000000000643000000930000056100000184001d00000094000a000000ac000200000391006e000002b800000004000000000000000000000000000002040180000200008f005ece` + capGW29k9ET = `aa55f703fa1801110e310e1aad000f000001de1aad0000000002a7168d001200000186168d000000000000000202020909001d1387000002470919001b1387000002350920001d13850000024b0001000006c7ffffeae500000133000007b708fb00071386000000000015090b0007138800000000000509190006138500000000002500000287000002800000028b0000004200001ba0000100f1000000cd00001db40eda0000ffff0000000000000000002000010000000000000000b237000000090000af6100000497000c0000005700000001a39e01b600000000000000000000000000060000000000000000000000000000020400ce00000000030064b6` + + // ET battery info (source byte 0xF7, block PDU READ 24 @ 0x9088) + capGW10kETBattery = `aa55f7033000ff01000001015e001900190000004400630005000001010000000000000000000000000000000000000000000000006447` + capGW25kETBattery = `aa55f7033000ff0137000100e600000028000000640064000400000105000000000316000000000000000000000000000000000000dc7a` +) + +// --------------------------------------------------------------------------- +// buildPDU tests +// --------------------------------------------------------------------------- + +func TestBuildPDU_DTpower(t *testing.T) { + // DT power: register 0x75AF, count 2 → READ 2 @ 0x75AF + got := buildPDU(0x75AF, 2) + assert.Equal(t, []byte{0x7f, 0x03, 0x75, 0xaf, 0x00, 0x02}, got) +} + +func TestBuildPDU_ETgrid(t *testing.T) { + // ET grid: register 0x8943, count 2 + got := buildPDU(0x8943, 2) + assert.Equal(t, []byte{0x7f, 0x03, 0x89, 0x43, 0x00, 0x02}, got) +} + +func TestBuildPDU_SoC(t *testing.T) { + // ET SoC: register 0x908F, count 1 (U16) + got := buildPDU(0x908F, 1) + assert.Equal(t, []byte{0x7f, 0x03, 0x90, 0x8f, 0x00, 0x01}, got) +} + +// --------------------------------------------------------------------------- +// stripAA55Header tests +// --------------------------------------------------------------------------- + +func TestStripAA55Header_DT(t *testing.T) { + // DT source byte = 0x7F + payload, err := stripAA55Header(mustHex(t, capGW3000DNS30)) + require.NoError(t, err) + assert.Equal(t, 146, len(payload)) +} + +func TestStripAA55Header_ET(t *testing.T) { + // ET source byte = 0xF7 — must also be accepted + payload, err := stripAA55Header(mustHex(t, capGW10kET)) + require.NoError(t, err) + assert.Equal(t, 250, len(payload)) +} + +func TestStripAA55Header_BadMagic(t *testing.T) { + _, err := stripAA55Header([]byte{0xFF, 0x55, 0x7F, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04, 0x00, 0x00}) + require.Error(t, err) +} + +func TestStripAA55Header_Short(t *testing.T) { + _, err := stripAA55Header([]byte{0xAA, 0x55, 0x7F, 0x03, 0x10, 0x01, 0x02}) + require.Error(t, err) + assert.Contains(t, err.Error(), "short") +} + +// --------------------------------------------------------------------------- +// decodeAt tests +// --------------------------------------------------------------------------- + +func TestDecodeAt_Int32BE_Positive(t *testing.T) { + payload := make([]byte, 4) + binary.BigEndian.PutUint32(payload, uint32(int32(1972))) + v, err := decodeAt(payload, 0, "int32be") + require.NoError(t, err) + assert.InDelta(t, 1972.0, v, 0) +} + +func TestDecodeAt_Int32BE_Negative(t *testing.T) { + payload := make([]byte, 4) + v32 := int32(-2512) + binary.BigEndian.PutUint32(payload, uint32(v32)) + v, err := decodeAt(payload, 0, "int32be") + require.NoError(t, err) + assert.InDelta(t, -2512.0, v, 0) +} + +func TestDecodeAt_Uint32BE(t *testing.T) { + payload := make([]byte, 4) + binary.BigEndian.PutUint32(payload, 43513) + v, err := decodeAt(payload, 0, "uint32be") + require.NoError(t, err) + assert.InDelta(t, 43513.0, v, 0) +} + +func TestDecodeAt_Uint16BE(t *testing.T) { + payload := make([]byte, 2) + binary.BigEndian.PutUint16(payload, 68) + v, err := decodeAt(payload, 0, "uint16be") + require.NoError(t, err) + assert.InDelta(t, 68.0, v, 0) +} + +func TestDecodeAt_Int16BE_Negative(t *testing.T) { + payload := make([]byte, 2) + v16 := int16(-300) + binary.BigEndian.PutUint16(payload, uint16(v16)) + v, err := decodeAt(payload, 0, "int16be") + require.NoError(t, err) + assert.InDelta(t, -300.0, v, 0) +} + +func TestDecodeAt_TooShort(t *testing.T) { + _, err := decodeAt([]byte{0x00}, 0, "int32be") + require.Error(t, err) +} + +func TestDecodeAt_UnknownType(t *testing.T) { + _, err := decodeAt(make([]byte, 4), 0, "float32") + require.Error(t, err) +} + +// --------------------------------------------------------------------------- +// modbusCRC16 tests +// --------------------------------------------------------------------------- + +func TestModbusCRC16_DTPdu(t *testing.T) { + // DT power PDU 7f 03 75 af 00 02 → CRC d1 ba + pdu := buildPDU(0x75AF, 2) + crc := modbusCRC16(pdu) + // Verify round-trip: CRC is 2 bytes and deterministic + assert.Len(t, crc, 2) + assert.Equal(t, crc, modbusCRC16(pdu), "CRC must be deterministic") +} + +func TestModbusCRC16_ETPdu(t *testing.T) { + // ET grid PDU 7f 03 89 43 00 02 → CRC is 2 bytes + pdu := buildPDU(0x8943, 2) + crc := modbusCRC16(pdu) + assert.Len(t, crc, 2) + assert.Equal(t, crc, modbusCRC16(pdu)) +} + +func TestModbusCRC16_KnownValue(t *testing.T) { + // The original block-read DT PDU 7f 03 75 94 00 49 → CRC d5 c2 + // This is a known-good value verified against real hardware. + pdu, err := parsePDUHex("7f0375940049") + require.NoError(t, err) + assert.Equal(t, []byte{0xd5, 0xc2}, modbusCRC16(pdu)) +} + +// --------------------------------------------------------------------------- +// Real-capture register value tests +// +// These verify that extracting bytes at the register's offset within a +// block-read capture gives the same value a per-register read would return +// at offset 0. This is the core correctness guarantee for the register map. +// --------------------------------------------------------------------------- + +// TestDT_Power verifies DT power register (0x75AF = block offset 54). +func TestDT_Power_GW3000DNS30(t *testing.T) { + assertBlockOffset(t, capGW3000DNS30, 54, "int32be", 1.0, 1972.0) +} + +func TestDT_Power_GW17K(t *testing.T) { + assertBlockOffset(t, capGW17kDT, 54, "int32be", 1.0, 12470.0) +} + +func TestDT_Power_GW20KAU(t *testing.T) { + assertBlockOffset(t, capGW20kAUDT, 54, "int32be", 1.0, 4957.0) +} + +// TestDT_Energy verifies DT energy register (0x75C1 = block offset 90). +func TestDT_Energy_GW17K(t *testing.T) { + assertBlockOffset(t, capGW17kDT, 90, "uint32be", 0.1, 29984.4) +} + +func TestDT_Energy_GW6000(t *testing.T) { + assertBlockOffset(t, capGW6000DT, 90, "uint32be", 0.1, 13350.2) +} + +// TestDT_Energy_GW20KAU verifies energy for GW20KAU-DT. +func TestDT_Energy_GW20KAU(t *testing.T) { + assertBlockOffset(t, capGW20kAUDT, 90, "uint32be", 0.1, 4304.8) +} + +// TestET_PV verifies ET pv register (0x8941 = block offset 74). +func TestET_PV_GW10K(t *testing.T) { + assertBlockOffset(t, capGW10kET, 74, "int32be", 1.0, 831.0) +} + +// TestET_Grid verifies ET grid register (0x8943 = block offset 78). +func TestET_Grid_GW10K_TinyExport(t *testing.T) { + assertBlockOffset(t, capGW10kET, 78, "int32be", 1.0, -3.0) +} + +func TestET_Grid_GW25K_Importing(t *testing.T) { + assertBlockOffset(t, capGW25kET, 78, "int32be", 1.0, 1511.0) +} + +func TestET_Grid_GW29K9_Exporting(t *testing.T) { + assertBlockOffset(t, capGW29k9ET, 78, "int32be", 1.0, -5403.0) +} + +// TestET_Battery verifies ET battery register (0x896E = block offset 164). +// Negative = charging. +func TestET_Battery_GW10K_Charging(t *testing.T) { + assertBlockOffset(t, capGW10kET, 164, "int32be", 1.0, -2512.0) +} + +// TestET_Energy verifies ET energy register (0x8977 = block offset 182). +func TestET_Energy_GW10K(t *testing.T) { + assertBlockOffset(t, capGW10kET, 182, "uint32be", 0.1, 6085.3) +} + +func TestET_Energy_GW25K(t *testing.T) { + assertBlockOffset(t, capGW25kET, 182, "uint32be", 0.1, 160.3) +} + +// TestET_SoC verifies ET SoC register (0x908F = battery info block offset 14). +func TestET_SoC_GW10K(t *testing.T) { + assertBlockOffset(t, capGW10kETBattery, 14, "uint16be", 1.0, 68.0) +} + +func TestET_SoC_GW25K(t *testing.T) { + assertBlockOffset(t, capGW25kETBattery, 14, "uint16be", 1.0, 100.0) +} + +// --------------------------------------------------------------------------- +// Integration tests — end-to-end FloatGetter via mock UDP server +// --------------------------------------------------------------------------- + +// TestFloatGetter_DT_Power verifies the full query/decode pipeline using the +// GW17K-DT real capture sliced to just the power register bytes: 12470 W. +func TestFloatGetter_DT_Power(t *testing.T) { + // Simulate what the inverter returns for READ 2 @ 0x75AF: + // a 4-byte payload containing the value at block offset 54. + response := singleRegResponse(t, capGW17kDT, 54, 4, 0x7f) + p := &AA55UDP{ + log: util.NewLogger("test"), + conn: mockConn(t, response), + pdu: buildPDU(0x75AF, 2), + decode: "int32be", + scale: 1.0, + } + getter, err := p.FloatGetter() + require.NoError(t, err) + val, err := getter() + require.NoError(t, err) + assert.InDelta(t, 12470.0, val, 0.5) +} + +// TestFloatGetter_DT_Energy verifies scale 0.1: 299844 × 0.1 = 29984.4 kWh. +func TestFloatGetter_DT_Energy(t *testing.T) { + response := singleRegResponse(t, capGW17kDT, 90, 4, 0x7f) + p := &AA55UDP{ + log: util.NewLogger("test"), + conn: mockConn(t, response), + pdu: buildPDU(0x75C1, 2), + decode: "uint32be", + scale: 0.1, + } + getter, err := p.FloatGetter() + require.NoError(t, err) + val, err := getter() + require.NoError(t, err) + assert.InDelta(t, 29984.4, val, 0.001) +} + +// TestFloatGetter_ET_PV verifies ET pv power: GW10K-ET = 831 W. +func TestFloatGetter_ET_PV(t *testing.T) { + response := singleRegResponse(t, capGW10kET, 74, 4, 0xf7) + p := &AA55UDP{ + log: util.NewLogger("test"), + conn: mockConn(t, response), + pdu: buildPDU(0x8941, 2), + decode: "int32be", + scale: 1.0, + } + getter, err := p.FloatGetter() + require.NoError(t, err) + val, err := getter() + require.NoError(t, err) + assert.InDelta(t, 831.0, val, 0.5) +} + +// TestFloatGetter_ET_Battery verifies charging battery: GW10K-ET = -2512 W. +func TestFloatGetter_ET_Battery(t *testing.T) { + response := singleRegResponse(t, capGW10kET, 164, 4, 0xf7) + p := &AA55UDP{ + log: util.NewLogger("test"), + conn: mockConn(t, response), + pdu: buildPDU(0x896E, 2), + decode: "int32be", + scale: 1.0, + } + getter, err := p.FloatGetter() + require.NoError(t, err) + val, err := getter() + require.NoError(t, err) + assert.InDelta(t, -2512.0, val, 0.5) +} + +// TestFloatGetter_ET_SoC verifies SoC: GW10K-ET = 68%. +func TestFloatGetter_ET_SoC(t *testing.T) { + response := singleRegResponse(t, capGW10kETBattery, 14, 2, 0xf7) + p := &AA55UDP{ + log: util.NewLogger("test"), + conn: mockConn(t, response), + pdu: buildPDU(0x908F, 1), + decode: "uint16be", + scale: 1.0, + } + getter, err := p.FloatGetter() + require.NoError(t, err) + val, err := getter() + require.NoError(t, err) + assert.InDelta(t, 68.0, val, 0.5) +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +func mustHex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + require.NoError(t, err) + return b +} + +// assertBlockOffset extracts bytes at offset within a block-read capture and +// asserts the decoded value matches expected. This verifies that the register +// address arithmetic is correct: the bytes the inverter would return for a +// single-register read are identical to the bytes at the register's offset +// within the block. +func assertBlockOffset(t *testing.T, capHex string, offset int, decode string, scale, expected float64) { + t.Helper() + payload, err := stripAA55Header(mustHex(t, capHex)) + require.NoError(t, err) + v, err := decodeAt(payload, offset, decode) + require.NoError(t, err) + assert.InDelta(t, expected, v*scale, 0.05) +} + +// singleRegResponse builds the AA55 response frame that an inverter would +// return for a single-register read, by slicing the value bytes out of a +// real block-read capture at the given offset. +// src is the inverter source byte (0x7f for DT, 0xf7 for ET). +func singleRegResponse(t *testing.T, capHex string, offset, valueBytes int, src byte) []byte { + t.Helper() + blockPayload, err := stripAA55Header(mustHex(t, capHex)) + require.NoError(t, err) + require.GreaterOrEqual(t, len(blockPayload), offset+valueBytes) + + value := blockPayload[offset : offset+valueBytes] + frame := []byte{0xAA, 0x55, src, 0x03, byte(valueBytes)} + frame = append(frame, value...) + frame = append(frame, 0x00, 0x00) // CRC not validated by stripAA55Header + return frame +} + +// mockConn starts a UDP server that responds with response to every packet, +// and returns a *net.UDPConn already dialled at that server. +func mockConn(t *testing.T, response []byte) *net.UDPConn { + t.Helper() + srv, err := net.ListenPacket("udp4", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { srv.Close() }) + + go func() { + buf := make([]byte, 512) + for { + _, addr, err := srv.ReadFrom(buf) + if err != nil { + return + } + _, _ = srv.WriteTo(response, addr) + } + }() + + addr, err := net.ResolveUDPAddr("udp4", srv.LocalAddr().String()) + require.NoError(t, err) + conn, err := net.DialUDP("udp4", nil, addr) + require.NoError(t, err) + t.Cleanup(func() { conn.Close() }) + return conn +} diff --git a/templates/definition/meter/goodwe-wifi-dt.yaml b/templates/definition/meter/goodwe-wifi-dt.yaml new file mode 100644 index 000000000..8033ce9c7 --- /dev/null +++ b/templates/definition/meter/goodwe-wifi-dt.yaml @@ -0,0 +1,33 @@ +template: goodwe-wifi-dt +requirements: + evcc: ["skiptest"] +products: + - brand: GoodWe + description: + generic: SDT/DT Inverter (WiFi) + +params: + - name: usage + choice: ["pv"] + + - name: host + help: + en: IP address of the inverter (port 8899) + de: IP-Adresse des Wechselrichters (Port 8899) + example: 192.168.1.26 + +render: | + type: custom + power: + source: aa55udp + host: {{ .host }} + register: 30127 + count: 2 + decode: int32be + energy: + source: aa55udp + host: {{ .host }} + register: 30145 + count: 2 + decode: uint32be + scale: 0.1 diff --git a/templates/definition/meter/goodwe-wifi-es.yaml b/templates/definition/meter/goodwe-wifi-es.yaml new file mode 100644 index 000000000..aef1c4f7f --- /dev/null +++ b/templates/definition/meter/goodwe-wifi-es.yaml @@ -0,0 +1,54 @@ +template: goodwe-wifi-es +covers: ["goodwe-wifi"] +requirements: + evcc: ["skiptest"] +products: + - brand: GoodWe + description: + generic: ES/EM Hybrid Inverter (WiFi) + +params: + - name: usage + choice: ["grid", "pv", "battery"] + + - name: host + help: + en: IP address of the inverter (port 8899) + de: IP-Adresse des Wechselrichters (Port 8899) + example: 192.168.1.26 + + - name: uri + deprecated: true + +render: | + type: custom + {{- if eq .usage "grid" }} + power: + source: aa55udp + host: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }} + register: 29964 + count: 2 + decode: int32be + {{- end }} + {{- if eq .usage "pv" }} + power: + source: aa55udp + host: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }} + register: 29958 + count: 2 + decode: int32be + {{- end }} + {{- if eq .usage "battery" }} + power: + source: aa55udp + host: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }} + register: 29970 + count: 2 + decode: int32be + soc: + source: aa55udp + host: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }} + register: 29966 + count: 1 + decode: uint16be + {{- end }} diff --git a/templates/definition/meter/goodwe-wifi-et.yaml b/templates/definition/meter/goodwe-wifi-et.yaml new file mode 100644 index 000000000..35f96a619 --- /dev/null +++ b/templates/definition/meter/goodwe-wifi-et.yaml @@ -0,0 +1,71 @@ +template: goodwe-wifi-et +requirements: + evcc: ["skiptest"] +products: + - brand: GoodWe + description: + generic: ET/EH/BT/BH Hybrid Inverter (WiFi) + +params: + - name: usage + choice: ["grid", "pv", "battery"] + + - name: host + help: + en: IP address of the inverter (port 8899) + de: IP-Adresse des Wechselrichters (Port 8899) + example: 192.168.1.26 + +render: | + type: custom + {{- if eq .usage "grid" }} + power: + source: aa55udp + host: {{ .host }} + register: 35139 + count: 2 + decode: int32be + energy: + source: aa55udp + host: {{ .host }} + register: 36017 + count: 2 + decode: float32 + scale: 0.001 + {{- end }} + {{- if eq .usage "pv" }} + power: + source: aa55udp + host: {{ .host }} + register: 35137 + count: 2 + decode: int32be + energy: + source: aa55udp + host: {{ .host }} + register: 35191 + count: 2 + decode: uint32be + scale: 0.1 + {{- end }} + {{- if eq .usage "battery" }} + power: + source: aa55udp + host: {{ .host }} + register: 35182 + count: 2 + decode: int32be + energy: + source: aa55udp + host: {{ .host }} + register: 35209 + count: 2 + decode: uint32be + scale: 0.1 + soc: + source: aa55udp + host: {{ .host }} + register: 37007 + count: 1 + decode: uint16be + {{- end }} diff --git a/templates/definition/meter/goodwe-wifi.yaml b/templates/definition/meter/goodwe-wifi.yaml deleted file mode 100644 index 21a063d2d..000000000 --- a/templates/definition/meter/goodwe-wifi.yaml +++ /dev/null @@ -1,20 +0,0 @@ -template: goodwe-wifi -# UDP implementation is broken -# deprecated: true -products: - - brand: GoodWe - description: - generic: GoodWe over Wifi -requirements: - evcc: ["skiptest"] -params: - - name: usage - choice: ["grid", "pv", "battery"] - - name: uri - description: - en: IP address or hostname - de: IP-Adresse des Hostname -render: | - type: goodwe-wifi - usage: {{ .usage }} - uri: {{ .uri }} From cb19de4e89def215c9b7a4fe87f1059551fddddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20L=C3=B6b?= <39953358+loebse@users.noreply.github.com> Date: Wed, 25 Mar 2026 08:56:27 +0100 Subject: [PATCH 0017/1413] Polestar: fix resume path and adjust regex (#28466) --- vehicle/polestar/identity.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/vehicle/polestar/identity.go b/vehicle/polestar/identity.go index 00a0f2579..a524b9c11 100644 --- a/vehicle/polestar/identity.go +++ b/vehicle/polestar/identity.go @@ -69,8 +69,8 @@ func (v *Identity) login() (*oauth2.Token, error) { "client_id": {ClientID}, "redirect_uri": {RedirectURI}, "response_type": {"code"}, + "scope": {"openid profile email"}, "state": {lo.RandomString(16, lo.AlphanumericCharset)}, - "scope": {"openid", "profile", "email"}, "code_challenge": {oauth2.S256ChallengeFromVerifier(cv)}, "code_challenge_method": {"S256"}, } @@ -93,7 +93,7 @@ func (v *Identity) login() (*oauth2.Token, error) { return nil, err } - matches := regexp.MustCompile(`(?:url|action):\s*"/as/(.+?)/resume/as/authorization\.ping"`).FindStringSubmatch(string(body)) + matches := regexp.MustCompile(`(?:url|action):\s*"(.+?)"`).FindStringSubmatch(string(body)) if len(matches) < 2 { return nil, errors.New("could not find resume path") @@ -106,7 +106,8 @@ func (v *Identity) login() (*oauth2.Token, error) { "client_id": {ClientID}, } - uri = fmt.Sprintf("%s/as/%s/resume/as/authorization.ping", OAuthURI, matches[1]) + uri = OAuthURI + "/" + strings.TrimLeft(matches[1], "/") // Remove leading / + req, _ = request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), map[string]string{ "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", @@ -118,7 +119,6 @@ func (v *Identity) login() (*oauth2.Token, error) { } defer resp.Body.Close() - // Extract authorization code from response code := resp.Request.URL.Query().Get("code") if code == "" { return nil, errors.New("missing authorization code") From a687f30a2b54d0974c524423da7ce7d0fb72f2cb Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:27:08 +0000 Subject: [PATCH 0018/1413] Add Everhome Ecotracker (#28547) --- .../definition/meter/everhome-ecotracker.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 templates/definition/meter/everhome-ecotracker.yaml diff --git a/templates/definition/meter/everhome-ecotracker.yaml b/templates/definition/meter/everhome-ecotracker.yaml new file mode 100644 index 000000000..8a4b460c3 --- /dev/null +++ b/templates/definition/meter/everhome-ecotracker.yaml @@ -0,0 +1,15 @@ +template: everhome-ecotracker +products: + - brand: Everhome + description: + generic: Ecotracker +params: + - name: usage + choice: ["grid"] + - name: host +render: | + type: custom + power: + source: http + uri: http://{{ .host }}/v1/json + jq: .power From ad03bcb17e8c78a386c54674ff36760625711403 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 25 Mar 2026 15:03:13 +0100 Subject: [PATCH 0019/1413] Forecast: only adjust y-scale if experimental (#28548) --- assets/js/components/Forecast/ForecastModal.vue | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/assets/js/components/Forecast/ForecastModal.vue b/assets/js/components/Forecast/ForecastModal.vue index e5752a247..ab5df04fc 100644 --- a/assets/js/components/Forecast/ForecastModal.vue +++ b/assets/js/components/Forecast/ForecastModal.vue @@ -98,9 +98,10 @@ export default defineComponent({ return !!this.forecast.solar && this.experimental; }, solar() { - return this.showSolarAdjust && this.solarAdjusted - ? adjustedSolar(this.forecast.solar) - : this.forecast.solar; + if (this.showSolarAdjust && this.solarAdjusted) { + return adjustedSolar(this.forecast.solar); + } + return this.forecast.solar ? { ...this.forecast.solar, scale: undefined } : undefined; }, solarAdjustText() { let percent = ""; From ef365f7907586a8fbddebaa2c1b5f7f391074bdf Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 25 Mar 2026 17:09:29 +0100 Subject: [PATCH 0020/1413] Config UI: ensure cleanup when template switch (#28552) --- .../js/components/Config/DeviceModal/DeviceModalBase.vue | 4 +++- tests/config-ext-meter.spec.ts | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue index 40bdd13b7..5bd10b632 100644 --- a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue +++ b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue @@ -483,7 +483,9 @@ export default defineComponent({ } const isYamlInput = this.isYamlInputTypeByValue(newValue as ConfigType); - if (!isYamlInput) { + if (isYamlInput) { + this.template = null; + } else { this.loadTemplate(); } diff --git a/tests/config-ext-meter.spec.ts b/tests/config-ext-meter.spec.ts index 39b42473b..a80e25aa9 100644 --- a/tests/config-ext-meter.spec.ts +++ b/tests/config-ext-meter.spec.ts @@ -104,9 +104,11 @@ test.describe("ext meter", async () => { await meterModal.getByRole("button", { name: "Add regular consumer" }).click(); await meterModal.getByLabel("Title").fill("Custom ext meter"); - await meterModal.getByLabel("Usage").selectOption("battery"); - await meterModal.getByLabel("Manufacturer").selectOption("Demo battery"); - await meterModal.getByLabel("Charge").fill("50"); + await meterModal.getByLabel("Usage").selectOption("pv"); + + // switch to an in-beteen template to ensure we dont leak values + await meterModal.getByLabel("Manufacturer").selectOption("SunSpec Hybrid Inverter"); + await expect(meterModal.getByLabel("IP address or hostname")).toBeVisible(); await meterModal.getByLabel("Manufacturer").selectOption("User-defined device"); await page.waitForLoadState("networkidle"); From 7b5103df44deda0e215c9805d7d706aa293a05d8 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 25 Mar 2026 20:22:31 +0100 Subject: [PATCH 0021/1413] UI: New Navigation, Battery & Forecast Page (#27753) --- assets/css/app.css | 13 +- assets/css/breakpoints.css | 7 + assets/js/components/AboutModal.stories.ts | 106 ++++ assets/js/components/AboutModal.vue | 249 +++++++++ .../Battery/BatterySettingsModal.vue | 500 +---------------- .../Battery/BatteryUsageSettings.vue | 511 ++++++++++++++++++ assets/js/components/BottomTabs/Bar.vue | 116 ++++ assets/js/components/BottomTabs/Item.vue | 130 +++++ assets/js/components/BottomTabs/MoreItem.vue | 86 +++ assets/js/components/BottomTabs/MoreMenu.vue | 226 ++++++++ assets/js/components/Footer/Footer.vue | 2 +- .../js/components/Footer/OfflineIndicator.vue | 35 +- .../js/components/Footer/Version.stories.ts | 30 +- assets/js/components/Footer/Version.vue | 115 +--- assets/js/components/Forecast/Co2Chart.vue | 128 +++++ assets/js/components/Forecast/PriceChart.vue | 174 ++++++ assets/js/components/Forecast/SolarChart.vue | 139 +++++ assets/js/components/Forecast/chartMixin.ts | 67 +++ assets/js/components/Forecast/echarts.ts | 202 +++++++ assets/js/components/Helper/GenericModal.vue | 4 +- .../js/components/Loadpoints/Loadpoints.vue | 6 +- .../components/MaterialIcon/ForecastGraph.vue | 18 + assets/js/components/MaterialIcon/More.vue | 18 + .../js/components/MaterialIcon/Sessions.vue | 18 + assets/js/components/Savings/Savings.vue | 309 +++++++---- assets/js/components/Savings/types.d.ts | 1 - assets/js/components/Site/Site.vue | 24 +- .../js/components/Tariff/SmartTariffBase.vue | 2 +- assets/js/components/Tariff/TariffChart.vue | 6 +- assets/js/components/Top/Header.vue | 34 +- .../js/components/Top/TopNavigationArea.vue | 27 +- assets/js/router.ts | 29 +- assets/js/settings.ts | 8 + assets/js/types/evcc.ts | 20 + assets/js/utils/haptic.ts | 3 + assets/js/utils/version.ts | 23 + assets/js/views/App.vue | 26 +- assets/js/views/Battery.vue | 80 +++ assets/js/views/Config.vue | 2 +- assets/js/views/Energy.vue | 106 ---- assets/js/views/Forecast.vue | 323 +++++++++++ i18n/de.json | 42 +- i18n/en.json | 51 +- package-lock.json | 32 ++ package.json | 3 +- server/updater/github.go | 2 +- tests/bottom-nav.spec.ts | 104 ++++ tests/statistics.spec.ts | 52 +- tests/utils.ts | 7 +- 49 files changed, 3327 insertions(+), 889 deletions(-) create mode 100644 assets/js/components/AboutModal.stories.ts create mode 100644 assets/js/components/AboutModal.vue create mode 100644 assets/js/components/Battery/BatteryUsageSettings.vue create mode 100644 assets/js/components/BottomTabs/Bar.vue create mode 100644 assets/js/components/BottomTabs/Item.vue create mode 100644 assets/js/components/BottomTabs/MoreItem.vue create mode 100644 assets/js/components/BottomTabs/MoreMenu.vue create mode 100644 assets/js/components/Forecast/Co2Chart.vue create mode 100644 assets/js/components/Forecast/PriceChart.vue create mode 100644 assets/js/components/Forecast/SolarChart.vue create mode 100644 assets/js/components/Forecast/chartMixin.ts create mode 100644 assets/js/components/Forecast/echarts.ts create mode 100644 assets/js/components/MaterialIcon/ForecastGraph.vue create mode 100644 assets/js/components/MaterialIcon/More.vue create mode 100644 assets/js/components/MaterialIcon/Sessions.vue create mode 100644 assets/js/utils/haptic.ts create mode 100644 assets/js/utils/version.ts create mode 100644 assets/js/views/Battery.vue delete mode 100644 assets/js/views/Energy.vue create mode 100644 assets/js/views/Forecast.vue create mode 100644 tests/bottom-nav.spec.ts diff --git a/assets/css/app.css b/assets/css/app.css index 1507ccb13..142a5ab4a 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -91,6 +91,9 @@ --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); --safe-area-inset-left: env(safe-area-inset-left, 0px); --safe-area-inset-right: env(safe-area-inset-right, 0px); + + --tab-bar-height: 3rem; + --tab-bar-background: var(--evcc-background); } :root.dark { @@ -108,6 +111,7 @@ --bs-border-color-translucent: rgba(255, 255, 255, 0.175); --bs-code-color: var(--evcc-dark-green); --evcc-backdrop: #000000aa; /* black + transparent */ + --tab-bar-background: var(--evcc-box); color-scheme: dark; } @@ -362,7 +366,7 @@ a:hover { background-color: var(--bs-gray-dark) !important; } -@media screen and (min-width: 400px) and (max-width: 574px) { +@media screen and (--xs-to-sm) { .d-xs-none { display: none !important; } @@ -478,6 +482,13 @@ small { filter: invert(1); } +.circle-badge { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; +} + .dropdown-menu { border: none; border-radius: 10px; diff --git a/assets/css/breakpoints.css b/assets/css/breakpoints.css index 5081a9385..80ba53869 100644 --- a/assets/css/breakpoints.css +++ b/assets/css/breakpoints.css @@ -1,9 +1,16 @@ /* Bootstrap Breakpoints as Custom Media Queries */ +@custom-media --xs-and-up (min-width: 400px); @custom-media --sm-and-up (min-width: 576px); @custom-media --md-and-up (min-width: 768px); @custom-media --lg-and-up (min-width: 992px); @custom-media --xl-and-up (min-width: 1200px); @custom-media --xxl-and-up (min-width: 1400px); +/* Range Breakpoints */ +@custom-media --xs-to-sm (min-width: 400px) and (max-width: 575.98px); +@custom-media --sm-to-md (min-width: 576px) and (max-width: 767.98px); +@custom-media --sm-to-lg (min-width: 576px) and (max-width: 991.98px); +@custom-media --md-to-lg (min-width: 768px) and (max-width: 991.98px); + @custom-media --light-mode (prefers-color-scheme: light); @custom-media --dark-mode (prefers-color-scheme: dark); diff --git a/assets/js/components/AboutModal.stories.ts b/assets/js/components/AboutModal.stories.ts new file mode 100644 index 000000000..3a9eac6ee --- /dev/null +++ b/assets/js/components/AboutModal.stories.ts @@ -0,0 +1,106 @@ +import AboutModal from "./AboutModal.vue"; +import type { Meta, StoryFn } from "@storybook/vue3"; + +const releaseNotes = ` +

v0.303.1

+

Changelog

+

Other Changes ☀️

+
    +
  • Home Assistant: allow switch for enable/disable
  • +
  • Nexblue: remove broken 1p3p
  • +
  • Optimizer: return infeasable error
  • +
  • Revert: Safari: web socket bug redirect workaround
  • +
+

Bug Fixes 🐞

+
    +
  • HomeAssistant: fix changelog
  • +
  • Optimizer: fix invalid battery capacity
  • +
  • SGready: fix panic
  • +
+

v0.303.0

+

Changelog

+

Breaking Changes 🚨

+
    +
  • HEMS: refactor handling of production/feedin limits (BC)
  • +
  • Migrate optimizer (BC)
  • +
+

New Features 💫

+
    +
  • Add RAEDIAN NEO and NEX AC charger
  • +
  • Audi: add vehicle features
  • +
  • ChargeX: add heartbeat to prevent PAC_Target_Timeout fallback
  • +
  • Heating: add continuous feature to improve heatpump experience
  • +
  • Sigenergy: add maxacpower
  • +
  • Tariff UI: add multiline formula support
  • +
+

Bug Fixes 🐞

+
    +
  • Mercedes: fix auth
  • +
  • Nexblue: fix phase switching API endpoint
  • +
  • Planner: fix this.updatePlanPreviewDebounced is null
  • +
  • Websocket: fix logging breaking digest auth
  • +
+`; + +export default { + title: "AboutModal", + component: AboutModal, + argTypes: { + installed: { control: "text" }, + commit: { control: "text" }, + availableVersion: { control: "text" }, + releaseNotes: { control: "text" }, + hasUpdater: { control: "boolean" }, + uploadMessage: { control: "text" }, + uploadProgress: { control: "number" }, + }, +} as Meta; + +const Template: StoryFn = (args) => ({ + components: { AboutModal }, + setup() { + return { args }; + }, + template: '', + mounted() { + const el = document.getElementById("aboutModal"); + if (el) { + import("bootstrap/js/dist/modal").then(({ default: Modal }) => { + Modal.getOrCreateInstance(el).show(); + }); + } + }, +}); + +export const Stable = Template.bind({}); +Stable.args = { + installed: "0.303.1", + availableVersion: "0.303.1", +}; + +export const StableUpdateAvailable = Template.bind({}); +StableUpdateAvailable.args = { + installed: "0.303.0", + availableVersion: "0.303.1", + releaseNotes, +}; + +export const StableUpdateWithUpdater = Template.bind({}); +StableUpdateWithUpdater.args = { + installed: "0.303.0", + availableVersion: "0.303.1", + releaseNotes, + hasUpdater: true, +}; + +export const Nightly = Template.bind({}); +Nightly.args = { + installed: "0.303.1", + commit: "5ce7be4a9f3b2c1d", + availableVersion: "0.303.1", +}; + +export const DevBuild = Template.bind({}); +DevBuild.args = { + installed: "0.0.0", +}; diff --git a/assets/js/components/AboutModal.vue b/assets/js/components/AboutModal.vue new file mode 100644 index 000000000..f981aab49 --- /dev/null +++ b/assets/js/components/AboutModal.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/assets/js/components/Battery/BatterySettingsModal.vue b/assets/js/components/Battery/BatterySettingsModal.vue index 181110c52..e62206fa5 100644 --- a/assets/js/components/Battery/BatterySettingsModal.vue +++ b/assets/js/components/Battery/BatterySettingsModal.vue @@ -30,217 +30,14 @@ -
-

- {{ $t("batterySettings.batteryLevel") }}: - {{ fmtSoc(batterySoc) }} - - {{ line }} - -

-
-
- - - {{ fmtSoc(selectedBufferSoc) }} - - - - - - {{ fmtSoc(selectedPrioritySoc) }} - - -
-
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
-
-

- - - {{ $t("batterySettings.legendTopName") }} - - - - - - {{ $t("batterySettings.legendTopAutostart") }} - - - {{ selectedBufferStartName }} - - - - -

-

- - - {{ $t("batterySettings.legendMiddleName") }} - - - - -

-

- - - {{ $t("batterySettings.legendBottomName") }} - - - - -

-
- -
- -
-
-
-
+ - - diff --git a/assets/js/components/Battery/BatteryUsageSettings.vue b/assets/js/components/Battery/BatteryUsageSettings.vue new file mode 100644 index 000000000..234aed40e --- /dev/null +++ b/assets/js/components/Battery/BatteryUsageSettings.vue @@ -0,0 +1,511 @@ + + + + + diff --git a/assets/js/components/BottomTabs/Bar.vue b/assets/js/components/BottomTabs/Bar.vue new file mode 100644 index 000000000..b7aba7cb8 --- /dev/null +++ b/assets/js/components/BottomTabs/Bar.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/assets/js/components/BottomTabs/Item.vue b/assets/js/components/BottomTabs/Item.vue new file mode 100644 index 000000000..71053ab1c --- /dev/null +++ b/assets/js/components/BottomTabs/Item.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/assets/js/components/BottomTabs/MoreItem.vue b/assets/js/components/BottomTabs/MoreItem.vue new file mode 100644 index 000000000..2f93dac30 --- /dev/null +++ b/assets/js/components/BottomTabs/MoreItem.vue @@ -0,0 +1,86 @@ + + + diff --git a/assets/js/components/BottomTabs/MoreMenu.vue b/assets/js/components/BottomTabs/MoreMenu.vue new file mode 100644 index 000000000..ecb26c80d --- /dev/null +++ b/assets/js/components/BottomTabs/MoreMenu.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/assets/js/components/Footer/Footer.vue b/assets/js/components/Footer/Footer.vue index 4011e49c5..aecfd2f56 100644 --- a/assets/js/components/Footer/Footer.vue +++ b/assets/js/components/Footer/Footer.vue @@ -1,7 +1,7 @@ + + diff --git a/assets/js/components/Forecast/PriceChart.vue b/assets/js/components/Forecast/PriceChart.vue new file mode 100644 index 000000000..71e54fb0d --- /dev/null +++ b/assets/js/components/Forecast/PriceChart.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/assets/js/components/Forecast/SolarChart.vue b/assets/js/components/Forecast/SolarChart.vue new file mode 100644 index 000000000..0248fe551 --- /dev/null +++ b/assets/js/components/Forecast/SolarChart.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/assets/js/components/Forecast/chartMixin.ts b/assets/js/components/Forecast/chartMixin.ts new file mode 100644 index 000000000..4df452935 --- /dev/null +++ b/assets/js/components/Forecast/chartMixin.ts @@ -0,0 +1,67 @@ +import { defineComponent, markRaw } from "vue"; +import { echarts } from "./echarts"; + +// chartOption is provided by each consuming component's computed +type WithChartOption = { chartOption: Record }; + +export default defineComponent({ + props: { + chartWidth: { type: Number, required: true }, + endDate: { type: Date, required: true }, + scrollLeft: { type: Number, default: 0 }, + }, + emits: ["scroll"], + data(): { + chart: echarts.ECharts | null; + startDate: Date; + tooltipVisible: boolean; + } { + return { + chart: null, + tooltipVisible: false, + startDate: new Date(), + }; + }, + watch: { + chartOption: { + handler() { + this.chart?.setOption((this as unknown as WithChartOption).chartOption); + }, + deep: true, + }, + scrollLeft(val: number) { + const el = this.$refs["scrollEl"] as HTMLElement; + if (el && Math.abs(el.scrollLeft - val) > 1) { + el.scrollLeft = val; + } + }, + }, + mounted() { + this.updateStartDate(); + this.initChart(); + }, + beforeUnmount() { + this.chart?.dispose(); + }, + methods: { + updateStartDate() { + const now = new Date(); + now.setMinutes(0, 0, 0); + this.startDate = now; + }, + onScroll(e: Event) { + this.$emit("scroll", (e.target as HTMLElement).scrollLeft); + }, + initChart() { + const el = this.$refs["chartEl"] as HTMLElement; + this.chart = markRaw(echarts.init(el)); + this.chart.setOption((this as unknown as WithChartOption).chartOption); + this.chart.on("showTip", () => { + this.tooltipVisible = true; + }); + this.chart.on("hideTip", () => { + this.tooltipVisible = false; + }); + }, + }, +}); diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts new file mode 100644 index 000000000..d8f61a122 --- /dev/null +++ b/assets/js/components/Forecast/echarts.ts @@ -0,0 +1,202 @@ +import * as echarts from "echarts/core"; +import colors from "@/colors"; +import type { ForecastSlot } from "./types"; +import { BarChart, LineChart } from "echarts/charts"; +import { + GridComponent, + TooltipComponent, + MarkPointComponent, + AxisPointerComponent, +} from "echarts/components"; +import { SVGRenderer } from "echarts/renderers"; + +echarts.use([ + BarChart, + LineChart, + GridComponent, + TooltipComponent, + AxisPointerComponent, + MarkPointComponent, + SVGRenderer, +]); + +export const FONT_FAMILY = "Montserrat, sans-serif"; + +export function markPointLabel( + color: string, + data: { coord: [string, number]; value: string; label?: { offset?: [number, number] } }[], + startDate?: Date, + endDate?: Date +) { + // threshold: points within the first 5% of the time range get shifted right + const threshold = + startDate && endDate + ? startDate.getTime() + (endDate.getTime() - startDate.getTime()) * 0.05 + : 0; + + const adjustedData = data.map((d) => { + const ts = new Date(d.coord[0]).getTime(); + if (threshold && ts < threshold) { + const baseOffset = d.label?.offset ?? [0, -2]; + return { + ...d, + label: { + ...d.label, + offset: [baseOffset[0] + 30, baseOffset[1]] as [number, number], + }, + }; + } + return d; + }); + + return { + animation: true, + clip: false, + symbol: "rect", + symbolSize: 1, + label: { + show: true, + position: "top", + fontFamily: FONT_FAMILY, + fontSize: 14, + fontWeight: "bold" as const, + color: colors.background, + backgroundColor: color, + borderRadius: 4, + padding: [5, 10], + offset: [0, -2], + formatter: (p: { data: { value: string } }) => p.data.value, + }, + data: adjustedData, + }; +} + +export function tooltipStyle( + color: string, + getChart?: () => { convertToPixel: echarts.ECharts["convertToPixel"] } | null +) { + return { + backgroundColor: color, + borderColor: color, + borderWidth: 0, + padding: [5, 10], + extraCssText: "box-shadow: none; border-radius: 4px; text-align: center;", + position( + point: [number, number], + params: { value: [string, number] }[] | { value: [string, number] }, + el: HTMLElement + ): [number, number] { + const w = el?.offsetWidth || 0; + const h = el?.offsetHeight || 0; + const margin = 8; + const arr = Array.isArray(params) ? params : [params]; + const p = arr[0]; + const chart = getChart?.(); + if (chart && p?.value) { + const pixelY = chart.convertToPixel({ seriesIndex: 0 }, p.value)?.[1]; + if (pixelY != null) { + return [point[0] - w / 2, pixelY - h - margin]; + } + } + return [point[0] - w / 2, point[1] - h - margin]; + }, + textStyle: { + fontFamily: FONT_FAMILY, + fontSize: 14, + fontWeight: "bold" as const, + color: colors.background, + }, + }; +} + +export function forecastGrid() { + return { top: 36, right: 16, bottom: 4, left: 48, borderWidth: 0 }; +} + +export function forecastXAxes(startDate: Date, endDate: Date, weekdayShort: (d: Date) => string) { + return [ + { + type: "time", + min: startDate, + max: endDate, + minInterval: 3600 * 1000, + maxInterval: 3600 * 1000, + axisLabel: { + color: colors.muted, + fontSize: 14, + lineHeight: Math.round(14 * 1.1), + margin: 4, + formatter: (value: number) => { + const date = new Date(value); + const h = date.getHours(); + if (h % 2 !== 0) return ""; + if (h === 0) return `${h}\n${weekdayShort(date)}`; + return `${h}`; + }, + }, + splitLine: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + }, + { + type: "time", + position: "bottom", + min: startDate, + max: endDate, + minInterval: 24 * 3600 * 1000, + maxInterval: 24 * 3600 * 1000, + axisLabel: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { + show: true, + showMinLine: false, + showMaxLine: false, + lineStyle: { color: colors.border || "", type: "dashed" }, + }, + }, + ]; +} + +export function forecastYAxis(overrides: Record = {}) { + const { axisLabel, ...rest } = overrides; + return { + type: "value", + min: 0, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { + showMinLine: false, + showMaxLine: false, + lineStyle: { color: colors.border || "" }, + }, + axisLabel: { + fontSize: 14, + ...(axisLabel as Record), + }, + ...rest, + }; +} + +export function clampStart(ts: string, startDate: Date): string { + return new Date(ts) < startDate ? startDate.toISOString() : ts; +} + +export function filterForecastSlots( + slots: ForecastSlot[], + startDate: Date, + endDate: Date +): ForecastSlot[] { + if (!Array.isArray(slots)) return []; + return slots.filter((s) => new Date(s.end) >= startDate && new Date(s.start) <= endDate); +} + +export function minSlotIndex(slots: ForecastSlot[]): number { + return slots.reduce((min, s, i) => (s.value < (slots[min]?.value || Infinity) ? i : min), 0); +} + +export function maxSlotIndex(slots: ForecastSlot[]): number { + return slots.reduce((max, s, i) => (s.value > (slots[max]?.value || 0) ? i : max), 0); +} + +export { echarts }; diff --git a/assets/js/components/Helper/GenericModal.vue b/assets/js/components/Helper/GenericModal.vue index 2f55ecb32..381fc5319 100644 --- a/assets/js/components/Helper/GenericModal.vue +++ b/assets/js/components/Helper/GenericModal.vue @@ -13,9 +13,9 @@ > @@ -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 0109/1413] 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 0110/1413] 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 0111/1413] 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 0112/1413] 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 0113/1413] 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 0114/1413] 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 0115/1413] 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 0116/1413] 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 0117/1413] 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 0118/1413] 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 0158/1413] 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 0159/1413] 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 0160/1413] 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 0161/1413] 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 0162/1413] 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 0163/1413] 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 0164/1413] 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 0165/1413] 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 0166/1413] 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 0167/1413] 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 0179/1413] 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 0180/1413] 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 0181/1413] 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 0182/1413] 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 0183/1413] 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 0184/1413] 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 0185/1413] 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 0186/1413] 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 0187/1413] 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 0188/1413] 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 0189/1413] 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 0190/1413] 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 0191/1413] 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 0192/1413] 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 0193/1413] 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 0194/1413] 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 0195/1413] 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 0196/1413] 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 @@