@@ -93,6 +115,7 @@
diff --git a/charger/easee.go b/charger/easee.go
index 438ecaadfa..7af54173c6 100644
--- a/charger/easee.go
+++ b/charger/easee.go
@@ -764,11 +764,7 @@ func (c *Easee) Phases1p3p(phases int) error {
c.dispatcher.CancelOrphan(easee.CIRCUIT_MAX_CURRENT_P1)
}
} else {
- // charger level
- if phases == 3 {
- phases = 2 // mode 2 means auto
- }
-
+ // charger level: phaseMode 1 and 3 lock the phases, 2 would leave them to the charger
// change phaseMode only if necessary
if phases != c.phaseMode {
data := easee.ChargerSettings{
@@ -803,10 +799,9 @@ func (c *Easee) GetPhases() (int, error) {
}
}
} else {
- // charger level
- phases = c.phaseMode
- if phases == 2 { // map automatic to 3p
- phases = 3
+ // charger level: automatic (2, left by earlier versions) is unknown so the loadpoint locks the phases
+ if c.phaseMode != 2 {
+ phases = c.phaseMode
}
}
return phases, nil
diff --git a/charger/easee_test.go b/charger/easee_test.go
index 951ce8d48d..62b606e138 100644
--- a/charger/easee_test.go
+++ b/charger/easee_test.go
@@ -471,6 +471,54 @@ func TestEasee_Phases1p3p_registersExpectedOrphan(t *testing.T) {
"expected orphan should be registered before the POST")
}
+// TestEasee_Phases1p3p_chargerLevel verifies that charger level phase switching locks
+// the requested phases instead of handing 3p to the charger's automatic (#34620)
+func TestEasee_Phases1p3p_chargerLevel(t *testing.T) {
+ const chargerID = "TESTTEST"
+
+ e := newEasee()
+ e.charger = chargerID
+ e.opMode = easee.ModeDisconnected // no pause/resume
+
+ httpmock.ActivateNonDefault(e.Client)
+ defer httpmock.DeactivateAndReset()
+
+ var sent []int
+ uri := fmt.Sprintf("%s/chargers/%s/settings", easee.API, chargerID)
+ httpmock.RegisterResponder(http.MethodPost, uri, func(req *http.Request) (*http.Response, error) {
+ var data easee.ChargerSettings
+ if err := json.NewDecoder(req.Body).Decode(&data); err != nil {
+ return nil, err
+ }
+ sent = append(sent, *data.PhaseMode)
+ return httpmock.NewStringResponse(200, ""), nil
+ })
+
+ for _, tc := range []struct {
+ phaseMode, phases int
+ sent []int
+ }{
+ {1, 3, []int{3}},
+ {2, 3, []int{3}}, // automatic from earlier versions
+ {3, 1, []int{1}},
+ {3, 3, nil}, // already locked
+ } {
+ sent = nil
+ e.phaseMode = tc.phaseMode
+
+ require.NoError(t, e.Phases1p3p(tc.phases))
+ assert.Equal(t, tc.sent, sent, "phaseMode %d to %dp", tc.phaseMode, tc.phases)
+ }
+
+ // automatic is not reported as 3p, so the loadpoint switches to lock it
+ for phaseMode, phases := range map[int]int{1: 1, 2: 0, 3: 3} {
+ e.phaseMode = phaseMode
+ res, err := e.GetPhases()
+ require.NoError(t, err)
+ assert.Equal(t, phases, res, "phaseMode %d", phaseMode)
+ }
+}
+
func TestLivenessCheck_staleObservations(t *testing.T) {
e := newEasee()
e.opMode = easee.ModeCharging
diff --git a/charger/homeassistant.go b/charger/homeassistant.go
index 714e744dba..5cfe0f563a 100644
--- a/charger/homeassistant.go
+++ b/charger/homeassistant.go
@@ -45,6 +45,7 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Charger, error) {
Currents []string // optional - current sensors for L1, L2, L3
Voltages []string // optional - voltage sensors for L1, L2, L3
Phases string // optional - select entity for 1p/3p phase switching
+ Temp string // optional - temperature sensor (°C), for heating devices
}
if err := util.DecodeOther(other, &cc); err != nil {
@@ -101,6 +102,11 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Charger, error) {
implement.Has(c, implement.MeterEnergy(func() (float64, error) { return conn.GetFloatState(cc.Energy) }))
}
+ // temperature (optional), reported as soc for heating devices
+ if cc.Temp != "" {
+ implement.Has(c, implement.Battery(func() (float64, error) { return conn.GetTemperatureState(cc.Temp) }))
+ }
+
// phase currents (optional)
if phases, err := homeassistant.ValidatePhaseEntities(cc.Currents); len(phases) > 0 {
implement.Has(c, implement.PhaseCurrents(func() (float64, float64, float64, error) { return conn.GetPhaseFloatStates(phases) }))
diff --git a/charger/viessmann/api.go b/charger/viessmann/api.go
index 6255504d22..7330e313cf 100644
--- a/charger/viessmann/api.go
+++ b/charger/viessmann/api.go
@@ -12,12 +12,23 @@ import (
// Gateway is a Viessmann communication module.
type Gateway struct {
Serial string `json:"serial"`
+ // GatewayType is the module model, e.g. VitoconnectOPTO2 or TCU301 (IoT API v2 Gateway schema)
+ GatewayType string `json:"gatewayType"`
+}
+
+// Address is the location of an installation (IoT API v2 Installation schema).
+type Address struct {
+ Street string `json:"street"`
+ HouseNumber string `json:"houseNumber"`
+ City string `json:"city"`
}
// Installation is a Viessmann installation including its gateways.
type Installation struct {
- ID int `json:"id"`
- Gateways []Gateway `json:"gateways"`
+ ID int `json:"id"`
+ Description string `json:"description"`
+ Address Address `json:"address"`
+ Gateways []Gateway `json:"gateways"`
}
// API is the Viessmann IoT API client.
diff --git a/charger/viessmann/api_test.go b/charger/viessmann/api_test.go
index 9e05a5f587..905cbb28e8 100644
--- a/charger/viessmann/api_test.go
+++ b/charger/viessmann/api_test.go
@@ -28,8 +28,8 @@ func TestInstallations(t *testing.T) {
require.Equal(t, "true", r.URL.Query().Get("includeGateways"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[
- {"id":3242119,"gateways":[{"serial":"7472258009383262"},{"serial":"7472258009383263"}]},
- {"id":1000001,"gateways":[{"serial":"9999999999999999"}]}
+ {"id":3242119,"description":"Home","address":{"street":"Main St","houseNumber":"1","zip":"10115","city":"Berlin","country":"de"},"gateways":[{"serial":"7472258009383262","version":"1.4.0.0","gatewayType":"VitoconnectOPTO2"},{"serial":"7472258009383263","gatewayType":"TCU301"}]},
+ {"id":1000001,"description":null,"address":{"street":null,"city":null},"gateways":[{"serial":"9999999999999999"}]}
]}`))
})
@@ -37,8 +37,11 @@ func TestInstallations(t *testing.T) {
require.NoError(t, err)
require.Len(t, res, 2)
assert.Equal(t, 3242119, res[0].ID)
+ assert.Equal(t, "Home", res[0].Description)
+ assert.Equal(t, Address{Street: "Main St", HouseNumber: "1", City: "Berlin"}, res[0].Address)
assert.Len(t, res[0].Gateways, 2)
- assert.Equal(t, "7472258009383262", res[0].Gateways[0].Serial)
+ assert.Equal(t, Gateway{Serial: "7472258009383262", GatewayType: "VitoconnectOPTO2"}, res[0].Gateways[0])
+ assert.Equal(t, Installation{ID: 1000001, Gateways: []Gateway{{Serial: "9999999999999999"}}}, res[1])
}
func TestInstallationsError(t *testing.T) {
diff --git a/charger/viessmann/service.go b/charger/viessmann/service.go
index f3318a6841..f05642c888 100644
--- a/charger/viessmann/service.go
+++ b/charger/viessmann/service.go
@@ -1,12 +1,14 @@
package viessmann
import (
+ "cmp"
"context"
"encoding/json"
"errors"
"net/http"
"slices"
"strconv"
+ "strings"
"github.com/evcc-io/evcc/server/service"
"github.com/evcc-io/evcc/util"
@@ -61,35 +63,52 @@ func equipment(req *http.Request) ([]Installation, error) {
return api.Installations()
}
-// values extracts the installation ids or, with gateways set, the deduplicated
-// gateway serials of the given installations.
-func values(installations []Installation, gateways bool) []string {
- res := []string{}
+// label describes the installation by its description or, if unset, its address.
+func (inst Installation) label() string {
+ if desc := strings.TrimSpace(inst.Description); desc != "" {
+ return desc
+ }
+
+ street := strings.TrimSpace(inst.Address.Street + " " + inst.Address.HouseNumber)
+ return strings.Trim(street+", "+inst.Address.City, ", ")
+}
+
+// equipmentOptions lists the installations or, with gateways set, the deduplicated
+// gateways of the given installations, described by location and gateway type.
+// All options are valid choices, match keeps the single-item auto-fill.
+func equipmentOptions(installations []Installation, gateways bool) []service.Option {
+ res := []service.Option{}
for _, inst := range installations {
if !gateways {
- res = append(res, strconv.Itoa(inst.ID))
+ serials := make([]string, 0, len(inst.Gateways))
+ for _, gw := range inst.Gateways {
+ if serial := strings.TrimSpace(gw.Serial); serial != "" {
+ serials = append(serials, serial)
+ }
+ }
+ res = append(res, service.Option{Value: strconv.Itoa(inst.ID), Label: inst.label(), Hint: strings.Join(serials, ", "), Match: true})
continue
}
for _, gw := range inst.Gateways {
- if !slices.Contains(res, gw.Serial) {
- res = append(res, gw.Serial)
+ if !slices.ContainsFunc(res, func(o service.Option) bool { return o.Value == gw.Serial }) {
+ res = append(res, service.Option{Value: gw.Serial, Label: strings.TrimSpace(gw.GatewayType), Hint: inst.label(), Match: true})
}
}
}
- slices.Sort(res)
+ slices.SortFunc(res, func(a, b service.Option) int { return cmp.Compare(a.Value, b.Value) })
return res
}
-// getEquipment lists the account's installation ids or, with detail=gateways,
-// their gateway serials, driving the respective selection in the template.
+// getEquipment lists the account's installations or, with detail=gateways,
+// their gateways, driving the respective selection in the template.
func getEquipment(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
- res := []string{}
+ res := []service.Option{}
defer func() { _ = json.NewEncoder(w).Encode(res) }()
installations, err := equipment(req)
@@ -101,7 +120,7 @@ func getEquipment(w http.ResponseWriter, req *http.Request) {
return
}
- res = values(installations, req.URL.Query().Get("detail") == "gateways")
+ res = equipmentOptions(installations, req.URL.Query().Get("detail") == "gateways")
}
// measurementFeatures are the data points the charger template's optional
diff --git a/charger/viessmann/service_test.go b/charger/viessmann/service_test.go
index bdbfb7b414..94706a9916 100644
--- a/charger/viessmann/service_test.go
+++ b/charger/viessmann/service_test.go
@@ -5,22 +5,50 @@ import (
"net/http/httptest"
"testing"
+ "github.com/evcc-io/evcc/server/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func TestValues(t *testing.T) {
+func TestInstallationLabel(t *testing.T) {
+ for _, tc := range []struct {
+ inst Installation
+ label string
+ }{
+ {Installation{Description: "Home", Address: Address{Street: "Main St", City: "Berlin"}}, "Home"},
+ {Installation{Description: " ", Address: Address{City: "Berlin"}}, "Berlin"},
+ {Installation{Address: Address{Street: "Main St", HouseNumber: "1", City: "Berlin"}}, "Main St 1, Berlin"},
+ {Installation{Address: Address{Street: "Main St"}}, "Main St"},
+ {Installation{Address: Address{City: "Berlin"}}, "Berlin"},
+ {Installation{}, ""},
+ } {
+ assert.Equal(t, tc.label, tc.inst.label())
+ }
+}
+
+func TestEquipmentOptions(t *testing.T) {
installations := []Installation{
- {ID: 3242119, Gateways: []Gateway{{Serial: "7472258009383263"}, {Serial: "7472258009383262"}}},
- {ID: 1000001, Gateways: []Gateway{{Serial: "7472258009383262"}}},
+ {ID: 3242119, Description: "Home", Gateways: []Gateway{{Serial: "7472258009383263", GatewayType: "TCU301 "}, {Serial: "7472258009383262", GatewayType: "VitoconnectOPTO2"}}},
+ {ID: 1000001, Address: Address{Street: "Main St", HouseNumber: "1", City: "Berlin"}, Gateways: []Gateway{{Serial: "7472258009383262", GatewayType: "VitoconnectOPTO2"}}},
+ {ID: 2000002},
}
- assert.Equal(t, []string{"1000001", "3242119"}, values(installations, false))
- assert.Equal(t, []string{"7472258009383262", "7472258009383263"}, values(installations, true))
+ // all options match: single-item lists are auto-filled by the config UI
+ assert.Equal(t, []service.Option{
+ {Value: "1000001", Label: "Main St 1, Berlin", Hint: "7472258009383262", Match: true},
+ {Value: "2000002", Match: true},
+ {Value: "3242119", Label: "Home", Hint: "7472258009383263, 7472258009383262", Match: true},
+ }, equipmentOptions(installations, false))
+
+ // shared gateway listed once, attributed to the first installation
+ assert.Equal(t, []service.Option{
+ {Value: "7472258009383262", Label: "VitoconnectOPTO2", Hint: "Home", Match: true},
+ {Value: "7472258009383263", Label: "TCU301", Hint: "Home", Match: true},
+ }, equipmentOptions(installations, true))
// empty list, not null
- assert.Equal(t, []string{}, values(nil, false))
- assert.Equal(t, []string{}, values(nil, true))
+ assert.Equal(t, []service.Option{}, equipmentOptions(nil, false))
+ assert.Equal(t, []service.Option{}, equipmentOptions(nil, true))
}
// The service handler must return an empty JSON list when the user has not
diff --git a/cmd/root.go b/cmd/root.go
index 88851519ad..560cb0f512 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -470,6 +470,7 @@ func runRoot(cmd *cobra.Command, args []string) {
valueChan <- util.Param{Key: keys.Timezone, Val: time.Now().Format("MST -07:00")}
valueChan <- util.Param{Key: keys.Experimental, Val: isExperimental()}
valueChan <- util.Param{Key: keys.Optimizer, Val: isOptimizer()}
+ valueChan <- util.Param{Key: keys.OptimizerAutomatic, Val: core.OptimizerAutomatic()}
valueChan <- util.Param{Key: keys.Mcp, Val: isMcp()}
// run shutdown functions on stop
diff --git a/cmd/setup.go b/cmd/setup.go
index c60a2cf310..451a6ed842 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -3,6 +3,8 @@ package cmd
import (
"cmp"
"context"
+ "crypto/sha256"
+ "encoding/hex"
"errors"
"fmt"
"os"
@@ -937,6 +939,13 @@ func networkSettings(conf *globalconfig.Network) error {
return nil
}
+// mdnsInstance returns a stable instance name per installation so multiple evcc instances don't conflict,
+// the machine id is hashed as it must not be published. The port separates instances sharing a host.
+func mdnsInstance(id string, port int) string {
+ sum := sha256.Sum256(fmt.Appendf(nil, "mdns:%s:%d", id, port))
+ return "evcc-" + hex.EncodeToString(sum[:4])
+}
+
// setup MDNS
func configureMDNS(conf globalconfig.Network) error {
host := strings.TrimSuffix(conf.Host, ".local")
@@ -951,7 +960,7 @@ func configureMDNS(conf globalconfig.Network) error {
text = append(text, "external_url="+externalURL)
}
- zc, err := zeroconf.RegisterProxy("evcc", "_http._tcp", "local.", conf.Port, host, nil, text, nil)
+ zc, err := zeroconf.RegisterProxy(mdnsInstance(machine.ID(), conf.Port), "_http._tcp", "local.", conf.Port, host, nil, text, nil)
if err != nil {
return err
}
diff --git a/core/fork_hooks_test.go b/core/fork_hooks_test.go
index da77ac2641..7bffb76d86 100644
--- a/core/fork_hooks_test.go
+++ b/core/fork_hooks_test.go
@@ -39,7 +39,8 @@ var forkHooks = []struct {
{"core/loadpoint.go", "Loadpoint.setLimit", []string{"lmCircuit", "done"}},
{"core/loadpoint.go", "Loadpoint.circuitAllowsPhases", []string{"lmm", "PeekPower"}},
{"core/loadpoint.go", "Loadpoint.fastChargingPhases", []string{"effectiveMinCurrentFor", "phaseScaleDelay"}},
- {"core/loadpoint.go", "Loadpoint.pvScalePhases", []string{"lmm", "PeekCurrent", "min1pCurrentOr", "effectiveMinCurrentFor", "phaseScaleDelay"}},
+ {"core/loadpoint.go", "Loadpoint.pvScalePhases", []string{"lmm", "PeekCurrent", "min1pCurrentOr", "effectiveMinCurrentFor", "phaseScaleDelay", "phaseScaleDelay1p"}},
+ {"core/loadpoint.go", "Loadpoint.batterySupported", []string{"projectPhaseSwitch1p"}},
{"core/loadpoint.go", "Loadpoint.boostPower", []string{"effectiveMinCurrentFor"}},
{"core/loadpoint.go", "Loadpoint.scalePhases", []string{"minCurrentScaleUp"}},
{"core/loadpoint.go", "Loadpoint.pvMaxCurrent", []string{"effectiveMinCurrentFor", "effectiveMaxCurrentFor", "projectPhaseSwitch1p", "holdStage"}},
diff --git a/core/keys/global.go b/core/keys/global.go
index 103ce6c856..c47b25072e 100644
--- a/core/keys/global.go
+++ b/core/keys/global.go
@@ -32,6 +32,7 @@ const (
Plant = "plant"
Telemetry = "telemetry"
Optimizer = "optimizer"
+ OptimizerAutomatic = "optimizerAutomatic" // optimizer automatic level: off, battery, full
Mcp = "mcp"
DemoMode = "demoMode"
Remote = "remote"
diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go
index aa5a85c460..ef8d1c0b5f 100644
--- a/core/keys/loadpoint.go
+++ b/core/keys/loadpoint.go
@@ -51,6 +51,8 @@ const (
Suggestion = "suggestion" // optimizer's advisory suggestion for the current slot
// smart charging
+ OptimizerControlled = "optimizerControlled" // optimizer decides start/stop
+
SmartCostActive = "smartCostActive" // smart cost active
SmartCostLimit = "smartCostLimit" // smart cost limit, fast charge when costs are below
SmartCostNextStart = "smartCostNextStart" // smart cost next start, time of next fast charging
diff --git a/core/lm/README.md b/core/lm/README.md
index 5794d95c16..d625ce9c20 100644
--- a/core/lm/README.md
+++ b/core/lm/README.md
@@ -981,7 +981,7 @@ Every change in an evcc file. Check these when merging a new evcc version.
| `core/site_load_predictor.go` | `homeProfileCustom` call in `homeProfile` |
| `core/site_optimizer.go` | `optimizerGridTariff` for the grid price, `applyLmOptimizerInputs` where the request is assembled, `lmOptimizerResult` after the solve, `lmForecastLowest` for the forecast, `lmOptimizeLater`/`lmOptimizeAgain` in `optimizerUpdateAsync` |
| `core/site/api.go` | embeds `CustomAPI` |
-| `core/loadpoint.go` | `loadpointCustom` field; `setLimit` checks against `lp.lmCircuit()` and calls `done`; two `lp.lmm().Peek*` probes; 1p currents: restore and publish calls, phase scaling (`pvScalePhases`, `pvMaxCurrent`, `fastChargingPhases`, `boostPower`) asks `effectiveMinCurrentFor`/`effectiveMaxCurrentFor` (the 1p check in `pvScalePhases` through `min1pCurrentOr`: evcc's `minCurrent` without 1p values), `scalePhases` drops to `minCurrentScaleUp` before scaling up (3p min if above the 1p min), `pvMaxCurrent` projects a pending 1p switch with `projectPhaseSwitch1p` (wraps evcc's `projectPhaseSwitch`), `holdStage` (in front of the disable branch, behind the always-charge/battery return) keeps a heater in stages on its running stages until the disable delay has passed, the phase timers take `phaseScaleDelay`; `publishStages` after `publishPhaseSwitch`; `publishChargePower` instead of publishing `chargePower`, `meteredPower` for the energy collector and the charge rater (heater in stages without a power sensor) |
+| `core/loadpoint.go` | `loadpointCustom` field; `setLimit` checks against `lp.lmCircuit()` and calls `done`; two `lp.lmm().Peek*` probes; 1p currents: restore and publish calls, phase scaling (`pvScalePhases`, `pvMaxCurrent`, `fastChargingPhases`, `boostPower`) asks `effectiveMinCurrentFor`/`effectiveMaxCurrentFor` (the 1p check in `pvScalePhases` through `min1pCurrentOr`: evcc's `minCurrent` without 1p values), `scalePhases` drops to `minCurrentScaleUp` before scaling up (3p min if above the 1p min), `pvMaxCurrent` projects a pending 1p switch with `projectPhaseSwitch1p` (wraps evcc's `projectPhaseSwitch`), `holdStage` (in front of the disable branch, behind the always-charge/battery return) keeps a heater in stages on its running stages until the disable delay has passed, the phase timers take `phaseScaleDelay` (scaling down: `phaseScaleDelay1p`, the disable delay when 1p is not sustainable and evcc disables instead), `batterySupported` projects a pending 1p switch with `projectPhaseSwitch1p`; `publishStages` after `publishPhaseSwitch`; `publishChargePower` instead of publishing `chargePower`, `meteredPower` for the energy collector and the charge rater (heater in stages without a power sensor) |
| `core/loadpoint_effective.go` | `effectiveMinCurrent`/`effectiveMaxCurrent` split per phase count (as in evcc PR 32505), min/max power use it |
| `core/loadpoint/config.go`, `server/http_config_loadpoint_handler.go` | `PhaseSwitchConfig` in the dynamic config, applied after min/max current, read back for the ui |
| `core/circuit/circuit.go` | over power logged via `overPowerLog()` (info, no ui notification) |
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 8fe4917f55..54e1ed4bfa 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -23,6 +23,7 @@ import (
"github.com/evcc-io/evcc/core/settings"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/core/soc"
+ "github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/core/wrapper"
"github.com/evcc-io/evcc/messenger"
"github.com/evcc-io/evcc/util"
@@ -166,6 +167,11 @@ type Loadpoint struct {
planOverrunSent bool // notification has been sent already
planLocked PlanLock // locked plan
+ // optimizer
+ suggestion *types.Suggestion // optimizer suggestion for the current slot
+ suggestionUpdated time.Time // time the suggestion was received
+ optimizerPlan optimizerPlan // optimizer charging schedule
+
// cached state
status api.ChargeStatus // Charger status
chargePower float64 // Charging power
@@ -407,13 +413,13 @@ func (lp *Loadpoint) restoreSettings() {
lp.setLimitEnergy(v)
}
if v, err := lp.settings.Float(keys.SmartCostLimit); err == nil {
- lp.SetSmartCostLimit(&v)
+ lp.setSmartCostLimit(&v)
}
if v, err := lp.settings.Float(keys.SolarShare); err == nil {
lp.SetSolarShare(v)
}
if v, err := lp.settings.Float(keys.SmartFeedInPriorityLimit); err == nil {
- lp.SetSmartFeedInPriorityLimit(&v)
+ lp.setSmartFeedInPriorityLimit(&v)
}
if v, err := lp.settings.Int(keys.BatteryBoostLimit); err == nil {
lp.SetBatteryBoostLimit(int(v))
@@ -690,7 +696,9 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() {
lp.triggerOptimizer()
}
-// triggerOptimizer re-runs the optimizer when the loadpoint's profile changed
+// triggerOptimizer re-runs the optimizer when the loadpoint's profile changed.
+// A burst of setters, e.g. on disconnect, is coalesced by optimizerUpdateAsync's
+// TryLock- only the first call gets through while a run is in flight.
func (lp *Loadpoint) triggerOptimizer() {
if lp.site != nil {
lp.site.Optimize()
@@ -1001,10 +1009,15 @@ func (lp *Loadpoint) setLimit(current float64) error {
currentLimit := circuit.ValidateCurrent(lp.actualMaxChargeCurrent(), current)
activePhases := lp.ActivePhases()
- powerLimit := circuit.ValidatePower(lp.chargePower, currentToPower(current, activePhases))
- currentLimitViaPower := powerToCurrent(powerLimit, activePhases)
+ requestedPower := currentToPower(current, activePhases)
+ powerLimit := circuit.ValidatePower(lp.chargePower, requestedPower)
- limited := lp.roundedCurrent(min(currentLimit, currentLimitViaPower))
+ // avoid float round trip through power if the circuit does not cap
+ limited := currentLimit
+ if powerLimit < requestedPower {
+ limited = min(limited, powerToCurrent(powerLimit, activePhases))
+ }
+ limited = lp.roundedCurrent(limited)
if minCurrent := lp.effectiveMinCurrent(); limited < minCurrent && current >= minCurrent {
lp.log.DEBUG.Printf("circuit limit %.3gA below min current %.3gA", limited, minCurrent)
}
@@ -1584,7 +1597,7 @@ func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64, ma
lp.ResetMeasuredPhases()
}
- var waiting bool
+ var waiting, disable bool
activePhases := lp.ActivePhases()
availablePower := lp.chargePower - sitePower
scalable := activePhases > 1 && lp.phasesConfigured < 3
@@ -1595,23 +1608,31 @@ func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64, ma
lp.log.DEBUG.Printf("available power %.0fW < %.0fW min %dp threshold", availablePower, float64(activePhases)*Voltage*minCurrent, activePhases)
}
- // while charging, scaling down only helps if 1p is sustainable, otherwise it
- // merely delays the pv disable timer by the phase timer duration. Without a
- // disable to wait for, scaling down is the only way to reduce power (#33208).
+ // while charging, scaling down only helps if 1p is sustainable, otherwise the
+ // loadpoint disables once the phase timer elapses. Without a disable to wait
+ // for, scaling down is the only way to reduce power (#33208).
min1pCurrent := lp.min1pCurrentOr(minCurrent) // custom: 1p limits, see core/loadpoint_phasecurrents.go
// Climater keep-alive suppresses the disable timer, checked last to avoid vehicle polling.
useful := !lp.enabled || !lp.charging() || !mayDisable || powerToCurrent(availablePower, 1) >= min1pCurrent || lp.vehicleClimateActive()
- if insufficient && !useful {
+ disable = insufficient && !useful
+ if disable {
lp.log.DEBUG.Printf("available power %.0fW < %.0fW min 1p threshold, disabling instead of scaling down", availablePower, Voltage*min1pCurrent)
}
// scaling down also frees load management headroom for min power on activePhases
- scalable = insufficient && useful || !lp.circuitAllowsPhases(activePhases, minCurrent)
+ scalable = insufficient || !lp.circuitAllowsPhases(activePhases, minCurrent)
}
- // scale down phases
+ // scale down phases. The phase timer keeps running while 3p min is not covered,
+ // whether 1p would be sustainable or not, so passing clouds cannot restart it (#34746)
if scalable {
- if lp.phaseTimerElapsed(lp.phaseScaleDelay(1), phaseScale1p) { // custom: phase delay
+ // custom: phase delay, see core/loadpoint_phasecurrents.go; no switch when disabling
+ if lp.phaseTimerElapsed(lp.phaseScaleDelay1p(disable), phaseScale1p) {
+ if disable {
+ lp.elapsePVTimer()
+ return 0
+ }
+
if err := lp.scalePhases(1); err != nil {
// a charger may report it cannot switch phases right now
// (api.ErrNotAvailable); assume a failed switch and stay silent
@@ -1689,12 +1710,14 @@ func (lp *Loadpoint) publishTimer(name string, delay time.Duration, action strin
}
}
-// projectPhaseSwitch returns site power and phases after a pending scale down to 1p.
-// The phase timer can only be active once the loadpoint is at min current.
+// projectPhaseSwitch returns site power and phases after a pending scale down to 1p:
+// the loadpoint drops from its current draw, at least min power, to min current on 1p.
+// At 1p the phase timer can only be a scale up, which leaves site power as it is.
func (lp *Loadpoint) projectPhaseSwitch(sitePower, minCurrent float64) (float64, int) {
phases := lp.ActivePhases()
- if lp.hasPhaseSwitching() && !lp.phaseTimer.IsZero() {
- sitePower -= Voltage * minCurrent * float64(phases-1)
+ if lp.hasPhaseSwitching() && !lp.phaseTimer.IsZero() && phases > 1 {
+ // site power loses the current draw, at least min power on all phases, and gains 1p min power
+ sitePower -= max(lp.chargePower, currentToPower(minCurrent, phases)) - currentToPower(minCurrent, 1)
phases = 1
}
return sitePower, phases
@@ -1770,7 +1793,7 @@ func (lp *Loadpoint) batterySupported(sitePower, batteryPower float64, batteryBu
minCurrent := lp.effectiveMinCurrent()
// grid import while the battery discharges, net of residual power and controller tolerance
- demand, phases := lp.projectPhaseSwitch(sitePower-batteryPower-lp.site.GetResidualPower()-lp.batteryTolerance(), minCurrent)
+ demand, phases, minCurrent := lp.projectPhaseSwitch1p(sitePower-batteryPower-lp.site.GetResidualPower()-lp.batteryTolerance(), minCurrent) // custom: 1p minimum, see core/loadpoint_phasecurrents.go
if demand > lp.pvDisableThreshold(minCurrent, phases) {
lp.log.DEBUG.Printf("battery support: %.0fW grid import", demand)
return false
@@ -2464,10 +2487,18 @@ NO_DIM:
mode := lp.GetMode()
lp.publish(keys.Mode, mode)
+ lp.publish(keys.OptimizerControlled, lp.optimizerControlled())
// update and publish plan without being short-circuited by modes etc.
plannerActive := lp.plannerActive()
+ // the optimizer schedules the plan itself, the planner only backstops it.
+ // A stalled optimizer has no suggestion and releases the planner again.
+ suggestion := lp.activeSuggestion()
+ if suggestion != nil {
+ plannerActive = lp.planDeadlineCritical()
+ }
+
// update and publish min soc not reached state
minSocNotReached := lp.minSocNotReached()
lp.publish(keys.MinSocNotReached, minSocNotReached)
@@ -2513,8 +2544,17 @@ NO_DIM:
err = lp.fastCharging()
case mode == api.ModeSmart:
+ // optimizer decides start/stop and level, replacing the price limits
+ if suggestion != nil {
+ if handled, e := lp.optimizerCharging(suggestion); handled {
+ err = e
+ break
+ }
+ // surplus regime or stop: the pv loop below tracks the measured surplus
+ }
+
// cheap tariff
- if smartCostActive {
+ if suggestion == nil && smartCostActive {
rate, _ := consumption.At(time.Now())
lp.log.DEBUG.Printf("smart consumption active: %.2f", rate.Value)
err = lp.fastCharging()
@@ -2523,7 +2563,7 @@ NO_DIM:
}
// attractive feedin
- if smartFeedInPriorityActive {
+ if suggestion == nil && smartFeedInPriorityActive {
rate, _ := feedin.At(time.Now())
lp.log.DEBUG.Printf("smart feed-in active: %.2f", rate.Value)
@@ -2540,6 +2580,12 @@ NO_DIM:
break
}
+ // the optimizer plans the home battery- bufferSoc and bufferStartSoc must not
+ // keep charging off the battery against its stop (#34606)
+ if suggestion != nil {
+ batteryBuffered, batteryStart = false, false
+ }
+
targetCurrent := lp.pvMaxCurrent(sitePower, batteryPower, batteryBuffered, batteryStart)
if targetCurrent == 0 && lp.vehicleClimateActive() {
diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go
index 9a7f13c9fc..cfdd899cb4 100644
--- a/core/loadpoint/api.go
+++ b/core/loadpoint/api.go
@@ -147,6 +147,8 @@ type API interface {
RequestUpdate()
// GetPlanGoal returns the plan goal and if the goal is soc based
GetPlanGoal() (float64, bool)
+ // GetPlanRemainingEnergy returns the energy plan's missing energy in kWh
+ GetPlanRemainingEnergy() float64
// GetPlanRequiredDuration returns required duration of plan to reach the goal from current state
GetPlanRequiredDuration(goal, maxPower float64) time.Duration
// GetPlanStrategy returns the plan strategy
@@ -157,6 +159,8 @@ type API interface {
SocBasedPlanning() bool
// GetPlan creates a charging plan
GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration, continuous bool) api.Rates
+ // EffectivePlan returns the charging plan in effect, its average power and true if the optimizer created it
+ EffectivePlan(planTime time.Time, requiredDuration time.Duration, strategy api.PlanStrategy) (api.Rates, float64, bool)
// GetSocConfig returns the soc poll settings
GetSocConfig() SocConfig
@@ -206,7 +210,7 @@ type API interface {
// GetSmartCostLimit return the smart cost limit
GetSmartCostLimit() *float64
// SetSmartCostLimit sets the smart cost limit
- SetSmartCostLimit(limit *float64)
+ SetSmartCostLimit(limit *float64) error
// GetSolarShare gets the solar share
GetSolarShare() float64
@@ -215,7 +219,7 @@ type API interface {
// GetSmartFeedInPriorityLimit return the smart feed-in limit
GetSmartFeedInPriorityLimit() *float64
// SetSmartFeedInPriorityLimit sets the smart feed-in limit
- SetSmartFeedInPriorityLimit(limit *float64)
+ SetSmartFeedInPriorityLimit(limit *float64) error
//
// power and energy
diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go
index 117bfc69bf..4a3e33ac10 100644
--- a/core/loadpoint/config.go
+++ b/core/loadpoint/config.go
@@ -1,6 +1,8 @@
package loadpoint
import (
+ "cmp"
+ "errors"
"time"
"github.com/evcc-io/evcc/api"
@@ -73,9 +75,13 @@ func SplitConfig(payload map[string]any) (DynamicConfig, map[string]any, error)
func (payload DynamicConfig) Apply(lp API) error {
lp.SetTitle(payload.Title)
lp.SetPriority(payload.Priority)
- lp.SetSmartCostLimit(payload.SmartCostLimit)
- lp.SetSmartFeedInPriorityLimit(payload.SmartFeedInPriorityLimit)
+ // limits the optimizer controls are rejected, the remaining config still applies
+ limitErr := errors.Join(
+ lp.SetSmartCostLimit(payload.SmartCostLimit),
+ lp.SetSmartFeedInPriorityLimit(payload.SmartFeedInPriorityLimit),
+ )
lp.SetSolarShare(payload.SolarShare)
+
lp.SetThresholds(payload.Thresholds)
lp.SetPlanEnergy(payload.PlanTime, payload.PlanEnergy)
lp.SetPlanStrategy(payload.PlanStrategy)
@@ -128,5 +134,5 @@ func (payload DynamicConfig) Apply(lp API) error {
err = payload.applyPhaseSwitch(lp) // custom: see config_custom.go
}
- return err
+ return cmp.Or(err, limitErr)
}
diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go
index d2204ff3ce..ad319a6a67 100644
--- a/core/loadpoint/mock.go
+++ b/core/loadpoint/mock.go
@@ -109,6 +109,22 @@ func (mr *MockAPIMockRecorder) EffectiveMinPower() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectiveMinPower", reflect.TypeOf((*MockAPI)(nil).EffectiveMinPower))
}
+// EffectivePlan mocks base method.
+func (m *MockAPI) EffectivePlan(planTime time.Time, requiredDuration time.Duration, strategy api.PlanStrategy) (api.Rates, float64, bool) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "EffectivePlan", planTime, requiredDuration, strategy)
+ ret0, _ := ret[0].(api.Rates)
+ ret1, _ := ret[1].(float64)
+ ret2, _ := ret[2].(bool)
+ return ret0, ret1, ret2
+}
+
+// EffectivePlan indicates an expected call of EffectivePlan.
+func (mr *MockAPIMockRecorder) EffectivePlan(planTime, requiredDuration, strategy any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePlan", reflect.TypeOf((*MockAPI)(nil).EffectivePlan), planTime, requiredDuration, strategy)
+}
+
// EffectivePlanId mocks base method.
func (m *MockAPI) EffectivePlanId() int {
m.ctrl.T.Helper()
@@ -559,6 +575,20 @@ func (mr *MockAPIMockRecorder) GetPlanGoal() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanGoal", reflect.TypeOf((*MockAPI)(nil).GetPlanGoal))
}
+// GetPlanRemainingEnergy mocks base method.
+func (m *MockAPI) GetPlanRemainingEnergy() float64 {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetPlanRemainingEnergy")
+ ret0, _ := ret[0].(float64)
+ return ret0
+}
+
+// GetPlanRemainingEnergy indicates an expected call of GetPlanRemainingEnergy.
+func (mr *MockAPIMockRecorder) GetPlanRemainingEnergy() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanRemainingEnergy", reflect.TypeOf((*MockAPI)(nil).GetPlanRemainingEnergy))
+}
+
// GetPlanRequiredDuration mocks base method.
func (m *MockAPI) GetPlanRequiredDuration(goal, maxPower float64) time.Duration {
m.ctrl.T.Helper()
@@ -1114,9 +1144,11 @@ func (mr *MockAPIMockRecorder) SetPriority(arg0 any) *gomock.Call {
}
// SetSmartCostLimit mocks base method.
-func (m *MockAPI) SetSmartCostLimit(limit *float64) {
+func (m *MockAPI) SetSmartCostLimit(limit *float64) error {
m.ctrl.T.Helper()
- m.ctrl.Call(m, "SetSmartCostLimit", limit)
+ ret := m.ctrl.Call(m, "SetSmartCostLimit", limit)
+ ret0, _ := ret[0].(error)
+ return ret0
}
// SetSmartCostLimit indicates an expected call of SetSmartCostLimit.
@@ -1126,9 +1158,11 @@ func (mr *MockAPIMockRecorder) SetSmartCostLimit(limit any) *gomock.Call {
}
// SetSmartFeedInPriorityLimit mocks base method.
-func (m *MockAPI) SetSmartFeedInPriorityLimit(limit *float64) {
+func (m *MockAPI) SetSmartFeedInPriorityLimit(limit *float64) error {
m.ctrl.T.Helper()
- m.ctrl.Call(m, "SetSmartFeedInPriorityLimit", limit)
+ ret := m.ctrl.Call(m, "SetSmartFeedInPriorityLimit", limit)
+ ret0, _ := ret[0].(error)
+ return ret0
}
// SetSmartFeedInPriorityLimit indicates an expected call of SetSmartFeedInPriorityLimit.
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index 532aea9983..d0567e8cb7 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -208,6 +208,7 @@ func (lp *Loadpoint) SetMode(mode api.ChargeMode) {
}
lp.requestUpdate()
+ lp.triggerOptimizer()
}
}
@@ -381,6 +382,7 @@ func (lp *Loadpoint) SetLimitSoc(soc int) {
if lp.limitSoc != soc {
lp.setLimitSoc(soc)
lp.requestUpdate()
+ lp.triggerOptimizer()
}
}
@@ -442,6 +444,7 @@ func (lp *Loadpoint) SetLimitEnergy(energy float64) {
if lp.limitEnergy != energy {
lp.setLimitEnergy(energy)
lp.requestUpdate()
+ lp.triggerOptimizer()
}
}
@@ -496,6 +499,7 @@ func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error {
if lp.planEnergy != energy || !lp.planTime.Equal(finishAt) {
lp.setPlanEnergy(finishAt, energy)
lp.requestUpdate()
+ lp.triggerOptimizer()
}
return nil
@@ -799,6 +803,11 @@ func (lp *Loadpoint) GetChargePowerFlexibility(rates api.Rates) float64 {
return 0
}
+ // an optimizer setpoint does not yield to a higher priority loadpoint
+ if s := lp.activeSuggestion(); s != nil && s.Action == actionCharge && !lp.surplusRegime(s) {
+ return 0
+ }
+
if loadpoint.SurplusFlexible(lp) {
return lp.GetChargePower()
}
@@ -985,15 +994,36 @@ func (lp *Loadpoint) StartVehicleDetection() {
lp.startVehicleDetection()
}
-// GetSmartCostLimit gets the smart cost limit
+// GetSmartCostLimit gets the smart cost limit. The optimizer replaces it in
+// automatic mode, so it reads as unset and none of its consumers apply it.
func (lp *Loadpoint) GetSmartCostLimit() *float64 {
+ if lp.optimizerControlled() {
+ return nil
+ }
+
lp.RLock()
defer lp.RUnlock()
return lp.smartCostLimit
}
// SetSmartCostLimit sets the smart cost limit
-func (lp *Loadpoint) SetSmartCostLimit(val *float64) {
+func (lp *Loadpoint) SetSmartCostLimit(val *float64) error {
+ if lp.optimizerControlled() {
+ // the getter reads nil in automatic mode- a config round-trip writing it
+ // back must not discard the stored limit
+ if val != nil {
+ return ErrOptimizerAutomatic
+ }
+
+ return nil
+ }
+
+ lp.setSmartCostLimit(val)
+
+ return nil
+}
+
+func (lp *Loadpoint) setSmartCostLimit(val *float64) {
lp.Lock()
defer lp.Unlock()
@@ -1028,15 +1058,34 @@ func (lp *Loadpoint) SetSolarShare(val float64) {
}
}
-// GetSmartFeedInPriorityLimit gets the smart feed-in limit
+// GetSmartFeedInPriorityLimit gets the smart feed-in limit. The optimizer
+// replaces it in automatic mode, so it reads as unset.
func (lp *Loadpoint) GetSmartFeedInPriorityLimit() *float64 {
+ if lp.optimizerControlled() {
+ return nil
+ }
+
lp.RLock()
defer lp.RUnlock()
return lp.smartFeedInPriorityLimit
}
// SetSmartFeedInPriorityLimit sets the smart cost feed-in
-func (lp *Loadpoint) SetSmartFeedInPriorityLimit(val *float64) {
+func (lp *Loadpoint) SetSmartFeedInPriorityLimit(val *float64) error {
+ if lp.optimizerControlled() {
+ if val != nil {
+ return ErrOptimizerAutomatic
+ }
+
+ return nil
+ }
+
+ lp.setSmartFeedInPriorityLimit(val)
+
+ return nil
+}
+
+func (lp *Loadpoint) setSmartFeedInPriorityLimit(val *float64) {
lp.Lock()
defer lp.Unlock()
diff --git a/core/loadpoint_boost_test.go b/core/loadpoint_boost_test.go
index 71597db132..9ff9990b4d 100644
--- a/core/loadpoint_boost_test.go
+++ b/core/loadpoint_boost_test.go
@@ -18,12 +18,21 @@ type mockSite struct {
maxDischargePower *float64
residualPower float64
optimized int
+ automatic bool
}
func (m *mockSite) Optimize() {
m.optimized++
}
+func (m *mockSite) Automatic() bool {
+ return m.automatic
+}
+
+func (m *mockSite) AutomaticLoadpoints() bool {
+ return m.automatic
+}
+
func (m *mockSite) GetBatteryMaxDischargePower() *float64 {
return m.maxDischargePower
}
diff --git a/core/loadpoint_optimizer.go b/core/loadpoint_optimizer.go
new file mode 100644
index 0000000000..1bfd27918a
--- /dev/null
+++ b/core/loadpoint_optimizer.go
@@ -0,0 +1,186 @@
+package core
+
+import (
+ "math"
+ "time"
+
+ "github.com/evcc-io/evcc/api"
+ "github.com/evcc-io/evcc/core/planner"
+ "github.com/evcc-io/evcc/core/types"
+)
+
+// setSuggestion stores the optimizer suggestion for the current slot
+func (lp *Loadpoint) setSuggestion(s *types.Suggestion) {
+ lp.Lock()
+ defer lp.Unlock()
+
+ lp.suggestion = s
+ lp.suggestionUpdated = lp.clock.Now()
+}
+
+// setOptimizerPlan stores the charging schedule of the last solve
+func (lp *Loadpoint) setOptimizerPlan(plan optimizerPlan) {
+ lp.Lock()
+ defer lp.Unlock()
+
+ lp.optimizerPlan = plan
+}
+
+// OptimizerPlan returns the optimizer's remaining charging schedule up to the
+// plan time and its average power, nil if the optimizer is not in charge. Later
+// slots serve surplus, not the plan.
+func (lp *Loadpoint) OptimizerPlan(planTime time.Time) (api.Rates, float64) {
+ if lp.activeSuggestion() == nil {
+ return nil, 0
+ }
+
+ lp.RLock()
+ defer lp.RUnlock()
+
+ now := lp.clock.Now()
+
+ var rates api.Rates
+ var energy float64
+ for i, slot := range lp.optimizerPlan.rates {
+ // keep what is left until the plan time, like the planner does: the schedule
+ // is from the last solve and its goal slot may end after the plan time
+ clipped := slot
+ if clipped.Start.Before(now) {
+ clipped.Start = now
+ }
+ if clipped.End.After(planTime) {
+ clipped.End = planTime
+ }
+ if !clipped.Start.Before(clipped.End) {
+ continue
+ }
+
+ rates = append(rates, clipped)
+ energy += lp.optimizerPlan.energy[i] * float64(clipped.End.Sub(clipped.Start)) / float64(slot.End.Sub(slot.Start))
+ }
+
+ if len(rates) == 0 {
+ return nil, 0
+ }
+
+ return rates, energy / planner.Duration(rates).Hours()
+}
+
+// optimizerControlled indicates that the optimizer decides for this loadpoint.
+// Heating devices and switch sockets cannot be modelled and keep their limits,
+// as do vehicles without known capacity- optimizerRequest skips them, too.
+func (lp *Loadpoint) optimizerControlled() bool {
+ if lp.site == nil || !lp.site.AutomaticLoadpoints() ||
+ lp.chargerHasFeature(api.Heating) || lp.chargerHasFeature(api.IntegratedDevice) {
+ return false
+ }
+
+ v := lp.GetVehicle()
+ return v != nil && v.Capacity() > 0
+}
+
+// activeSuggestion returns the optimizer's start/stop decision for the current slot,
+// nil if the optimizer does not control this loadpoint
+func (lp *Loadpoint) activeSuggestion() *types.Suggestion {
+ if !lp.optimizerControlled() {
+ return nil
+ }
+
+ lp.RLock()
+ defer lp.RUnlock()
+
+ // a stalled optimizer must not keep the loadpoint gated
+ if lp.suggestion == nil || lp.clock.Since(lp.suggestionUpdated) > suggestionMaxAge {
+ return nil
+ }
+
+ return lp.suggestion
+}
+
+// planDeadlineCritical returns true if the plan goal can only be reached by
+// charging now. Backstops a plan the optimizer can no longer satisfy.
+func (lp *Loadpoint) planDeadlineCritical() bool {
+ planTime := lp.EffectivePlanTime()
+ if planTime.IsZero() {
+ return false
+ }
+
+ goal, _ := lp.GetPlanGoal()
+ if goal <= 0 {
+ return false
+ }
+
+ required := lp.GetPlanRequiredDuration(goal, lp.EffectiveMaxPower())
+
+ // past the plan time the goal was missed- keep charging like plannerActive does
+ if remaining := lp.clock.Until(planTime); remaining > 0 {
+ return required >= remaining
+ }
+
+ return required > 0
+}
+
+// surplusRegime indicates that the suggested charge power matches the forecast
+// surplus, leaving the loadpoint to the pv loop instead of pinning it to a setpoint.
+// Full power is grid-fed by definition and never counts as surplus.
+func (lp *Loadpoint) surplusRegime(s *types.Suggestion) bool {
+ full := s.Charge >= lp.EffectiveMaxPower()-suggestionThreshold
+ return s.Action == actionCharge && !full && math.Abs(s.Grid) <= suggestionThreshold
+}
+
+// optimizerCharging applies the optimizer's charging decision. It returns false
+// if the loadpoint is to follow pv surplus instead, leaving current and phases
+// to the regular pv control loop. Otherwise current and phases follow the
+// suggested power, only the level is the optimizer's decision.
+func (lp *Loadpoint) optimizerCharging(s *types.Suggestion) (bool, error) {
+ if lp.surplusRegime(s) {
+ // the charge power matches the forecast surplus, which only holds on average-
+ // the pv loop tracks the measured one, so its timers must keep running
+ if lp.pvTimer.Equal(elapsed) {
+ // elapsed by a previous grid-fed slot, would disable on the first dip
+ lp.resetPVTimer()
+ }
+
+ lp.log.DEBUG.Printf("optimizer: charge (%.0fW), following pv surplus", s.Charge)
+ return false, nil
+ }
+
+ if s.Action != actionCharge && !lp.GetAlwaysCharge().Active() {
+ // a stop rests on the forecast surplus, which may be too low- the pv loop still
+ // enables on measured surplus. A timer elapsed by a grid-fed slot stops at once.
+ if !lp.enabled && lp.pvTimer.Equal(elapsed) {
+ lp.resetPVTimer()
+ }
+
+ lp.log.DEBUG.Println("optimizer: stop, following measured pv surplus")
+ return false, nil
+ }
+
+ lp.elapsePVTimer() // let PV mode disable immediately afterwards
+
+ // full power, or more than the active phases can deliver: fastCharging scales
+ // up and owns the phase timer, resetting it here would restart the delay every cycle
+ full := s.Charge >= lp.EffectiveMaxPower()-suggestionThreshold
+ if s.Action == actionCharge && (full || s.Charge > currentToPower(lp.effectiveMaxCurrent(), lp.ActivePhases())) {
+ lp.log.DEBUG.Printf("optimizer: charge (%.0fW), full power", s.Charge)
+ return true, lp.fastCharging()
+ }
+
+ lp.resetPhaseTimer()
+
+ if s.Action == actionCharge {
+ // a limited setpoint, e.g. a minimum demand or a grid import limit
+ if current := powerToCurrent(s.Charge, lp.ActivePhases()); current >= lp.effectiveMinCurrent() {
+ lp.log.DEBUG.Printf("optimizer: charge (%.0fW)", s.Charge)
+ return true, lp.setLimit(current)
+ }
+
+ // below what the active phases can deliver, minCharging scales down instead
+ lp.log.DEBUG.Printf("optimizer: charge (%.0fW), minimum power", s.Charge)
+ return true, lp.minCharging()
+ }
+
+ // always charge keeps its minimum power, the optimizer plans with it
+ lp.log.DEBUG.Println("optimizer: stop, keeping minimum power")
+ return true, lp.minCharging()
+}
diff --git a/core/loadpoint_phasecurrents.go b/core/loadpoint_phasecurrents.go
index 43750893a6..3c855d4e91 100644
--- a/core/loadpoint_phasecurrents.go
+++ b/core/loadpoint_phasecurrents.go
@@ -179,6 +179,15 @@ func (lp *Loadpoint) projectPhaseSwitch1p(sitePower, minCurrent float64) (float6
return sitePower, phases, minCurrent
}
+// phaseScaleDelay1p is the delay before scaling down to 1p. When 1p is not
+// sustainable evcc disables instead of switching: then the disable delay applies.
+func (lp *Loadpoint) phaseScaleDelay1p(disable bool) time.Duration {
+ if disable {
+ return lp.GetDisableDelay()
+ }
+ return lp.phaseScaleDelay(1)
+}
+
// phaseScaleDelay returns the delay before switching to the given phases: the
// phase delay if set, else the enable delay for 3p and the disable delay for 1p
func (lp *Loadpoint) phaseScaleDelay(phases int) time.Duration {
diff --git a/core/loadpoint_phasecurrents_test.go b/core/loadpoint_phasecurrents_test.go
index c91d6dc6e1..a2e3c005f0 100644
--- a/core/loadpoint_phasecurrents_test.go
+++ b/core/loadpoint_phasecurrents_test.go
@@ -280,6 +280,10 @@ func TestPhaseScaleDelay(t *testing.T) {
lp.phaseScale3pDelay, lp.phaseScale1pDelay = 5*time.Minute, 4*time.Minute
assert.Equal(t, 5*time.Minute, lp.phaseScaleDelay(3))
assert.Equal(t, 4*time.Minute, lp.phaseScaleDelay(1))
+
+ // 1p not sustainable: evcc disables instead of switching, after the disable delay
+ assert.Equal(t, 4*time.Minute, lp.phaseScaleDelay1p(false))
+ assert.Equal(t, 3*time.Minute, lp.phaseScaleDelay1p(true))
}
func TestPvScalePhasesDelays(t *testing.T) {
diff --git a/core/loadpoint_phases_test.go b/core/loadpoint_phases_test.go
index 8229759f80..ec2f480874 100644
--- a/core/loadpoint_phases_test.go
+++ b/core/loadpoint_phases_test.go
@@ -494,6 +494,48 @@ func TestPvScalePhasesTimer(t *testing.T) {
}
}
+// the projection drops the loadpoint from its actual draw to 1p min, not from 3p min (#34746)
+func TestProjectPhaseSwitch(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ charger := &struct {
+ *api.MockCharger
+ *api.MockPhaseSwitcher
+ }{
+ api.NewMockCharger(ctrl),
+ api.NewMockPhaseSwitcher(ctrl),
+ }
+
+ Voltage = 100
+ const site = 500.0
+
+ for _, tc := range []struct {
+ desc string
+ phases int
+ chargePower float64
+ projected float64
+ }{
+ {"at min current", 3, 3 * Voltage * minA, site - 2*Voltage*minA},
+ {"above min current", 3, 3 * Voltage * (minA + 1), site - 3*Voltage*(minA+1) + Voltage*minA},
+ {"below min power", 3, Voltage * minA, site - 2*Voltage*minA},
+ // a scale up pending at 1p leaves site power alone
+ {"scale up at 1p", 1, Voltage * (minA + 1), site},
+ } {
+ lp := &Loadpoint{
+ log: util.NewLogger("foo"),
+ clock: clock.NewMock(),
+ charger: charger,
+ phases: tc.phases,
+ measuredPhases: tc.phases,
+ chargePower: tc.chargePower,
+ phaseTimer: time.Unix(1, 0),
+ }
+
+ projected, phases := lp.projectPhaseSwitch(site, minA)
+ require.Equal(t, tc.projected, projected, tc.desc)
+ require.Equal(t, 1, phases, tc.desc)
+ }
+}
+
func TestScalePhasesIfAvailable(t *testing.T) {
ctrl := gomock.NewController(t)
diff --git a/core/loadpoint_plan.go b/core/loadpoint_plan.go
index eabb4d0cb3..49b2a1dd1d 100644
--- a/core/loadpoint_plan.go
+++ b/core/loadpoint_plan.go
@@ -75,6 +75,14 @@ func (lp *Loadpoint) remainingPlanEnergy(planEnergy float64) float64 {
return max(0, planEnergy-(lp.getChargedEnergy()/1e3-lp.planEnergyOffset))
}
+// GetPlanRemainingEnergy returns the energy plan's missing energy in kWh
+func (lp *Loadpoint) GetPlanRemainingEnergy() float64 {
+ lp.RLock()
+ defer lp.RUnlock()
+ _, energy := lp.getPlanEnergy()
+ return lp.remainingPlanEnergy(energy)
+}
+
// GetPlanRequiredDuration is the estimated total charging duration
func (lp *Loadpoint) GetPlanRequiredDuration(goal, maxPower float64) time.Duration {
lp.RLock()
@@ -127,6 +135,19 @@ func (lp *Loadpoint) GetPlan(targetTime time.Time, requiredDuration, preconditio
return lp.planner.Plan(requiredDuration, precondition, targetTime, continuous)
}
+// EffectivePlan returns the charging plan in effect. While in control the
+// optimizer schedules the plan itself, returned with its average power and
+// true. Everything else is the planner's.
+func (lp *Loadpoint) EffectivePlan(planTime time.Time, requiredDuration time.Duration, strategy api.PlanStrategy) (api.Rates, float64, bool) {
+ if requiredDuration > 0 {
+ if plan, power := lp.OptimizerPlan(planTime); plan != nil {
+ return plan, power, true
+ }
+ }
+
+ return lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous), 0, false
+}
+
// plannerActive checks if the charging plan has a currently active slot
func (lp *Loadpoint) plannerActive() (active bool) {
defer func() {
@@ -178,11 +199,14 @@ func (lp *Loadpoint) plannerActive() (active bool) {
strategy := lp.getEffectivePlanStrategy()
- plan = lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous)
+ plan, power, optimized := lp.EffectivePlan(planTime, requiredDuration, strategy)
if plan == nil {
lp.log.DEBUG.Println("!! plan: plan nil")
return false
}
+ if optimized {
+ maxPower = power
+ }
var overrun string
if excessDuration := requiredDuration - lp.clock.Until(planTime); excessDuration > 0 {
@@ -208,9 +232,19 @@ func (lp *Loadpoint) plannerActive() (active bool) {
activeSlot := planner.SlotAt(lp.clock.Now(), plan)
active = !activeSlot.End.IsZero()
+ // the optimizer starts and stops by itself, the planner's rules below don't
+ // apply. Its plan is active during a slot and while the deadline is critical.
+ if optimized {
+ active = active || lp.planDeadlineCritical()
+ if active && lp.planLocked.Id == 0 && isSocBased {
+ lp.lockPlanGoal(planTime, int(goal), lp.getPlanId())
+ }
+ return active
+ }
+
if active {
- // ignore short plans if not already active
- if slotRemaining := lp.clock.Until(activeSlot.End); !lp.planActive && slotRemaining < tariff.SlotDuration-time.Minute && !planner.SlotHasSuccessor(activeSlot, plan) {
+ // short slots cost an extra start/stop cycle, defer them if a later slot follows anyway
+ if slotRemaining := lp.clock.Until(activeSlot.End); !lp.planActive && slotRemaining < tariff.SlotDuration-time.Minute && planner.SlotHasLaterSuccessor(activeSlot, plan) {
lp.log.DEBUG.Printf("plan: slot too short- ignoring remaining %v", slotRemaining.Round(time.Second))
return false
}
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index 53dbadbd5d..1b11a60fb2 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -84,7 +84,8 @@ func attachListeners(t *testing.T, lp *Loadpoint) {
}
uiChan, pushChan, lpChan := createChannels(t)
- lp.Prepare(new(Site), uiChan, pushChan, lpChan)
+ // the optimizer runs async- without a logger its panic recovery panics again
+ lp.Prepare(&Site{log: util.NewLogger("site")}, uiChan, pushChan, lpChan)
}
func TestNew(t *testing.T) {
@@ -774,6 +775,85 @@ func TestPVHysteresisAfterPhaseSwitch(t *testing.T) {
}
}
+// the phase timer and the pv disable timer hand their shortfall over to each other, so
+// charging at 3p min stops within one disable delay under passing clouds (#34746)
+func TestPVDisablePhaseTimerHandover(t *testing.T) {
+ const dt = time.Minute
+ Voltage = 100
+
+ // charging 3p at min current: 1p min is not covered at import above 2 * 1p min
+ bad := 2*Voltage*minA + 100 // disable instead of scaling down
+ good := Voltage * minA / 2 // 1p covered, 3p not: scale down
+
+ type se struct {
+ site float64
+ delay time.Duration
+ current float64
+ }
+ tc := []struct {
+ name string
+ series []se
+ scaled bool
+ }{
+ {"disables within one delay", []se{
+ {bad, 0, minA},
+ {good, 20 * time.Second, minA},
+ {bad, 40 * time.Second, minA},
+ {bad, dt + time.Second, 0},
+ }, false},
+ {"scales down within one delay", []se{
+ {bad, 0, minA},
+ {good, 20 * time.Second, minA},
+ {bad, 40 * time.Second, minA},
+ {good, dt + time.Second, minA},
+ }, true},
+ }
+
+ for _, tc := range tc {
+ t.Run(tc.name, func(t *testing.T) {
+ clock := clock.NewMock()
+ clock.Add(time.Hour) // avoid time.IsZero
+ ctrl := gomock.NewController(t)
+
+ switcher := api.NewMockPhaseSwitcher(ctrl)
+ if tc.scaled {
+ switcher.EXPECT().Phases1p3p(1)
+ }
+
+ charger := struct {
+ *api.MockCharger
+ *api.MockPhaseSwitcher
+ }{
+ api.NewMockCharger(ctrl), switcher,
+ }
+
+ lp := &Loadpoint{
+ log: util.NewLogger("foo"),
+ wakeUpTimer: NewTimer(),
+ clock: clock,
+ charger: charger,
+ minCurrent: minA,
+ maxCurrent: maxA,
+ phases: 3,
+ measuredPhases: 3,
+ solarShare: 1,
+ Disable: loadpoint.ThresholdConfig{
+ Delay: dt,
+ },
+ status: api.StatusC,
+ enabled: true,
+ chargePower: 3 * Voltage * minA,
+ }
+
+ start := clock.Now()
+ for step, se := range tc.series {
+ clock.Set(start.Add(se.delay))
+ assert.Equal(t, se.current, lp.pvMaxCurrent(se.site, 0, false, false), step)
+ }
+ })
+ }
+}
+
func TestConnectionDurationDropDetection(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)
diff --git a/core/optimizer_automatic_test.go b/core/optimizer_automatic_test.go
new file mode 100644
index 0000000000..927bf9ba00
--- /dev/null
+++ b/core/optimizer_automatic_test.go
@@ -0,0 +1,589 @@
+package core
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ evbus "github.com/asaskevich/EventBus"
+ "github.com/benbjohnson/clock"
+ "github.com/evcc-io/evcc/api"
+ "github.com/evcc-io/evcc/core/keys"
+ coresettings "github.com/evcc-io/evcc/core/settings"
+ "github.com/evcc-io/evcc/core/types"
+ "github.com/evcc-io/evcc/db/settings"
+ "github.com/evcc-io/evcc/messenger"
+ "github.com/evcc-io/evcc/util"
+ "github.com/evcc-io/evcc/util/config"
+ "github.com/evcc-io/evcc/util/sponsor"
+ "github.com/stretchr/testify/assert"
+ "go.uber.org/mock/gomock"
+)
+
+// enableAutomatic puts the optimizer in control for the duration of the test
+func enableAutomatic(t *testing.T) {
+ t.Helper()
+
+ subject := sponsor.Subject
+ sponsor.Subject = "test"
+
+ for _, k := range []string{keys.Experimental, keys.Optimizer} {
+ settings.SetBool(k, true)
+ }
+ settings.SetString(keys.OptimizerAutomatic, OptimizerAutomaticFull)
+
+ t.Cleanup(func() {
+ sponsor.Subject = subject
+ for _, k := range []string{keys.Experimental, keys.Optimizer} {
+ settings.SetBool(k, false)
+ }
+ settings.SetString(keys.OptimizerAutomatic, OptimizerAutomaticOff)
+ })
+}
+
+// TestAutomaticLevels verifies that the battery level controls the battery only
+func TestAutomaticLevels(t *testing.T) {
+ enableAutomatic(t)
+ site := &Site{}
+
+ for _, tc := range []struct {
+ stored string
+ battery, loadpoints bool
+ }{
+ {OptimizerAutomaticOff, false, false},
+ {OptimizerAutomaticBattery, true, false},
+ {OptimizerAutomaticFull, true, true},
+ {"true", true, true}, // boolean switch before the levels
+ {"false", false, false},
+ } {
+ settings.SetString(keys.OptimizerAutomatic, tc.stored)
+ assert.Equal(t, tc.battery, site.Automatic(), tc.stored)
+ assert.Equal(t, tc.loadpoints, site.AutomaticLoadpoints(), tc.stored)
+ }
+}
+
+func automaticLoadpoint(t *testing.T, ac api.AlwaysCharge, automatic bool) (*Loadpoint, *api.MockCharger, *gomock.Controller) {
+ t.Helper()
+
+ ctrl := gomock.NewController(t)
+ charger := api.NewMockCharger(ctrl)
+
+ Voltage = 230
+
+ lp := &Loadpoint{
+ log: util.NewLogger("foo"),
+ bus: evbus.New(),
+ clock: clock.NewMock(),
+ charger: charger,
+ chargeMeter: newChargeMeter(&Null{}),
+ chargeRater: &Null{},
+ chargeTimer: &Null{},
+ wakeUpTimer: NewTimer(),
+ minCurrent: minA,
+ maxCurrent: maxA,
+ phases: 1,
+ status: api.StatusC,
+ mode: api.ModeSmart,
+ alwaysCharge: ac,
+ }
+
+ // only vehicles with known capacity can be modelled by the optimizer
+ lp.vehicle = modelledVehicle(ctrl)
+
+ attachListeners(t, lp)
+
+ // attachListeners assigns a real site
+ lp.site = &mockSite{automatic: automatic}
+
+ charger.EXPECT().Status().Return(api.StatusC, nil)
+ charger.EXPECT().Enabled().Return(true, nil)
+
+ return lp, charger, ctrl
+}
+
+// modelledVehicle returns a vehicle the optimizer can model as storage
+func modelledVehicle(ctrl *gomock.Controller) api.Vehicle {
+ v := api.NewMockVehicle(ctrl)
+ v.EXPECT().Capacity().Return(50.0).AnyTimes()
+ v.EXPECT().Phases().Return(0).AnyTimes()
+ v.EXPECT().Features().Return(nil).AnyTimes()
+ v.EXPECT().OnIdentified().Return(api.ActionConfig{}).AnyTimes()
+ v.EXPECT().GetTitle().Return("").AnyTimes()
+ v.EXPECT().Icon().Return("").AnyTimes()
+ v.EXPECT().Identifiers().Return(nil).AnyTimes()
+ v.EXPECT().Soc().Return(50.0, nil).AnyTimes()
+ return v
+}
+
+func TestOptimizerGate(t *testing.T) {
+ enableAutomatic(t)
+
+ // the 1p test loadpoint tops out at 3680W
+ full := types.Suggestion{Action: actionCharge, Charge: 3680, Grid: 1000}
+ stop := types.Suggestion{Action: actionStop}
+
+ tc := []struct {
+ mode api.ChargeMode
+ ac api.AlwaysCharge
+ s types.Suggestion
+ buffered bool // battery above bufferSoc/bufferStartSoc, discharging into the vehicle
+ expect func(h *api.MockCharger)
+ }{
+ // optimizer starts and stops smart charging, replacing the price limits.
+ // Stop hands over to the pv loop, which disables at once after a grid-fed slot
+ {api.ModeSmart, api.AlwaysChargeOff, full, false, func(h *api.MockCharger) { h.EXPECT().MaxCurrent(int64(maxA)) }},
+ {api.ModeSmart, api.AlwaysChargeOff, stop, false, func(h *api.MockCharger) { h.EXPECT().Enable(false) }},
+
+ // the optimizer plans the battery, its buffer must not keep the vehicle charging (#34606)
+ {api.ModeSmart, api.AlwaysChargeOff, stop, true, func(h *api.MockCharger) { h.EXPECT().Enable(false) }},
+
+ // always charge keeps its minimum power when the optimizer stops
+ {api.ModeSmart, api.AlwaysChargeOn, full, false, func(h *api.MockCharger) { h.EXPECT().MaxCurrent(int64(maxA)) }},
+ {api.ModeSmart, api.AlwaysChargeOn, stop, false, nil}, // already at min current
+
+ // a grid-fed power below the maximum is applied as current
+ {api.ModeSmart, api.AlwaysChargeOff, types.Suggestion{Action: actionCharge, Charge: 2300, Grid: 1000}, false, func(h *api.MockCharger) { h.EXPECT().MaxCurrent(int64(10)) }},
+
+ // off and fast remain the user's decision
+ {api.ModeOff, api.AlwaysChargeOff, full, false, func(h *api.MockCharger) { h.EXPECT().Enable(false) }},
+ {api.ModeNow, api.AlwaysChargeOff, stop, false, func(h *api.MockCharger) { h.EXPECT().MaxCurrent(int64(maxA)) }},
+ }
+
+ for _, tc := range tc {
+ t.Log(tc)
+
+ lp, charger, ctrl := automaticLoadpoint(t, tc.ac, true)
+ lp.mode = tc.mode
+ lp.clock.(*clock.Mock).Add(time.Hour) // elapsed must lie in the past
+ lp.pvTimer = elapsed // previous grid-fed slot
+ lp.setSuggestion(&tc.s)
+
+ if tc.expect != nil {
+ tc.expect(charger)
+ }
+
+ // grid import above min power, no measured surplus
+ if tc.buffered {
+ lp.Update(1972, 1737, nil, nil, true, true, 0, nil, nil, nil)
+ } else {
+ lp.Update(2000, 0, nil, nil, false, false, 0, nil, nil, nil)
+ }
+
+ ctrl.Finish()
+ }
+}
+
+// TestOptimizerSurplusRegime covers a charge power matched to the forecast surplus:
+// control falls through to the pv loop and its enable/disable timer keeps running
+// instead of being elapsed on every cycle
+func TestOptimizerSurplusRegime(t *testing.T) {
+ enableAutomatic(t)
+
+ lp, charger, ctrl := automaticLoadpoint(t, api.AlwaysChargeOff, true)
+ lp.Disable.Delay = time.Minute
+ lp.offeredCurrent = maxA
+
+ // a previous grid-fed slot left the pv timer elapsed
+ lp.pvTimer = elapsed
+
+ lp.setSuggestion(&types.Suggestion{Action: actionCharge, Charge: 2300})
+
+ // surplus dip: the loadpoint steps down to min current while the disable
+ // timer runs instead of disabling right away
+ charger.EXPECT().MaxCurrent(int64(minA))
+ lp.Update(4000, 0, nil, nil, false, false, 0, nil, nil, nil)
+
+ assert.False(t, lp.pvTimer.Equal(elapsed), "pv timer must not stay elapsed")
+ assert.False(t, lp.pvTimer.IsZero(), "pv disable timer must be running")
+
+ ctrl.Finish()
+}
+
+// TestOptimizerStopFollowsSurplus covers a stop based on a forecast surplus that is
+// too low: the pv loop still enables on measured surplus after the enable delay
+func TestOptimizerStopFollowsSurplus(t *testing.T) {
+ enableAutomatic(t)
+
+ stop := &types.Suggestion{Action: actionStop}
+
+ lp := NewLoadpoint(util.NewLogger("foo"), nil)
+ lp.site = &mockSite{automatic: true}
+ lp.vehicle = modelledVehicle(gomock.NewController(t))
+
+ // disabled: a timer elapsed by a grid-fed slot must not skip the enable delay
+ lp.pvTimer = elapsed
+ handled, err := lp.optimizerCharging(stop)
+ assert.NoError(t, err)
+ assert.False(t, handled, "stop must leave the decision to the pv loop")
+ assert.True(t, lp.pvTimer.IsZero(), "enable delay must apply")
+
+ // enabled after a grid-fed slot: disable without measured surplus at once
+ lp.enabled = true
+ lp.pvTimer = elapsed
+ handled, _ = lp.optimizerCharging(stop)
+ assert.False(t, handled)
+ assert.True(t, lp.pvTimer.Equal(elapsed))
+}
+
+// TestOptimizerPrioritySoc covers the battery priority of a loadpoint the optimizer
+// controls: prioritySoc does not apply, a stop gives the battery priority and a
+// surplus charge the vehicle (#34606)
+func TestOptimizerPrioritySoc(t *testing.T) {
+ // battery charging 1000W; below prioritySoc the site hides it and forces 100W residual power
+ charging := siteState{battery: types.BatteryState{Power: -1000}}
+ above := sitePowerResult{state: charging, power: -1000}
+ below := sitePowerResult{state: charging, power: 100, priorityAdjustment: -1100}
+
+ for _, tc := range []struct {
+ name string
+ automatic bool
+ action string
+ res sitePowerResult
+ expect float64
+ }{
+ // the report: above prioritySoc the vehicle took the battery's charge power against the stop
+ {"stop above prioritySoc", true, actionStop, above, 0},
+ {"stop below prioritySoc", true, actionStop, below, 0},
+ {"charge above prioritySoc", true, actionCharge, above, -1000},
+ {"charge below prioritySoc", true, actionCharge, below, -1000},
+ // a discharging battery is not hidden by a stop
+ {"stop while discharging", true, actionStop, sitePowerResult{state: siteState{battery: types.BatteryState{Power: 500}}, power: 500}, 500},
+ // prioritySoc applies to loadpoints the optimizer does not control
+ {"uncontrolled below prioritySoc", false, actionStop, below, 100},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ lp := NewLoadpoint(util.NewLogger("foo"), nil)
+ lp.site = &mockSite{automatic: tc.automatic}
+ lp.vehicle = modelledVehicle(gomock.NewController(t))
+ lp.setSuggestion(&types.Suggestion{Action: tc.action})
+
+ site := &Site{log: util.NewLogger("foo")}
+ assert.Equal(t, tc.expect, site.loadpointSitePower(lp, tc.res))
+ })
+ }
+}
+
+// TestOptimizerFlexibility covers a loadpoint the optimizer pins to a setpoint:
+// it does not yield to a higher priority loadpoint, so its power is not flexible
+func TestOptimizerFlexibility(t *testing.T) {
+ enableAutomatic(t)
+ Voltage = 230
+
+ for _, tc := range []struct {
+ s types.Suggestion
+ want float64
+ }{
+ {types.Suggestion{Action: actionCharge, Charge: 3680, Grid: 1000}, 0}, // full power
+ {types.Suggestion{Action: actionCharge, Charge: 2300, Grid: 1000}, 0}, // grid-fed setpoint
+ {types.Suggestion{Action: actionCharge, Charge: 2300}, 2700}, // surplus regime, pv loop yields
+ {types.Suggestion{Action: actionStop}, 2700}, // stop, pv loop yields
+ } {
+ lp := NewLoadpoint(util.NewLogger("foo"), nil)
+ lp.mode = api.ModeSmart
+ lp.status = api.StatusC
+ lp.chargePower = 2700
+ lp.phases = 1
+ lp.vehicle = modelledVehicle(gomock.NewController(t))
+ lp.site = &mockSite{automatic: true}
+ lp.setSuggestion(&tc.s)
+
+ assert.Equal(t, tc.want, lp.GetChargePowerFlexibility(nil), tc.s)
+ }
+}
+
+func TestOptimizerGateInactive(t *testing.T) {
+ enableAutomatic(t)
+
+ // automatic disabled: pv surplus decides, the suggestion is advisory only
+ lp, _, ctrl := automaticLoadpoint(t, api.AlwaysChargeOff, false)
+ lp.setSuggestion(&types.Suggestion{Action: actionCharge})
+
+ assert.Nil(t, lp.activeSuggestion())
+
+ // pv is balanced at zero site power, a gated loadpoint would go to max current
+ lp.Update(0, 0, nil, nil, false, false, 0, nil, nil, nil)
+
+ ctrl.Finish()
+}
+
+func TestOptimizerGateStale(t *testing.T) {
+ enableAutomatic(t)
+
+ // a stalled optimizer must not keep the loadpoint gated
+ lp, _, ctrl := automaticLoadpoint(t, api.AlwaysChargeOff, true)
+ lp.setSuggestion(&types.Suggestion{Action: actionCharge})
+ lp.suggestionUpdated = lp.clock.Now().Add(-suggestionMaxAge - time.Minute)
+
+ assert.Nil(t, lp.activeSuggestion())
+
+ lp.Update(0, 0, nil, nil, false, false, 0, nil, nil, nil)
+
+ ctrl.Finish()
+}
+
+func TestSmartCostLimitUnavailable(t *testing.T) {
+ enableAutomatic(t)
+
+ limit := 0.2
+
+ lp := &Loadpoint{
+ log: util.NewLogger("foo"),
+ clock: clock.NewMock(),
+ settings: coresettings.NewDatabaseSettingsAdapter("test"),
+ }
+ lp.site = &mockSite{automatic: true}
+ lp.vehicle = modelledVehicle(gomock.NewController(t))
+
+ assert.ErrorIs(t, lp.SetSmartCostLimit(&limit), ErrOptimizerAutomatic)
+ assert.ErrorIs(t, lp.SetSmartFeedInPriorityLimit(&limit), ErrOptimizerAutomatic)
+ assert.Nil(t, lp.GetSmartCostLimit())
+
+ // clearing is a no-op, so a config round-trip does not discard the stored limit
+ assert.NoError(t, lp.SetSmartCostLimit(nil))
+
+ // loadpoints the optimizer cannot model keep their limits
+ lp.charger = struct {
+ api.Charger
+ api.FeatureDescriber
+ }{FeatureDescriber: &featureCharger{features: []api.Feature{api.Heating}}}
+
+ assert.NoError(t, lp.SetSmartCostLimit(&limit))
+ assert.Equal(t, &limit, lp.GetSmartCostLimit())
+
+ // a vehicle without known capacity cannot be modelled either
+ lp.charger = nil
+ lp.vehicle = nil
+
+ assert.NoError(t, lp.SetSmartFeedInPriorityLimit(&limit))
+ assert.Equal(t, &limit, lp.GetSmartFeedInPriorityLimit())
+}
+
+func TestBatteryModeAutomatic(t *testing.T) {
+ enableAutomatic(t)
+
+ ctrl := gomock.NewController(t)
+ batCon := batteryControllerMock(ctrl)
+
+ var bat api.Meter = &struct {
+ api.Meter
+ api.BatteryController
+ }{
+ BatteryController: batCon,
+ }
+
+ site := &Site{
+ log: util.NewLogger("foo"),
+ batteryMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{Name: "bat"}, bat)},
+ }
+
+ // optimizer decides to grid charge, replacing the grid charge limit
+ site.setSuggestions(map[string]types.Suggestion{
+ batteryKey("bat"): {Action: api.BatteryCharge.String()},
+ })
+
+ batCon.EXPECT().SetBatteryMode(api.BatteryCharge)
+ site.updateBatteryMode(false, false, api.Rate{})
+ assert.Equal(t, api.BatteryCharge, site.GetBatteryMode())
+
+ // an expired solve releases the battery
+ site.clearSuggestions()
+
+ batCon.EXPECT().SetBatteryMode(api.BatteryNormal)
+ site.updateBatteryMode(false, false, api.Rate{})
+ assert.Equal(t, api.BatteryNormal, site.GetBatteryMode())
+
+ ctrl.Finish()
+}
+
+func TestBatteryGridChargeLimitUnavailable(t *testing.T) {
+ enableAutomatic(t)
+
+ ctrl := gomock.NewController(t)
+ batCon := api.NewMockBatteryController(ctrl)
+
+ var bat api.Meter = &struct {
+ api.Meter
+ api.BatteryController
+ }{
+ BatteryController: batCon,
+ }
+
+ site := &Site{
+ log: util.NewLogger("foo"),
+ batteryMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{Name: "bat"}, bat)},
+ }
+
+ limit := 0.2
+ assert.ErrorIs(t, site.SetBatteryGridChargeLimit(&limit), ErrOptimizerAutomatic)
+ assert.ErrorIs(t, site.SetBatteryDischargeControl(true), ErrOptimizerAutomatic)
+}
+
+// TestOptimizerPhaseScaleUp covers a grid-fed charge on 1p behind a circuit:
+// the scale up delay must run across control cycles instead of restarting on each
+func TestOptimizerPhaseScaleUp(t *testing.T) {
+ for _, charge := range []float64{11040, 9200} { // full power, and more than 1p delivers
+ t.Run(fmt.Sprintf("%.0fW", charge), func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ clck := clock.NewMock()
+ clck.Add(time.Hour)
+
+ Voltage = 230
+
+ lp := NewLoadpoint(util.NewLogger("foo"), nil)
+ lp.clock = clck
+ lp.minCurrent = minA
+ lp.maxCurrent = maxA
+ lp.Enable.Delay = 3 * time.Minute
+ lp.phases = 1
+ lp.status = api.StatusC
+ lp.chargePower = 3680
+ lp.wakeUpTimer = NewTimer()
+
+ plainCharger := api.NewMockCharger(ctrl)
+ phaseCharger := api.NewMockPhaseSwitcher(ctrl)
+ lp.charger = struct {
+ *api.MockCharger
+ *api.MockPhaseSwitcher
+ }{plainCharger, phaseCharger}
+
+ plainCharger.EXPECT().Enabled().Return(true, nil).AnyTimes()
+ plainCharger.EXPECT().Enable(gomock.Any()).Return(nil).AnyTimes()
+ plainCharger.EXPECT().MaxCurrent(gomock.Any()).Return(nil).AnyTimes()
+
+ circuit := api.NewMockCircuit(ctrl)
+ lp.circuit = circuit
+ circuit.EXPECT().GetMaxPower().Return(0.0).AnyTimes()
+ circuit.EXPECT().ValidatePower(gomock.Any(), gomock.Any()).DoAndReturn(func(_, new float64) float64 { return new }).AnyTimes()
+ circuit.EXPECT().ValidateCurrent(gomock.Any(), gomock.Any()).DoAndReturn(func(_, new float64) float64 { return new }).AnyTimes()
+
+ s := &types.Suggestion{Action: actionCharge, Charge: charge, Grid: charge}
+
+ handled, err := lp.optimizerCharging(s)
+ assert.True(t, handled)
+ assert.NoError(t, err)
+ started := lp.phaseTimer
+ assert.False(t, started.IsZero(), "scale up timer must be running")
+
+ // next cycle keeps the timer
+ clck.Add(time.Minute)
+ _, err = lp.optimizerCharging(s)
+ assert.NoError(t, err)
+ assert.Equal(t, started, lp.phaseTimer, "scale up timer must not restart")
+
+ // delay elapsed
+ clck.Add(lp.Enable.Delay)
+ phaseCharger.EXPECT().Phases1p3p(3).Return(nil)
+ _, err = lp.optimizerCharging(s)
+ assert.NoError(t, err)
+ assert.Equal(t, 3, lp.GetPhases())
+ })
+ }
+}
+
+func TestOptimizerPlanClipsAtPlanTime(t *testing.T) {
+ lp := &Loadpoint{
+ log: util.NewLogger("foo"),
+ clock: clock.NewMock(),
+ vehicle: modelledVehicle(gomock.NewController(t)),
+ site: &mockSite{automatic: true},
+ }
+
+ now := lp.clock.Now()
+ slot := func(i int) api.Rate {
+ return api.Rate{Start: now.Add(time.Duration(i) * 15 * time.Minute), End: now.Add(time.Duration(i+1) * 15 * time.Minute)}
+ }
+
+ // two plan slots, one surplus slot after the plan time
+ lp.setSuggestion(&types.Suggestion{Action: actionCharge})
+ lp.setOptimizerPlan(optimizerPlan{
+ rates: api.Rates{slot(0), slot(1), slot(8)},
+ energy: []float64{500, 250, 1000},
+ })
+
+ plan, power := lp.OptimizerPlan(now.Add(time.Hour))
+ assert.Equal(t, api.Rates{slot(0), slot(1)}, plan)
+ assert.InDelta(t, 1500, power, 1e-6)
+
+ // plan time inside the second slot: 500Wh plus a third of 250Wh in 20 minutes
+ planTime := now.Add(20 * time.Minute)
+ plan, power = lp.OptimizerPlan(planTime)
+ assert.Equal(t, api.Rates{slot(0), {Start: slot(1).Start, End: planTime}}, plan)
+ assert.InDelta(t, 1750, power, 1e-6)
+
+ plan, _ = lp.OptimizerPlan(now)
+ assert.Nil(t, plan)
+
+ // 20 minutes later the first slot has ended and the second is half done
+ lp.clock.(*clock.Mock).Add(20 * time.Minute)
+ plan, power = lp.OptimizerPlan(now.Add(time.Hour))
+ assert.Equal(t, api.Rates{{Start: lp.clock.Now(), End: slot(1).End}}, plan)
+ assert.InDelta(t, 1000, power, 1e-6)
+}
+
+// TestOptimizerPlanActive covers the optimizer's schedule replacing the planner:
+// the plan is active during its slots and once the deadline is critical, the
+// planner's continuation rules don't apply
+func TestOptimizerPlanActive(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ clck := clock.NewMock()
+
+ Voltage = 230
+
+ lp := &Loadpoint{
+ log: util.NewLogger("foo"),
+ clock: clck,
+ charger: api.NewMockCharger(ctrl),
+ pushChan: make(chan messenger.Event, 1),
+ vehicle: modelledVehicle(ctrl),
+ site: &mockSite{automatic: true},
+ status: api.StatusC,
+ phases: 1,
+ minCurrent: minA,
+ maxCurrent: maxA,
+ vehicleSoc: 50,
+ }
+
+ now := clck.Now()
+ planTime := now.Add(4 * time.Hour)
+ slot := func(i int) api.Rate {
+ return api.Rate{Start: now.Add(time.Duration(i) * 15 * time.Minute), End: now.Add(time.Duration(i+1) * 15 * time.Minute)}
+ }
+ optimize := func() {
+ // plan of 5kWh, 1.4h at 3680W- the lock is dropped whenever the plan goes inactive
+ lp.planLocked = PlanLock{Time: planTime, Soc: 60, Id: 1}
+ lp.setSuggestion(&types.Suggestion{Action: actionCharge})
+ lp.setOptimizerPlan(optimizerPlan{
+ rates: api.Rates{slot(0), slot(4), slot(14)},
+ energy: []float64{920, 920, 920},
+ })
+ }
+
+ // goal reached: nothing to plan
+ optimize()
+ plan, _, optimized := lp.EffectivePlan(planTime, 0, api.PlanStrategy{})
+ assert.Nil(t, plan)
+ assert.False(t, optimized)
+
+ // first slot
+ assert.True(t, lp.plannerActive())
+
+ // between slots the planner would avoid the restart
+ clck.Add(20 * time.Minute)
+ optimize()
+ assert.False(t, lp.plannerActive())
+
+ // second slot
+ clck.Add(45 * time.Minute)
+ optimize()
+ assert.True(t, lp.plannerActive())
+
+ // deadline critical ahead of the last slot
+ clck.Add(2 * time.Hour)
+ optimize()
+ assert.True(t, lp.plannerActive())
+
+ // stalled optimizer falls back to the planner
+ clck.Add(time.Hour)
+ _, _, optimized = lp.EffectivePlan(planTime, time.Hour, api.PlanStrategy{})
+ assert.False(t, optimized)
+}
diff --git a/core/optimizer_schedule_test.go b/core/optimizer_schedule_test.go
index c05fb8f7dd..455648509c 100644
--- a/core/optimizer_schedule_test.go
+++ b/core/optimizer_schedule_test.go
@@ -99,6 +99,7 @@ func TestApplyOptimizerResultSchedule(t *testing.T) {
require.True(t, ok)
assert.Equal(t, api.BatteryCharge.String(), s.Action)
assert.InDelta(t, tc.power, s.Charge, 1e-3)
+ assert.InDelta(t, tc.power, s.Grid, 1e-3)
require.NotNil(t, site.battery.Forecast)
require.NotNil(t, site.battery.Forecast.Highest)
assert.Equal(t, tc.highest, site.battery.Forecast.Highest.Time)
@@ -115,3 +116,25 @@ func TestApplyOptimizerResultSchedule(t *testing.T) {
})
}
}
+
+func TestLoadpointPlan(t *testing.T) {
+ now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
+ schedule := optimizerSchedule{
+ timestamps: []time.Time{now.Add(-15 * time.Minute), now, now.Add(15 * time.Minute), now.Add(30 * time.Minute)},
+ dt: []int{900, 900, 900, 900},
+ }
+
+ // expired slot, charging, noise, charging
+ res := optimizer.BatteryResult{ChargingPower: []float32{1000, 500, 5, 250}}
+ prices := []float32{0.1e-3, 0.2e-3, 0.3e-3, 0.4e-3}
+
+ plan := loadpointPlan(res, prices, schedule, now)
+
+ require.Len(t, plan.rates, 2)
+ assert.Equal(t, now, plan.rates[0].Start)
+ assert.Equal(t, now.Add(15*time.Minute), plan.rates[0].End)
+ assert.InDelta(t, 0.2, plan.rates[0].Value, 1e-6)
+ assert.Equal(t, now.Add(30*time.Minute), plan.rates[1].Start)
+ assert.InDelta(t, 0.4, plan.rates[1].Value, 1e-6)
+ assert.Equal(t, []float64{500, 250}, plan.energy)
+}
diff --git a/core/planner/helper.go b/core/planner/helper.go
index 61bc66e08c..df028d801d 100644
--- a/core/planner/helper.go
+++ b/core/planner/helper.go
@@ -65,15 +65,19 @@ func SlotAt(time time.Time, plan api.Rates) api.Rate {
return api.Rate{}
}
-// SlotHasSuccessor returns if the slot has an immediate successor.
+// SlotHasLaterSuccessor returns if the slot has no immediate successor but the plan continues later.
// Does not require the plan to be sorted by start time.
-func SlotHasSuccessor(r api.Rate, plan api.Rates) bool {
+func SlotHasLaterSuccessor(r api.Rate, plan api.Rates) bool {
+ var later bool
for _, slot := range plan {
- if r.End.Equal(slot.Start) {
- return true
+ switch {
+ case slot.Start.Equal(r.End):
+ return false
+ case slot.Start.After(r.End):
+ later = true
}
}
- return false
+ return later
}
// IsFirst returns if the slot is the first slot in the plan.
diff --git a/core/planner/helper_test.go b/core/planner/helper_test.go
index 92943fe856..067bfb189b 100644
--- a/core/planner/helper_test.go
+++ b/core/planner/helper_test.go
@@ -21,21 +21,21 @@ func TestSplitPrecondition(t *testing.T) {
assert.Equal(t, rr[3:], precond, "precond")
}
-func TestSlotHasSuccessor(t *testing.T) {
- plan := rates([]float64{20, 60, 10, 80, 40, 90}, time.Now(), time.Hour)
+func TestSlotHasLaterSuccessor(t *testing.T) {
+ clock := clock.NewMock()
+ rr := rates([]float64{1, 2, 3, 4}, clock.Now(), tariff.SlotDuration)
- last := plan[len(plan)-1]
- rand.Shuffle(len(plan)-1, func(i, j int) {
- plan[i], plan[j] = plan[j], plan[i]
- })
+ // single slot
+ require.False(t, SlotHasLaterSuccessor(rr[0], api.Rates{rr[0]}))
- for i := range plan {
- if plan[i] != last {
- require.True(t, SlotHasSuccessor(plan[i], plan))
- }
- }
+ // adjacent successor
+ require.False(t, SlotHasLaterSuccessor(rr[0], api.Rates{rr[1], rr[0]}))
+
+ // last slot
+ require.False(t, SlotHasLaterSuccessor(rr[3], api.Rates{rr[3], rr[0]}))
- require.False(t, SlotHasSuccessor(last, plan))
+ // later successor
+ require.True(t, SlotHasLaterSuccessor(rr[0], api.Rates{rr[3], rr[0]}))
}
func TestIsFirst(t *testing.T) {
diff --git a/core/site.go b/core/site.go
index aa5403a343..06b83f35f9 100644
--- a/core/site.go
+++ b/core/site.go
@@ -48,6 +48,7 @@ const standbyPower = 10 // consider less than 10W as charger in standby
type updater interface {
loadpoint.API
Update(sitePower, batteryPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64, dim *bool)
+ activeSuggestion() *types.Suggestion
}
var _ site.API = (*Site)(nil)
@@ -469,7 +470,7 @@ func (site *Site) restoreSettings() error {
}
}
if v, err := settings.Bool(keys.BatteryDischargeControl); err == nil {
- if err := site.SetBatteryDischargeControl(v); err != nil && !errors.Is(err, ErrBatteryControlNotAvailable) {
+ if err := site.setBatteryDischargeControl(v); err != nil && !errors.Is(err, ErrBatteryControlNotAvailable) {
return err
}
}
@@ -484,7 +485,7 @@ func (site *Site) restoreSettings() error {
}
}
if v, err := settings.Float(keys.BatteryGridChargeLimit); err == nil {
- if err := site.SetBatteryGridChargeLimit(&v); err != nil && !errors.Is(err, ErrBatteryControlNotAvailable) {
+ if err := site.setBatteryGridChargeLimit(&v); err != nil && !errors.Is(err, ErrBatteryControlNotAvailable) {
return err
}
}
@@ -828,8 +829,11 @@ func (site *Site) updateBatteryMeters() {
maxDischargePower = -1 // any battery without a limit disables the cap
}
- _, controllable := api.Cap[api.BatteryController](meter)
+ ctrl, controllable := api.Cap[api.BatteryController](meter)
mm[i].Controllable = new(controllable)
+ if controllable {
+ mm[i].Modes = lo.Map(ctrl.BatteryModes(), func(m api.BatteryMode, _ int) string { return m.String() })
+ }
}
// retain the last known soc when all reads failed, so that a transient
@@ -1107,6 +1111,38 @@ func optimizerEnabled() bool {
return exp && opt
}
+// optimizer automatic levels: what the optimizer controls instead of only advising
+const (
+ OptimizerAutomaticOff = "off"
+ OptimizerAutomaticBattery = "battery" // home battery only
+ OptimizerAutomaticFull = "full" // home battery and loadpoints
+)
+
+var OptimizerAutomaticLevels = []string{OptimizerAutomaticOff, OptimizerAutomaticBattery, OptimizerAutomaticFull}
+
+// OptimizerAutomatic returns the configured optimizer automatic level
+func OptimizerAutomatic() string {
+ switch v, _ := settings.String(keys.OptimizerAutomatic); v {
+ case OptimizerAutomaticBattery, OptimizerAutomaticFull:
+ return v
+ case "true", "1":
+ // stored by the boolean switch before the levels existed
+ return OptimizerAutomaticFull
+ default:
+ return OptimizerAutomaticOff
+ }
+}
+
+// Automatic returns true if the optimizer controls the home battery instead of only advising
+func (site *Site) Automatic() bool {
+ return OptimizerAutomatic() != OptimizerAutomaticOff && optimizerEnabled() && sponsor.IsAuthorized()
+}
+
+// AutomaticLoadpoints returns true if the optimizer controls the loadpoints as well
+func (site *Site) AutomaticLoadpoints() bool {
+ return OptimizerAutomatic() == OptimizerAutomaticFull && site.Automatic()
+}
+
// sitePowerResult is the outcome of the site power calculation
type sitePowerResult struct {
// measured state, including the estimates for missing meters
@@ -1123,7 +1159,7 @@ type sitePowerResult struct {
batteryStart bool
// adjustment applied for battery priority below prioritySoc; adding it back
- // restores the unadjusted site power for a battery-boosting loadpoint
+ // restores the unadjusted site power for loadpoints prioritySoc does not apply to
priorityAdjustment float64
}
@@ -1222,6 +1258,29 @@ func (site *Site) updateLoadpoints(rates api.Rates) float64 {
return sum
}
+// loadpointSitePower returns the site power the loadpoint controls against:
+// the surplus reserved for higher-priority loadpoints and its own battery priority
+func (site *Site) loadpointSitePower(lp updater, res sitePowerResult) float64 {
+ sitePower := res.power
+
+ // reserve surplus claimed by higher-priority loadpoints that are starting up (#31194)
+ sitePower += site.reservedPVPower(lp)
+
+ // prioritySoc does not apply to a boosting loadpoint, which deliberately drains the
+ // battery (#30541), nor to one the optimizer controls, since the optimizer plans the
+ // home battery: a stop gives the battery priority, a charge takes the surplus (#34606)
+ suggestion := lp.activeSuggestion()
+ if suggestion != nil || lp.GetBatteryBoost() != boostDisabled {
+ sitePower += res.priorityAdjustment
+ }
+
+ if suggestion != nil && suggestion.Action != actionCharge && res.state.battery.Power < 0 {
+ sitePower -= res.state.battery.Power + res.state.excessDCPower
+ }
+
+ return sitePower
+}
+
// reservedPVPower returns the anticipated surplus claimed by higher-priority PV loadpoints
// that are starting up, so lower-priority loadpoints defer enabling against it (#31194).
func (site *Site) reservedPVPower(lp updater) float64 {
@@ -1277,7 +1336,6 @@ func (site *Site) update(lp updater) {
// don't resurrect the pre-disable solve on re-enable
site.setLastOptimizerSolve(nil)
}
- go site.optimizerUpdateAsync(tariff.SlotDuration)
site.updatePower(lp, state, totalChargePower, consumption, feedin)
site.updateCustom(state) // custom: see core/site_lm.go
@@ -1347,19 +1405,8 @@ func (site *Site) updatePower(lp updater, state siteState, totalChargePower floa
// TODO
if lp != nil {
- sitePower := res.power
-
- // reserve surplus claimed by higher-priority loadpoints that are starting up (#31194)
- sitePower += site.reservedPVPower(lp)
-
- // battery boost deliberately drains the battery, hence battery priority
- // below prioritySoc does not apply to the boosting loadpoint (#30541)
- if lp.GetBatteryBoost() != boostDisabled {
- sitePower += res.priorityAdjustment
- }
-
lp.Update(
- sitePower, state.battery.Power, consumption, feedin, res.batteryBuffered, res.batteryStart,
+ site.loadpointSitePower(lp, res), state.battery.Power, consumption, feedin, res.batteryBuffered, res.batteryStart,
greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints),
hems.Dimmed(site.hems),
)
@@ -1516,6 +1563,9 @@ func (site *Site) loopLoadpoints(next chan<- updater) {
active := site.activeLoadpoints()
for {
+ // optimizer runs on its own cadence, checked once per loadpoint cycle
+ go site.optimizerUpdateAsync(false)
+
if len(active) == 0 {
logOnce.Do(func() {
site.log.INFO.Println("no loadpoints configured, running in meter-only mode")
diff --git a/core/site/api.go b/core/site/api.go
index b3c93fc2f6..1556035ba5 100644
--- a/core/site/api.go
+++ b/core/site/api.go
@@ -22,6 +22,10 @@ type API interface {
ActiveLoadpoints() iter.Seq2[int, loadpoint.API]
Vehicles() Vehicles
Optimize()
+ // Automatic returns true if the optimizer controls the home battery instead of only advising
+ Automatic() bool
+ // AutomaticLoadpoints returns true if the optimizer controls the loadpoints as well
+ AutomaticLoadpoints() bool
// Meta
GetTitle() string
diff --git a/core/site_api.go b/core/site_api.go
index 6511ed50f6..1c1456fa02 100644
--- a/core/site_api.go
+++ b/core/site_api.go
@@ -23,6 +23,9 @@ var (
ErrBatteryNotConfigured = errors.New("battery not configured")
ErrBatteryControlNotAvailable = errors.New("battery control not available")
ErrBatteryGridDischargeNotAvailable = errors.New("battery grid discharge not available")
+
+ // ErrOptimizerAutomatic marks settings the optimizer decides on its own
+ ErrOptimizerAutomatic = errors.New("not available in automatic mode")
)
// filterConfigurableDevices filters references to configurable devices of the given handler
@@ -46,7 +49,7 @@ func filterConfigurableCurtailers(ref []string) []string {
// Optimize updates the optimizer
func (site *Site) Optimize() {
- go site.optimizerUpdateAsync(0)
+ go site.optimizerUpdateAsync(true)
}
// GetTitle returns the title
@@ -423,7 +426,7 @@ func (site *Site) SetGridExportLimit(power float64) error {
site.publish(keys.GridExportLimit, power)
// re-run the optimizer so the new limit takes effect immediately
- go site.optimizerUpdateAsync(0)
+ go site.optimizerUpdateAsync(true)
}
return nil
@@ -453,7 +456,7 @@ func (site *Site) SetProfilePercentile(percentile *float64) error {
site.publish(keys.ProfilePercentile, percentile)
// re-run the optimizer so the new profile takes effect immediately
- go site.optimizerUpdateAsync(0)
+ go site.optimizerUpdateAsync(true)
return nil
}
@@ -465,8 +468,13 @@ func (site *Site) GetTariff(tariff api.TariffUsage) api.Tariff {
return site.tariffs.Get(tariff)
}
-// GetBatteryDischargeControl returns the battery control mode (no discharge only)
+// GetBatteryDischargeControl returns the battery control mode (no discharge only).
+// The optimizer replaces it in automatic mode, so it reads as disabled.
func (site *Site) GetBatteryDischargeControl() bool {
+ if site.Automatic() {
+ return false
+ }
+
site.RLock()
defer site.RUnlock()
return site.batteryDischargeControl
@@ -474,6 +482,14 @@ func (site *Site) GetBatteryDischargeControl() bool {
// SetBatteryDischargeControl sets the battery control mode (no discharge only)
func (site *Site) SetBatteryDischargeControl(val bool) error {
+ if site.Automatic() {
+ return ErrOptimizerAutomatic
+ }
+
+ return site.setBatteryDischargeControl(val)
+}
+
+func (site *Site) setBatteryDischargeControl(val bool) error {
site.log.DEBUG.Println("set battery discharge control:", val)
if !site.hasBatteryControl() {
@@ -518,7 +534,7 @@ func (site *Site) SetBatteryGridDischarge(val bool) error {
if changed {
// re-run the optimizer so the new discharge mode takes effect immediately
- go site.optimizerUpdateAsync(0)
+ go site.optimizerUpdateAsync(true)
// drop the limit, it is meaningless without the opt-in
if !val {
@@ -549,17 +565,30 @@ func (site *Site) SetSolarAdjusted(val bool) {
site.publish(keys.SolarAdjusted, val)
// re-run the optimizer so the adjusted forecast takes effect immediately
- go site.optimizerUpdateAsync(0)
+ go site.optimizerUpdateAsync(true)
}
}
+// The optimizer replaces the limit in automatic mode, so it reads as unset.
func (site *Site) GetBatteryGridChargeLimit() *float64 {
+ if site.Automatic() {
+ return nil
+ }
+
site.RLock()
defer site.RUnlock()
return site.batteryGridChargeLimit
}
func (site *Site) SetBatteryGridChargeLimit(val *float64) error {
+ if site.Automatic() {
+ return ErrOptimizerAutomatic
+ }
+
+ return site.setBatteryGridChargeLimit(val)
+}
+
+func (site *Site) setBatteryGridChargeLimit(val *float64) error {
site.log.DEBUG.Println("set grid charge limit:", printPtr("%.3f", val))
if !site.hasBatteryControl() {
@@ -581,7 +610,7 @@ func (site *Site) SetBatteryGridChargeLimit(val *float64) error {
}
// re-run the optimizer so the new limit takes effect immediately
- go site.optimizerUpdateAsync(0)
+ go site.optimizerUpdateAsync(true)
}
return nil
@@ -654,7 +683,7 @@ func (site *Site) SetOptimizerChargingStrategy(strategy string) error {
site.publish(keys.OptimizerChargingStrategy, strategy)
// re-run the optimizer so the new strategy takes effect immediately
- go site.optimizerUpdateAsync(0)
+ go site.optimizerUpdateAsync(true)
}
return nil
diff --git a/core/site_battery.go b/core/site_battery.go
index 84008c9396..ccc5152f00 100644
--- a/core/site_battery.go
+++ b/core/site_battery.go
@@ -116,6 +116,17 @@ func (site *Site) requiredBatteryMode(batteryGridChargeActive, batteryGridDischa
if extMode != batMode {
res = extMode
}
+ case site.Automatic() && site.unmodelledCharging():
+ // the suggestion ignores loads the optimizer cannot model as storage
+ res = keepUnlessModified(api.BatteryHold)
+ case site.Automatic():
+ // optimizer decides, replacing grid charge limit and discharge control
+ if mode, ok := site.batterySuggestionMode(); ok {
+ res = keepUnlessModified(mode)
+ } else if batteryModeModified(batMode) {
+ // no suggestion: release the battery
+ res = api.BatteryNormal
+ }
case batteryGridChargeActive:
// independent limits (buy vs feed-in rate) can both be active at once;
// charge wins to avoid buying and immediately selling
@@ -136,6 +147,50 @@ func (site *Site) requiredBatteryMode(batteryGridChargeActive, batteryGridDischa
return res
}
+// unmodelledCharging reports a loadpoint charging at full power that the optimizer
+// does not control: it cannot model it as storage (unknown vehicle capacity, see
+// optimizerRequest) or only the battery is automatic. Its battery suggestion does
+// not account for that load, so the battery must be held.
+func (site *Site) unmodelledCharging() bool {
+ for _, lp := range site.activeLoadpoints() {
+ if lp.optimizerControlled() {
+ continue
+ }
+
+ if lp.GetStatus() == api.StatusC && lp.IsFastChargingActive() {
+ return true
+ }
+ }
+
+ return false
+}
+
+// batterySuggestionMode returns the optimizer's mode for the first controllable battery.
+// TODO apply per battery once the site tracks more than a single battery mode
+func (site *Site) batterySuggestionMode() (api.BatteryMode, bool) {
+ for _, dev := range site.batteryMeters {
+ if dev == nil {
+ continue
+ }
+
+ s := site.suggestion(batteryKey(dev.Config().Name), site.GetBatteryMode().String())
+ if s == nil {
+ continue
+ }
+
+ mode, err := api.BatteryModeString(s.Action)
+ if err != nil {
+ // unknown action, release the battery
+ site.log.DEBUG.Printf("battery %s: cannot apply suggestion %s", deviceTitleOrName(dev), s.Action)
+ return api.BatteryNormal, true
+ }
+
+ return mode, true
+ }
+
+ return api.BatteryUnknown, false
+}
+
// batterySocLimitReached reports whether the battery has reached the soc bound
// that should stop the requested mode: the max soc when charging, or the min
// soc reserve when discharging to grid. A configured limit of 0 disables the
diff --git a/core/site_optimizer.go b/core/site_optimizer.go
index c96684436e..4da2ca6dbd 100644
--- a/core/site_optimizer.go
+++ b/core/site_optimizer.go
@@ -131,7 +131,7 @@ type batteryResult struct {
Empty time.Time `json:"empty,omitzero"`
}
-// suggestionThreshold ignores numerical noise around zero power (W)
+// suggestionThreshold ignores numerical noise in power comparisons (W)
const suggestionThreshold = 50
// advisory actions for a loadpoint/vehicle slot; battery actions use api.BatteryMode
@@ -172,27 +172,42 @@ func suggestionEvent(detail batteryDetail, s types.Suggestion) messenger.Event {
// Because the optimization is linear, the slot is at an operating-range extreme, so it
// maps cleanly onto the discrete battery mode / loadpoint intent that control would later apply.
// An idle battery is interpreted from the grid flow: importing means discharge is withheld
-// (hold), exporting means charging is withheld (holdcharge).
-func currentSlotSuggestion(detail batteryDetail, res optimizer.BatteryResult, slot int, gridImporting, gridExporting bool, slotHours float64) types.Suggestion {
+// (hold), exporting means charging is withheld (holdcharge). At its soc bound nothing is
+// withheld, the bound does it, so the battery stays in normal mode.
+func currentSlotSuggestion(detail batteryDetail, req optimizer.BatteryConfig, res optimizer.BatteryResult, slot int, gridImport, gridExport float32, slotHours float64) types.Suggestion {
if slot < 0 || slotHours <= 0 || slot >= len(res.ChargingPower) || slot >= len(res.DischargingPower) {
return types.Suggestion{}
}
charge := float64(res.ChargingPower[slot]) / slotHours
discharge := float64(res.DischargingPower[slot]) / slotHours
+ gridImporting := float64(gridImport)/slotHours > suggestionThreshold
+ gridExporting := float64(gridExport)/slotHours > suggestionThreshold
- s := types.Suggestion{Charge: charge, Discharge: discharge}
+ s := types.Suggestion{
+ Charge: charge,
+ Discharge: discharge,
+ Grid: float64(gridImport-gridExport) / slotHours,
+ }
if detail.Type == batteryTypeBattery {
idle := charge <= suggestionThreshold && discharge <= suggestionThreshold
+
+ var empty, full bool
+ if slot < len(res.StateOfCharge) {
+ soc := res.StateOfCharge[slot]
+ empty = soc <= req.SMin
+ full = req.SMax > 0 && soc >= req.SMax
+ }
+
switch {
case charge > suggestionThreshold && gridImporting:
// charging while importing means grid charging
s.Action = api.BatteryCharge.String()
- case idle && gridImporting:
+ case idle && gridImporting && !empty:
// idle while importing: discharge is deliberately withheld
s.Action = api.BatteryHold.String()
- case idle && gridExporting:
+ case idle && gridExporting && !full:
// idle while exporting: surplus is exported instead of charged
s.Action = api.BatteryHoldCharge.String()
case discharge > suggestionThreshold && gridExporting:
@@ -225,6 +240,41 @@ func loadpointCurrentAction(lp *Loadpoint) string {
return actionStop
}
+// suggestionMaxAge releases a loadpoint gated by a solve the site stopped
+// refreshing; the site itself expires its cached solve in reapplySuggestions
+const suggestionMaxAge = 2 * tariff.SlotDuration
+
+// optimizerPlan is a loadpoint's charging schedule from the last solve
+type optimizerPlan struct {
+ rates api.Rates // charging slots valued at the grid import price
+ energy []float64 // charge energy per slot, Wh
+}
+
+// loadpointPlan extracts the remaining charging slots of a battery result
+func loadpointPlan(res optimizer.BatteryResult, prices []float32, schedule optimizerSchedule, now time.Time) optimizerPlan {
+ var plan optimizerPlan
+
+ for slot := range schedule.endsAfter(now) {
+ if slot >= len(res.ChargingPower) || slot >= len(prices) {
+ break
+ }
+
+ energy := float64(res.ChargingPower[slot])
+ if energy/schedule.duration(slot).Hours() <= suggestionThreshold {
+ continue
+ }
+
+ plan.energy = append(plan.energy, energy)
+ plan.rates = append(plan.rates, api.Rate{
+ Start: schedule.timestamps[slot],
+ End: schedule.end(slot),
+ Value: float64(prices[slot]) * 1e3, // per Wh to per kWh
+ })
+ }
+
+ return plan
+}
+
// setSuggestions replaces the suggestions applied on each publish
func (site *Site) setSuggestions(suggestions map[string]types.Suggestion) {
site.Lock()
@@ -258,18 +308,23 @@ func (site *Site) suggestion(key, currentAction string) *types.Suggestion {
return &s
}
-// publishSuggestions publishes the loadpoints' suggestions
+// publishSuggestions publishes the loadpoints' suggestions and hands them to the
+// loadpoints, where they act as start/stop gate while the optimizer is in control
func (site *Site) publishSuggestions() {
for id, lp := range site.loadpoints {
if lp == nil {
continue
}
+ s := site.suggestion(loadpointKey(id), loadpointCurrentAction(lp))
+
var val any
- if s := site.suggestion(loadpointKey(id), loadpointCurrentAction(lp)); s != nil {
+ if s != nil {
val = *s
}
site.publishLoadpoint(id, keys.Suggestion, val)
+
+ lp.setSuggestion(s)
}
}
@@ -377,27 +432,37 @@ const slotsPerHour = float64(time.Hour / tariff.SlotDuration)
// startup); the slot gate is left open so the next cycle retries.
var errOptimizerNotReady = errors.New("battery measurements not ready")
+// optimizerInterval is the refresh cadence in automatic mode. It divides the
+// slot duration so every slot starts on a fresh result. Advisory suggestions
+// only need one run per slot.
+const optimizerInterval = 5 * time.Minute
+
// optimizerUpdateAsync runs the optimizer unless the last run is younger than
-// minAge. Pass 0 to force a run, e.g. when a changed setting should take effect
-// without waiting for the next slot. It is a no-op when the optimizer is not
+// the mode's interval. Pass force to run regardless, e.g. when a changed setting
+// should take effect immediately. It is a no-op when the optimizer is not
// active or a run is already in progress; the running update reflects the
-// change on its next slot.
-func (site *Site) optimizerUpdateAsync(minAge time.Duration) {
+// change on its next run.
+func (site *Site) optimizerUpdateAsync(force bool) {
if !sponsor.IsAuthorized() || !optimizerEnabled() {
return
}
if !site.optimizerMu.TryLock() {
- site.lmOptimizeLater(minAge) // custom: a forced run is not dropped, see core/site_optimizer_reserve_pass.go
+ site.lmOptimizeLater(force) // custom: a forced run is not dropped, see core/site_optimizer_reserve_pass.go
return
}
defer site.lmOptimizeAgain() // custom: after unlocking, see core/site_optimizer_reserve_pass.go
defer site.optimizerMu.Unlock()
- if minAge == 0 {
+ interval := tariff.SlotDuration
+ if site.Automatic() {
+ interval = optimizerInterval
+ }
+
+ if force {
// keep the gate open so a not-ready run is retried on the next cycle
site.optimizerUpdated = time.Time{}
- } else if time.Since(site.optimizerUpdated) < minAge {
+ } else if time.Since(site.optimizerUpdated) < interval {
return
}
@@ -415,11 +480,10 @@ func (site *Site) optimizerUpdateAsync(minAge time.Duration) {
site.optimizerUpdated = time.Now()
+ // a failed run keeps the advice: dropping it would release the controlled
+ // devices for one cycle. reapplySuggestions expires the cached solve.
if err != nil {
site.log.ERROR.Println("optimizer:", err)
-
- // stale advice must not linger
- site.clearSuggestions()
}
}()
@@ -445,7 +509,8 @@ func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.Optim
minLen = min(minLen, len(solar))
}
- if optimizerURI() == OPTIMIZER_URI {
+ hosted := optimizerURI() == OPTIMIZER_URI
+ if hosted {
minLen = slotsUntil(grid, optimizerHorizon(time.Now()), minLen)
}
@@ -609,6 +674,10 @@ func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.Optim
details.DemandDetails = nil
}
+ if hosted {
+ coarsenHorizon(&req, &details)
+ }
+
return req, details, nil
}
@@ -746,8 +815,13 @@ func (site *Site) reapplySuggestions(now time.Time) {
func (site *Site) applyOptimizerResult(req optimizer.OptimizationInput, details requestDetails, res optimizer.OptimizationResult, schedule optimizerSchedule, now time.Time, completed time.Time) {
slot := schedule.activeSlot(now)
slotHours := schedule.duration(slot).Hours()
- gridImporting := slot >= 0 && slot < len(res.GridImport) && res.GridImport[slot] > 0
- gridExporting := slot >= 0 && slot < len(res.GridExport) && res.GridExport[slot] > 0
+ var gridImport, gridExport float32
+ if slot >= 0 && slot < len(res.GridImport) {
+ gridImport = res.GridImport[slot]
+ }
+ if slot >= 0 && slot < len(res.GridExport) {
+ gridExport = res.GridExport[slot]
+ }
var batteries []batteryResult
suggestions := make(map[string]types.Suggestion, len(req.Batteries))
@@ -766,7 +840,7 @@ func (site *Site) applyOptimizerResult(req optimizer.OptimizationInput, details
}),
})
- suggestion := currentSlotSuggestion(detail, batRes, slot, gridImporting, gridExporting, slotHours)
+ suggestion := currentSlotSuggestion(detail, batReq, batRes, slot, gridImport, gridExport, slotHours)
if suggestion.Action == "" {
continue
}
@@ -774,6 +848,16 @@ func (site *Site) applyOptimizerResult(req optimizer.OptimizationInput, details
// uncontrollable devices can't act on a suggestion
if key := detail.key(); key != "" && detail.controllable {
suggestions[key] = suggestion
+
+ // the schedule is gated by the suggestion, so it needs no clearing.
+ // Without the plan goal in the model its slots are no plan.
+ if detail.loadpoint != nil {
+ var plan optimizerPlan
+ if batReq.SGoal != nil {
+ plan = loadpointPlan(batRes, req.TimeSeries.PN, schedule, now)
+ }
+ site.loadpoints[*detail.loadpoint].setOptimizerPlan(plan)
+ }
}
}
@@ -1284,6 +1368,74 @@ func asTimestamps(dt []int, now time.Time) []time.Time {
return res
}
+// coarseSlotsAfter is where the request switches from quarter hour to clock hour slots: the long
+// horizons are the requests that take the hosted optimizer's slow split path and set its p99.
+const coarseSlotsAfter = 48 * time.Hour
+
+// coarsenHorizon merges the quarter hour slots beyond coarseSlotsAfter into clock hours. Quarter
+// slots bridge to the first full hour, and a plan goal ends its slot so the departure keeps its
+// time. Energies are summed, prices averaged by duration.
+func coarsenHorizon(req *optimizer.OptimizationInput, details *requestDetails) {
+ dt, ts := req.TimeSeries.Dt, details.Timestamps
+
+ goalEnds := func(i int) bool {
+ return slices.ContainsFunc(req.Batteries, func(b optimizer.BatteryConfig) bool {
+ return i < len(b.SGoal) && b.SGoal[i] != 0
+ })
+ }
+
+ // first slot index of every output slot; hourly latches at the first full hour past the boundary
+ starts := make([]int, 0, len(dt))
+ coarse := ts[0].Add(coarseSlotsAfter)
+ var hourly bool
+ for i := range dt {
+ if hourly && ts[i].Minute() != 0 && !goalEnds(i-1) {
+ continue
+ }
+ starts = append(starts, i)
+ hourly = hourly || (!ts[i].Before(coarse) && ts[i].Minute() == 0)
+ }
+ if len(starts) == len(dt) {
+ return
+ }
+ ends := append(slices.Clone(starts[1:]), len(dt))
+
+ coarseDt := lo.Map(starts, func(s, g int) int { return lo.Sum(dt[s:ends[g]]) })
+ sum := func(v []float32) []float32 {
+ if v == nil {
+ return nil
+ }
+ return lo.Map(starts, func(s, g int) float32 { return lo.Sum(v[s:ends[g]]) })
+ }
+ mean := func(v []float32) []float32 {
+ return lo.Map(starts, func(s, g int) float32 {
+ var weighted float32
+ for i := s; i < ends[g]; i++ {
+ weighted += v[i] * float32(dt[i])
+ }
+ return weighted / float32(coarseDt[g])
+ })
+ }
+
+ req.TimeSeries.Dt = coarseDt
+ req.TimeSeries.Gt = sum(req.TimeSeries.Gt)
+ req.TimeSeries.Ft = sum(req.TimeSeries.Ft)
+ req.TimeSeries.PN = mean(req.TimeSeries.PN)
+ req.TimeSeries.PE = mean(req.TimeSeries.PE)
+ for i := range req.Batteries {
+ b := &req.Batteries[i]
+ b.PDemand = sum(b.PDemand)
+ if b.SGoal != nil {
+ // a goal ends its group, so it sits in the group's last slot
+ b.SGoal = lo.Map(starts, func(s, g int) float32 { return b.SGoal[ends[g]-1] })
+ }
+ }
+ for i := range details.DemandDetails {
+ details.DemandDetails[i].Values = sum(details.DemandDetails[i].Values)
+ }
+ details.Timestamps = lo.Map(starts, func(s, _ int) time.Time { return ts[s] })
+}
+
func scaleAndPrune(rates api.Rates, scale float64, maxLen int) []float32 {
res := make([]float32, 0, maxLen)
@@ -1307,7 +1459,8 @@ func (site *Site) applyPlanGoal(lp loadpoint.API, bat *optimizer.BatteryConfig,
if vehicle := lp.GetVehicle(); socBased && vehicle != nil {
goal *= vehicle.Capacity() * 10
} else {
- goal *= 1000 // Wh
+ // energy plans count from the energy charged since the plan was set, on top of the current state
+ goal = float64(bat.SInitial) + lp.GetPlanRemainingEnergy()*1e3 // Wh
}
ts := lp.EffectivePlanTime()
diff --git a/core/site_optimizer_reserve_pass.go b/core/site_optimizer_reserve_pass.go
index d53789bd6b..10446f21b0 100644
--- a/core/site_optimizer_reserve_pass.go
+++ b/core/site_optimizer_reserve_pass.go
@@ -18,8 +18,8 @@ import (
"errors"
"net/http"
"slices"
- "time"
+ "github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util/sponsor"
optimizer "github.com/evcc-io/optimizer/client"
)
@@ -420,8 +420,8 @@ func passErr(err error, res *optimizer.OptimizationResult) any {
// lmOptimizeLater remembers a forced run requested while one is running: a
// changed setting or grid charging starting then waits for the next slot
// otherwise, which with several passes per run happens often
-func (site *Site) lmOptimizeLater(minAge time.Duration) {
- if minAge != 0 {
+func (site *Site) lmOptimizeLater(force bool) {
+ if !force {
return
}
@@ -438,7 +438,7 @@ func (site *Site) lmOptimizeLater(minAge time.Duration) {
// lmOptimizeAgain runs a remembered forced run once the running one is done
func (site *Site) lmOptimizeAgain() {
if site.lms().optimizeAgain.Swap(false) {
- go site.optimizerUpdateAsync(0)
+ go site.optimizerUpdateAsync(true)
}
}
@@ -460,6 +460,36 @@ func (site *Site) lmOptimizerPasses(client *optimizer.ClientWithResponses, req *
return
}
- floor := site.lmReservePass(client, req, home, *plan, res)
- site.lmSocChargePass(client, req, home, *plan, floor, res)
+ p := plan.coarse(req.TimeSeries.Dt)
+ floor := site.lmReservePass(client, req, home, p, res)
+ site.lmSocChargePass(client, req, home, p, floor, res)
+}
+
+// coarse maps the grid goals onto the slots of the request: the plan is made on
+// quarter hours (after a partial first slot), the hosted optimizer gets clock
+// hours beyond 48 hours (coarsenHorizon). A goal goes to the slot its quarter
+// hour ends in.
+func (p lmPlan) coarse(dt []int) lmPlan {
+ if len(dt) == 0 || len(p.grid) == 0 {
+ return p
+ }
+
+ quarter := int(tariff.SlotDuration.Seconds())
+ ends := make([]int, len(dt))
+ for i, d := range dt {
+ ends[i] = d
+ if i > 0 {
+ ends[i] += ends[i-1]
+ }
+ }
+
+ grid := make([]lmGridGoal, 0, len(p.grid))
+ for _, g := range p.grid {
+ end := dt[0] + g.slot*quarter
+ j, _ := slices.BinarySearch(ends, end)
+ grid = append(grid, lmGridGoal{slot: min(j, len(dt)-1), level: g.level})
+ }
+ p.grid = grid
+
+ return p
}
diff --git a/core/site_optimizer_reserve_pass_test.go b/core/site_optimizer_reserve_pass_test.go
index 25d9522784..9013b20bd8 100644
--- a/core/site_optimizer_reserve_pass_test.go
+++ b/core/site_optimizer_reserve_pass_test.go
@@ -6,7 +6,6 @@ import (
"net/http/httptest"
"sync/atomic"
"testing"
- "time"
"github.com/evcc-io/evcc/util"
optimizer "github.com/evcc-io/optimizer/client"
@@ -183,10 +182,10 @@ func TestLmOptimizeAgain(t *testing.T) {
// a run is in progress
site.optimizerMu.Lock()
- site.lmOptimizeLater(time.Minute)
+ site.lmOptimizeLater(false)
assert.False(t, site.lms().optimizeAgain.Load(), "a periodic run waits for the next slot")
- site.lmOptimizeLater(0)
+ site.lmOptimizeLater(true)
assert.True(t, site.lms().optimizeAgain.Load())
site.optimizerMu.Unlock()
@@ -194,7 +193,7 @@ func TestLmOptimizeAgain(t *testing.T) {
assert.False(t, site.lms().optimizeAgain.Load(), "run once")
// the running one finished before the request was remembered: it is not lost
- site.lmOptimizeLater(0)
+ site.lmOptimizeLater(true)
assert.False(t, site.lms().optimizeAgain.Load(), "started right away")
}
@@ -312,3 +311,16 @@ func TestLmGridChargeBlockedPeak(t *testing.T) {
site.peak().demand = 5000
assert.False(t, site.lmGridChargeBlocked())
}
+
+// The grid goals follow the hosted optimizer's hourly slots beyond 48 hours.
+func TestLmPlanCoarse(t *testing.T) {
+ plan := lmPlan{grid: []lmGridGoal{{slot: 2, level: 1000}, {slot: 6, level: 2000}}}
+
+ // quarter hours after a partial first slot: unchanged
+ fine := []int{600, 900, 900, 900, 900, 900, 900, 900}
+ assert.Equal(t, plan.grid, plan.coarse(fine).grid)
+
+ // slots 3-6 merged into one hour: the goal of slot 6 ends in it
+ coarse := []int{600, 900, 900, 3600, 900}
+ assert.Equal(t, []lmGridGoal{{slot: 2, level: 1000}, {slot: 3, level: 2000}}, plan.coarse(coarse).grid)
+}
diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go
index 85ee48b74c..1a3975e30c 100644
--- a/core/site_optimizer_test.go
+++ b/core/site_optimizer_test.go
@@ -11,6 +11,7 @@ import (
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
optimizer "github.com/evcc-io/optimizer/client"
+ "github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
@@ -124,6 +125,28 @@ func TestApplyPrecondition(t *testing.T) {
assert.Nil(t, applyPrecondition(lp, nil, 8))
}
+// TestApplyPlanGoalEnergy guards that an energy plan only asks for the missing energy on top of
+// the current state instead of the full plan energy (optimizer#190)
+func TestApplyPlanGoalEnergy(t *testing.T) {
+ ctrl := gomock.NewController(t)
+
+ lp := loadpoint.NewMockAPI(ctrl)
+ lp.EXPECT().GetPlanGoal().Return(15.0, false).AnyTimes()
+ lp.EXPECT().GetVehicle().Return(nil).AnyTimes()
+ lp.EXPECT().EffectivePlanTime().Return(time.Now().Add(2 * time.Hour)).AnyTimes()
+ lp.EXPECT().GetPlanRemainingEnergy().Return(11.0).AnyTimes() // 4 kWh charged already
+
+ site := &Site{log: util.NewLogger("foo")}
+
+ for _, sInitial := range []float32{0, 20000} { // offline vehicle, vehicle with soc
+ bat := optimizer.BatteryConfig{SInitial: sInitial, SMax: 66000}
+ site.applyPlanGoal(lp, &bat, 16)
+
+ require.Len(t, bat.SGoal, 16)
+ assert.Equal(t, sInitial+11000, lo.Max(bat.SGoal), "s_initial %.0f", sInitial)
+ }
+}
+
func TestLoadpointCurrentAction(t *testing.T) {
for _, tc := range []struct {
name string
@@ -164,6 +187,58 @@ func TestAsTimestamps(t *testing.T) {
}, got)
}
+func TestCoarsenHorizon(t *testing.T) {
+ now := time.Date(2025, 1, 1, 7, 23, 0, 0, time.Local)
+ minLen := 1 + 4*60 // 07:23 until Jan 3rd 19:30
+ dt := timeSteps(minLen, now)
+ ts := asTimestamps(dt, now)
+
+ series := func(v float32) []float32 { return lo.RepeatBy(minLen, func(i int) float32 { return v + float32(i) }) }
+ goal := make([]float32, minLen)
+ goal[204] = 5000 // slot 10:15-10:30 on Jan 3rd, the departure
+ req := optimizer.OptimizationInput{
+ TimeSeries: optimizer.TimeSeries{Dt: dt, Gt: series(100), Ft: series(200), PN: series(0.3), PE: series(0.1)},
+ Batteries: []optimizer.BatteryConfig{{PDemand: series(10), SGoal: goal}, {}},
+ }
+ details := requestDetails{Timestamps: ts, DemandDetails: []demandDetail{{Values: series(50)}}}
+ gt, ft, home := lo.Sum(req.TimeSeries.Gt), lo.Sum(req.TimeSeries.Ft), lo.Sum(details.DemandDetails[0].Values)
+ pn, demand := req.TimeSeries.PN, req.Batteries[0].PDemand
+
+ coarsenHorizon(&req, &details)
+ res := req.TimeSeries
+
+ // quarter slots up to 48h and on to the full hour, then clock hours, the goal and the end cut short
+ require.Len(t, res.Dt, 195+13)
+ assert.Equal(t, 420, res.Dt[0])
+ assert.Equal(t, lo.RepeatBy(194, func(int) int { return 900 }), res.Dt[1:195], "quarter slots until 08:00 on Jan 3rd")
+ assert.Equal(t, time.Date(2025, 1, 3, 8, 0, 0, 0, time.Local), details.Timestamps[195])
+ assert.Equal(t, []int{3600, 3600, 1800, 1800, 3600}, res.Dt[195:200], "goal at 10:30 ends its hour early")
+ assert.Equal(t, 1800, res.Dt[len(res.Dt)-1], "last hour ends with the forecast at 19:30")
+ schedule := optimizerSchedule{timestamps: details.Timestamps, dt: res.Dt}
+ assert.Equal(t, time.Date(2025, 1, 3, 10, 30, 0, 0, time.Local), schedule.end(197))
+ assert.Equal(t, time.Date(2025, 1, 3, 19, 30, 0, 0, time.Local), schedule.end(len(res.Dt)-1))
+
+ // energies are preserved, prices averaged by duration, the goal keeps its end
+ assert.InDelta(t, gt, lo.Sum(res.Gt), 0.1)
+ assert.InDelta(t, ft, lo.Sum(res.Ft), 0.1)
+ assert.InDelta(t, home, lo.Sum(details.DemandDetails[0].Values), 0.1)
+ assert.InDelta(t, lo.Mean(pn[195:199]), res.PN[195], 1e-4)
+ assert.InDelta(t, lo.Sum(demand[195:199]), req.Batteries[0].PDemand[195], 0.01)
+ assert.Equal(t, float32(5000), req.Batteries[0].SGoal[197])
+ assert.Equal(t, 1, lo.CountBy(req.Batteries[0].SGoal, func(v float32) bool { return v != 0 }))
+ assert.Nil(t, req.Batteries[1].PDemand)
+ assert.Nil(t, req.Batteries[1].SGoal)
+
+ // within 48h nothing changes
+ short := optimizer.OptimizationInput{TimeSeries: optimizer.TimeSeries{Dt: timeSteps(4*47, now)}}
+ short.TimeSeries.Gt = lo.RepeatBy(len(short.TimeSeries.Dt), func(int) float32 { return 1 })
+ before := requestDetails{Timestamps: asTimestamps(short.TimeSeries.Dt, now)}
+ after := before
+ coarsenHorizon(&short, &after)
+ assert.Equal(t, 4*47, len(short.TimeSeries.Dt))
+ assert.Equal(t, before, after)
+}
+
func TestPlanSlot(t *testing.T) {
// now aligned to a 15-minute boundary: s_goal[i] models the SoC at the END of slot i,
// so a plan 2h out (exactly 8 slots away) must land on slot 7, not 8 (#33831)
@@ -636,44 +711,64 @@ func TestBlendMeasured(t *testing.T) {
func TestCurrentSlotSuggestion(t *testing.T) {
// slotHours 1 makes the per-slot Wh values map 1:1 to W
for _, tc := range []struct {
- name string
- typ batteryType
- charge, disch float32
- importing, export bool
- want string
+ name string
+ typ batteryType
+ charge, disch float32
+ gridImp, gridExp float32
+ want string
}{
- {"battery grid charge", batteryTypeBattery, 3000, 0, true, false, "charge"},
- {"battery pv charge (no import)", batteryTypeBattery, 3000, 0, false, true, "normal"},
- {"battery hold (idle while importing)", batteryTypeBattery, 0, 0, true, false, "hold"},
- {"battery holdcharge (idle while exporting)", batteryTypeBattery, 0, 0, false, true, "holdcharge"},
- {"battery discharge (self-consumption while importing)", batteryTypeBattery, 0, 2000, true, false, "normal"},
- {"battery grid discharge (discharge while exporting)", batteryTypeBattery, 0, 2000, false, true, "discharge"},
- {"battery idle balanced", batteryTypeBattery, 0, 0, false, false, "normal"},
- {"loadpoint charge", batteryTypeLoadpoint, 11000, 0, false, false, "charge"},
- {"loadpoint stop", batteryTypeLoadpoint, 0, 0, false, false, "stop"},
- {"vehicle below threshold is stop", batteryTypeVehicle, 40, 0, false, false, "stop"},
+ {"battery grid charge", batteryTypeBattery, 3000, 0, 1000, 0, "charge"},
+ {"battery pv charge (no import)", batteryTypeBattery, 3000, 0, 0, 1000, "normal"},
+ {"battery hold (idle while importing)", batteryTypeBattery, 0, 0, 1000, 0, "hold"},
+ {"battery holdcharge (idle while exporting)", batteryTypeBattery, 0, 0, 0, 1000, "holdcharge"},
+ {"battery discharge (self-consumption while importing)", batteryTypeBattery, 0, 2000, 1000, 0, "normal"},
+ {"battery grid discharge (discharge while exporting)", batteryTypeBattery, 0, 2000, 0, 1000, "discharge"},
+ {"battery idle balanced", batteryTypeBattery, 0, 0, 0, 0, "normal"},
+ {"loadpoint charge", batteryTypeLoadpoint, 11000, 0, 0, 0, "charge"},
+ {"loadpoint stop", batteryTypeLoadpoint, 0, 0, 0, 0, "stop"},
+ {"vehicle below threshold is stop", batteryTypeVehicle, 40, 0, 0, 0, "stop"},
} {
t.Run(tc.name, func(t *testing.T) {
res := optimizer.BatteryResult{
ChargingPower: []float32{tc.charge},
DischargingPower: []float32{tc.disch},
}
- s := currentSlotSuggestion(batteryDetail{Type: tc.typ}, res, 0, tc.importing, tc.export, 1)
+ s := currentSlotSuggestion(batteryDetail{Type: tc.typ}, optimizer.BatteryConfig{}, res, 0, tc.gridImp, tc.gridExp, 1)
assert.Equal(t, tc.want, s.Action)
assert.InDelta(t, tc.charge, s.Charge, 1e-3)
assert.InDelta(t, tc.disch, s.Discharge, 1e-3)
+ assert.InDelta(t, tc.gridImp-tc.gridExp, s.Grid, 1e-3)
})
}
// no result yields an empty suggestion
- assert.Empty(t, currentSlotSuggestion(batteryDetail{Type: batteryTypeBattery}, optimizer.BatteryResult{}, 0, true, false, 1))
+ assert.Empty(t, currentSlotSuggestion(batteryDetail{Type: batteryTypeBattery}, optimizer.BatteryConfig{}, optimizer.BatteryResult{}, 0, 1000, 0, 1))
res := optimizer.BatteryResult{
ChargingPower: []float32{100, 0},
DischargingPower: []float32{0, 0},
}
- s := currentSlotSuggestion(batteryDetail{Type: batteryTypeBattery}, res, 1, true, false, 1)
+ s := currentSlotSuggestion(batteryDetail{Type: batteryTypeBattery}, optimizer.BatteryConfig{}, res, 1, 1000, 0, 1)
assert.Equal(t, api.BatteryHold.String(), s.Action)
+
+ // an idle battery at its soc bound withholds nothing: normal instead of hold/holdcharge
+ battery := batteryDetail{Type: batteryTypeBattery}
+ req := optimizer.BatteryConfig{SMin: 1000, SMax: 9000}
+ res = optimizer.BatteryResult{
+ ChargingPower: []float32{0},
+ DischargingPower: []float32{0},
+ StateOfCharge: []float32{1000},
+ }
+ assert.Equal(t, api.BatteryNormal.String(), currentSlotSuggestion(battery, req, res, 0, 1000, 0, 1).Action, "empty battery idle while importing")
+ res.StateOfCharge[0] = 9000
+ assert.Equal(t, api.BatteryNormal.String(), currentSlotSuggestion(battery, req, res, 0, 0, 1000, 1).Action, "full battery idle while exporting")
+ res.StateOfCharge[0] = 5000
+ assert.Equal(t, api.BatteryHold.String(), currentSlotSuggestion(battery, req, res, 0, 1000, 0, 1).Action, "idle within bounds is withheld")
+ assert.Equal(t, api.BatteryHoldCharge.String(), currentSlotSuggestion(battery, req, res, 0, 0, 1000, 1).Action, "idle within bounds is withheld")
+
+ // solver noise on the grid is no import/export (#34657)
+ assert.Equal(t, api.BatteryNormal.String(), currentSlotSuggestion(battery, req, res, 0, 4e-05, 0, 570.0/3600).Action, "idle with import noise")
+ assert.Equal(t, api.BatteryNormal.String(), currentSlotSuggestion(battery, req, res, 0, 0, 4e-05, 570.0/3600).Action, "idle with export noise")
}
// TestSuggestionActionable ensures the actionable flag follows the current state
diff --git a/core/site_snow.go b/core/site_snow.go
index a22903ff18..f189183ea8 100644
--- a/core/site_snow.go
+++ b/core/site_snow.go
@@ -115,7 +115,7 @@ func (site *Site) setSnowCover(val, byAuto bool) {
site.publish(keys.SnowCover, val)
site.publish(keys.SnowCoverAuto, val && byAuto)
- go site.optimizerUpdateAsync(0)
+ go site.optimizerUpdateAsync(true)
}
// snowClearStep rates a completed slot: the new count of free slots in a row
diff --git a/core/types/types.go b/core/types/types.go
index f3b254861c..0a6c2854a9 100644
--- a/core/types/types.go
+++ b/core/types/types.go
@@ -20,6 +20,7 @@ type Measurement struct {
Capacity *float64 `json:"capacity,omitempty"`
Soc *float64 `json:"soc,omitempty"`
Controllable *bool `json:"controllable,omitempty"`
+ Modes []string `json:"modes,omitempty"`
Suggestion *Suggestion `json:"suggestion,omitempty"`
}
@@ -30,6 +31,7 @@ type Suggestion struct {
Action string `json:"action"`
Charge float64 `json:"charge"` // recommended charge power, W
Discharge float64 `json:"discharge"` // recommended discharge power, W
+ Grid float64 `json:"grid"` // planned site grid flow, positive means import, W
// Actionable indicates the suggestion differs from the current operating mode.
Actionable bool `json:"actionable"`
}
diff --git a/docs/agents/core-domain.md b/docs/agents/core-domain.md
index 56523def0b..cf3322d80d 100644
--- a/docs/agents/core-domain.md
+++ b/docs/agents/core-domain.md
@@ -103,6 +103,8 @@ optimal current, sends single command. Resilient to restarts and missed updates.
| `bufferSoc` | Above this: EV can draw from battery reserves |
| `bufferStartSoc` | Above this: EV charging can begin even if importing |
+None of these apply to a loadpoint the optimizer controls: the optimizer plans the home battery, so a stop gives the battery priority and a charge takes the surplus (`loadpointSitePower`, `#34606`).
+
## Effective Price Calculation
```
diff --git a/docs/agents/easee-architecture.md b/docs/agents/easee-architecture.md
index 1890e5e240..d8ca6057fd 100644
--- a/docs/agents/easee-architecture.md
+++ b/docs/agents/easee-architecture.md
@@ -198,7 +198,7 @@ This POST returns HTTP `200` but still fires a `CommandResponse` with `ID=22` (e
### Charger-Level (fallback)
-Uses `PhaseMode` setting: `1` for single-phase, `2` (auto) for 3-phase.
+Uses `PhaseMode` setting: `1` locked single-phase, `3` locked 3-phase. `2` (auto, set by earlier versions) is reported as unknown phases so the loadpoint locks it.
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`)
diff --git a/go.mod b/go.mod
index 2d7480126f..e92b5ec12e 100644
--- a/go.mod
+++ b/go.mod
@@ -269,6 +269,7 @@ require (
tool (
github.com/dmarkham/enumer
github.com/evcc-io/evcc/cmd/implement
+ github.com/getkin/kin-openapi/cmd/validate
github.com/gokrazy/tools/cmd/gok
github.com/google/go-licenses/v2
go.uber.org/mock/mockgen
diff --git a/i18n/da.json b/i18n/da.json
index 656c97a448..a7c8cf3419 100644
--- a/i18n/da.json
+++ b/i18n/da.json
@@ -7,6 +7,18 @@
"authorizationSuccessful": "Godkendelse lykkedes",
"buttonConnect": "Opret forbindelse til {provider}",
"buttonDisconnect": "Afbryd forbindelse",
+ "challenge": {
+ "captcha": {
+ "label": "Indtast tegnene, der vises på billedet.",
+ "title": "Captcha"
+ },
+ "code": {
+ "label": "Indsæt koden eller adressen på siden som du blev videresendt til.",
+ "title": "Autorisationskode"
+ },
+ "openLogin": "Åbn login-side",
+ "submit": "Fortsæt"
+ },
"confirmLogout": "Er du sikker på, at du vil afbryde {title}?",
"connect": "forbind",
"disconnect": "afbryd",
@@ -43,6 +55,12 @@
"bufferTitle": "Batteri som opladningsbuffer",
"discharge": "Forhindrer hjemmebatteriet at aflade i hurtig mode og ved planlagt opladning.",
"gridDischarge": "Tillad hjemmebatteriet at aflade til elnettet.",
+ "gridDischargeConfirm": {
+ "country": "Konfigureret land: {country}.",
+ "description": "Om batterienergi må leveres til elnettet afhænger af dit land og din indfødningsaftale. Hvis batteriet også oplades fra elnettet, er den eksporterede energi ikke længere udelukkende solenergi. I mange indfødningsordninger betyder det, at du mister din betaling, eller at det er forbudt. Kontrollér først din indfødningsaftale og netoperatørens krav.",
+ "noCountry": "Intet land er konfigureret. Indstil det under {link}.",
+ "title": "Eksporter til nettet fra batteriet"
+ },
"priority": "Hjemmebatteriet har prioritet, mens det er under {soc}. Derefter får ladepunkterne overskuddet.",
"priorityNone": "Soloverskud går direkte til ladepunkterne. Sæt en værdi over {soc} for at prioritere hjemmebatteriet.",
"priorityTitle": "Hvor sendes overskuddet først hen?"
@@ -144,8 +162,35 @@
"switchsocket": "Brugerdefineret stikkontakt"
}
},
+ "circuit": {
+ "addMeter": "Tilføj dedikeret energimåler",
+ "custom": "Bruger-defineret kredsløb",
+ "deleteChildrenFirst": "For at slette dette kredsløb, skal det underordnede kredsløb først slettes.",
+ "meterDedicated": "Dedikeret måler",
+ "meterGrid": "Elnetsmåler",
+ "meterHelp": "Måler, der måler det samlede forbrug i dette kredsløb.",
+ "meterLabel": "Energimåler",
+ "meterLabelAdd": "Tilføj kredsløbs-måler",
+ "meterLabelEdit": "Juster kredsløbs-måler",
+ "meterNone": "Ingen måler",
+ "meterSelectionHelpDedicatedMeter": "Tilføj en eksisterende måler til nedenstående kredsløb.",
+ "meterSelectionHelpGridMeter": "Bruger den eksisterende elnetsmåler i installationen.",
+ "meterSelectionHelpNoMeter": "Summen af alle ladepunkter bruges.",
+ "meterSelectionLabel": "Vælg måler",
+ "parentCircuit": "Overordnet kredsløb",
+ "template": "Kredsløb",
+ "titleAdd": "Tilføj kredsløb",
+ "titleEdit": "Tilpas kredsløb",
+ "titleLabel": "Titel"
+ },
"circuits": {
+ "addMainCircuit": "Tilføj hovedkredsløb",
+ "addSubCircuit": "Tilføj underkredsløb",
+ "chargingPointsNote": "Tildel ladepunkter og varmere til et kredsløb i deres egne indstillinger.",
"description": "Sikrer, at summen af alle ladepunkter forbundet til et kredsløb ikke overstiger de konfigurerede effekt- og strømgrænser. Kredsløb kan indlejres for at opbygge et hierarki.",
+ "legacyWarning": "Ny konfiguration af kredsløb tilgængelig! Fjern og gem din konfiguration her for at bruge den nye guidede proces.",
+ "meterDedicated": "Måler",
+ "meterGrid": "Elnet",
"title": "Belastningsstyring",
"usableMeters": "Gyldige målerreferencer"
},
@@ -216,6 +261,8 @@
"identifier": "RFID-identifikator",
"loginBlocked": "Grænsen for loginforsøg er nået",
"max": "max",
+ "maxCurrent": "Max. strøm",
+ "maxPower": "Max. effekt",
"messengers": "Tjenester",
"no": "nej",
"odometer": "Kilometertæller",
@@ -352,6 +399,7 @@
"enable": "Aktiver",
"error": "Fejl",
"experimental": "Eksperimentel",
+ "forceEnable": "Aktiver alligevel",
"forceSave": "Gem alligevel",
"fromYamlHint": "Bemærk: Er konfigureret via evcc.yaml. Fjern indtastningen fra filen for at aktivere redigering her.",
"hideAdvancedSettings": "Skjul avancerede indstillinger",
@@ -370,6 +418,7 @@
"saving": "Gemmer…",
"selectFile": "Gennemse",
"showAdvancedSettings": "Vis avancerede indstillinger",
+ "site": "Anlæg",
"telemetry": "Telemetri",
"templateLoading": "Indlæser...",
"title": "Titel",
@@ -888,6 +937,22 @@
"labelVendorId": "Leverandør-ID",
"title": "SMA Sunny Home Manager"
},
+ "site": {
+ "country": {
+ "description": "Bruges til regionsspecifikke oplysninger og standardindstillinger.",
+ "label": "Land"
+ },
+ "currency": {
+ "description": "Bruges til at formatere pris, omkostninger og besparelser baseret på dine tariffer.",
+ "example": "Din opladningspris var {price}. Du sparede {amount}.",
+ "label": "Valuta"
+ },
+ "siteTitle": {
+ "description": "Vises på hovedskærmen og i browserfanen.",
+ "label": "Titel"
+ },
+ "title": "Anlæg"
+ },
"sponsor": {
"addToken": "Indtast token",
"changeToken": "Udskift token",
@@ -1193,6 +1258,7 @@
},
"general": {
"back": "Tilbage",
+ "cancel": "Afbryd",
"download": "Download",
"error": "Fejl",
"none": "ingen",
diff --git a/i18n/de.json b/i18n/de.json
index 474a4b32ad..ebbb5db8d6 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -1,7 +1,7 @@
{
"authProviders": {
- "authCode": "Autorisierungscode",
- "authCodeHelp": "Kopiere diesen Code und verwende ihn im nächsten Schritt. Gültig für {duration}.",
+ "authCode": "Authentifizierungscode",
+ "authCodeHelp": "Kopiere diesen Code und verwende ihn im nächsten Schritt. Er ist gültig für {duration}.",
"authorizationFailed": "Autorisierung fehlgeschlagen",
"authorizationRequired": "Autorisierung erforderlich",
"authorizationSuccessful": "Autorisierung erfolgreich",
@@ -49,8 +49,8 @@
"buffer": "Energie der Hausbatterie oberhalb von {soc} darf für die Unterstützung der Ladepunkte genutzt werden und zögert die Abschaltung im Smart-Modus hinaus. PV-Ladung automatisch starten {start}.",
"bufferNone": "Energie der Hausbatterie wird nicht für die Ladepunkte genutzt. Setze einen Wert unter {soc}, um die Unterstützung zu aktivieren.",
"bufferStart": {
- "above": "ab einem Speicherstand von {soc}",
- "full": "erst, wenn der Speicher voll ist",
+ "above": "ab einem Ladestand von {soc}",
+ "full": "erst, wenn die Hausbatterie voll ist",
"never": "nur mit genug Überschuss"
},
"bufferTitle": "Batterie als Ladepuffer",
@@ -62,6 +62,7 @@
"noCountry": "Kein Land konfiguriert. Lege es unter {link} fest.",
"title": "Netzeinspeisung aus der Batterie"
},
+ "optimizerControlledHint": "Für {loadpoints} deaktiviert. Der {optimizer} entscheidet stattdessen.",
"priority": "Hausbatterie hat Vorrang, solange sie unter {soc} liegt. Dann erhalten die Ladepunkte den Überschuss.",
"priorityNone": "Sonnenüberschuss geht direkt an die Ladepunkte. Setze einen Wert über {soc}, um die Hausbatterie zu bevorzugen.",
"priorityTitle": "Wohin geht der Überschuss zuerst?"
@@ -986,7 +987,7 @@
"title": "MQTT"
},
"network": {
- "descriptionExternalUrl": "Adresse, mit der sich andere Geräte mit evcc verbinden und für die Autodiscovery der evcc-App.",
+ "descriptionExternalUrl": "Adresse, mit der sich andere Geräte mit evcc verbinden und für die Auto-Discovery der evcc-App.",
"descriptionHost": "Wird verwendet, um evcc in deinem lokalen Netzwerk anzukündigen.",
"descriptionInternalUrl": "Lokale Netzwerkadresse von evcc.",
"descriptionPort": "Port für die Web-Oberfläche und API. Du musst deine Browser-URL aktualisieren, wenn du dies änderst.",
@@ -1038,7 +1039,27 @@
"usernameHelp": "Für HTTP-Basic-Auth am Upstream-Server."
},
"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.",
+ "automatic": "Automatische Steuerung",
+ "automaticLevel": {
+ "battery": {
+ "description": "Steuert nur die Hausbatterie.",
+ "label": "Hausbatterie",
+ "status": "{automatic} steuert die Hausbatterie."
+ },
+ "full": {
+ "description": "Steuert die Hausbatterie, startet und stoppt das Laden der Fahrzeuge im Smart-Modus, ersetzt Preis- und CO₂-Grenzen und übernimmt Ladepläne. Unverändert: Aus- und Schnellmodus, Heizgeräte, schaltbare Steckdosen, Fahrzeuge mit unbekannter Batteriekapazität.",
+ "label": "Hausbatterie & Ladepunkte",
+ "status": "{automatic} steuert Hausbatterie und Ladepunkte."
+ },
+ "off": {
+ "description": "Der Optimizer schlägt nur vor. Nichts wird gesteuert.",
+ "status": "{automatic} ist aus. Der Optimizer schlägt nur vor."
+ }
+ },
+ "controlled": "Durch automatische Steuerung deaktiviert. Der {optimizer} entscheidet stattdessen.",
+ "controls": "Steuert",
+ "linkWord": "Optimizer",
+ "description": "Nutzt Solarprognose, Strompreise und deinen typischen Verbrauch, um Batterie und Laden zu planen. Daten werden an den evcc Optimierungsdienst übertragen.",
"enable": "Optimizer aktivieren",
"info": "Bei neuen Installationen kann es bis zu 24 Stunden dauern, bis evcc genügend Daten gesammelt hat.",
"title": "Optimizer"
@@ -1176,7 +1197,7 @@
"grid": "Netzanschluss",
"integrations": "Integrationen",
"loadpoints": "Ladepunkte & Heizungen",
- "meter": "PV & Batterie",
+ "meter": "PV & Speicher",
"peakshaving": "Lastmanagement-Details",
"services": "Dienste",
"system": "System",
@@ -1737,6 +1758,10 @@
},
"issueDescription": "Beschreibung",
"issueTitle": "Titel",
+ "nightlyHint": "Vielleicht ist der Fehler bereits behoben. Bitte teste vorher die aktuelle {nightlyVersion}.",
+ "nightlyHintVersion": "Nightly-Version",
+ "searchHint": "Bevor du einen Fehler meldest, durchsuche die bestehenden {issues}.",
+ "searchHintLink": "GitHub-Issues",
"stepsToReproduce": "Schritte zur Reproduktion",
"subTitleDiscussion": "Beschreibe dein Problem",
"subTitleEmail": "Beschreibe dein Problem",
@@ -2071,6 +2096,7 @@
"targetChargePlan": {
"chargeDuration": "Ladedauer",
"co2Label": "CO₂-Emission ⌀",
+ "optimizerHint": "Hinweis: Dieser Plan wurde vom {optimizer} erstellt.",
"priceLabel": "Energiepreis",
"timeRange": "{day} {range} Uhr",
"unknownPrice": "noch unbekannt"
@@ -2128,6 +2154,7 @@
"feedinPriorityPausing": "Solarladen pausiert, um die Einspeisung zu maximieren.",
"finished": "Abgeschlossen.",
"minCharge": "Mindestladung bis {soc}.",
+ "optimizerControlledTooltip": "Automatische Steuerung: Der Optimizer startet und stoppt das Laden.",
"pvDisable": "Zu wenig Überschuss. Ladung wird gleich pausiert.",
"pvEnable": "Überschuss verfügbar. Ladung wird gleich fortgesetzt.",
"scale1p": "Reduziere gleich auf 1-phasiges Laden.",
diff --git a/i18n/en.json b/i18n/en.json
index b8b87e06b0..15635f78f0 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -62,6 +62,7 @@
"noCountry": "No country configured. Set it under {link}.",
"title": "Grid export from battery"
},
+ "optimizerControlledHint": "Disabled for {loadpoints}. The {optimizer} decides instead.",
"priority": "Home battery has priority while it is below {soc}. Then the charging points receive the surplus.",
"priorityNone": "Solar surplus goes straight to the charging points. Set a value above {soc} to prioritize the home battery.",
"priorityTitle": "Where does the surplus go first?"
@@ -1038,7 +1039,27 @@
"usernameHelp": "For HTTP Basic Auth to the upstream server."
},
"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.",
+ "automatic": "Automatic control",
+ "automaticLevel": {
+ "battery": {
+ "description": "Only controls the home battery.",
+ "label": "Home battery",
+ "status": "{automatic} controls the home battery."
+ },
+ "full": {
+ "description": "Controls the home battery, starts and stops vehicle charging in smart mode, replaces price and CO₂ limits and takes over plans. Unchanged: off and fast mode, heating devices, switch sockets, vehicles with unknown capacity.",
+ "label": "Home battery & charging points",
+ "status": "{automatic} controls home battery and charging points."
+ },
+ "off": {
+ "description": "The optimizer only suggests. Nothing is controlled.",
+ "status": "{automatic} is off. The optimizer only suggests."
+ }
+ },
+ "controlled": "Disabled by automatic control. The {optimizer} decides instead.",
+ "controls": "Controls",
+ "linkWord": "optimizer",
+ "description": "Uses solar forecast, electricity prices and your consumption patterns to plan battery and charging. Data is sent to the evcc optimization service.",
"enable": "Enable Optimizer",
"info": "For new installations, it may take up to 24 hours until evcc has collected enough data.",
"title": "Optimizer"
@@ -1737,6 +1758,10 @@
},
"issueDescription": "Description",
"issueTitle": "Title",
+ "nightlyHint": "It might already be fixed. Please verify with the latest {nightlyVersion} before.",
+ "nightlyHintVersion": "Nightly version",
+ "searchHint": "Before reporting a bug, search existing {issues}.",
+ "searchHintLink": "GitHub issues",
"stepsToReproduce": "Steps to reproduce",
"subTitleDiscussion": "Describe your problem",
"subTitleEmail": "Describe your problem",
@@ -2071,6 +2096,7 @@
"targetChargePlan": {
"chargeDuration": "Charging time",
"co2Label": "CO₂ emission ⌀",
+ "optimizerHint": "Note: This plan was created by the {optimizer}.",
"priceLabel": "Energy price",
"timeRange": "{day} {range} h",
"unknownPrice": "still unknown"
@@ -2128,6 +2154,7 @@
"feedinPriorityPausing": "Solar charging paused to maximize feed-in.",
"finished": "Finished.",
"minCharge": "Minimum charging to {soc}.",
+ "optimizerControlledTooltip": "Automatic control. The optimizer starts and stops charging.",
"pvDisable": "Not enough surplus. Pausing soon.",
"pvEnable": "Surplus available. Starting soon.",
"scale1p": "Reducing to 1-phase charging soon.",
diff --git a/i18n/es.json b/i18n/es.json
index dc46c2b58d..2af385bacf 100644
--- a/i18n/es.json
+++ b/i18n/es.json
@@ -7,12 +7,24 @@
"authorizationSuccessful": "Autorización correcta",
"buttonConnect": "Conectarse a {provider}",
"buttonDisconnect": "Desconectar",
+ "challenge": {
+ "captcha": {
+ "label": "Introduce los caracteres mostrados en la imagen.",
+ "title": "Captcha"
+ },
+ "code": {
+ "label": "Pega el código o la dirección de la página a la que se te redirigió.",
+ "title": "Código de autorización"
+ },
+ "openLogin": "Abrir página de inicio de sesión",
+ "submit": "Continuar"
+ },
"confirmLogout": "¿Estás seguro de que deseas desconectar {title}?",
"connect": "conectar",
"disconnect": "desconectar",
"loggedOut": "Cierre de sesión correcto",
"logoutFailed": "No se ha podido cerrar la sesión",
- "modalDescriptionLogin": "Complete el proceso de autorización para establecer la conexión con {provider}.",
+ "modalDescriptionLogin": "Completa el proceso de autorización para establecer la conexión con {provider}.",
"modalDescriptionLogout": "Esto desconectará tu cuenta de {provider} y eliminará el acceso a sus datos.",
"success": "{title} ya está conectado y listo para usar.",
"successCloseModal": "Ahora puede cerrar este cuadro de diálogo.",
@@ -34,7 +46,7 @@
"combined": "Combinado",
"config": {
"buffer": "El remanente de carga doméstica sobre el {soc} puede ser usado para apoyar a los puntos de carga y retrasa la desconexión en modo inteligente. Iniciar la carga solar automáticamente {start}.",
- "bufferNone": "La energía de la batería doméstica no se usa en los puntos de carga. Configure un valor por debajo de {soc} para habilitar su uso.",
+ "bufferNone": "La energía de la batería doméstica no se usa en los puntos de carga. Configura un valor por debajo de {soc} para habilitar su uso.",
"bufferStart": {
"above": "una vez el nivel de batería es superior a {soc}",
"full": "una vez la batería está llena",
@@ -43,8 +55,14 @@
"bufferTitle": "Batería como un búfer de carga",
"discharge": "Prevenir la descarga de la batería doméstica en modo rápido y durante la carga programada.",
"gridDischarge": "Permitir la descarga de la batería doméstica a la red.",
+ "gridDischargeConfirm": {
+ "country": "País configurado: {country}.",
+ "description": "La posibilidad de verter energía de la batería a la red depende de tu país y de tu contrato de venta de excedentes. Si además la batería se carga desde la red, la energía vertida deja de ser puramente solar. En muchas tarifas de vertido esto implica perder la remuneración o incluso está prohibido. Consulta primero tu contrato y los requisitos de tu operador de red.",
+ "noCountry": "No hay ningún país configurado. Defínelo en {link}.",
+ "title": "Vertido a la red desde la batería"
+ },
"priority": "La batería doméstica tiene prioridad mientras esté por debajo del {soc}. Por encima, los puntos de carga reciben el excedente.",
- "priorityNone": "El excedente solar va directo a los puntos de carga. Configure un valor por encima de {soc} para priorizar la batería doméstica.",
+ "priorityNone": "El excedente solar va directo a los puntos de carga. Configura un valor por encima de {soc} para priorizar la batería doméstica.",
"priorityTitle": "¿Cuál es el destino prioritario de los excedentes?"
},
"optimizer": {
@@ -97,7 +115,7 @@
"aux": {
"description": "Dispositivo que ajusta su consumo en función del excedente disponible (como los calentadores de agua inteligentes). evcc espera que este dispositivo reduzca su consumo de energía si es necesario.",
"titleAdd": "Añadir consumidor autorregulado",
- "titleEdit": "Editar Consumidor autorregulado"
+ "titleEdit": "Editar consumidor autorregulado"
},
"battery": {
"title": "Batería",
@@ -105,8 +123,8 @@
"titleEdit": "Editar batería"
},
"charge": {
- "titleAdd": "Añadir medidor de carga",
- "titleEdit": "Editar medidor de carga"
+ "titleAdd": "Añadir contador de carga",
+ "titleEdit": "Editar contador de carga"
},
"charger": {
"chargers": "Cargadores de vehículos eléctricos",
@@ -114,28 +132,28 @@
"heatingdevices": "Dispositivos de calefacción",
"ocppConfirmContinue": "Tu cargador aún no se ha conectado a evcc. ¿Estás seguro de que deseas continuar?",
"ocppConnected": "¡Conectado!",
- "ocppDescription": "evcc tiene un servidor OCPP integrado. Siga estos pasos:",
+ "ocppDescription": "evcc tiene un servidor OCPP integrado. Sigue estos pasos:",
"ocppHelp": "Copia esta URL en la configuración de tu cargador. Consulta el manual del fabricante para más detalles. El cargador añadirá automáticamente su identificador único (ID de estación) a la URL. En casos excepcionales, es posible que debas especificar el identificador manualmente. Ejemplo: `{url}`",
"ocppLabel": "URL del servidor OCPP",
"ocppNextStep": "Siguiente paso",
- "ocppStep1": "Configure su cargador para utilizar evcc como servidor OCPP.",
+ "ocppStep1": "Configura tu cargador para utilizar evcc como servidor OCPP.",
"ocppStep2": "Espera a que tu cargador se conecte a evcc.",
"ocppStep3": "Continúe y complete la configuración.",
"ocppWaiting": "Esperando conexión",
"switchsockets": "Tomas conmutables",
"template": "Fabricante",
"titleAdd": {
- "charging": "Añadir Cargador",
- "heating": "Añadir Calefactor"
+ "charging": "Añadir cargador",
+ "heating": "Añadir dispositivo de calefacción"
},
"titleEdit": {
- "charging": "Editar Cargador",
- "heating": "Editar Calefactor"
+ "charging": "Editar cargador",
+ "heating": "Editar dispositivo de calefacción"
},
"type": {
"custom": {
"charging": "Cargador definido por el usuario",
- "heating": "Calefactor definido por el usuario"
+ "heating": "Dispositivo de calefacción definido por el usuario"
},
"heatpump": "Bomba de calor definida por el usuario",
"sgready": "Bomba de calor definida por el usuario (sg-ready via plugins)",
@@ -144,14 +162,42 @@
"switchsocket": "Enchufe conmutable definido por el usuario"
}
},
+ "circuit": {
+ "addMeter": "Añadir contador de energía dedicado",
+ "custom": "Circuito definido por el usuario",
+ "deleteChildrenFirst": "Para eliminar este circuito, elimina primero sus circuitos subordinados.",
+ "meterDedicated": "Contador dedicado",
+ "meterGrid": "Contador de red",
+ "meterHelp": "Contador que mide el consumo total de este circuito.",
+ "meterLabel": "Contador de energía",
+ "meterLabelAdd": "Añadir contador de circuito",
+ "meterLabelEdit": "Editar contador de circuito",
+ "meterNone": "Sin contador",
+ "meterSelectionHelpDedicatedMeter": "Añade abajo un contador existente a este circuito.",
+ "meterSelectionHelpGridMeter": "Utiliza el contador de red existente de tu instalación.",
+ "meterSelectionHelpNoMeter": "Se usa la suma de todos los puntos de carga.",
+ "meterSelectionLabel": "Selección de contador",
+ "parentCircuit": "Circuito superior",
+ "template": "Circuito",
+ "titleAdd": "Añadir circuito",
+ "titleEdit": "Editar circuito",
+ "titleLabel": "Título"
+ },
"circuits": {
+ "addMainCircuit": "Añadir circuito principal",
+ "addSubCircuit": "Añadir circuito subordinado",
+ "chargingPointsNote": "Asigna los puntos de carga y las calefacciones a un circuito en los ajustes de cada uno.",
"description": "Asegura que la suma de todos los puntos de carga conectados a un circuito no exceda la potencia configurada y los límites de corriente. Los circuitos pueden ser anidados para construir una jerarquía.",
- "title": "Gestión de Carga",
- "usableMeters": "Referencias de medidores utilizables"
+ "legacyWarning": "¡Hay una nueva configuración de circuitos disponible! Elimina aquí tu configuración actual y guarda para usar el nuevo proceso guiado.",
+ "meterDedicated": "Contador",
+ "meterGrid": "Red eléctrica",
+ "title": "Gestión de carga",
+ "usableMeters": "Referencias de contadores utilizables"
},
"consumer": {
"description": "Monitorea electrodomésticos más pequeños que no se pueden controlar (frigorífico, lavadora...). Te ofrece una mejor visión general del consumo de tu hogar.",
- "titleEdit": "Editar medidor de consumo"
+ "titleAdd": "Añadir contador de consumo",
+ "titleEdit": "Editar contador de consumo"
},
"control": {
"description": "Los valores predeterminados son razonables. Cámbialos solo si sabes lo que estás haciendo.",
@@ -168,34 +214,34 @@
"title": "Moneda"
},
"curtailer": {
- "description": "Dispositivo que limita la inyección de energía a petición del operador de la red. Muchos inversores solares permiten esta función directamente a través de la configuración del medidor solar. Solo es necesaria una configuración independiente en el caso de algunos fabricantes o de sistemas con múltiples inversores.",
+ "description": "Dispositivo que limita el vertido a la red a petición del operador de red (p. ej., § 9 EEG en Alemania). Muchos inversores solares permiten esta función directamente a través de la configuración del contador solar. Solo es necesaria una configuración independiente en el caso de algunos fabricantes o de sistemas con varios inversores.",
"generic": "Integraciones genéricas",
"specific": "Integraciones específicas",
"template": "Fabricante",
"title": "Limitación de vertido",
- "titleAdd": "Agregar Dispositivo para Limitación de Vertido",
- "titleEdit": "Editar Dispositivo para Limitación de Vertido"
+ "titleAdd": "Añadir dispositivo de limitación de vertido",
+ "titleEdit": "Editar dispositivo de limitación de vertido"
},
"deviceValue": {
"activeClients": "Clientes activos",
"amount": "Importe",
"asleep": "en reposo",
- "broker": "Corredor",
+ "broker": "Broker",
"bucket": "Bucket",
"capacity": "Capacidad",
"chargeStatus": "Estado",
"chargeStatusA": "no conectado",
- "chargeStatusB": "conectad@",
+ "chargeStatusB": "conectado",
"chargeStatusC": "cargando",
"chargeStatusE": "sin energía",
"chargeStatusF": "error",
- "chargedEnergy": "Cargado",
+ "chargedEnergy": "Cargado (sesión)",
"co2": "Red de CO₂",
"configured": "Configurado",
"connected": "Conectado",
"connections": "Conexiones",
"controllable": "Controlable",
- "currency": "Divisa",
+ "currency": "Moneda",
"current": "Corriente",
"currentRange": "Corriente",
"curtailLimit": "Límite de vertido",
@@ -211,24 +257,26 @@
"feedinPrice": "Precio de entrada",
"forecast": "Previsión",
"gridPrice": "Precio de red",
- "heaterTempLimit": "Límite del calentador",
+ "heaterTempLimit": "Límite de calefacción",
"identifier": "Identificador RFID",
"loginBlocked": "Límite de inicios de sesión alcanzado",
"max": "máx.",
+ "maxCurrent": "Corriente máxima",
+ "maxPower": "Potencia máxima",
"messengers": "Servicios",
"no": "no",
"odometer": "Cuentakilómetros",
"org": "Organización",
"outdoorTemp": "Temperatura exterior",
- "phaseCurrents": "Actual",
+ "phaseCurrents": "Corriente",
"phasePowers": "Potencia",
"phaseVoltages": "Voltaje",
- "phases1p3p": "Interruptor de fase",
+ "phases1p3p": "Conmutación de fases",
"power": "Potencia",
"powerRange": "Potencia",
"price": "Precio",
"productionLimited": "Producción limitada",
- "range": "Alcance",
+ "range": "Autonomía",
"remoteEnabled": "Activado",
"returnEnergy": "Energía (inversa)",
"singlePhase": "Monofásico",
@@ -287,20 +335,20 @@
"title": "Certificados"
},
"configuredDevices": "Dispositivos configurados",
- "description": "Configuración que permite a evcc comunicarse con dispositivos compatibles EEBus, como cargadores o una unidad de control de su operador de red. Toda la inicialización y la generación de certificados pertinentes se realizan automáticamente al iniciar del sistema.",
- "descriptionAdvanced": "No es necesario realizar cambios. Solo realice cambios si sabe realmente lo que está haciendo. Si cambia el SHIP-id o los certificados, tendrá que volver a emparejar sus dispositivos.",
+ "description": "Configuración que permite a evcc comunicarse con dispositivos compatibles con EEBus, como cargadores o una unidad de control de tu operador de red. Toda la inicialización y la generación de certificados pertinentes se realizan automáticamente en el primer inicio.",
+ "descriptionAdvanced": "No es necesario realizar cambios. Solo realiza cambios si sabes realmente lo que estás haciendo. Si cambias el SHIP-ID o los certificados, tendrás que volver a emparejar tus dispositivos.",
"interfaces": "Interfaces",
- "interfacesHelp": "Limite las interfaces de red que EEBus debe utilizar para evitar problemas de comunicación. Deje blanco para utilizar todas las interfaces. Una entrada por línea.",
+ "interfacesHelp": "Limita las interfaces de red que EEBus debe utilizar para evitar problemas de comunicación. Deja el campo en blanco para utilizar todas las interfaces. Una entrada por línea.",
"noConfiguredDevices": "No hay dispositivos configurados mediante SKI.",
"noPairings": "Sin dispositivos emparejados.",
"pairings": "Dispositivos emparejados",
"port": "Puerto",
"portHelp": "El puerto que se va a utilizar.",
"qr": "Código de emparejamiento",
- "qrExplain": "Escanee desde otro dispositivo EEBus durante la instalación para emparejarlos automáticamente, sin necesidad de introducir el SKI manualmente.",
- "removeConfirm": "Se eliminará toda la configuración de EEBus. Se generarán nuevos certificados e identificadores en el próximo inicio. ¿Está seguro?",
+ "qrExplain": "Escanea desde otro dispositivo EEBus durante la instalación para emparejar automáticamente, sin necesidad de introducir el SKI manualmente.",
+ "removeConfirm": "Se eliminará toda la configuración de EEBus. Se generarán nuevos certificados e identificadores en el próximo inicio. ¿Estás seguro?",
"removePairing": "Eliminar emparejamiento",
- "removePairingConfirm": "El dispositivo se desconectará y deberá volver a emparejarse. ¿Está seguro?",
+ "removePairingConfirm": "El dispositivo se desconectará y deberá volver a emparejarse. ¿Estás seguro?",
"shipid": "SHIP-ID",
"shipidExplain": "Identificador permanente del dispositivo para su identificación en la red EEBus.",
"shipidHelp": "Este SHIP-ID está vinculado a los certificados que se indican a continuación.",
@@ -314,8 +362,8 @@
},
"ext": {
"description": "Realiza el seguimiento de contadores que no se utilizan para fines normativos ni se muestran en la interfaz de usuario (subdistribuciones, contadores de red alternativa o solares, etc.). Sus datos se registran y exportan al igual que los de los demás contadores.",
- "titleAdd": "Añadir medidor de consumo",
- "titleEdit": "Editar medidor adicional"
+ "titleAdd": "Añadir contador adicional",
+ "titleEdit": "Editar contador adicional"
},
"form": {
"danger": "Peligro",
@@ -338,7 +386,7 @@
"confirmSave": "Hay cambios sin guardar. ¿Guardar ahora?",
"copied": "¡Copiado!",
"copy": "Copiar",
- "customHelp": "Cree un dispositivo definido por el usuario utilizando el sistema de complementos de evcc.",
+ "customHelp": "Crea un dispositivo definido por el usuario con el sistema de plugins de evcc.",
"customOption": "Dispositivo definido por el usuario",
"delete": "Eliminar",
"disable": "Deshabilitar",
@@ -351,6 +399,7 @@
"enable": "Habilitar",
"error": "Error",
"experimental": "Experimental",
+ "forceEnable": "Habilitar de todos modos",
"forceSave": "Guardar de todos modos",
"fromYamlHint": "Nota: Configurado a través de evcc.yaml. Elimina la entrada del archivo para poder editar aquí.",
"hideAdvancedSettings": "Ocultar configuración avanzada",
@@ -369,16 +418,17 @@
"saving": "Guardando…",
"selectFile": "Explorar",
"showAdvancedSettings": "Mostrar configuración avanzada",
+ "site": "Instalación",
"telemetry": "Telemetría",
"templateLoading": "Cargando...",
"title": "Título",
- "typeDeprecated": "El tipo «{type}» está obsoleto y se eliminará en una versión futura. Consulte el registro de cambios y vuelva a crear este dispositivo.",
+ "typeDeprecated": "El tipo '{type}' está obsoleto y se eliminará en una versión futura. Consulta el registro de cambios y vuelve a crear este dispositivo.",
"validateSave": "Validar y guardar"
},
"grid": {
"title": "Contador de red",
- "titleAdd": "Añadir medidor de red",
- "titleEdit": "Editar medidor de cuadrícula"
+ "titleAdd": "Añadir contador de red",
+ "titleEdit": "Editar contador de red"
},
"hems": {
"changeConfirm": "La configuración existente se eliminará. ¿Continuar?",
@@ -411,7 +461,7 @@
}
},
"icon": {
- "change": "cambio",
+ "change": "cambiar",
"label": "Icono"
},
"influx": {
@@ -420,7 +470,7 @@
"labelBucket": "Bucket",
"labelCheckInsecure": "Permitir certificados autofirmados",
"labelDatabase": "Base de datos",
- "labelInsecure": "Verificación de certificado",
+ "labelInsecure": "Validación de certificado",
"labelOrg": "Organización",
"labelPassword": "Contraseña",
"labelToken": "Token de la API",
@@ -433,14 +483,14 @@
"loadpoint": {
"addCharger": {
"charging": "Añadir cargador",
- "heating": "Añadir calefactor"
+ "heating": "Añadir dispositivo de calefacción"
},
- "addMeter": "Añadir medidor de energía dedicado",
+ "addMeter": "Añadir contador de energía dedicado",
"advancedConfig": "Configuración avanzada",
"cancel": "Cancelar",
"chargerError": {
"charging": "Es necesario configurar un cargador.",
- "heating": "Es necesario configurar un calefactor."
+ "heating": "Es necesario configurar un dispositivo de calefacción."
},
"chargerLabel": {
"charging": "Cargador",
@@ -453,11 +503,12 @@
"circuitUnassigned": "sin asignar",
"created": "“{name}” fue creado y utiliza valores predeterminados razonables.",
"createdTitle": {
- "charging": "Punto de carga creado"
+ "charging": "Punto de carga creado",
+ "heating": "Calefacción creada"
},
"creating": {
"charging": "Cargador guardado. Creando punto de carga…",
- "heating": "Dispositivo de calefacción guardado. Creando calefactor…"
+ "heating": "Dispositivo de calefacción guardado. Creando calefacción …"
},
"currentRangeHelp": {
"charging": "Típica: 6-16 A para cargadores de 11 kW, 6-32 A para cargadores de 22 kW (trifásicos).",
@@ -468,11 +519,11 @@
"heating": "Se establece al iniciar el sistema."
},
"defaultModeHelpKeep": "Mantiene el último modo seleccionado.",
- "defaultModeKeep": "mantener estado actual",
+ "defaultModeKeep": "mantener como está",
"defaultModeLabel": "Modo predeterminado",
"delete": "Eliminar",
- "electricalSubtitle": "En caso de duda, consulte a su electricista.",
- "electricalTitle": "Eléctrico",
+ "electricalSubtitle": "En caso de duda, consulta a tu electricista.",
+ "electricalTitle": "Parte eléctrica",
"energyMeterHelp": "Medidor adicional si el cargador no tiene uno integrado.",
"energyMeterLabel": "Contador de energía",
"estimateLabel": "Interpolar el nivel de carga entre actualizaciones de la API",
@@ -507,8 +558,8 @@
"priorityHelp": "Las prioridades más altas obtienen acceso preferencial al excedente solar.",
"priorityLabel": "Prioridad",
"save": "Guardar",
- "solarBehaviorCustomHelp": "Defina sus propios umbrales y retrasos de activación y desactivación.",
- "solarBehaviorDefaultHelp": "Comience después de {enableDelay} de excedente suficiente. Deténgase cuando no haya suficiente excedente para {disableDelay}.",
+ "solarBehaviorCustomHelp": "Define tus propios umbrales y retrasos de activación y desactivación.",
+ "solarBehaviorDefaultHelp": "Comienza tras {enableDelay} de excedente suficiente. Detente cuando no haya excedente suficiente durante {disableDelay}.",
"solarBehaviorLabel": "Solar",
"solarModeCustom": "personalizado",
"solarModeMaximum": "máximo solar",
@@ -517,19 +568,19 @@
"temperatureRangeHelp": "Rango de visualización para la barra de temperatura. No afecta al control.",
"temperatureRangeTitle": "Rango de temperatura",
"thresholdDisableDelayLabel": "Desactivar retraso",
- "thresholdDisableHelpInvalid": "Utilice un valor positivo.",
- "thresholdDisableHelpPositive": "Deténgase cuando se utilice más de {power} de la red durante {delay}.",
- "thresholdDisableHelpZero": "Deténgase cuando no se pueda satisfacer la potencia mínima requerida durante {delay}.",
- "thresholdDisableLabel": "Desactivar la alimentación de la red eléctrica",
+ "thresholdDisableHelpInvalid": "Usa un valor positivo.",
+ "thresholdDisableHelpPositive": "Se detiene cuando se utiliza más de {power} de la red durante {delay}.",
+ "thresholdDisableHelpZero": "Se detiene cuando no se puede satisfacer la potencia mínima requerida durante {delay}.",
+ "thresholdDisableLabel": "Umbral de desactivación de la potencia de red",
"thresholdEnableDelayLabel": "Habilitar retraso",
- "thresholdEnableHelpInvalid": "Utilice un valor negativo.",
- "thresholdEnableHelpNegative": "Comience cuando haya {surplus} excedente disponible para {delay}.",
- "thresholdEnableHelpZero": "Comience cuando se pueda satisfacer la potencia mínima requerida durante {delay}.",
- "thresholdEnableLabel": "Habilitar la alimentación de la red eléctrica",
+ "thresholdEnableHelpInvalid": "Usa un valor negativo.",
+ "thresholdEnableHelpNegative": "Comienza cuando hay {surplus} de excedente disponible durante {delay}.",
+ "thresholdEnableHelpZero": "Se inicia cuando se puede satisfacer la potencia mínima requerida durante {delay}.",
+ "thresholdEnableLabel": "Umbral de activación de la potencia de red",
"titleAdd": {
"charging": "Añadir punto de recarga",
"heating": "Añadir calefacción",
- "unknown": "Añadir punto de carga o calentador"
+ "unknown": "Añadir punto de carga o calefacción"
},
"titleEdit": {
"charging": "Editar punto de recarga",
@@ -538,7 +589,7 @@
},
"titleExample": {
"charging": "Garaje, cochera, etc.",
- "heating": "Bomba de calor, calefactor, etc."
+ "heating": "Bomba de calor, calefacción, etc."
},
"titleLabel": "Título",
"vehicleAutoDetection": "detección automática",
@@ -549,10 +600,10 @@
"vehiclesTitle": "Vehículos"
},
"main": {
- "addAdditional": "Añadir medidor adicional",
+ "addAdditional": "Añadir contador adicional",
"addConsumer": "Añadir consumidor",
"addCurtailer": "Añadir dispositivo de limitación de vertido",
- "addGrid": "Añadir medidor de red",
+ "addGrid": "Añadir contador de red",
"addLoadpoint": "Añadir punto de carga o calefacción",
"addPvBattery": "Añadir solar o batería",
"addTariffs": "Añadir aranceles",
@@ -564,8 +615,8 @@
"title": "Configuración",
"unconfigured": "no configurado",
"vehicles": "Mis vehículos",
- "welcomeBannerText": "Empiece por crear al menos un **cargador**, **calentador**, **red**, **solar**, **batería** o **contador adicional**. Si solo desea realizar una prueba, elija un **dispositivo de demostración**.",
- "welcomeBannerTitle": "¡Configuremos su sistema!"
+ "welcomeBannerText": "Empieza creando al menos un **cargador**, **calefacción**, **red eléctrica**, **solar**, **batería** o **contador adicional**. Si solo quieres hacer una prueba, elige un **dispositivo de demostración**.",
+ "welcomeBannerTitle": "¡Configuremos tu sistema!"
},
"mcp": {
"description": "Activa un servidor Model Context Protocol para que asistentes de IA como Claude puedan leer el estado de tu sistema y controlar la carga.",
@@ -643,17 +694,17 @@
"meter": {
"cancel": "Cancelar",
"convertToConsumer": {
- "confirm": "Convierte este \"medidor adicional\" en un \"consumidor\".",
+ "confirm": "Se convierte este \"contador adicional\" en un \"consumidor\".",
"link": "Convertir en consumidor"
},
- "delete": "Borrar",
+ "delete": "Eliminar",
"generic": "Integraciones genéricas",
"option": {
"aux": "Añadir consumidor autorregulado",
- "battery": "Añadir medidor de batería",
+ "battery": "Añadir contador de batería",
"consumer": "Añadir consumidor regular",
- "grid": "Añadir medidor de red",
- "pv": "Añadir medidor solar"
+ "grid": "Añadir contador de red",
+ "pv": "Añadir contador solar"
},
"save": "Guardar",
"specific": "Integraciones específicas",
@@ -664,7 +715,7 @@
"aux": "Consumidor autorregulado",
"battery": "Batería",
"charge": "Cargador",
- "grid": "Red",
+ "grid": "Red eléctrica",
"label": "Uso",
"pv": "Producción"
},
@@ -717,16 +768,16 @@
},
"mqtt": {
"authentication": "Autenticación",
- "description": "Conéctese a un bróker MQTT para intercambiar datos con otros sistemas de su red.",
- "descriptionClientId": "Autor de los mensajes. Si está vacío, se utiliza «evcc-[rand]».",
+ "description": "Conéctate a un broker MQTT para intercambiar datos con otros sistemas de tu red.",
+ "descriptionClientId": "Autor de los mensajes. Si está vacío, se utiliza `evcc-[rand]`.",
"descriptionTopic": "Déjelo vacío para desactivar la publicación.",
- "labelBroker": "Corredor",
+ "labelBroker": "Broker",
"labelCaCert": "Certificado de servidor (CA)",
"labelCheckInsecure": "Permitir certificados autofirmados",
"labelClientCert": "Certificado de cliente",
"labelClientId": "ID del cliente",
"labelClientKey": "Clave del cliente",
- "labelInsecure": "Verificación de certificado",
+ "labelInsecure": "Validación de certificado",
"labelPassword": "Contraseña",
"labelTopic": "Tema",
"labelUser": "Nombre de usuario",
@@ -735,9 +786,9 @@
},
"network": {
"descriptionExternalUrl": "Dirección para otros dispositivos que deseen conectarse a evcc y para la detección automática de la aplicación evcc.",
- "descriptionHost": "Se utiliza para anunciar evcc en su red local.",
+ "descriptionHost": "Se utiliza para anunciar evcc en tu red local.",
"descriptionInternalUrl": "Dirección de red local de evcc.",
- "descriptionPort": "Puerto para la interfaz web y la API. Si lo cambia, deberá actualizar la URL de su navegador.",
+ "descriptionPort": "Puerto para la interfaz web y la API. Si lo cambias, tendrás que actualizar la URL de tu navegador.",
"labelExternalUrl": "URL externa",
"labelHost": "Nombre de host mDNS",
"labelInternalUrl": "URL interna",
@@ -759,7 +810,7 @@
},
"title": "Servidor OCPP",
"url": "URL del servidor",
- "urlHelp": "Copie esta URL en la configuración de su cargador. Consulte el manual del fabricante para obtener más detalles. Se espera que el cargador añada automáticamente su identificador único (ID de estación) a la URL. En casos excepcionales, es posible que tenga que especificar manualmente el identificador. Ejemplo: `{url}`"
+ "urlHelp": "Copia esta URL en la configuración de tu cargador. Consulta el manual del fabricante para más detalles. El cargador añadirá automáticamente su identificador único (ID de estación) a la URL. En casos excepcionales, es posible que debas especificar el identificador manualmente. Ejemplo: `{url}`"
},
"ocppforwarder": {
"description": "Permite reenviar los mensajes OCPP de este cargador a un servidor externo (plataforma de facturación, distribuidora, etc.), adicionalmente al envío a evcc.",
@@ -768,19 +819,27 @@
"labelCheckInsecure": "Permitir certificados autofirmados",
"labelInsecure": "Validación de certificado",
"password": "Contraseña",
- "passwordHelp": "Para la autenticación básica HTTP al servidor ascendente.",
+ "passwordHelp": "Para la autenticación HTTP Basic con el servidor externo.",
"readOnly": {
"check": "Bloquear comandos del servidor ascendente",
"help": {
"false": "El sistema ascendente puede enviar comandos al cargador a través de evcc.",
"true": "El sistema ascendente recibe todos los mensajes del cargador, pero no puede enviar comandos. evcc conserva el control exclusivo."
- }
- }
+ },
+ "label": "Comandos del servidor externo"
+ },
+ "status": "Estado",
+ "upstreamStationId": "ID de estación",
+ "upstreamStationIdHelp": "Identificador del cargador que se envía al servidor externo.",
+ "upstreamUrl": "URL del servidor externo",
+ "upstreamUrlHelp": "URL WebSocket OCPP del servidor externo.",
+ "username": "Nombre de usuario",
+ "usernameHelp": "Para la autenticación HTTP Basic con el servidor externo."
},
"optimizer": {
"description": "Analiza la previsión solar, los precios de la electricidad y tus patrones de consumo para optimizar la estrategia de batería y carga. Los datos se envían al servicio de optimización de evcc para procesarlos. Actualmente solo calcula y muestra los resultados. Aún no controla dispositivos.",
"enable": "Activar optimizador",
- "info": "La entrada del optimizador en el menú puede tardar unos minutos en aparecer. En instalaciones nuevas, evcc puede necesitar hasta 24 horas para recopilar suficientes datos.",
+ "info": "En instalaciones nuevas, evcc puede tardar hasta 24 horas en recopilar suficientes datos.",
"title": "Optimizador"
},
"options": {
@@ -824,42 +883,87 @@
"deviceName": "Nombre del dispositivo",
"disconnected": "Desconectado",
"done": "Hecho",
- "enableLabel": "Habilitar acceso remoto"
+ "enableLabel": "Habilitar acceso remoto",
+ "expiration": "Expiración",
+ "expirationNone": "Nunca",
+ "expired": "caducado",
+ "expiresIn": "caduca {time}",
+ "lastActive": "activo {time}",
+ "loginBlocked": "Los inicios de sesión remotos están bloqueados durante un minuto debido a demasiados intentos fallidos.",
+ "manualLogin": "O inicia sesión manualmente en {url} desde tu navegador con estas credenciales:",
+ "noClients": "Todavía no hay clientes. Nadie puede conectarse aún.",
+ "password": "Contraseña",
+ "passwordOnce": "Esta contraseña solo se muestra una vez. Escanea el código QR o cópiala ahora. No podrás volver a verla.",
+ "privacy": "Al habilitar esta función, aceptas nuestra {0}.",
+ "privacyLink": "política de privacidad",
+ "qrInstall": "Instala la app de evcc para {ios} o {android}.",
+ "qrScan": "Escanea el código con la cámara de tu móvil para conectarte. Si ya estás usando el móvil, púlsalo.",
+ "removeClient": "Eliminar cliente",
+ "title": "Acceso remoto",
+ "url": "URL pública",
+ "username": "Nombre de usuario"
},
"section": {
- "additionalMeter": "Medidores adicionales",
+ "additionalMeter": "Contadores adicionales",
+ "consumers": "Consumidores",
"general": "General",
"grid": "Red eléctrica",
"integrations": "Integraciones",
- "loadpoints": "Carga y calefacción",
- "meter": "Solar y Batería",
+ "loadpoints": "Puntos de carga y calefacciones",
+ "meter": "Solar y batería",
"services": "Servicios",
"system": "Sistema",
"vehicles": "Vehículos"
},
+ "security": {
+ "authDisabledHint": "La autenticación está deshabilitada. La gestión de credenciales no está disponible.",
+ "description": "Gestiona la autenticación y las credenciales de esta instancia.",
+ "passwordDescription": "Cambia la contraseña de acceso a la interfaz de configuración.",
+ "passwordTitle": "Contraseña de administrador",
+ "title": "Seguridad",
+ "updatePassword": "Cambiar contraseña"
+ },
"shm": {
"cardTitle": "Sunny Home Manager",
- "description": "evcc está equipado con integración para SMA Sunny Home Manager (SHM) a través del protocolo SEMP. Si se ejecuta en la misma red, después de iniciar sesión en su cuenta de Sunny Portal, se le ofrecerá automáticamente añadir todos los cargadores configurados en evcc como consumidores recién detectados. Todo debería estar listo para su uso inmediato, sin necesidad de realizar los ajustes que se indican a continuación.",
+ "description": "evcc incluye integración con SMA Sunny Home Manager (SHM) mediante el protocolo SEMP. Si se ejecuta en la misma red, después de iniciar sesión en tu cuenta de Sunny Portal se te ofrecerá automáticamente añadir todos los cargadores configurados en evcc como consumidores recién detectados. Todo debería estar listo para usar de inmediato, sin necesidad de realizar los ajustes que se indican a continuación.",
"descriptionDeviceId": "Cadena HEX de 12 caracteres. Prefijo para todos los dispositivos (punto de carga, etc.).",
+ "descriptionDeviceSerial": "Cadena HEX de 12 caracteres. Número de serie base para todos los dispositivos (punto de carga, etc.). Por defecto, evcc lo deriva de la dirección MAC del host.",
"descriptionIdPattern": "Patrón identificador",
- "descriptionIds": "En Sunny Portal, cada dispositivo de consumo necesita un identificador único. evcc genera un identificador único basado en su hardware. Si migra evcc a otro hardware, estos identificadores pueden cambiar. Si desea mantener el historial, puede anular los identificadores generados aquí. Abra la URL SEMP (/semp) para comprobar sus identificadores actuales.",
+ "descriptionIds": "En Sunny Portal, cada dispositivo de consumo necesita un identificador único. evcc genera un identificador único basado en tu hardware. Si migras evcc a otro hardware, estos identificadores pueden cambiar. Si quieres mantener el historial, puedes anular aquí los identificadores generados. Abre la URL SEMP (/semp) para comprobar tus identificadores actuales.",
"descriptionSempUrl": "URL de SEMP",
"descriptionVendorId": "Cadena HEX de 8 caracteres. Prefijo general de todas las entidades. Por defecto, evcc utilizará su propio ID de proveedor interno.",
"labelDeviceId": "Identificador del dispositivo",
+ "labelDeviceSerial": "Número de serie del dispositivo",
"labelVendorId": "Identificación del proveedor",
"title": "SMA Sunny Home Manager"
},
+ "site": {
+ "country": {
+ "description": "Se utiliza para avisos y valores predeterminados específicos de cada país.",
+ "label": "País"
+ },
+ "currency": {
+ "description": "Se utiliza para mostrar los precios de la energía, los costes y los ahorros según tu tarifa.",
+ "example": "La carga te ha costado {price}. Has ahorrado {amount}.",
+ "label": "Moneda"
+ },
+ "siteTitle": {
+ "description": "Se muestra en la pantalla principal y en la pestaña del navegador.",
+ "label": "Título"
+ },
+ "title": "Instalación"
+ },
"sponsor": {
- "addToken": "Ingresa el token del patrocinador",
+ "addToken": "Introducir token",
"changeToken": "Cambiar el token de patrocinador",
"description": "El modelo de patrocinio nos ayuda a mantener el proyecto y a desarrollar de forma sostenible nuevas y emocionantes funciones. Como patrocinador, tendrás acceso a todas las implementaciones del cargador.",
- "descriptionToken": "Obtienes el token desde {url}. También ofrecemos un token de prueba para probar {trialToken}.",
- "enterYourToken": "Introduce tu token",
+ "descriptionToken": "Como patrocinador de GitHub, encontrarás tu token en {url}. Para empezar, ofrecemos un {trialToken}.",
+ "enterYourToken": "Tu token de patrocinador",
"error": "El token del patrocinador no es válido.",
"invalid": "no válido",
"labelToken": "Token del patrocinador",
"title": "Patrocinio",
- "tokenRequired": "Debe configurar un token de patrocinador antes de poder crear este dispositivo.",
+ "tokenRequired": "Debes configurar un token de patrocinador antes de poder crear este dispositivo.",
"tokenRequiredFeature": "Esta función requiere un token de patrocinador.",
"tokenRequiredLearnMore": "Más información.",
"tokenRequiredShort": "No hay ningún token de patrocinador configurado.",
@@ -873,17 +977,19 @@
"action": "Descargar copia de seguridad...",
"confirmationButton": "Descargar copia de seguridad",
"confirmationText": "Descargar el archivo de la base de datos.",
- "description": "Haga una copia de seguridad de sus datos en un archivo. Este archivo se puede utilizar para restaurar sus datos en caso de fallo del sistema.",
+ "description": "Haz una copia de seguridad de tus datos en un archivo. Este archivo se puede utilizar para restaurar tus datos en caso de fallo del sistema.",
"title": "Copia de seguridad"
},
"cancel": "Cancelar",
- "description": "Copia de seguridad, restauración y restablecimiento de datos. Útil si desea trasladar sus datos a otro sistema.",
- "note": "Nota: Todas las acciones anteriores solo afectan a los datos de su base de datos. El archivo de configuración evcc.yaml permanece sin cambios.",
+ "description": "Copia de seguridad, restauración y restablecimiento de datos. Útil si quieres trasladar tus datos a otro sistema.",
+ "note": "Nota: Todas las acciones anteriores solo afectan a los datos de tu base de datos. El archivo de configuración evcc.yaml permanece sin cambios.",
"reset": {
"action": "Restablecer...",
"confirmationButton": "Restablecer y reiniciar",
"confirmationText": "Esto eliminará permanentemente los datos seleccionados. Asegúrate de haber descargado una copia de seguridad primero.",
"description": "¿Tienes problemas con la configuración y quieres empezar de nuevo? Elimina todos los datos y empieza de cero.",
+ "remote": "Acceso remoto",
+ "remoteDescription": "Elimina la configuración y las credenciales.",
"sessions": "Sesiones de carga",
"sessionsDescription": "Elimina el historial de sesiones de carga.",
"settings": "Configuración y ajustes",
@@ -894,7 +1000,7 @@
"action": "Restaurar...",
"confirmationButton": "Restaurar y reiniciar",
"confirmationText": "Esto sobrescribirá toda tu base de datos. Asegúrate de haber descargado una copia de seguridad primero.",
- "description": "Restaure sus datos desde un archivo de copia de seguridad. Esto sobrescribirá todos sus datos actuales.",
+ "description": "Restaura tus datos desde un archivo de copia de seguridad. Esto sobrescribirá todos tus datos actuales.",
"labelFile": "Archivo de copia de seguridad",
"title": "Restaurar"
},
@@ -904,11 +1010,92 @@
"restart": "Reiniciar",
"restartRequiredDescription": "Por favor, reinicie para ver el efecto.",
"restartRequiredMessage": "Configuración modificada.",
- "restartingDescription": "Espere por favor…",
+ "restartingDescription": "Espera, por favor…",
"restartingMessage": "Reiniciando evcc."
},
+ "tariff": {
+ "addForecast": "Añadir previsión",
+ "addTariff": "Añadir tarifa",
+ "co2": {
+ "description": "Previsión de la intensidad de CO₂ de la electricidad de la red. Para optimizar la carga en función del CO₂ y calcular el ahorro de emisiones.",
+ "titleAdd": "Añadir previsión de CO₂",
+ "titleEdit": "Editar previsión de CO₂"
+ },
+ "co2Services": "Servicios de CO₂",
+ "customForecast": "Previsión definida por el usuario",
+ "customTariff": "Tarifa definida por el usuario",
+ "description": "Configura tus tarifas de energía y previsiones. Usa la configuración basada en dispositivos para una gestión dinámica o YAML para ajustes estáticos.",
+ "feedIn": {
+ "description": "Remuneración por la electricidad vertida a la red. Se utiliza para calcular los costes reales de carga.",
+ "titleAdd": "Añadir tarifa de vertido a la red",
+ "titleEdit": "Editar tarifa de vertido a la red"
+ },
+ "generic": "Integraciones genéricas",
+ "grid": {
+ "description": "Precio de la electricidad consumida de la red. Se utiliza para calcular los costes reales de carga y para la carga optimizada por precio de vehículos, dispositivos de calefacción o la carga de la batería doméstica desde la red.",
+ "titleAdd": "Añadir tarifa de consumo de la red",
+ "titleEdit": "Editar tarifa de consumo de la red"
+ },
+ "legacyWarning": "¡Hay una nueva configuración de tarifas disponible! Elimina aquí tus tarifas actuales y guarda para usar el nuevo proceso guiado.",
+ "option": {
+ "co2": "Añadir previsión de CO₂",
+ "feedIn": "Añadir tarifa de vertido a la red",
+ "grid": "Añadir tarifa de consumo de la red",
+ "planner": "Añadir previsión para la planificación",
+ "solar": "Añadir previsión solar",
+ "temperature": "Añadir previsión de temperatura"
+ },
+ "planner": {
+ "description": "Ajuste avanzado. Normalmente no es necesario, ya que las tarifas eléctricas dinámicas o las previsiones de CO₂ se utilizan automáticamente. Habilita una fuente de datos adicional que solo se usa para la planificación de la carga, no para estadísticas ni cálculos de precios.",
+ "titleAdd": "Añadir previsión para la planificación",
+ "titleEdit": "Editar previsión para la planificación"
+ },
+ "services": "Servicios",
+ "solar": {
+ "description": "Previsión de producción solar para tu sistema solar. Se muestra en la interfaz y se utilizará más adelante para algoritmos de optimización.",
+ "titleAdd": "Añadir previsión solar",
+ "titleEdit": "Editar previsión solar"
+ },
+ "temperature": {
+ "description": "Previsión de la temperatura exterior en tu ubicación. Se muestra en la interfaz y se utiliza para predecir la demanda de calefacción.",
+ "titleAdd": "Añadir previsión de temperatura",
+ "titleEdit": "Editar previsión de temperatura"
+ },
+ "template": "Proveedor",
+ "title": "Tarifas y previsiones",
+ "titleChoice": "¿Qué quieres añadir?",
+ "type": {
+ "co2": "Intensidad de CO₂",
+ "feedIn": "Precio de vertido a la red",
+ "grid": "Precio de consumo de la red",
+ "planner": "Planificador",
+ "solar": "Solar",
+ "temperature": "Previsión de temperatura"
+ },
+ "zones": {
+ "add": "Añadir tramo",
+ "allDays": "Todos los días",
+ "allMonths": "Todos los meses",
+ "allTimes": "Siempre",
+ "cancel": "Cancelar",
+ "charge": "Cargar",
+ "days": "Días",
+ "edit": "Editar",
+ "hours": "Horas",
+ "months": "Meses",
+ "price": "Precio",
+ "priceRequired": "El precio es obligatorio",
+ "remove": "Eliminar tramo",
+ "save": "Guardar",
+ "selectAll": "Todos los días",
+ "timeFrom": "Desde",
+ "timeRangeError": "La hora de inicio debe ser anterior a la hora de fin. Para abarcar la medianoche, crea dos tramos distintos.",
+ "timeTo": "Hasta",
+ "weekdays": "Días de la semana"
+ }
+ },
"telemetry": {
- "description": "Configure el intercambio de datos para ayudar a mejorar evcc. Su privacidad es importante para nosotros y la participación es totalmente opcional.",
+ "description": "Configura el intercambio de datos para ayudar a mejorar evcc. Tu privacidad es importante para nosotros y la participación es totalmente opcional.",
"title": "Telemetría"
},
"title": {
@@ -919,7 +1106,7 @@
"validation": {
"failed": "fallido",
"label": "Estado",
- "running": "Validando…",
+ "running": "validando…",
"success": "exitoso",
"unknown": "desconocido",
"validate": "validar"
@@ -939,7 +1126,7 @@
"greenEnergy": "Energía Solar",
"greenEnergySub1": "Cargado con evcc",
"greenEnergySub2": "desde octubre de 2022",
- "greenShare": "Parte solar",
+ "greenShare": "Cuota solar",
"greenShareSub1": "energía proporcionada por",
"greenShareSub2": "solar y almacenamiento de baterías",
"power": "potencia de carga",
@@ -948,13 +1135,22 @@
"tabTitle": "Comunidad en vivo"
},
"savings": {
- "co2Saved": "{value} guardado",
+ "co2Saved": "{value} ahorrado",
"co2Title": "Emisiones de CO₂",
"configurePriceCo2": "Aprenda a configurar los datos sobre precios y CO₂.",
"footerLong": "{percent} Autoconsumo",
"footerShort": "{percent} Sol",
+ "indicator": {
+ "co2": "Emisiones de CO₂",
+ "co2saved": "CO₂ ahorrado",
+ "none": "ninguno",
+ "price": "precio de la energía",
+ "savings": "ahorros",
+ "solar": "energía solar"
+ },
+ "indicatorLabel": "Información de la cabecera",
"modalTitle": "Evaluación de la energía de carga",
- "moneySaved": "{value} guardado",
+ "moneySaved": "{value} ahorrado",
"percentGrid": "{grid} kWh red",
"percentSelf": "{self} kWh solar",
"percentTitle": "Energía solar",
@@ -965,9 +1161,10 @@
"total": "desde siempre"
},
"periodLabel": "Periodo",
- "priceTitle": "Precio de energía",
+ "priceTitle": "Precio de la energía",
"referenceGrid": "red",
"referenceLabel": "Datos de referencia",
+ "sessionInfo": "Basado en sesiones de carga completadas.",
"tabTitle": "Mis datos"
},
"sponsor": {
@@ -989,30 +1186,55 @@
"optInSponsorship": "Se requiere patrocinio."
},
"version": {
- "availableLong": "nueva versión disponible",
+ "availableLong": "actualización disponible",
+ "community": "comunidad evcc",
+ "labelEmail": "Correo electrónico",
+ "labelPhone": "Teléfono",
+ "labelRelease": "Canal",
+ "labelVersion": "Versión",
+ "labelWebsite": "Sitio web",
+ "latestVersion": "al día",
+ "madeByCommunity": "Creado por la {0}.",
"modalCancel": "Cancelar",
"modalDownload": "Descargar",
"modalInstalledVersion": "Versión instalada",
+ "modalLatest": "Estás usando la última versión.",
+ "modalNextRelease": "Novedades de la próxima versión",
"modalNoReleaseNotes": "No hay notas de lanzamiento disponibles. Puedes encontrar más información sobre la nueva versión aquí:",
"modalTitle": "Nueva versión disponible",
"modalUpdate": "Instalar",
"modalUpdateNow": "Instalar ahora",
"modalUpdateStarted": "Iniciando la nueva versión de evcc…",
- "modalUpdateStatusStart": "Instalación iniciada:"
+ "modalUpdateStatusStart": "Instalación iniciada:",
+ "modalViewOnGitHub": "Ver en GitHub",
+ "openSource": "código abierto",
+ "poweredBy": "con tecnología de"
}
},
"forecast": {
"co2": {
"average": "Promedio",
+ "constant": "Intensidad de CO₂",
"lowestHour": "La hora más limpia",
"range": "Rango"
},
+ "empty": {
+ "co2": "Consulta cuándo es limpia la energía de la red en tu región. Los planes de carga se optimizarán para reducir las emisiones y calcularán el ahorro de CO₂.",
+ "price": "Configura tu tarifa de electricidad dinámica para optimizar automáticamente los planes de carga y calcular el ahorro.",
+ "setup": "Configurar tarifas y previsiones",
+ "solar": "Consulta la producción solar prevista para hoy y los próximos días. En el futuro también se utilizará para la optimización automática de la carga.",
+ "temperature": "Consulta la temperatura exterior prevista en tu ubicación. Se utiliza para predecir la demanda de calefacción."
+ },
+ "hideLine": "ocultar línea",
"modalTitle": "Previsión",
"price": {
"average": "Promedio",
+ "constant": "Precio",
"lowestHour": "La hora más barata",
"range": "Rango"
},
+ "priceZoom": "ampliar",
+ "showLine": "mostrar línea",
"solar": {
"dayAfterTomorrow": "Pasado mañana",
"partly": "en parte",
@@ -1021,14 +1243,27 @@
"tomorrow": "Mañana"
},
"solarAdjust": "Ajustar la previsión solar basándose en datos de producción reales {percent}.",
+ "solarAdjustMedium": "ajuste con datos reales",
+ "solarAdjustShort": "ajuste",
+ "temperature": {
+ "average": "Promedio",
+ "range": "Rango"
+ },
"type": {
- "co2": "CO₂",
- "price": "Precio",
- "solar": "Solar"
+ "co2": "Emisiones de CO₂",
+ "price": "Precio de red",
+ "solar": "Producción solar",
+ "temperature": "Temperatura exterior"
}
},
"general": {
- "note": "Nota:"
+ "back": "Volver",
+ "cancel": "Cancelar",
+ "download": "Descargar",
+ "error": "Error",
+ "none": "ninguno",
+ "note": "Nota:",
+ "warning": "Advertencia"
},
"header": {
"about": "Sobre",
@@ -1041,6 +1276,7 @@
"sessions": "Sesiones de carga"
},
"help": {
+ "contactButton": "Contáctanos",
"discussionsButton": "Debates en GitHub",
"documentationButton": "Documentación",
"issueButton": "Informar de un problema",
@@ -1048,7 +1284,7 @@
"logsButton": "Ver los registros",
"logsDescription": "Comprueba los registros en busca de errores.",
"modalTitle": "¿Necesitas ayuda?",
- "primaryActions": "Si algo no funciona como debería, puedes obtener ayuda aqui.",
+ "primaryActions": "Si algo no funciona como debería, puedes obtener ayuda aquí.",
"restart": {
"cancel": "Cancelar",
"confirm": "¡Sí, reinicia!",
@@ -1065,10 +1301,10 @@
"copied": "¡Copiado!",
"copy": "Copiar",
"copySelected": "Copia seleccionada",
- "description": "Incluya la configuración y los registros para ayudarnos a reproducir el problema rápidamente. Le animamos a compartir toda la información posible. Por lo general, no es necesario indicar el estado.",
+ "description": "Incluye la configuración y los registros para ayudarnos a reproducir el problema rápidamente. Te animamos a compartir toda la información posible. Por lo general, no es necesario incluir el estado.",
"include": "incluir",
"lines": "líneas",
- "logs": "Troncos",
+ "logs": "Registros",
"logsDescription": "Entradas recientes del registro que pueden ayudar a identificar el problema.",
"showDetails": "Mostrar detalles",
"source": "Fuente",
@@ -1083,8 +1319,11 @@
"additionalContext": "Contexto adicional",
"additionalContextPlaceholder": "Cualquier información adicional que pueda ser útil...\n- Detalles de la configuración\n- Lo que ha intentado\n- Detalles del entorno",
"createButtonDiscussion": "Iniciar debate en GitHub...",
+ "createButtonEmail": "Enviar solicitud por correo electrónico",
"createButtonIssue": "Crear incidencia en GitHub...",
"description": "¿Tu instalación no funciona como esperabas? Utiliza esta página para obtener ayuda o informar de problemas. Proporciona suficientes detalles para ayudarnos a comprender y reproducir el problema, pero procura que tu descripción sea concisa, clara y fácil de seguir.",
+ "downloadButton": "Descargar información de depuración",
+ "downloadHint": "Adjunta este archivo a tu correo electrónico en caso de que se requiera más información.",
"helpType": {
"discussion": "Necesito ayuda con mi configuración",
"discussionDescription": "Los debates comunitarios proporcionan respuestas.",
@@ -1096,18 +1335,19 @@
"issueTitle": "Título",
"stepsToReproduce": "Pasos para reproducir",
"subTitleDiscussion": "Describe tu problema",
- "subTitleIssue": "Describa el problema",
+ "subTitleEmail": "Describe tu problema",
+ "subTitleIssue": "Describe el problema",
"summary": {
"confirmationButtonDiscussion": "Iniciar debate en GitHub",
"confirmationButtonIssue": "Crear incidencia en GitHub",
"copied": "¡Copiado!",
"copyButton": "Copiar información adicional",
"instructions": "Debido a las limitaciones de tamaño de las URL de GitHub, este es un proceso de dos pasos:",
- "singleStepDescription": "Haga clic en el botón siguiente para abrir GitHub con un formulario prellenado que contiene los detalles de su problema. Los datos confidenciales se han ocultado automáticamente, pero compruébelos antes de compartirlos.",
- "step1Description": "Haga clic en el botón de abajo para crear una entrada básica en GitHub con su título, descripción y detalles.",
+ "singleStepDescription": "Haz clic en el botón de abajo para abrir GitHub con un formulario prellenado que contiene los detalles de tu problema. Los datos confidenciales se han ocultado automáticamente, pero compruébalos antes de compartirlos.",
+ "step1Description": "Haz clic en el botón de abajo para crear una entrada básica en GitHub con tu título, descripción y detalles.",
"step2Description": "Después de crear la entrada, vuelve aquí para copiar la información adicional que aparece a continuación y pégala en tu formulario de GitHub. Se ha ocultado la información confidencial, pero compruébala dos veces antes de compartirla.",
"stepOneDiscussion": "Paso 1: Crear un debate básico",
- "stepOneIssue": "Paso 1: Crear un problema básico",
+ "stepOneIssue": "Paso 1: Crear una incidencia básica",
"stepTwo": "Paso 2: Copiar información adicional",
"title": "Resumen del problema de GitHub"
},
@@ -1141,6 +1381,15 @@
"title": "Autenticación"
},
"main": {
+ "alwaysCharge": {
+ "description": "sin interrupciones",
+ "label": "Cargar siempre",
+ "labelHeating": "Calentar siempre",
+ "minCurrent": "mín. {current}",
+ "once": "hasta el final de la sesión",
+ "onceTip": "Solo para esta sesión",
+ "permanent": "permanece activado"
+ },
"chargingPlan": {
"active": "Activo",
"addRepeatingPlan": "Añadir plan recurrente",
@@ -1148,85 +1397,157 @@
"goal": "Objetivo de la carga",
"modalTitle": "Plan de carga",
"none": "ninguno",
+ "once": "puntual",
"optimization": {
"cheapest": "más barato",
+ "cheapestDescription": "Selecciona los tramos más baratos. Puede provocar varios arranques y paradas.",
+ "cleanest": "más limpio",
+ "cleanestDescription": "Selecciona los tramos más limpios. Puede provocar varios arranques y paradas.",
"continuous": "continuo",
+ "continuousCo2Description": "Selecciona la serie más limpia de tramos ininterrumpidos.",
+ "continuousPriceDescription": "Selecciona la serie más barata de tramos ininterrumpidos.",
+ "disabledDescription": "Requiere una tarifa dinámica o una previsión de CO₂.",
"label": "Optimización"
},
"planNumber": "Plan {number}",
"precondition": {
- "label": "Cobro tardío",
+ "description": "Desplaza una parte de la carga a justo antes de la salida: batería templada y menos tiempo con un nivel de carga alto.",
+ "disabledDescription": "Toda la carga se realiza justo antes de partir.",
+ "label": "Carga tardía",
"optionAll": "todo",
- "optionNo": "no"
+ "optionNo": "no",
+ "summary": "{precondition} antes",
+ "summaryAll": "justo antes de la salida"
},
"remove": "Quitar",
- "repeating": "repitiendo",
+ "repeating": "recurrente",
"repeatingPlans": "Planes recurrentes",
"selectAll": "Seleccionar todo",
+ "strategy": {
+ "label": "Estrategia"
+ },
"time": "Tiempo",
"title": "Plan",
"unsavedChanges": "Hay cambios sin guardar. ¿Aplicar ahora?",
"update": "Aplicar",
"weekdays": "Días"
},
+ "continuousStatus": {
+ "charging": "Boost activo.",
+ "connected": "Operación normal.",
+ "waitForVehicle": "Boost solicitado…"
+ },
"energyflow": {
"battery": "Batería",
"batteryCharge": "Cargar la batería",
+ "batteryChargeHold": "Batería (carga bloqueada)",
"batteryDischarge": "Descargar la batería",
"batteryDischargeHold": "Batería (bloqueada)",
- "batteryGridChargeActive": "carga de red activa",
- "batteryGridChargeLimit": "carga de la red cuando",
+ "batteryGridChargeActive": "Carga de red activa",
+ "batteryGridChargeLimit": "Carga de la red cuando",
"batteryTooltip": "{energy} de {total} ({soc})",
+ "forecast": "Previsión",
"forecastTooltip": "Previsión: producción solar restante para hoy",
"gridImport": "Consumo de red",
"homePower": "Consumo",
- "loadpoints": "Punto de carga | Punto de carga | {count} Puntos de carga",
+ "loadpoints": "Punto de carga | Punto de carga | {count} puntos de carga",
"loadpointsLimit": "{limit} límite",
"noEnergy": "Sin lecturas",
"pv": "Sistema solar",
- "pvExport": "Excedente",
+ "pvExport": "Vertido a la red",
"pvProduction": "Producción",
- "selfConsumption": "Autoconsumo"
+ "selfConsumption": "Autoconsumo",
+ "toggleDetails": "Detalles del flujo de energía"
},
"heatingStatus": {
"charging": "Calefacción…",
"connected": "A la espera.",
- "vehicleLimit": "Límite del calentador",
- "waitForVehicle": "Listo. Esperando el calentador…"
+ "minTemp": "Calefacción mínima hasta {temp}.",
+ "suggestionStartTooltip": "El optimizador recomienda calentar ahora.",
+ "suggestionStopTooltip": "El optimizador recomienda pausar la calefacción.",
+ "vehicleLimit": "Límite de calefacción",
+ "waitForVehicle": "Listo para calentar…"
},
"hemsWarning": {
+ "consumption": "Consumo",
+ "feedIn": "Vertido",
"title": "Límite externo activo"
},
+ "history": {
+ "direction": {
+ "battery": {
+ "energy": "cargado",
+ "returnEnergy": "descargado"
+ },
+ "grid": {
+ "energy": "importado",
+ "returnEnergy": "exportado"
+ },
+ "meter": {
+ "energy": "Energía",
+ "returnEnergy": "Energía (inversa)"
+ }
+ },
+ "download": "Descargar",
+ "empty": "No hay datos históricos para este período.",
+ "group": {
+ "battery": "Batería",
+ "consumer": "Consumo",
+ "forecast": "Previsión",
+ "grid": "Red eléctrica",
+ "loadpoint": "Carga y calefacción",
+ "meter": "Contadores adicionales",
+ "pv": "Producción"
+ },
+ "otherConsumers": "Otros",
+ "title": "Historial"
+ },
"loadpoint": {
"avgPrice": "Precio ⌀",
"charged": "Cargado",
"co2": "CO₂ ⌀",
"duration": "Duración",
+ "emission": "Emisión",
"fallbackName": "Punto de carga",
"finished": "Hora de finalización",
+ "last24hEnergy": "Últimas 24 horas",
+ "last7dEnergy": "Últimos 7 días",
+ "phasesMismatch": "Hay más fases activas de las configuradas. Revisa la configuración de fases.",
"power": "Potencia",
"price": "Coste",
"remaining": "Tiempo restante",
"remoteDisabledHard": "{source}: desactivado",
"remoteDisabledSoft": "{source}: Carga adaptativa desactivada",
- "solar": "Solar"
+ "solar": "Solar",
+ "todayEnergy": "Hoy"
},
"loadpointSettings": {
"batteryBoost": {
"description": "Carga rápida desde la batería doméstica hasta que se descargue a {limit}.",
+ "descriptionDisabled": "Selecciona un límite para permitir la carga rápida desde la batería doméstica.",
+ "disabled": "Deshabilitado",
"label": "Aumento de la batería",
- "mode": "Solo disponible en modo solar y min+solar.",
- "once": "Impulso activo para esta sesión de carga."
+ "mode": "Solo disponible en modo inteligente.",
+ "once": "Impulso activo para esta sesión de carga.",
+ "stateActive": "Boost de batería activo",
+ "stateBelowLimit": "Batería demasiado baja para el boost",
+ "stateHold": "Batería bloqueada",
+ "stateReady": "Boost de batería listo"
},
"batteryUsage": "Batería doméstica",
"currents": "Corriente de carga",
"default": "defecto",
+ "heating": "Calefacción",
"maxCurrent": {
"label": "Corriente de carga máxima"
},
"minCurrent": {
"label": "Corriente de carga mínima"
},
+ "minTemp": {
+ "description": "Por debajo de {0}, en modo inteligente también se utiliza energía de la batería y de la red para mantener esta temperatura.",
+ "label": "Temperatura mínima"
+ },
"phasesConfigured": {
"label": "Fases",
"no1p3pSupport": "¿Cómo está conectado tu cargador?",
@@ -1238,13 +1559,33 @@
},
"smartCostCheap": "Carga barata desde la red",
"smartCostClean": "Carga desde una red ecológica",
- "title": "Configuraciones {0}",
+ "solar": "Excedente solar",
+ "solarShare": {
+ "charging": {
+ "description": "Al menos el {share} de la potencia mínima de carga debe proceder de energía solar.",
+ "descriptionFull": "La carga comienza en cuanto el excedente cubre la potencia mínima de carga.",
+ "descriptionZero": "La carga comienza en cuanto hay algo de excedente disponible."
+ },
+ "heating": {
+ "description": "Al menos el {share} de la potencia mínima de calefacción debe provenir de energía solar.",
+ "descriptionFull": "La calefacción comienza en cuanto el excedente cubre la potencia mínima de calefacción.",
+ "descriptionZero": "La calefacción comienza en cuanto hay algo de excedente disponible."
+ },
+ "label": "Cuota solar",
+ "thresholdsHint": "Se han configurado umbrales de carga basados en la potencia. Elimínalos para usar este control deslizante.",
+ "thresholdsHintYaml": "Se han configurado umbrales de carga basados en la potencia. Elimina los umbrales de activación y desactivación de este punto de carga en tu evcc.yaml para usar este control deslizante.",
+ "thresholdsLink": "Editar configuración"
+ },
+ "title": "Ajustes de {0}",
"vehicle": "Vehículo"
},
"mode": {
+ "boost": "Boost",
"minpv": "Min+Sol",
+ "normal": "Normal",
"now": "Rápido",
"off": "Apagado",
+ "on": "Encendido",
"pv": "Solar",
"smart": "Inteligente"
},
@@ -1265,14 +1606,15 @@
"nextPlan": "Próximo plan",
"notReachableInTime": "El objetivo se alcanzará {overrun} más adelante.",
"onlyInPvMode": "El plan de carga solo funciona en modo solar.",
+ "onlyInSmartMode": "El plan de carga solo funciona en modo inteligente.",
"planDuration": "Tiempo de carga",
- "planPeriodLabel": "Período",
+ "planPeriodLabel": "Periodo",
"planPeriodValue": "de {start} a {end}",
"planUnknown": "aún no se sabe",
"preview": "Vista previa",
"priceLimit": "límite en el precio de {price}",
- "remove": "retirar",
- "setTargetTime": "niguno",
+ "remove": "Quitar",
+ "setTargetTime": "ninguno",
"targetIsAboveLimit": "El límite de carga configurado de {limit} se ignorará durante este periodo.",
"targetIsAboveVehicleLimit": "El límite del vehículo está por debajo del objetivo de carga.",
"targetIsInThePast": "Elige un tiempo en el futuro, Marty.",
@@ -1309,9 +1651,26 @@
"unknown": "Vehículo invitado",
"vehicleSoc": "Nivel de carga"
},
+ "vehicleSettings": {
+ "connectedTo": "conectado a {0}",
+ "description": "Estos valores predeterminados se aplican cuando el vehículo se conecta a un punto de carga.",
+ "editHint": "Los vehículos se pueden editar en {0}.",
+ "keepAsIs": "Mantener como está",
+ "learnMore": "Más información.",
+ "limitSoc": "Límite predeterminado",
+ "menu": "Vehículos",
+ "minSoc": "Cárga mínima",
+ "mode": "Modo predeterminado",
+ "notConnected": "no conectado",
+ "title": "Ajustes del vehículo"
+ },
"vehicleStatus": {
"awaitingAuthorization": "Esperando autorización.",
"batteryBoost": "Impulso de batería activo.",
+ "batteryBoostBelowLimit": "Batería demasiado baja para boost.",
+ "batteryBoostDisabled": "Boost de batería desactivado.",
+ "batteryBoostEnabled": "Boost hasta que la batería llegue a {limit}.",
+ "batteryBoostHold": "Batería bloqueada. Boost no disponible.",
"charging": "Cargando…",
"cheapEnergyCharging": "Energía barata disponible.",
"cheapEnergyNextStart": "Energía barata en {duration}.",
@@ -1321,21 +1680,24 @@
"cleanEnergySet": "Límite de CO₂ establecido.",
"climating": "Preacondicionamiento detectado.",
"connected": "Conectado.",
- "disconnectRequired": "Sesión terminada. Vuelva a conectarse.",
+ "disconnectRequired": "Sesión terminada. Vuelve a conectar el vehículo.",
"disconnected": "Desconectado.",
- "feedinPriorityNextStart": "Las altas tarifas de alimentación comienzan en {duration}.",
- "feedinPriorityPausing": "Carga solar pausada para maximizar la alimentación.",
+ "feedinPriorityNextStart": "Las tarifas de vertido altas comienzan dentro de {duration}.",
+ "feedinPriorityPausing": "Carga solar en pausa para maximizar el vertido.",
"finished": "Finalizado.",
"minCharge": "Carga mínima hasta {soc}.",
"pvDisable": "No hay suficiente excedente. Pausa inminente.",
"pvEnable": "Excedente disponible. La carga se reanudará en breve.",
"scale1p": "Próximamente se reducirá a carga monofásica.",
"scale3p": "Próximamente se aumentará a carga trifásica.",
+ "suggestionStartTooltip": "El optimizador recomienda cargar ahora.",
+ "suggestionStopTooltip": "El optimizador recomienda pausar la carga.",
"targetChargeActive": "Plan de carga activo. Finalización estimada en {duration}.",
"targetChargePlanned": "El plan de facturación comienza en {duration}.",
"targetChargeWaitForVehicle": "Plan de carga listo. Esperando vehículo…",
"vehicleLimit": "Límite de vehículos",
"vehicleLimitReached": "Se ha alcanzado el límite de vehículos.",
+ "waitForAuthorization": "Conectado. Esperando autorización…",
"waitForVehicle": "Listo. Esperando vehículo…",
"welcome": "Breve carga inicial para confirmar la conexión."
},
@@ -1350,8 +1712,8 @@
"offline": {
"configurationError": "Error durante el inicio. Comprueba tu configuración y reinicia.",
"message": "No conectado con servidor.",
- "restart": "Reanudar",
- "restartNeeded": "Es necesario aplicar los cambios.",
+ "restart": "Reiniciar",
+ "restartNeeded": "Necesario para aplicar los cambios.",
"restarting": "El servidor volverá en un momento.",
"starting": "Iniciando servidor..."
},
@@ -1365,49 +1727,55 @@
"noMatch": "Las contraseñas no coinciden",
"titleNew": "Establecer contraseña de administrador",
"titleUpdate": "Cambiar contraseña de administrador",
- "updatePassword": "Cambiar la contraseña"
+ "updatePassword": "Cambiar contraseña"
},
"session": {
+ "addOdometer": "Añadir valor",
+ "addedRange": "Autonomía añadida",
"cancel": "Cancelar",
"co2": "CO₂",
- "date": "Período",
- "delete": "Borrar",
- "finished": "Finalizó",
+ "date": "Periodo",
+ "delete": "Eliminar",
+ "finished": "Terminado",
"meter": "Contador",
- "meterstart": "Iniciar el medidor",
- "meterstop": "Parar el medidor",
+ "meterstart": "Contador al inicio",
+ "meterstop": "Contador al final",
"odometer": "Kilometraje",
- "price": "Precio",
+ "price": "Coste",
+ "soc": "Carga",
"started": "Comenzó",
"title": "Cargando la sesión"
},
"sessions": {
+ "addedRange": "Autonomía",
"avgPower": "⌀ Potencia",
"avgPrice": "Precio ⌀",
"chargeDuration": "Duración",
+ "chargedSoc": "Carga",
"chartTitle": {
"avgCo2ByGroup": "CO₂ ⌀ {byGroup}",
"avgPriceByGroup": "Precio ⌀ {byGroup}",
"byGroupLoadpoint": "por punto de carga",
"byGroupVehicle": "por vehículo",
"energy": "Energía cargada",
- "energyGrouped": "“Energía solar versus energía de red”",
+ "energyGrouped": "Energía solar vs. energía de red",
"energyGroupedByGroup": "Energía {byGroup}",
"energySubSolar": "{value} solar",
"energySubTotal": "{value} total",
- "groupedCo2ByGroup": "CO₂-Importe {byGroup}",
+ "groupedCo2ByGroup": "Cantidad de CO₂ {byGroup}",
"groupedPriceByGroup": "Coste total {byGroup}",
"historyCo2": "Emisiones de CO₂",
"historyCo2Sub": "{value} total",
- "historyPrice": "Precio de carga",
+ "historyPrice": "Costes de carga",
"historyPriceSub": "{value} total",
"solar": "Cuota solar anual",
- "solarByGroup": "Cuota Solar {byGroup}"
+ "solarByGroup": "Cuota solar {byGroup}"
},
"co2": "CO₂ ⌀",
"csv": {
+ "addedrange": "Autonomía añadida (km)",
"chargedenergy": "Energía (kWh)",
- "chargeduration": "«Duración»",
+ "chargeduration": "Duración",
"co2perkwh": "CO₂/kWh",
"created": "Hora de inicio",
"finished": "Terminado",
@@ -1416,12 +1784,16 @@
"meterstart": "Inicio de metro (kWh)",
"meterstop": "Parón de metro (kWh)",
"odometer": "Kilometraje (km)",
- "price": "Precio",
+ "price": "Coste",
"priceperkwh": "Precio/kWh",
+ "socend": "SoC final (%)",
+ "socstart": "SoC inicial (%)",
"solarpercentage": "Solar (%)",
"vehicle": "Vehículo"
},
"date": "Comenzar",
+ "download": "Descargar {period}",
+ "downloadTotal": "Descargar todo",
"energy": "Cargado",
"filter": {
"allLoadpoints": "todos los puntos de recarga",
@@ -1431,18 +1803,20 @@
"group": {
"co2": "Emisiones",
"grid": "Red eléctrica",
- "price": "Precio",
+ "price": "Coste",
"self": "Energía Solar"
},
"groupBy": {
"loadpoint": "Punto de carga",
- "none": "Total",
+ "none": "Solar",
"vehicle": "Vehículo"
},
"loadpoint": "Punto de carga",
"noData": "No hay sesiones de carga este mes.",
+ "odometer": "Kilometraje",
"overview": "Resumen",
"period": {
+ "day": "Día",
"month": "Mes",
"total": "Total",
"year": "Año"
@@ -1455,12 +1829,19 @@
"total": "Total",
"type": {
"co2": "CO₂",
- "price": "Precio",
- "solar": "Energía Solar"
+ "price": "Coste",
+ "solar": "Energía"
},
"vehicle": "Vehículo"
},
"settings": {
+ "dateFormat": {
+ "auto": "Automático",
+ "dmy": "DD/MM/AAAA",
+ "label": "Formato de fecha",
+ "mdy": "MM/DD/AAAA",
+ "ymd": "AAAA-MM-DD"
+ },
"deviceInfo": "Los ajustes que realice en este cuadro de diálogo solo afectarán a este dispositivo.",
"fullscreen": {
"enter": "Iniciar a pantalla completa",
@@ -1476,13 +1857,14 @@
"label": "Idioma"
},
"loadpoints": {
+ "disabledDevice": "deshabilitado",
"help": "Cambiar el orden y la visibilidad de la interfaz de usuario.",
"hide": "Ocultar {title}",
"label": "Puntos de recarga",
"show": "Mostrar {title}"
},
"sponsorToken": {
- "expires": "Tu token de patrocinador expira en {inXDays}.",
+ "expires": "Tu token de patrocinador caduca {inXDays}.",
"expiresUpdateUi": "{getNewToken} y puedes renovarlo aquí.",
"expiresUpdateYaml": "{getNewToken} y actualízalo en tu evcc.yaml.",
"getNewToken": "Coge uno nuevo",
@@ -1519,28 +1901,29 @@
"co2Label": "Emisiones de CO₂",
"co2Limit": "Límite de CO₂",
"enable": "Habilitar límite",
- "loadpointDescription": "Permite la carga rápida temporal en modo solar.",
- "modalTitle": "Red inteligente de carga",
+ "loadpointDescription": "Permite la carga rápida temporal en modo inteligente.",
+ "modalTitle": "Carga inteligente desde la red",
"none": "ninguno",
"priceLabel": "Precio de la energía",
"priceLimit": "Límite de precio",
"resetAction": "Eliminar límite",
- "resetWarning": "No hay configurado ningún precio dinámico de la red ni fuente de CO₂. Sin embargo, sigue habiendo un límite de {limit}. ¿Desea limpiar su configuración?",
+ "resetWarning": "No hay ningún precio dinámico de la red ni fuente de CO₂ configurados. Sin embargo, sigue habiendo un límite de {limit}. ¿Quieres limpiar tu configuración?",
"saved": "Guardado."
},
"smartFeedInPriority": {
- "activeHoursLabel": "Tiempo detenido",
- "description": "Interrumpe la recarga cuando los precios son elevados para dar prioridad a la alimentación rentable de la red.",
- "priceLabel": "Tarifa de alimentación",
- "priceLimit": "Límite de alimentación",
- "resetWarning": "No hay ninguna tarifa de alimentación dinámica configurada. Sin embargo, sigue habiendo un límite de {limit}. ¿Desea limpiar su configuración?",
- "title": "Prioridad de alimentación"
+ "activeHoursLabel": "Tiempo activo",
+ "batteryDescription": "Descarga la batería doméstica a la red mientras el precio de vertido es alto.",
+ "description": "Pausa la carga cuando los precios son altos para priorizar el vertido rentable a la red.",
+ "priceLabel": "Precio de vertido",
+ "priceLimit": "Límite de vertido",
+ "resetWarning": "No hay ninguna tarifa de vertido dinámica configurada. Sin embargo, sigue habiendo un límite de {limit}. ¿Quieres limpiar tu configuración?",
+ "title": "Prioridad de vertido"
},
"startupError": {
"configFile": "Archivo de configuración utilizado:",
"configuration": "Configuración",
"description": "Por favor revisa tu archivo de configuración. Si el mensaje de error no te ayuda, busca una solución en nuestro {0}.",
- "discussions": "Discusiones de GitHub",
+ "discussions": "Debates en GitHub",
"editConfiguration": "Editar configuración",
"fixAndRestart": "Por favor, soluciona el problema y reinicia el servidor.",
"hint": "Nota: También puede ser que tengas un aparato averiado (inversor, contador, ...). Comprueba sus conexiones de red.",
@@ -1548,5 +1931,13 @@
"lineErrorLink": "Línea {0}",
"restartButton": "Reiniciar",
"title": "Error al iniciar"
+ },
+ "tabBar": {
+ "battery": "Batería",
+ "charge": "Carga",
+ "comingSoon": "Esta página está en construcción.",
+ "forecast": "Previsión",
+ "more": "Más",
+ "sessions": "Sesiones"
}
}
diff --git a/i18n/fr.json b/i18n/fr.json
index 466dc1294a..2bc9f7b8f1 100644
--- a/i18n/fr.json
+++ b/i18n/fr.json
@@ -7,6 +7,18 @@
"authorizationSuccessful": "Autorisation réussie",
"buttonConnect": "Se connecter à {provider}",
"buttonDisconnect": "Déconnecter",
+ "challenge": {
+ "captcha": {
+ "label": "Saisissez les caractères affichés dans l'image.",
+ "title": "Captcha"
+ },
+ "code": {
+ "label": "Collez le code ou l'adresse de la page vers laquelle vous avez été redirigé.",
+ "title": "Code d'autorisation"
+ },
+ "openLogin": "Ouvrir la page de connexion",
+ "submit": "Continuer"
+ },
"confirmLogout": "Êtes-vous sûr de vouloir déconnecter {title} ?",
"connect": "Connecter",
"disconnect": "déconnecter",
@@ -25,6 +37,7 @@
"discharging": "en décharge",
"energy": "Energie",
"gridCharge": "charge depuis le réseau",
+ "gridDischarge": "décharge du réseau",
"hold": "décharge verrouillée",
"holdCharge": "charge verrouillée",
"ofTotal": "de {total}",
@@ -43,6 +56,12 @@
"bufferTitle": "Batterie en tant que tampon de charge",
"discharge": "Empêche la batterie domestique de se décharger en mode rapide pendant une recharge planifiée.",
"gridDischarge": "Autoriser la batterie domestique à se décharger vers le réseau.",
+ "gridDischargeConfirm": {
+ "country": "Pays configuré : {country}.",
+ "description": "La possibilité d'injecter l'énergie stockée dans la batterie vers le réseau dépend de votre pays et de votre contrat de rachat. Si la batterie est également rechargée à partir du réseau, l'énergie injectée n'est plus exclusivement d'origine solaire. Dans le cadre de nombreux tarifs de rachat, cela entraîne la perte de votre rémunération ou est tout simplement interdit. Vérifiez au préalable les conditions de votre contrat de rachat et les exigences du gestionnaire de réseau.",
+ "noCountry": "Aucun pays n'est configuré. Configurez-le via {link}.",
+ "title": "Exportation vers le réseau à partir de la batterie"
+ },
"priority": "La batterie domestique a la priorité tant qu’elle est chargée en dessous de {soc}. Ensuite, les points de charge reçoivent le surplus.",
"priorityNone": "Le surplus solaire va directement aux points de charge. Définissez une valeur au dessus de {soc} pour prioriser la batterie domestique.",
"priorityTitle": "Où va le surplus solaire en priorité ?"
@@ -144,11 +163,46 @@
"switchsocket": "Prise de commutation personnalisée"
}
},
+ "circuit": {
+ "addMeter": "Ajouter un compteur d'énergie dédié",
+ "custom": "Circuit défini par l'utilisateur",
+ "deleteChildrenFirst": "Pour supprimer ce circuit, commencez par supprimer ses circuits secondaires.",
+ "meterDedicated": "Compteur dédié",
+ "meterGrid": "Compteur de réseau",
+ "meterHelp": "Compteur mesurant la consommation totale de ce circuit.",
+ "meterLabel": "Compteur",
+ "meterLabelAdd": "Ajouter un compteur de circuit",
+ "meterLabelEdit": "Modifier le compteur de circuit",
+ "meterNone": "Pas de compteur",
+ "meterSelectionHelpDedicatedMeter": "Ajoutez un compteur existant au circuit ci-dessous.",
+ "meterSelectionHelpGridMeter": "Utilise le compteur de réseau déjà en place dans votre installation.",
+ "meterSelectionHelpNoMeter": "C'est la somme de tous les points de recharge qui est prise en compte.",
+ "meterSelectionLabel": "Choix du compteur",
+ "parentCircuit": "Circuit principal",
+ "template": "Circuit",
+ "titleAdd": "Ajouter un circuit",
+ "titleEdit": "Modifier le circuit",
+ "titleLabel": "Titre"
+ },
"circuits": {
+ "addMainCircuit": "Ajouter le circuit principal",
+ "addSubCircuit": "Ajouter un sous-circuit",
+ "chargingPointsNote": "Attribuer les bornes de recharge et les radiateurs à un circuit dans leurs propres paramètres.",
"description": "Vérifiez que la somme de tous les points de charges connectés à un circuit ne dépassent pas les limites de puissance et de courant. Les circuits peuvent être combinés pour construire une hiérarchie.",
+ "legacyWarning": "Une nouvelle configuration des circuits est disponible ! Supprimez et enregistrez votre configuration ici pour utiliser la nouvelle procédure guidée.",
+ "meterDedicated": "Compteur",
+ "meterGrid": "Réseau",
"title": "Gestion de la charge",
"usableMeters": "Références des compteurs utilisables"
},
+ "combobox": {
+ "count": "{count} suggestions",
+ "matching": "Correspondance",
+ "other": "Autre",
+ "searching": "Recherche…",
+ "suggestions": "Suggestions",
+ "used": "déjà utilisé"
+ },
"consumer": {
"description": "Permet de suivre la consommation des petits appareils électroménagers non contrôlables (réfrigérateur, lave-linge, etc.). Vous offre une meilleure vue d'ensemble de la consommation de votre foyer.",
"titleAdd": "Ajouter un compteur de consommation",
@@ -170,10 +224,10 @@
},
"curtailer": {
"description": "Dispositif qui limite l'alimentation sur demande de l'opérateur de réseau (§ 9 EEG). Beaucoup d’onduleurs supportent cela directement via leur configuration de compteur solaire. Une configuration séparée n'est nécessaire que pour certains fabricants ou systèmes multi-onduleurs.",
- "generic": "Intégrations génériques",
- "specific": "Intégrations spécifiques",
+ "generic": "Intégration générique",
+ "specific": "Intégration spécifique",
"template": "Fabricant",
- "title": "Limitation",
+ "title": "Limitation de l'injection",
"titleAdd": "Ajouter un appareil limitant",
"titleEdit": "Modifier un appareil limitant"
},
@@ -201,7 +255,7 @@
"currentRange": "Courant",
"curtailLimit": "Limite d’entrée",
"curtailable": "Suspendable",
- "curtailed": "Limite d'injection",
+ "curtailed": "Limitation de l'injection",
"detected": "Détecté",
"dimLimit": "Limite de consommation",
"dimmed": "Consommation limitée",
@@ -216,6 +270,8 @@
"identifier": "Identifiant RFID",
"loginBlocked": "Limit de login atteinte",
"max": "max",
+ "maxCurrent": "Courant max.",
+ "maxPower": "Puissance maximale",
"messengers": "Services",
"no": "non",
"odometer": "Compteur kilométrique",
@@ -254,7 +310,7 @@
},
"consumer": {
"energy": "Consommé",
- "returnEnergy": "Production"
+ "returnEnergy": "Produit"
},
"grid": {
"energy": "Importé",
@@ -334,7 +390,7 @@
"change": "Changer",
"clear": "Effacer",
"close": "Fermer",
- "confirmDisable": "Désactiver cet appareil ? Il sera ignoré après le prochain redémarrage.",
+ "confirmDisable": "Désactiver cet équipement ? Il sera ignoré dès le prochain redémarrage.",
"confirmEnable": "Réactiver cet appareil ?",
"confirmSave": "Il y a des modifications. Enregistrer maintenant ?",
"copied": "Copié !",
@@ -352,6 +408,7 @@
"enable": "Activer",
"error": "Erreur",
"experimental": "Expérimental",
+ "forceEnable": "Activer quand même",
"forceSave": "Enregistrer quand même",
"fromYamlHint": "Remarque : configuré via evcc.yaml. Supprimez l'entrée du fichier pour pouvoir effectuer des modifications.",
"hideAdvancedSettings": "Masquer les paramètres avancés",
@@ -370,6 +427,7 @@
"saving": "Enregistrement en cours…",
"selectFile": "Parcourir",
"showAdvancedSettings": "Afficher les paramètres avancés",
+ "site": "Localisation",
"telemetry": "Télémétrie",
"templateLoading": "Chargement...",
"title": "Titre",
@@ -888,6 +946,22 @@
"labelVendorId": "Identifiant du fournisseur",
"title": "SMA Sunny Home Manager"
},
+ "site": {
+ "country": {
+ "description": "Utilisé pour les indications et les valeurs par défaut spécifiques à une région.",
+ "label": "Pays"
+ },
+ "currency": {
+ "description": "Permet de calculer les prix de l'énergie, les coûts et les économies en fonction de votre tarif.",
+ "example": "Le prix facturé s'élevait à {price}. Vous avez économisé {amount}.",
+ "label": "Devise"
+ },
+ "siteTitle": {
+ "description": "Affiché sur l'écran principal et dans l'onglet du navigateur.",
+ "label": "Titre"
+ },
+ "title": "Localisation"
+ },
"sponsor": {
"addToken": "Entrer le jeton",
"changeToken": "Modifier le jeton",
@@ -1056,6 +1130,124 @@
"titleEdit": "Modifier le véhicule"
}
},
+ "energy": {
+ "battery": {
+ "charged": "Chargé",
+ "discharged": "Déchargé"
+ },
+ "consumers": {
+ "chart": {
+ "bars": "Barres",
+ "pattern": "Modèle"
+ },
+ "cost": "Coûts",
+ "others": "Autre",
+ "source": {
+ "battery": "batterie",
+ "grid": "réseau",
+ "pv": "solaire"
+ },
+ "title": "Consommateurs",
+ "view": {
+ "river": "Rivière",
+ "treemap": "Treemap"
+ }
+ },
+ "direction": {
+ "battery": {
+ "energy": "chargée",
+ "returnEnergy": "déchargée"
+ },
+ "grid": {
+ "energy": "importée",
+ "returnEnergy": "exportée"
+ },
+ "meter": {
+ "energy": "Énergie",
+ "returnEnergy": "Énergie (inversée)"
+ }
+ },
+ "empty": "Aucune donnée énergétique disponible pour cette période.",
+ "flow": {
+ "battery": "Batterie",
+ "batteryCharge": "Charge de la batterie",
+ "batteryDischarge": "Décharge de la batterie",
+ "charging": "Chargement",
+ "export": "Exportation vers le réseau",
+ "grid": "Importation du réseau",
+ "heating": "Chauffage",
+ "home": "Consommation",
+ "overview": "Aperçu",
+ "pv": "Production",
+ "title": "Flux d'énergie",
+ "usage": "Usage",
+ "withdrawal": "Retrait"
+ },
+ "grid": {
+ "amount": "Montant",
+ "cost": "Coût du réseau",
+ "energy": "Énergie",
+ "export": "Exportation",
+ "exportPrice": "Prix à l'exportation",
+ "import": "Importation",
+ "importPrice": "Prix d'importation",
+ "price": "Prix",
+ "revenue": "Revenus de rachat",
+ "title": "Réseau"
+ },
+ "group": {
+ "battery": "Batterie",
+ "consumer": "Consommation",
+ "forecast": "Prévisions",
+ "grid": "Réseau",
+ "loadpoint": "Chargement & Chauffage",
+ "meter": "Compteurs supplémentaires",
+ "pv": "Production"
+ },
+ "production": {
+ "above": "{value} au dessus",
+ "accuracy": "Précision des prévisions",
+ "below": "{value} en dessous",
+ "deviation": "Déviation",
+ "expectedTomorrow": "prévu demain",
+ "exported": "{value} exporté",
+ "produced": "Produit",
+ "remainingToday": "restant aujourd'hui",
+ "selfConsumed": "Autoconsommation",
+ "title": "Production"
+ },
+ "socTemp": {
+ "charged": "chargé",
+ "soc": "charge",
+ "temperature": "température",
+ "used": "utilisé"
+ },
+ "sources": {
+ "battery": "Batterie",
+ "cost": "Coûts",
+ "energy": "Énergie",
+ "grid": "Réseau",
+ "hint": "Énergie solaire et énergie stockée dans les batteries valorisée au prix de rachat.",
+ "price": "Prix/kWh",
+ "pv": "Solaire",
+ "share": "Parts",
+ "source": "Source",
+ "title": "Sources",
+ "total": "Total"
+ },
+ "stat": {
+ "autarky": "Autarcie",
+ "co2Saved": "CO₂ économisé",
+ "co2SavedTooltip": "Comparé au prélèvement de l'{energy} consommée sur le réseau.",
+ "configure": "Configurer maintenant",
+ "noCo2": "Aucune donnée sur le CO₂.",
+ "noPrice": "Aucune donnée de prix.",
+ "savings": "Économies",
+ "savingsTooltip": "Par rapport à l'achat de l'{energy} consommée auprès du réseau.",
+ "versus": "{value} par rapport {reference}"
+ },
+ "title": "Énergie"
+ },
"footer": {
"community": {
"greenEnergy": "Solaire",
@@ -1193,6 +1385,7 @@
},
"general": {
"back": "Retour",
+ "cancel": "Annuler",
"download": "Télécharger",
"error": "Erreur",
"none": "aucun",
@@ -1230,31 +1423,6 @@
"restartDescription": "Avez-vous essayé de redémarrer ?",
"secondaryActions": "Toujours pas de solution ? Voici quelques options plus avancées."
},
- "energy": {
- "direction": {
- "battery": {
- "energy": "chargée",
- "returnEnergy": "déchargée"
- },
- "grid": {
- "energy": "importée",
- "returnEnergy": "exportée"
- },
- "meter": {
- "energy": "Énergie",
- "returnEnergy": "Énergie (inversée)"
- }
- },
- "group": {
- "battery": "Batterie",
- "consumer": "Consommation",
- "forecast": "Prévisions",
- "grid": "Réseau",
- "loadpoint": "Chargement & Chauffage",
- "meter": "Compteurs supplémentaires",
- "pv": "Production"
- }
- },
"issue": {
"additional": {
"copied": "copié !",
@@ -1341,9 +1509,9 @@
},
"main": {
"alwaysCharge": {
- "description": "aucune interruption",
- "label": "Charger toujours",
- "labelHeating": "Chauffer toujours",
+ "description": "sans interruptions",
+ "label": "Toujours en charge",
+ "labelHeating": "Toujours en chauffe",
"minCurrent": "min {current}",
"once": "jusqu'à la fin de la session",
"onceTip": "Seulement pour cette session",
@@ -1404,6 +1572,7 @@
"batteryDischargeHold": "Batterie (verrouillée)",
"batteryGridChargeActive": "Charge réseau : active",
"batteryGridChargeLimit": "Charge réseau : quand",
+ "batteryStatus": "État de la batterie",
"batteryTooltip": "{energy} sur {total} ({soc})",
"forecast": "Prévisions",
"forecastTooltip": "prévision : production solaire restante aujourd’hui",
@@ -1867,6 +2036,7 @@
"battery": "Batterie",
"charge": "Charger",
"comingSoon": "Cette page est en cours de construction.",
+ "energy": "Énergie",
"forecast": "Prévisions",
"more": "Plus",
"sessions": "Sessions"
diff --git a/i18n/it.json b/i18n/it.json
index 1d01bf2061..f52a38861e 100644
--- a/i18n/it.json
+++ b/i18n/it.json
@@ -7,6 +7,16 @@
"authorizationSuccessful": "Autorizzazione riuscita",
"buttonConnect": "Connettiti a {provider}",
"buttonDisconnect": "Disconnetti",
+ "challenge": {
+ "captcha": {
+ "label": "Inserisci i caratteri mostrati nell'immagine."
+ },
+ "code": {
+ "title": "Codice di autorizzazione"
+ },
+ "openLogin": "Apri la pagina di login",
+ "submit": "Continua"
+ },
"confirmLogout": "Sei sicuro di voler disconnettere {title}?",
"connect": "connetti",
"disconnect": "disconnetti",
@@ -43,6 +53,11 @@
"bufferTitle": "Batteria come tampone di carica",
"discharge": "Prevenire la scarica della batteria domestica in modalità rapida e durante la ricarica programmata.",
"gridDischarge": "Consenti alla batteria domestica di fornire energia alla rete.",
+ "gridDischargeConfirm": {
+ "country": "Paese configurato: {country}.",
+ "description": "Poter esportare energia dalla batteria alla rete dipende dal tuo paese e dall'accordo di immissione in rete. Se la batteria viene anche caricata dalla rete, l'energia esportata non è più puramente solare. In base a molte tariffe di immissione, questo annulla il tuo compenso o è vietato. Verifica prima il tuo contratto di immissione e gli accordi con il tuo operatore di rete.",
+ "noCountry": "Nessun paese configurato. Impostalo in {link}."
+ },
"priority": "La batteria domestica ha la priorità finché il suo livello di carica è inferiore a {soc}. Successivamente, le colonnine di ricarica ricevono l'energia in eccesso.",
"priorityNone": "L'energia solare in eccesso viene inviata direttamente ai punti di ricarica. Imposta un valore superiore a {soc} per dare priorità alla batteria domestica.",
"priorityTitle": "Dove va a finire prima il surplus?"
@@ -144,11 +159,42 @@
"switchsocket": "Presa con interruttore definito dall'utente"
}
},
+ "circuit": {
+ "addMeter": "Aggiungi un contatore d'energia dedicato",
+ "custom": "Circuito definito dall'utente",
+ "deleteChildrenFirst": "Per eliminare questo circuito, elimina prima i suoi sotto circuiti.",
+ "meterDedicated": "Contatore dedicato",
+ "meterGrid": "Contatore di rete",
+ "meterHelp": "Contatore misurante il consumo totale di questo circuito.",
+ "meterLabel": "Contatore d'energia",
+ "meterLabelAdd": "Aggiungi un Contatore di Circuito",
+ "meterLabelEdit": "Modifica il Contatore di Circuito",
+ "meterNone": "Nessun contatore",
+ "meterSelectionHelpDedicatedMeter": "Aggiungi un contatore esistente al circuito sottostante.",
+ "meterSelectionHelpGridMeter": "Utilizza il contatore di rete esistente della tua installazione.",
+ "meterSelectionHelpNoMeter": "Viene utilizzata la somma di tutti i contatori di carica.",
+ "meterSelectionLabel": "Selezione del contatore",
+ "parentCircuit": "Circuito principale",
+ "template": "Circuito",
+ "titleAdd": "Aggiungi circuito",
+ "titleEdit": "Modifica circuito",
+ "titleLabel": "Titolo"
+ },
"circuits": {
+ "addMainCircuit": "Aggiungi il circuito principale",
+ "addSubCircuit": "Aggiungi sotto-circuito",
"description": "Garantisce che la somma dei carichi connessi ad un circuito non superi i limiti di corrente configurati. I circuiti possono essere annidati in una struttura gerarchica.",
+ "legacyWarning": "Nuove configurazioni di circuiti disponibili! Rimuovere e salvare la configurazione qui per utilizzare il nuovo processo guidato.",
+ "meterDedicated": "Contatore",
+ "meterGrid": "Rete",
"title": "Gestione del carico",
"usableMeters": "Riferimenti dei contatori utilizzabili"
},
+ "combobox": {
+ "count": "{count} suggerimenti",
+ "suggestions": "Suggerimenti",
+ "used": "già usato"
+ },
"consumer": {
"description": "Monitora elettrodomestici non controllabili (frigorifero, lavatrice, ecc.). Ti offre una migliore panoramica dei consumi domestici.",
"titleAdd": "Aggiungi contatore di consumo",
@@ -216,6 +262,8 @@
"identifier": "Identificatore RFID",
"loginBlocked": "Limite login raggiunto",
"max": "max",
+ "maxCurrent": "Corrente massima",
+ "maxPower": "Potenza massima",
"messengers": "Servizi",
"no": "no",
"odometer": "Chilometraggio",
@@ -352,6 +400,7 @@
"enable": "Abilita",
"error": "Errore",
"experimental": "Sperimentale",
+ "forceEnable": "Abilitare comunque",
"forceSave": "Salva comunque",
"fromYamlHint": "Nota: configurato tramite evcc.yaml. Rimuovere la voce dal file per abilitare la modifica qui.",
"hideAdvancedSettings": "Nascondi impostazioni avanzate",
@@ -370,6 +419,7 @@
"saving": "Salvataggio…",
"selectFile": "Sfoglia",
"showAdvancedSettings": "Mostra impostazioni avanzate",
+ "site": "Sito",
"telemetry": "Telemetria",
"templateLoading": "Caricamento...",
"title": "Titolo",
diff --git a/i18n/lb.json b/i18n/lb.json
index 339ca48d70..291a664475 100644
--- a/i18n/lb.json
+++ b/i18n/lb.json
@@ -1,12 +1,24 @@
{
"authProviders": {
- "authCode": "Autoriséierungscode",
+ "authCode": "Authentifikatiounscode",
"authCodeHelp": "Kopéierem dëse Code a benotz en am nächste Schratt. Gülteg fir {duration}.",
"authorizationFailed": "Autoriséierung feelgeschloen",
"authorizationRequired": "Autoriséierung néideg",
"authorizationSuccessful": "Autoriséierung erfollegräich",
"buttonConnect": "Mam {provider} verbannen",
"buttonDisconnect": "Trennen",
+ "challenge": {
+ "captcha": {
+ "label": "Gitt d'Zeechen an, déi am Bild gewise ginn.",
+ "title": "Captcha"
+ },
+ "code": {
+ "label": "Füügt de Code oder d'Adress vun der Säit un, op déi Dir weidergeleet gouft.",
+ "title": "Autoriséierungscode"
+ },
+ "openLogin": "D'Login-Säit opmaachen",
+ "submit": "Weider"
+ },
"confirmLogout": "Sécher, datt s du {title} trenne wëlls?",
"connect": "verbannen",
"disconnect": "trennen",
@@ -25,6 +37,7 @@
"discharging": "entluet",
"energy": "Energie",
"gridCharge": "Luede vum Netz",
+ "gridDischarge": "Entluede vum Netz",
"hold": "Entluede gespaart",
"holdCharge": "Luede gespaart",
"ofTotal": "vun {total}",
@@ -43,6 +56,12 @@
"bufferTitle": "Batterie als Luedpuffer",
"discharge": "Verhënnert d'Entluede vun der Batterie am schnelle Modus a wärend dem geplangten Oplueden.",
"gridDischarge": "Erlaabt der Batterie sech an d'Netz ze entlueden.",
+ "gridDischargeConfirm": {
+ "country": "Konfiguréiert Land: {country}.",
+ "description": "Ob d'Batterieenergie an d'Netz exportéiert ka ginn, hänkt vun Ärem Land an Ärem Einspeisungsvertrag of. Wann d'Batterie och vum Netz opgeluede gëtt, ass déi exportéiert Energie net méi reng Sonnen-Energie. Ënnert ville Einspeisetariffer verléiert Dir dommat Är Revenuen oder et ass verbueden. Iwwerpréift fir d'éischt Äre Vertrag an d'Conditioune vum Netzbedreiwer.",
+ "noCountry": "Kee Land konfiguréiert. Lee et ënner {link} fest.",
+ "title": "Netzaspeisung aus der Batterie"
+ },
"priority": "D'Batterie huet Prioritéit, soulaang de SOC ënner {soc} ass. Dann kréien d'Opluedpunkten de Rescht.",
"priorityNone": "Den Iwwerschoss vun der Solaranlag geet direkt op d'Opluedpunkten. Setzt e Wäert iwwer {soc} fir d'Batterie ze prioriséieren.",
"priorityTitle": "Wou geet den Iwwerschoss fir d'éischt hin?"
@@ -144,11 +163,46 @@
"switchsocket": "Benotzerdefinéirt schaltbar Steckdous"
}
},
+ "circuit": {
+ "addMeter": "Dedidéierten Engergiezieler dobäi fügen",
+ "custom": "Benotzerdefinéierte Stroumkrees",
+ "deleteChildrenFirst": "Fir dëse Stroumkrees ze läschen, läscht fir d'éischt seng ënnergeuerdnet Kreesser.",
+ "meterDedicated": "Dediéierten Zieler",
+ "meterGrid": "Netzzieler",
+ "meterHelp": "Zieler, deen de Gesamtverbrauch vun dësem Stroumkrees moosst.",
+ "meterLabel": "Zieler",
+ "meterLabelAdd": "Stroumkreeszieler derbäi fügen",
+ "meterLabelEdit": "Stroumkreeszieler beaarbechten",
+ "meterNone": "Keen Zieler",
+ "meterSelectionHelpDedicatedMeter": "Füügt e bestoende Zieler an dëse Stroumkrees derbäi.",
+ "meterSelectionHelpGridMeter": "Benotzt de bestoenden Zieler vun Ärer Installatioun.",
+ "meterSelectionHelpNoMeter": "D'Zomm vun alle Luedpunkte gëtt benotzt.",
+ "meterSelectionLabel": "Zielerauswal",
+ "parentCircuit": "Iwwergeuerdnete Stroumkrees",
+ "template": "Stroumkrees",
+ "titleAdd": "Füügt ee Stroumkreess derbäi",
+ "titleEdit": "Stroumkrees editéieren",
+ "titleLabel": "Titel"
+ },
"circuits": {
+ "addMainCircuit": "Haaptstroumkrees derbäi fügen",
+ "addSubCircuit": "Ënnergeuerdnete Stroumkrees derbäi fügen",
+ "chargingPointsNote": "Luedpunkte & Heizgeräter weist du an de jeeweilege Astellungen engem Stroumkrees zou.",
"description": "Sécherstellen, datt d'Zomm vun all den Luedstatiounen déi mam Circuit verbonne sinn net déi konfiguréiert Leeschtung an aktuell Grenzen iwwerschreit. Circuiten kënnen agenist ginn fir eng Hierarchie opzebauen.",
+ "legacyWarning": "Nei Konfiguratioun vun de Stroumkreesser verfügbar! Läsch a späicher hei deng Konfiguratioun, fir de neie geleetene Prozess ze benotzen.",
+ "meterDedicated": "Zieler",
+ "meterGrid": "Netz",
"title": "Management vun der Belaaschtung",
"usableMeters": "Verwendbar Zielerreferenzen"
},
+ "combobox": {
+ "count": "{count} Virschléi",
+ "matching": "Passend",
+ "other": "Aner",
+ "searching": "Sichen…",
+ "suggestions": "Virschléi",
+ "used": "scho benotzt"
+ },
"consumer": {
"description": "Verfollegt kleng, net-kontrolléierbar Apparater (Frigo, Wäschmaschinn, asw.). Dëst gëtt Iech e besseren Iwwerbléck iwwert Ären Hausenergieverbrauch.",
"titleAdd": "Füügt een Zieler derbäi",
@@ -216,6 +270,8 @@
"identifier": "RFID-Identifikatioun",
"loginBlocked": "Login-Limit erreecht",
"max": "max",
+ "maxCurrent": "Max. Stroum",
+ "maxPower": "Max. Leeschtung",
"messengers": "Servicer",
"no": "nee",
"odometer": "Kilometerzäler",
@@ -352,6 +408,7 @@
"enable": "Aktivéieren",
"error": "Feeler",
"experimental": "Experimentell",
+ "forceEnable": "Trotzdeem aktivéieren",
"forceSave": "Trotzdem späicheren",
"fromYamlHint": "Bemierkung: Konfiguréiert iwwer evcc.yaml. Läscht d'Astellungen aus der Datei fir d'Ännerung hei z'erméiglechen.",
"hideAdvancedSettings": "Fortgeschratten Astellunge verstoppen",
@@ -370,6 +427,7 @@
"saving": "Späichert…",
"selectFile": "Duerchsichen",
"showAdvancedSettings": "Fortgeschratten Astellungen uweisen",
+ "site": "Standuert",
"telemetry": "Telemetrie",
"templateLoading": "Lueden…",
"title": "Titel",
@@ -736,7 +794,7 @@
"title": "MQTT"
},
"network": {
- "descriptionExternalUrl": "Adresse, mat där sech aner Geräter mam evcc verbannen a fir den Autodiscovery vun der evcc-App.",
+ "descriptionExternalUrl": "Adresse, mat där sech aner Geräter mam evcc verbannen a fir den Auto-Discovery vun der evcc-App.",
"descriptionHost": "Gëtt verwennt, fir den evcc am lokalen Netzwierk unzekënnegen.",
"descriptionInternalUrl": "Lokal Netzwierkadress vum evcc.",
"descriptionPort": "Port fir de Web Interface an d'API. Du muss d'Browser-URL updaten, wann s du dëst änners.",
@@ -790,7 +848,7 @@
"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. Momentan berechent a visualiséiert et nëmmen. Et steiert nach keng Apparater.",
"enable": "Optimizer aktivéieren",
- "info": "Et kann e puer Minutten daueren, bis den Optimizer-Menüpunkt sichtbar gëtt. Bei neien Installatiounen kann et bis zu 24 Stonnen daueren, bis evcc genuch Donnéeë gesammelt huet.",
+ "info": "Bei neien Installatiounen kann et bis zu 24 Stonnen daueren, bis evcc genuch Donnéeë gesammelt huet.",
"title": "Optimizer"
},
"options": {
@@ -888,6 +946,22 @@
"labelVendorId": "Hiersteller-ID",
"title": "SMA Sunny Home Manager"
},
+ "site": {
+ "country": {
+ "description": "Gëtt fir regiounsspezifesch Hiweiser a Standardwäerter genotzt.",
+ "label": "Land"
+ },
+ "currency": {
+ "description": "Gëtt benotzt fir d'Energiepräisser, Käschten a Erspuernisser op Basis vun Ärem Tarif ze formatéieren.",
+ "example": "Ären Opluedpräis war {price}. Dir hutt {amount} gespuert.",
+ "label": "Wärung"
+ },
+ "siteTitle": {
+ "description": "Gëtt op dem Haaptbildschierm an am Browser-Tab ugewisen.",
+ "label": "Titel"
+ },
+ "title": "Standuert"
+ },
"sponsor": {
"addToken": "Token aginn",
"changeToken": "Token änneren",
@@ -1056,6 +1130,103 @@
"titleEdit": "Auto beaarbechten"
}
},
+ "energy": {
+ "battery": {
+ "charged": "Opgelueden",
+ "discharged": "Entlueden"
+ },
+ "consumers": {
+ "chart": {
+ "bars": "Balken",
+ "pattern": "Muster"
+ },
+ "cost": "Käschten",
+ "others": "Anerer",
+ "source": {
+ "battery": "Batterie",
+ "grid": "Netz",
+ "pv": "Solar"
+ },
+ "title": "Verbraucher",
+ "view": {
+ "river": "Floss",
+ "treemap": "Treemap"
+ }
+ },
+ "direction": {
+ "battery": {
+ "energy": "gelueden",
+ "returnEnergy": "entlueden"
+ },
+ "grid": {
+ "energy": "importéiert",
+ "returnEnergy": "agespeist"
+ },
+ "meter": {
+ "energy": "Energie",
+ "returnEnergy": "Energie (ëmgedréint)"
+ }
+ },
+ "empty": "Fir dësen Zäitraum sinn keng Energiedate verfügbar.",
+ "flow": {
+ "battery": "Batterie",
+ "batteryCharge": "Batterieladung",
+ "batteryDischarge": "Batterieentluedung",
+ "charging": "Oplueden",
+ "export": "Export an d'Netz",
+ "grid": "Import vum Netz",
+ "heating": "Heizung",
+ "home": "Verbrauch",
+ "overview": "Iwwersiicht",
+ "pv": "Produktioun",
+ "title": "Energiefloss",
+ "usage": "Benotzung",
+ "withdrawal": "Ofzéien"
+ },
+ "grid": {
+ "amount": "Betrag",
+ "cost": "Netzkäschten",
+ "energy": "Energie",
+ "export": "Export",
+ "exportPrice": "Exportpräis",
+ "import": "Import",
+ "importPrice": "Importpräis",
+ "price": "Präis",
+ "revenue": "Recette vum Aspeisen",
+ "title": "Netz"
+ },
+ "group": {
+ "battery": "Batterie",
+ "consumer": "Verbrauch",
+ "forecast": "Virso",
+ "grid": "Netz",
+ "loadpoint": "Lueden & Heizen",
+ "meter": "Aner Zieler",
+ "pv": "Produktioun"
+ },
+ "production": {
+ "above": "{value} driwwer",
+ "accuracy": "Genauegkeet vun der Prognose",
+ "below": "{value} drënner",
+ "deviation": "Ofwäichung",
+ "expectedTomorrow": "muer erwaart",
+ "exported": "{value} exportéiert",
+ "produced": "Produzéiert",
+ "remainingToday": "haut verbleiwend",
+ "selfConsumed": "Selbstverbrauch",
+ "title": "Produktioun"
+ },
+ "socTemp": {
+ "charged": "opgelueden",
+ "soc": "Opgelueden",
+ "temperature": "Temperatur",
+ "used": "verbraucht"
+ },
+ "sources": {
+ "battery": "Batterie",
+ "cost": "Käschten"
+ }
+ },
"footer": {
"community": {
"greenEnergy": "Solar",
@@ -1193,6 +1364,7 @@
},
"general": {
"back": "Zeréck",
+ "cancel": "Ofbriechen",
"download": "Eroflueden",
"error": "Feeler",
"none": "keng",
@@ -1230,31 +1402,6 @@
"restartDescription": "Hues du schonns probéiert d'Gerät aus- an erëm unzemaachen?",
"secondaryActions": "Nach ëmmer keng Léisung fonnt? Hei sinn e puer weider Méiglechkeeten."
},
- "energy": {
- "direction": {
- "battery": {
- "energy": "gelueden",
- "returnEnergy": "entlueden"
- },
- "grid": {
- "energy": "importéiert",
- "returnEnergy": "agespeist"
- },
- "meter": {
- "energy": "Energie",
- "returnEnergy": "Energie (ëmgedréint)"
- }
- },
- "group": {
- "battery": "Batterie",
- "consumer": "Verbrauch",
- "forecast": "Virso",
- "grid": "Netz",
- "loadpoint": "Lueden & Heizen",
- "meter": "Aner Zieler",
- "pv": "Produktioun"
- }
- },
"issue": {
"additional": {
"copied": "Kopéiert!",
diff --git a/i18n/lt.json b/i18n/lt.json
index 9d739f166b..676b18ad2e 100644
--- a/i18n/lt.json
+++ b/i18n/lt.json
@@ -7,6 +7,18 @@
"authorizationSuccessful": "Autorizacija Sėkminga",
"buttonConnect": "Prisijungti prie {provider}",
"buttonDisconnect": "Atsijungti",
+ "challenge": {
+ "captcha": {
+ "label": "Įveskite paveikslėlyje pavaizduotus simbolius.",
+ "title": "Captcha"
+ },
+ "code": {
+ "label": "Įklijuokite kodą arba puslapio, į kurį buvote nukreipti, adresą.",
+ "title": "Autorizacijos kodas"
+ },
+ "openLogin": "Atidaryti prisijungimo puslapį",
+ "submit": "Tęsti"
+ },
"confirmLogout": "Ar tikrai norite atjungti {title}?",
"connect": "prisijungti",
"disconnect": "atjungti",
@@ -25,6 +37,7 @@
"discharging": "iškrauna",
"energy": "Energija",
"gridCharge": "įkraunama iš tinklo",
+ "gridDischarge": "iškrova į tinklą",
"hold": "iškrovimas blokuojamas",
"holdCharge": "įkrovimas blokuojamas",
"ofTotal": "iš {total}",
@@ -43,6 +56,12 @@
"bufferTitle": "Kaupiklis kaip įkrovimo buferis",
"discharge": "Neleiskite iškrauti kaupiklio greituoju režimu ir planuojamo įkrovimo metu.",
"gridDischarge": "Leisti kaupikliui išsikrauti į tinklą.",
+ "gridDischargeConfirm": {
+ "country": "Sukonfigūruota šalis: {country}.",
+ "description": "Tai, ar baterijoje sukauptą energiją galima tiekti į elektros tinklą, priklauso nuo šalies ir sudarytos energijos tiekimo į tinklą sutarties. Jei baterija taip pat ir įkraunama iš tinklo, į tinklą tiekiama energija jau nebėra vien tik saulės energija. Pagal daugelį energijos supirkimo tarifų sąlygų dėl to galite prarasti teisę į atlygį arba toks energijos tiekimas gali būti draudžiamas. Pirmiausia peržiūrėkite energijos tiekimo į tinklą sutartį ir tinklo operatoriaus reikalavimus.",
+ "noCountry": "Šalis nesukonfigūruota. Nustatykite ją čia {link}.",
+ "title": "Energijos eksportas iš kaupiklio"
+ },
"priority": "Kaupiklis turi prioritetą, kol jo įkrova yra mažesnė nei {soc}. Po to įkrovikliai gauna perteklių.",
"priorityNone": "Saulės energijos perteklius patenka tiesiai į įkroviklius. Nustatykite reikšmę, didesnę nei {soc}, kad pirmenybė būtų teikiama kaupikliui.",
"priorityTitle": "Kur pirmiausia nukeliaus perteklius?"
@@ -144,11 +163,46 @@
"switchsocket": "Vartotojo apibrėžta išmanioji rozetė"
}
},
+ "circuit": {
+ "addMeter": "Pridėti atskirą energijos skaitiklį",
+ "custom": "Vartotojo apibrėžta grandinė",
+ "deleteChildrenFirst": "Norėdami pašalinti šią grandinę, pirmiausia pašalinkite jos antrines grandines.",
+ "meterDedicated": "Dedikuotas skaitiklis",
+ "meterGrid": "Tinklo skaitiklis",
+ "meterHelp": "Skaitiklis, matuojantis bendrą šios grandinės suvartojimą.",
+ "meterLabel": "Energijos skaitiklis",
+ "meterLabelAdd": "Pridėti Grandinės Matuoklį",
+ "meterLabelEdit": "Koreguoti Grandinės Matuoklį",
+ "meterNone": "Nėra skaitiklio",
+ "meterSelectionHelpDedicatedMeter": "Pridėkite esamą skaitiklį prie šios grandinės.",
+ "meterSelectionHelpGridMeter": "Naudoja esamą jūsų sistemos tinklo skaitiklį.",
+ "meterSelectionHelpNoMeter": "Naudojama visų įkroviklių suma.",
+ "meterSelectionLabel": "Skaitiklio Pasirinkimas",
+ "parentCircuit": "Pagrindinė grandinė",
+ "template": "Grandinė",
+ "titleAdd": "Pridėti grandinę",
+ "titleEdit": "Koreguoti grandinę",
+ "titleLabel": "Pavadinimas"
+ },
"circuits": {
+ "addMainCircuit": "Pridėti pagrindinę grandinę",
+ "addSubCircuit": "Pridėti papildomą grandinę",
+ "chargingPointsNote": "Grandinei priskirkite įkroviklius ir šildytuvus jų pačių nustatymuose.",
"description": "Užtikrina, kad naudojant visus į grandinę sujungtus įkroviklius, neviršytumėte sukonfigūruotų galios ir srovės ribų. Grandines galima sudėlioti, kad būtų sukurta hierarchija.",
+ "legacyWarning": "Galima naudoti naują grandinių konfigūracijos metodą. Pašalinkite ir išsaugokite savo konfigūraciją čia, kad galėtumėte pasinaudoti naujuoju vedliu.",
+ "meterDedicated": "Skaitiklis",
+ "meterGrid": "Tinklas",
"title": "Apkrovos valdymas",
"usableMeters": "Galimi skaitikliai"
},
+ "combobox": {
+ "count": "{count} pasiūlymai",
+ "matching": "Atitinka",
+ "other": "Kiti",
+ "searching": "Ieškoma…",
+ "suggestions": "Pasiūlymai",
+ "used": "jau panaudotas"
+ },
"consumer": {
"description": "Seka mažesnius, nevaldomus prietaisus (šaldytuvą, skalbimo mašiną ir kt.). Suteikia geresnį supratimą apie jūsų namų suvartojimą.",
"titleAdd": "Pridėti Vartotojo Skaitiklį",
@@ -216,6 +270,8 @@
"identifier": "RFID-Identifikatorius",
"loginBlocked": "Pasiektas prisijungimų limitas",
"max": "max",
+ "maxCurrent": "Max. srovė",
+ "maxPower": "Max. galia",
"messengers": "Paslaugos",
"no": "ne",
"odometer": "Odometras",
@@ -352,6 +408,7 @@
"enable": "Įjungti",
"error": "Klaida",
"experimental": "Eksperimentinės",
+ "forceEnable": "Vis tiek įjungti",
"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",
@@ -370,6 +427,7 @@
"saving": "Įrašoma…",
"selectFile": "Naršyti",
"showAdvancedSettings": "Rodyti išplėstinius nustatymus",
+ "site": "Vieta",
"telemetry": "Telemetrija",
"templateLoading": "Įkrauname...",
"title": "Pavadinimas",
@@ -888,6 +946,22 @@
"labelVendorId": "Gamintojo ID",
"title": "SMA Sunny Home Manager"
},
+ "site": {
+ "country": {
+ "description": "Naudojama regionui būdingiems patarimams ir numatytiesiems parametrams.",
+ "label": "Šalis"
+ },
+ "currency": {
+ "description": "Naudojamas energijos kainoms, išlaidoms ir sutaupymams formatuoti pagal jūsų tarifą.",
+ "example": "Jūsų mokėjimo kaina buvo {price}. Sutaupėte {amount}.",
+ "label": "Valiuta"
+ },
+ "siteTitle": {
+ "description": "Rodoma pagrindiniame ekrane ir naršyklės skirtuke.",
+ "label": "Pavadinimas"
+ },
+ "title": "Vieta"
+ },
"sponsor": {
"addToken": "Įveskite žetoną",
"changeToken": "Pakeisti žetoną",
@@ -1001,8 +1075,8 @@
"titleChoice": "Ką norite pridėti?",
"type": {
"co2": "CO₂ Intensyvumas",
- "feedIn": "Eksporto kaina",
- "grid": "Importo kaina",
+ "feedIn": "Tinklo Eksporto Kaina",
+ "grid": "Tinklo Importo Kaina",
"planner": "Planuoklis",
"solar": "Saulės",
"temperature": "Temperatūros Prognozė"
@@ -1056,6 +1130,124 @@
"titleEdit": "Koreguoti Automobilį"
}
},
+ "energy": {
+ "battery": {
+ "charged": "Įkrauta",
+ "discharged": "Iškrauta"
+ },
+ "consumers": {
+ "chart": {
+ "bars": "Stulpeliai",
+ "pattern": "Grafiko modelis"
+ },
+ "cost": "Kaina",
+ "others": "Kiti",
+ "source": {
+ "battery": "kaupiklis",
+ "grid": "tinklas",
+ "pv": "saulė"
+ },
+ "title": "Vartotojai",
+ "view": {
+ "river": "Upė",
+ "treemap": "Medis"
+ }
+ },
+ "direction": {
+ "battery": {
+ "energy": "įkrauta",
+ "returnEnergy": "iškrauta"
+ },
+ "grid": {
+ "energy": "iš tinklo",
+ "returnEnergy": "į tinklą"
+ },
+ "meter": {
+ "energy": "Energija iš tinklo",
+ "returnEnergy": "Energija (patiekta į tinklą)"
+ }
+ },
+ "empty": "Šiam laikotarpiui energijos duomenų nėra.",
+ "flow": {
+ "battery": "Kaupiklis",
+ "batteryCharge": "Kaupiklis įkrauta",
+ "batteryDischarge": "Kaupiklis iškrauta",
+ "charging": "Įkraunama",
+ "export": "Eksportas į tinklą",
+ "grid": "Importas iš tinklo",
+ "heating": "Šildoma",
+ "home": "Suvartojimas",
+ "overview": "Apžvalga",
+ "pv": "Gamyba",
+ "title": "Energijos tėkmė",
+ "usage": "Vartojimas",
+ "withdrawal": "Išimta"
+ },
+ "grid": {
+ "amount": "Kiekis",
+ "cost": "Tinklo kaina",
+ "energy": "Energija",
+ "export": "Eksportas",
+ "exportPrice": "Eksporto kaina",
+ "import": "Importas",
+ "importPrice": "Importo kaina",
+ "price": "Kaina",
+ "revenue": "Pajamos iš eksporto",
+ "title": "Tinklas"
+ },
+ "group": {
+ "battery": "Kaupiklis",
+ "consumer": "Vartojimas",
+ "forecast": "Prognozė",
+ "grid": "Tinklas",
+ "loadpoint": "Įkrovimas ir Šildymas",
+ "meter": "Papildomi skaitikliai",
+ "pv": "Gamyba"
+ },
+ "production": {
+ "above": "{value} virš",
+ "accuracy": "Prognozės tikslumas",
+ "below": "{value} žemiau",
+ "deviation": "Nuokrypis",
+ "expectedTomorrow": "tikimasi rytoj",
+ "exported": "{value} eksportuota",
+ "produced": "Pagaminta",
+ "remainingToday": "likę šiandien",
+ "selfConsumed": "Sunaudota savo reikmėms",
+ "title": "Gamyba"
+ },
+ "socTemp": {
+ "charged": "įkrauta",
+ "soc": "įkrova",
+ "temperature": "temperatūra",
+ "used": "sunaudota"
+ },
+ "sources": {
+ "battery": "Kaupiklis",
+ "cost": "Kaina",
+ "energy": "Energija",
+ "grid": "Tinklas",
+ "hint": "Saulės ir kaupiklio energija įvertinta pagal energijos supirkimo kainą.",
+ "price": "Kaina/kWh",
+ "pv": "Saulė",
+ "share": "Dalis",
+ "source": "Šaltinis",
+ "title": "Šaltiniai",
+ "total": "Viso"
+ },
+ "stat": {
+ "autarky": "Savarankiškumas",
+ "co2Saved": "CO₂ sutaupyta",
+ "co2SavedTooltip": "Palyginus su suvartojimu {energy} iš tinklo.",
+ "configure": "Konfigūruoti dabar",
+ "noCo2": "Nėra CO₂ duomenų.",
+ "noPrice": "Nėra kainos duomenų.",
+ "savings": "Sutaupymai",
+ "savingsTooltip": "Palyginus su suvartotos {energy} pirkimu iš tinklo.",
+ "versus": "{value} vs. {reference}"
+ },
+ "title": "Energija"
+ },
"footer": {
"community": {
"greenEnergy": "Saulės energija",
@@ -1193,6 +1385,7 @@
},
"general": {
"back": "Atgal",
+ "cancel": "Atšaukti",
"download": "Atsisiųsti",
"error": "Klaida",
"none": "nė vienas",
@@ -1230,31 +1423,6 @@
"restartDescription": "Ar pabandėte išjungti ir vėl įjungti?",
"secondaryActions": "Vis dar nepavyksta išspręsti problemos? Čia rasite sudėtingesnes opcijas."
},
- "energy": {
- "direction": {
- "battery": {
- "energy": "įkrauta",
- "returnEnergy": "iškrauta"
- },
- "grid": {
- "energy": "iš tinklo",
- "returnEnergy": "į tinklą"
- },
- "meter": {
- "energy": "Energija iš tinklo",
- "returnEnergy": "Energija (patiekta į tinklą)"
- }
- },
- "group": {
- "battery": "Kaupiklis",
- "consumer": "Vartojimas",
- "forecast": "Prognozė",
- "grid": "Tinklas",
- "loadpoint": "Įkrovimas ir Šildymas",
- "meter": "Papildomi skaitikliai",
- "pv": "Gamyba"
- }
- },
"issue": {
"additional": {
"copied": "nukopijuota!",
@@ -1404,6 +1572,7 @@
"batteryDischargeHold": "Kaupiklis (iškrovimas užblokuotas)",
"batteryGridChargeActive": "Įkrovimas iš tinklo: aktyvus",
"batteryGridChargeLimit": "Įkrauti iš tinklo: kai",
+ "batteryStatus": "Kaupiklio statusas",
"batteryTooltip": "{energy} iš {total} ({soc})",
"forecast": "Prognozė",
"forecastTooltip": "prognozė: likusi saulės energijos gamyba šiandien",
@@ -1413,7 +1582,7 @@
"loadpointsLimit": "{limit} limitas",
"noEnergy": "Nėra skaitiklių duomenų",
"pv": "Saulės jėgainė",
- "pvExport": "Eksportas",
+ "pvExport": "Tinklo eksportas",
"pvProduction": "Gamyba",
"selfConsumption": "Sunaudojama iškart",
"toggleDetails": "Energijos srauto detalės"
@@ -1868,6 +2037,7 @@
"battery": "Kaupiklis",
"charge": "Įkrovimas",
"comingSoon": "Šis puslapis yra kuriamas.",
+ "energy": "Energija",
"forecast": "Prognozė",
"more": "Daugiau",
"sessions": "Sesijos"
diff --git a/i18n/lv.json b/i18n/lv.json
index 2999975c30..9be71a5c14 100644
--- a/i18n/lv.json
+++ b/i18n/lv.json
@@ -7,6 +7,18 @@
"authorizationSuccessful": "Autorizācija veiksmīga",
"buttonConnect": "Savienot ar {provider}",
"buttonDisconnect": "Atvienot",
+ "challenge": {
+ "captcha": {
+ "label": "Ievadiet attēlā redzamos simbolus.",
+ "title": "Drošības kods"
+ },
+ "code": {
+ "label": "Ielīmējiet kodu vai pāradresētās lapas adresi.",
+ "title": "Autorizācijas kods"
+ },
+ "openLogin": "Atvērt pieteikšanās lapu",
+ "submit": "Turpināt"
+ },
"confirmLogout": "Vai tiešām vēlaties atvienot {title}?",
"connect": "savienot",
"disconnect": "atvienot",
@@ -25,6 +37,7 @@
"discharging": "izlādējas",
"energy": "Enerģija",
"gridCharge": "tīkla uzlāde",
+ "gridDischarge": "tīkla izlāde",
"hold": "izlāde bloķēta",
"holdCharge": "uzlāde bloķēta",
"ofTotal": "no {total}",
@@ -43,6 +56,12 @@
"bufferTitle": "Akumulators kā uzlādes buferis",
"discharge": "Novērst mājas akumulatora izlādi ātrajā režīmā un plānotās uzlādes laikā.",
"gridDischarge": "Atļaut mājas akumulatora izlādi tīklā.",
+ "gridDischargeConfirm": {
+ "country": "Konfigurētā valsts: {country}.",
+ "description": "Vai akumulatora enerģiju drīkst nodot tīklā, ir atkarīgs no jūsu valsts un atdeves līguma. Ja akumulators tiek uzlādēts arī no tīkla, nodotā enerģija vairs nav tikai saules enerģija. Saskaņā ar daudziem atdeves tarifiem tas anulē jūsu atlīdzību vai arī ir aizliegts. Vispirms pārbaudiet savu atdeves līgumu un tīkla operatora prasības.",
+ "noCountry": "Nav konfigurēta neviena valsts. Iestatiet to sadaļā {link}.",
+ "title": "Akumulatora enerģijas nodošana tīklā"
+ },
"priority": "Mājas akumulatoram ir prioritāte, kamēr tas ir zem {soc}. Pēc tam uzlādes punkti saņem pārpalikumu.",
"priorityNone": "Saules pārpalikums nonāk tieši uzlādes punktos. Iestatiet vērtību virs {soc}, lai dotu priekšroku mājas akumulatoram.",
"priorityTitle": "Kur pārpalikums nonāk vispirms?"
@@ -144,11 +163,46 @@
"switchsocket": "Lietotāja definēta pārslēdzama rozete"
}
},
+ "circuit": {
+ "addMeter": "Pievienot atsevišķu enerģijas skaitītāju",
+ "custom": "Lietotāja definēta ķēde",
+ "deleteChildrenFirst": "Lai dzēstu šo ķēdi, vispirms dzēsiet tās apakšķēdes.",
+ "meterDedicated": "Atsevišķs skaitītājs",
+ "meterGrid": "Tīkla skaitītājs",
+ "meterHelp": "Skaitītājs, kas mēra šīs ķēdes kopējo patēriņu.",
+ "meterLabel": "Enerģijas skaitītājs",
+ "meterLabelAdd": "Pievienot ķēdes skaitītāju",
+ "meterLabelEdit": "Rediģēt ķēdes skaitītāju",
+ "meterNone": "Nav skaitītāja",
+ "meterSelectionHelpDedicatedMeter": "Pievienojiet esošu skaitītāju šai ķēdei zemāk.",
+ "meterSelectionHelpGridMeter": "Izmanto jūsu iekārtas esošo tīkla skaitītāju.",
+ "meterSelectionHelpNoMeter": "Tiek izmantota visu uzlādes punktu summa.",
+ "meterSelectionLabel": "Skaitītāja izvēle",
+ "parentCircuit": "Galvenā ķēde",
+ "template": "Ķēde",
+ "titleAdd": "Pievienot ķēdi",
+ "titleEdit": "Rediģēt ķēdi",
+ "titleLabel": "Virsraksts"
+ },
"circuits": {
+ "addMainCircuit": "Pievienot galveno ķēdi",
+ "addSubCircuit": "Pievienot apakšķēdi",
+ "chargingPointsNote": "Piešķiriet uzlādes punktus un sildītājus ķēdei to attiecīgajos iestatījumos.",
"description": "Nodrošina, ka visu ķēdei pieslēgto uzlādes punktu summa nepārsniedz konfigurētās jaudas un strāvas robežas. Ķēdes var ligzdot, veidojot hierarhiju.",
+ "legacyWarning": "Pieejama jauna ķēžu konfigurācija! Noņemiet un saglabājiet savu konfigurāciju šeit, lai izmantotu jauno vadīto procesu.",
+ "meterDedicated": "Skaitītājs",
+ "meterGrid": "Elektrotīkls",
"title": "Slodzes pārvaldība",
"usableMeters": "Izmantojamās skaitītāju atsauces"
},
+ "combobox": {
+ "count": "{count} ieteikumi",
+ "matching": "Atbilst",
+ "other": "Cits",
+ "searching": "Meklē…",
+ "suggestions": "Ieteikumi",
+ "used": "jau izmantots"
+ },
"consumer": {
"description": "Seko mazākām, nevadāmām ierīcēm (ledusskapis, veļasmašīna, …). Sniedz labāku pārskatu par jūsu mājas patēriņu.",
"titleAdd": "Pievienot patēriņa skaitītāju",
@@ -216,6 +270,8 @@
"identifier": "RFID identifikators",
"loginBlocked": "Pieslēgšanās ierobežojums sasniegts",
"max": "maks",
+ "maxCurrent": "Maksimālā strāvas vērtība",
+ "maxPower": "Maks. jauda",
"messengers": "Pakalpojumi",
"no": "nē",
"odometer": "Odometrs",
@@ -352,6 +408,7 @@
"enable": "Iespējot",
"error": "Kļūda",
"experimental": "Eksperimentāla versija",
+ "forceEnable": "Tomēr iespējot",
"forceSave": "Saglabāt tik un tā",
"fromYamlHint": "Piezīme: Konfigurēts caur evcc.yaml. Noņemiet ierakstu no datnes, lai iespējotu rediģēšanu šeit.",
"hideAdvancedSettings": "Slēpt paplašinātos iestatījumus",
@@ -370,6 +427,7 @@
"saving": "Saglabā…",
"selectFile": "Pārlūkot",
"showAdvancedSettings": "Rādīt paplašinātos iestatījumus",
+ "site": "Vietne",
"telemetry": "Telemetrija",
"templateLoading": "Ielādē...",
"title": "Nosaukums",
@@ -562,7 +620,7 @@
"configured": "konfigurēts",
"edit": "rediģēt",
"loadpointRequired": "Jābūt konfigurētam vismaz vienam uzlādes punktam.",
- "name": "Nosaukums",
+ "name": "Vārds",
"title": "Konfigurācija",
"unconfigured": "nav konfigurēts",
"vehicles": "Mani transportlīdzekļi",
@@ -679,7 +737,7 @@
"connectionHintSerial": "Ierīce ir tieši savienota caur RS485 (vai USB-uz-RS485 adapteri).",
"connectionHintTcpip": "Ierīce ir sasniedzama caur tīklu (LAN/WiFi).",
"connectionValueSerial": "RS485",
- "connectionValueTcpip": "Tīkls",
+ "connectionValueTcpip": "Tīklojums",
"delay": "Aizture",
"delayHint": "Neobligāts gaidīšanas laiks pirms katra reģistra piekļuves. Nepieciešams tikai lēnām ierīcēm.",
"device": "Ierīces nosaukums",
@@ -736,7 +794,7 @@
"title": "MQTT"
},
"network": {
- "descriptionExternalUrl": "Adrese citām ierīcēm, kas vēlas savienoties ar evcc, un evcc lietotnes automātiskai atklāšanai.",
+ "descriptionExternalUrl": "Adrese citām ierīcēm, kas vēlas pieslēgties evcc, un evcc lietotnes automātiskajai atklāšanai.",
"descriptionHost": "Izmanto, lai paziņotu par evcc jūsu lokālajā tīklā.",
"descriptionInternalUrl": "evcc lokālā tīkla adrese.",
"descriptionPort": "Ports tīmekļa saskarnei un API. Ja to izmainīsiet, būs jāatjaunina pārlūka URL.",
@@ -744,7 +802,7 @@
"labelHost": "mDNS resursdatora nosaukums",
"labelInternalUrl": "Iekšējais URL",
"labelPort": "Ports",
- "title": "Tīkls",
+ "title": "Tīklojums",
"warningUrlPath": "URL parasti nav nepieciešams ceļš. Vai esat pārliecināts, ka tas ir pareizi?"
},
"ocpp": {
@@ -888,6 +946,22 @@
"labelVendorId": "Ražotāja ID",
"title": "SMA Sunny Home Manager"
},
+ "site": {
+ "country": {
+ "description": "Tiek izmantots reģionam raksturīgiem ieteikumiem un noklusējuma vērtībām.",
+ "label": "Valsts"
+ },
+ "currency": {
+ "description": "Tiek izmantots, lai atbilstoši jūsu tarifam formatētu enerģijas cenas, izmaksas un ietaupījumus.",
+ "example": "Jūsu uzlādes cena bija {price}. Jūs ietaupījāt {amount}.",
+ "label": "Valūta"
+ },
+ "siteTitle": {
+ "description": "Tiek parādīts galvenajā ekrānā un pārlūkprogrammas cilnē.",
+ "label": "Nosaukums"
+ },
+ "title": "Vietne"
+ },
"sponsor": {
"addToken": "Ievadiet marķieri",
"changeToken": "Mainīt marķieri",
@@ -1056,6 +1130,124 @@
"titleEdit": "Rediģēt transportlīdzekli"
}
},
+ "energy": {
+ "battery": {
+ "charged": "Uzlādēts",
+ "discharged": "Izlādēts"
+ },
+ "consumers": {
+ "chart": {
+ "bars": "Stabiņi",
+ "pattern": "Raksts"
+ },
+ "cost": "Izmaksas",
+ "others": "Citi",
+ "source": {
+ "battery": "akumulators",
+ "grid": "tīkls",
+ "pv": "saules"
+ },
+ "title": "Patērētāji",
+ "view": {
+ "river": "Plūsma",
+ "treemap": "Koku karte"
+ }
+ },
+ "direction": {
+ "battery": {
+ "energy": "uzlādēts",
+ "returnEnergy": "izlādēts"
+ },
+ "grid": {
+ "energy": "importēts",
+ "returnEnergy": "eksportēts"
+ },
+ "meter": {
+ "energy": "Enerģija",
+ "returnEnergy": "Enerģija (atpakaļvirzienā)"
+ }
+ },
+ "empty": "Šim laika diapazonam nav pieejamu enerģijas datu.",
+ "flow": {
+ "battery": "Akumulators",
+ "batteryCharge": "Akumulatora lādēšana",
+ "batteryDischarge": "Akumulatora izlādēšana",
+ "charging": "Lādēšanās",
+ "export": "Eksports uz tīklu",
+ "grid": "Imports no tīkla",
+ "heating": "Apkure",
+ "home": "Patēriņš",
+ "overview": "Pārskats",
+ "pv": "Ražošana",
+ "title": "Enerģijas plūsma",
+ "usage": "Lietojums",
+ "withdrawal": "Ņemšana no tīkla"
+ },
+ "grid": {
+ "amount": "Daudzums",
+ "cost": "Tīkla izmaksas",
+ "energy": "Enerģija",
+ "export": "Eksports",
+ "exportPrice": "Eksporta cena",
+ "import": "Imports",
+ "importPrice": "Importa cena",
+ "price": "Cena",
+ "revenue": "Atdeves ieņēmumi",
+ "title": "Tīkls"
+ },
+ "group": {
+ "battery": "Akumulators",
+ "consumer": "Patēriņš",
+ "forecast": "Prognoze",
+ "grid": "Tīkls",
+ "loadpoint": "Uzlāde un sildīšana",
+ "meter": "Papildu patēriņa skaitītāji",
+ "pv": "Ražošana"
+ },
+ "production": {
+ "above": "{value} virs",
+ "accuracy": "Prognozes precizitāte",
+ "below": "{value} zem",
+ "deviation": "Novirze",
+ "expectedTomorrow": "paredzams rīt",
+ "exported": "{value} eksportēts",
+ "produced": "Saražots",
+ "remainingToday": "atlicis šodien",
+ "selfConsumed": "Pašpatērēts",
+ "title": "Ražošana"
+ },
+ "socTemp": {
+ "charged": "uzlādēts",
+ "soc": "uzlāde",
+ "temperature": "temperatūra",
+ "used": "izmantots"
+ },
+ "sources": {
+ "battery": "Akumulators",
+ "cost": "Izmaksas",
+ "energy": "Enerģija",
+ "grid": "Tīkls",
+ "hint": "Saules un akumulatora enerģija novērtēta pēc atdeves cenas.",
+ "price": "Cena/kWh",
+ "pv": "Saules",
+ "share": "Daļa",
+ "source": "Avots",
+ "title": "Avoti",
+ "total": "Kopā"
+ },
+ "stat": {
+ "autarky": "Autarkija",
+ "co2Saved": "Ietaupītais CO₂",
+ "co2SavedTooltip": "Salīdzinot ar patērētās {energy} iegūšanu no tīkla.",
+ "configure": "Konfigurēt tagad",
+ "noCo2": "Nav CO₂ datu.",
+ "noPrice": "Nav cenu datu.",
+ "savings": "Ietaupījums",
+ "savingsTooltip": "Salīdzinot ar patērētās {energy} iegādi no tīkla.",
+ "versus": "{value} pret {reference}"
+ },
+ "title": "Enerģija"
+ },
"footer": {
"community": {
"greenEnergy": "Saules enerģija",
@@ -1193,6 +1385,7 @@
},
"general": {
"back": "Atpakaļ",
+ "cancel": "Atcelt",
"download": "Lejupielādēt",
"error": "Kļūda",
"none": "nav",
@@ -1230,31 +1423,6 @@
"restartDescription": "Vai mēģinājāt izslēgt un atkal ieslēgt?",
"secondaryActions": "Joprojām nevarat atrisināt problēmu? Šeit ir vēl dažas radikālākas iespējas."
},
- "energy": {
- "direction": {
- "battery": {
- "energy": "uzlādēts",
- "returnEnergy": "izlādēts"
- },
- "grid": {
- "energy": "importēts",
- "returnEnergy": "eksportēts"
- },
- "meter": {
- "energy": "Enerģija",
- "returnEnergy": "Enerģija (atpakaļvirzienā)"
- }
- },
- "group": {
- "battery": "Akumulators",
- "consumer": "Patēriņš",
- "forecast": "Prognoze",
- "grid": "Tīkls",
- "loadpoint": "Uzlāde un sildīšana",
- "meter": "Papildu patēriņa skaitītāji",
- "pv": "Ražošana"
- }
- },
"issue": {
"additional": {
"copied": "nokopēts!",
@@ -1404,6 +1572,7 @@
"batteryDischargeHold": "Akumulators (izlāde bloķēta)",
"batteryGridChargeActive": "Tīkla uzlāde: aktīva",
"batteryGridChargeLimit": "Tīkla uzlāde: kad",
+ "batteryStatus": "Akumulatora statuss",
"batteryTooltip": "{energy} no {total} ({soc})",
"forecast": "Prognoze",
"forecastTooltip": "prognoze: atlikusī saules enerģijas ražošana šodien",
@@ -1868,6 +2037,7 @@
"battery": "Akumulators",
"charge": "Uzlāde",
"comingSoon": "Šī lapa ir izstrādes stadijā.",
+ "energy": "Enerģija",
"forecast": "Prognoze",
"more": "Vairāk",
"sessions": "Sesijas"
diff --git a/i18n/nl.json b/i18n/nl.json
index a95f1033c7..501ddd5d75 100644
--- a/i18n/nl.json
+++ b/i18n/nl.json
@@ -7,6 +7,18 @@
"authorizationSuccessful": "Autorisatie gelukt",
"buttonConnect": "Verbind met {provider}",
"buttonDisconnect": "Verbinding verbreken",
+ "challenge": {
+ "captcha": {
+ "label": "Voer de tekens in zoals getoond in de afbeelding.",
+ "title": "Captcha"
+ },
+ "code": {
+ "label": "Plak de code of het adres van de pagina waarnaar je bent doorverwezen.",
+ "title": "Autorisatiecode"
+ },
+ "openLogin": "Open inlogpagina",
+ "submit": "Doorgaan"
+ },
"confirmLogout": "Weet u zeker dat u de verbinding met {title} wilt verbreken?",
"connect": "verbinding maken",
"disconnect": "verbinding verbreken",
@@ -43,6 +55,12 @@
"bufferTitle": "Batterij als laadbuffer",
"discharge": "Blokkeer het ontladen van de thuisbatterij in snelle modus en bij gepland laden.",
"gridDischarge": "Sta ontladen van de batterij naar het net toe.",
+ "gridDischargeConfirm": {
+ "country": "Geconfigureerd land: {country}.",
+ "description": "Afhankelijk van de reglementen van het land en de afspraken met je energieleverancier is het mogelijk om energie uit de accu terug te voeden naar het net, de geëxporteerde energie is niet langer puur zonne-energie. Onder veel omstandigheden is dit niet toegestaan. Controleer de reglementen van de netbeheerder en het contract met je energieleverancier.",
+ "noCountry": "Geen land geconfigureerd. Stel dit in onder {link}.",
+ "title": "Exporteren naar het net vanuit de accu"
+ },
"priority": "Thuisbatterij heeft voorrang tot {soc} gehaald wordt. Na het behalen van het ingestelde percentage gaat het overschot naar de laadpunten.",
"priorityNone": "Zon-overschot gaat rechtstreeks naar de laadpunten. Stel een waarde boven {soc} in om de thuisbatterij voorrang te geven.",
"priorityTitle": "Waar gaat het overschot als eerst heen?"
@@ -134,21 +152,55 @@
},
"type": {
"custom": {
- "charging": "Gebruikersgedefinieerde lader",
- "heating": "Gebruikergedefinieerde verwarmer"
+ "charging": "Gebruikersgedefiniëerde lader",
+ "heating": "Gebruikersgedefiniëerde verwarmer"
},
- "heatpump": "Gebruikersgedefinieerde warmtepomp",
- "sgready": "Gebruikersgedefinieerde warmtepomp (sg-ready via plugins)",
- "sgready-boost": "Gebruikersgedefinieerde verwarmer (sg-ready-boost, verouderd)",
- "sgready-relay": "Gebruikersgedefinieerde warmtepomp (sg-ready via relais)",
- "switchsocket": "Gebruikersgedefinieerd schakelbaar stopcontact"
+ "heatpump": "Gebruikersgedefiniëerde warmtepomp",
+ "sgready": "Gebruikersgedefiniëerde warmtepomp (sg-ready via plugins)",
+ "sgready-boost": "Gebruikersgedefiniëerde verwarmer (sg-ready-boost, verouderd)",
+ "sgready-relay": "Gebruikersgedefiniëerde warmtepomp (sg-ready via relais)",
+ "switchsocket": "Gebruikersgedefiniëerd schakelbaar stopcontact"
}
},
+ "circuit": {
+ "addMeter": "Voeg een toegewijde energiemeter toe",
+ "custom": "Gebruikersgedefiniëerd circuit",
+ "deleteChildrenFirst": "Verwijder eerst de onderliggende circuits, alvorens dit circuit te verwijderen.",
+ "meterDedicated": "Toegewijde meter",
+ "meterGrid": "Netmeter",
+ "meterHelp": "Meter die het totale verbruik van het circuit meet.",
+ "meterLabel": "Energiemeter",
+ "meterLabelAdd": "Circuitmeter toevoegen",
+ "meterLabelEdit": "Circuitmeter wijzigen",
+ "meterNone": "Geen meter",
+ "meterSelectionHelpDedicatedMeter": "Voeg een bestaande meter toe aan dit circuit.",
+ "meterSelectionHelpGridMeter": "Gebruikt de bestaande netmeter van uw installatie.",
+ "meterSelectionHelpNoMeter": "De som van alle laadpunten wordt gebruikt.",
+ "meterSelectionLabel": "Meterselectie",
+ "parentCircuit": "Bovenliggend circuit",
+ "template": "Circuit",
+ "titleAdd": "Circuit toevoegen",
+ "titleEdit": "Circuit wijzigen",
+ "titleLabel": "Titel"
+ },
"circuits": {
+ "addMainCircuit": "Hoofdcircuit toevoegen",
+ "addSubCircuit": "Onderliggend circuit toevoegen",
"description": "Zorgt ervoor dat de som van alle laadpunten die zijn aangesloten op een circuit de ingestelde vermogens- en stroomlimieten niet overschrijdt. Circuits kunnen genest worden om een hiërarchie op te bouwen.",
+ "legacyWarning": "Nieuwe circuitconfiguratie beschikbaar! Verwijder en sla uw configuratie hier op om het nieuwe, begeleide proces te gebruiken.",
+ "meterDedicated": "Meter",
+ "meterGrid": "Net",
"title": "Load Balancing",
"usableMeters": "Bruikbare meterreferenties"
},
+ "combobox": {
+ "count": "{count} suggesties",
+ "matching": "Overeenkomend",
+ "other": "Anders",
+ "searching": "Bezig met zoeken…",
+ "suggestions": "Suggesties",
+ "used": "reeds in gebruik"
+ },
"consumer": {
"description": "Verschaft inzicht in kleinere, niet aanstuurbare apparaten (koelkasten, wasmachines, ...). Dit geeft een mooier overzicht van het verbruik in een huishouden.",
"titleAdd": "Voeg meter voor een verbruiker toe",
@@ -216,6 +268,8 @@
"identifier": "RFID-Nummer",
"loginBlocked": "Inloglimiet bereikt",
"max": "max",
+ "maxCurrent": "Max. stroomsterkte",
+ "maxPower": "Max. vermogen",
"messengers": "Diensten",
"no": "nee",
"odometer": "Kilometerstand",
@@ -233,7 +287,7 @@
"remoteEnabled": "Ingeschakeld",
"returnEnergy": "Energie (teruglevering)",
"singlePhase": "Monofase",
- "soc": "Laden",
+ "soc": "Lading",
"solarForecast": "Voorspelling van zonne-energie",
"switchDevice": "Stroomsterkte regeling",
"temp": "Temperatuur",
@@ -339,8 +393,8 @@
"confirmSave": "Er zijn niet-opgeslagen wijzigingen. Nu opslaan?",
"copied": "Gekopieerd!",
"copy": "Kopieer",
- "customHelp": "Creëer een gebruiker-gedefinieerd apparaat met evcc’s plugin systeem.",
- "customOption": "Gebruiker-gedefinieerd apparaat",
+ "customHelp": "Creëer een gebruikersgedefiniëerd apparaat met evcc’s plugin systeem.",
+ "customOption": "Gebruikersgedefiniëerd apparaat",
"delete": "Wissen",
"disable": "Uitschakelen",
"disabled": "uitgeschakeld",
@@ -352,6 +406,7 @@
"enable": "Inschakelen",
"error": "Fout",
"experimental": "Experimenteel",
+ "forceEnable": "Toch activeren",
"forceSave": "Toch opslaan",
"fromYamlHint": "Opmerking: Geconfigureerd via evcc.yaml. Verwijder de betreffende regels uit het bestand om hier bewerkingen mogelijk te maken.",
"hideAdvancedSettings": "Geavanceerde instellingen verbergen",
@@ -370,6 +425,7 @@
"saving": "Bezig met opslaan…",
"selectFile": "Bladeren",
"showAdvancedSettings": "Toon geavanceerde instellingen",
+ "site": "Locatie",
"telemetry": "Telemetrie",
"templateLoading": "Laden...",
"title": "Titel",
@@ -390,7 +446,7 @@
"limitpower": "Limiet (kW)",
"type": "Type"
},
- "customOption": "Door de gebruiker aangemaakte integratie",
+ "customOption": "Gebruikersgedefiniëerde integratie",
"description": "Vermogensbeperking door externe systemen (bijv. §14a EnWG, §9 EEG-interface of hoger niveau energiebeheersysteem). Werkt samen met de functie voor Load Balancing. Sommige meters ondersteunen ook curtailment van de zonne-energieopbrengst. Het curtailmentlimiet moet op elke omvormerconfiguratie ingesteld worden.",
"download": "Download",
"dynamicLimits": "Dynamische limieten",
@@ -407,8 +463,8 @@
"template": "Integratie",
"title": "Externe limiet",
"type": {
- "custom": "Gebruikergedefinieerde integratie",
- "relay": "Gebruikergedefinieerde integratie (relais)"
+ "custom": "Gebruikersgedefiniëerde integratie",
+ "relay": "Gebruikersgedefiniëerde integratie (relais)"
}
},
"icon": {
@@ -635,7 +691,7 @@
"title": "Meldingen"
},
"messenger": {
- "custom": "Gebruiker gedefinieerde dienst",
+ "custom": "Gebruikersgedefiniëerde dienst",
"generic": "Algemene dienst",
"primary": "Specifieke dienst",
"template": "Dienst",
@@ -888,6 +944,21 @@
"labelVendorId": "Vendor ID",
"title": "SMA Sunny Home Manager"
},
+ "site": {
+ "country": {
+ "description": "Wordt gebruikt voor regio-specifieke hints en standaardwaarden.",
+ "label": "Land"
+ },
+ "currency": {
+ "description": "Voor het opmaken van energieprijzen, kosten en besparingen gebaseerd op uw tarief.",
+ "example": "Uw laadkosten bedroegen {price}. U heeft {amount} bespaard.",
+ "label": "Valuta"
+ },
+ "siteTitle": {
+ "label": "Titel"
+ },
+ "title": "Locatie"
+ },
"sponsor": {
"addToken": "Token invoeren",
"changeToken": "Token bewerken",
@@ -1056,6 +1127,75 @@
"titleEdit": "Voertuig bewerken"
}
},
+ "energy": {
+ "battery": {
+ "charged": "Geladen",
+ "discharged": "Ontladen"
+ },
+ "consumers": {
+ "chart": {
+ "pattern": "Patroon"
+ },
+ "cost": "Kosten",
+ "source": {
+ "battery": "batterij",
+ "grid": "net"
+ },
+ "title": "Verbruikers"
+ },
+ "direction": {
+ "battery": {
+ "energy": "geladen",
+ "returnEnergy": "ontladen"
+ },
+ "grid": {
+ "energy": "geimporteerd",
+ "returnEnergy": "geexporteerd"
+ },
+ "meter": {
+ "energy": "Energie",
+ "returnEnergy": "Energie (omgekeerd)"
+ }
+ },
+ "empty": "Geen energiegegevens beschikbaar voor het gekozen tijdsvenster.",
+ "flow": {
+ "battery": "Batterij",
+ "batteryCharge": "Batterij laden",
+ "batteryDischarge": "Batterij ontladen",
+ "charging": "Laden",
+ "heating": "Verwarming",
+ "home": "Verbruik",
+ "overview": "Overzicht",
+ "pv": "Productie",
+ "title": "Energiestroom",
+ "usage": "Verbruik"
+ },
+ "grid": {
+ "amount": "Hoeveelheid",
+ "energy": "Energie",
+ "export": "Export",
+ "exportPrice": "Exportprijs",
+ "import": "Import",
+ "importPrice": "Importprijs",
+ "price": "Prijs",
+ "revenue": "Terugleververgoeding",
+ "title": "Net"
+ },
+ "group": {
+ "battery": "Batterij",
+ "consumer": "Verbruik",
+ "forecast": "Voorspelling",
+ "grid": "Net",
+ "loadpoint": "Laden & Verwarmen",
+ "meter": "Extra meters",
+ "pv": "Productie"
+ },
+ "production": {
+ "deviation": "Afwijking",
+ "produced": "Geproduceerd",
+ "title": "Productie"
+ }
+ },
"footer": {
"community": {
"greenEnergy": "Zon",
@@ -1193,6 +1333,7 @@
},
"general": {
"back": "Terug",
+ "cancel": "Annuleren",
"download": "Download",
"error": "Fout",
"none": "geen",
@@ -1230,31 +1371,6 @@
"restartDescription": "Geprobeerd om het uit en weer aan te zetten?",
"secondaryActions": "Nog niet gelukt om je probleem op te lossen? Hier zijn enkele hardhandige opties."
},
- "energy": {
- "direction": {
- "battery": {
- "energy": "geladen",
- "returnEnergy": "ontladen"
- },
- "grid": {
- "energy": "geimporteerd",
- "returnEnergy": "geexporteerd"
- },
- "meter": {
- "energy": "Energie",
- "returnEnergy": "Energie (omgekeerd)"
- }
- },
- "group": {
- "battery": "Batterij",
- "consumer": "Verbruik",
- "forecast": "Voorspelling",
- "grid": "Net",
- "loadpoint": "Laden & Verwarmen",
- "meter": "Extra meters",
- "pv": "Productie"
- }
- },
"issue": {
"additional": {
"copied": "gekopieerd!",
diff --git a/i18n/pt.json b/i18n/pt.json
index 12b866706c..93eda7548e 100644
--- a/i18n/pt.json
+++ b/i18n/pt.json
@@ -7,6 +7,18 @@
"authorizationSuccessful": "Autorização com sucesso",
"buttonConnect": "Ligar-se ao {provider}",
"buttonDisconnect": "Desligar",
+ "challenge": {
+ "captcha": {
+ "label": "Introduza os símbolos apresentados na imagem.",
+ "title": "Captcha"
+ },
+ "code": {
+ "label": "Coloque o código ou o endereço da página para a qual foi redirecionado.",
+ "title": "Código de autorização"
+ },
+ "openLogin": "Abrir a página de acesso",
+ "submit": "Continuar"
+ },
"confirmLogout": "Tem a certeza de que deseja desligar {title}?",
"connect": "Ligar",
"disconnect": "Desligar",
@@ -25,6 +37,7 @@
"discharging": "descarregando",
"energy": "Energia",
"gridCharge": "carga da rede",
+ "gridDischarge": "descarga da rede",
"hold": "descarga bloqueada",
"holdCharge": "carga bloqueada",
"ofTotal": "de {total}",
@@ -43,6 +56,12 @@
"bufferTitle": "Bateria como reserva de carga",
"discharge": "Evite a descarga da bateria doméstica no modo rápido e durante o carregamento programado.",
"gridDischarge": "Permitir que a bateria doméstica descarregue para a rede elétrica.",
+ "gridDischargeConfirm": {
+ "country": "País configurado: {country}.",
+ "description": "A possibilidade de exportar a energia da bateria para a rede depende do seu país e do seu contrato de injeção de energia. Se a bateria também for carregada a partir da rede, a energia exportada deixa de ser exclusivamente solar. Ao abrigo de muitas tarifas de injeção, isso pode implicar a perda da sua remuneração ou ser proibido. Verifique primeiro o seu contrato de injeção e os requisitos do operador da rede.",
+ "noCountry": "Não foi definido nenhum país. Defina-o em {link}.",
+ "title": "Exportação de energia para a rede a partir da bateria"
+ },
"priority": "A bateria doméstica tem prioridade enquanto o seu nível de carga (SOC) estiver abaixo de {soc}. Depois, os pontos de carregamento recebem o excedente.",
"priorityNone": "O excedente de energia solar é encaminhado diretamente para os pontos de carregamento. Defina um valor superior a {soc} para dar prioridade à bateria doméstica.",
"priorityTitle": "Para onde vai o excedente primeiro?"
@@ -144,11 +163,46 @@
"switchsocket": "Tomada de interrutor definida pelo utilizador"
}
},
+ "circuit": {
+ "addMeter": "Adicionar um contador de energia dedicado",
+ "custom": "Circuito definido pelo utilizador",
+ "deleteChildrenFirst": "Para eliminar este circuito, elimine primeiro os seus circuitos secundários.",
+ "meterDedicated": "Contador específico",
+ "meterGrid": "Contador de rede",
+ "meterHelp": "Contador que mede o consumo total deste circuito.",
+ "meterLabel": "Contador de energia",
+ "meterLabelAdd": "Adicionar medidor de circuito",
+ "meterLabelEdit": "Editar medidor de circuito",
+ "meterNone": "Sem contador",
+ "meterSelectionHelpDedicatedMeter": "Adicione um contador já existente a este circuito abaixo.",
+ "meterSelectionHelpGridMeter": "Utiliza o contador de rede já existente na sua instalação.",
+ "meterSelectionHelpNoMeter": "É utilizada a soma de todos os pontos de carregamento.",
+ "meterSelectionLabel": "Seleção do contador",
+ "parentCircuit": "Circuito principal",
+ "template": "Circuito",
+ "titleAdd": "Adicionar circuito",
+ "titleEdit": "Editar circuito",
+ "titleLabel": "Título"
+ },
"circuits": {
+ "addMainCircuit": "Adicionar circuito principal",
+ "addSubCircuit": "Adicionar circuito secundário",
+ "chargingPointsNote": "Atribuir pontos de carregamento e aquecedores a um circuito nas respetivas definições.",
"description": "Assegura que a soma de todos os Postos de Carregamento conectados a um circuito não excede os limites de potência e corrente configurados. Os circuitos podem ser combinados para construir uma hierarquia.",
+ "legacyWarning": "Já está disponível uma nova configuração de circuitos! Elimine e guarde aqui a sua configuração para utilizar o novo processo guiado.",
+ "meterDedicated": "Contador",
+ "meterGrid": "Rede",
"title": "Gestão de Cargas",
"usableMeters": "Referências dos contadores utilizáveis"
},
+ "combobox": {
+ "count": "{count} sugestões",
+ "matching": "Correspondência",
+ "other": "Outro",
+ "searching": "Procurando…",
+ "suggestions": "Sugestões",
+ "used": "já utilizado"
+ },
"consumer": {
"description": "Monitoriza os aparelhos mais pequenos e não controláveis (frigorífico, máquina de lavar roupa, ...). Proporciona-lhe uma melhor visão geral do consumo da sua casa.",
"titleAdd": "Adicionar contador de consumo",
@@ -168,6 +222,15 @@
"label": "Moeda",
"title": "Moeda"
},
+ "curtailer": {
+ "description": "Dispositivo que limita a injeção de energia a pedido do operador da rede (§ 9 da EEG). Muitos inversores solares suportam esta funcionalidade diretamente através da configuração do contador solar. Só é necessária uma configuração separada para alguns fabricantes ou para sistemas com vários inversores.",
+ "generic": "Integrações genéricas",
+ "specific": "Integrações específicas",
+ "template": "Fabricante",
+ "title": "Restrição",
+ "titleAdd": "Adicionar dispositivo de restrição",
+ "titleEdit": "Editar dispositivo de restrição"
+ },
"deviceValue": {
"activeClients": "Clientes ativos",
"amount": "Quantidade",
@@ -181,7 +244,7 @@
"chargeStatusC": "a carregar",
"chargeStatusE": "sem energia",
"chargeStatusF": "erro",
- "chargedEnergy": "Carregado",
+ "chargedEnergy": "Carregado (sessão)",
"co2": "Rede CO₂",
"configured": "Configurado",
"connected": "Conectado",
@@ -192,7 +255,7 @@
"currentRange": "Corrente",
"curtailLimit": "Limite de injeção na rede",
"curtailable": "Sujeito a restrições",
- "curtailed": "Limitação da injeção na rede",
+ "curtailed": "Limitedae injeção na rede",
"detected": "Detectado",
"dimLimit": "Limite de consumo",
"dimmed": "Consumo limitado",
@@ -207,6 +270,8 @@
"identifier": "Identificador RFID",
"loginBlocked": "Limite de login atingido",
"max": "max",
+ "maxCurrent": "Corrente máxima",
+ "maxPower": "Potência máxima",
"messengers": "Serviços",
"no": "não",
"odometer": "Conta Quilómetros",
@@ -230,6 +295,31 @@
"temp": "Temperatura",
"topic": "Topic",
"url": "URL",
+ "usage": {
+ "aux": {
+ "energy": "Consumido",
+ "returnEnergy": "Produzido"
+ },
+ "battery": {
+ "energy": "Descarregado",
+ "returnEnergy": "Cargado"
+ },
+ "charge": {
+ "energy": "Cargado",
+ "returnEnergy": "Descarregado"
+ },
+ "consumer": {
+ "energy": "Consumido",
+ "returnEnergy": "Produzido"
+ },
+ "grid": {
+ "energy": "Importado",
+ "returnEnergy": "Exportado"
+ },
+ "pv": {
+ "energy": "Produzido"
+ }
+ },
"vehicleLimitSoc": "Limite da carga",
"yes": "sim"
},
@@ -300,24 +390,31 @@
"change": "Alterar",
"clear": "Apagar",
"close": "Fechar",
+ "confirmDisable": "Desativar este dispositivo? Será ignorado após o próximo reinício.",
+ "confirmEnable": "Ativar novamente este dispositivo?",
"confirmSave": "Existem modificações não guardadas. Guardar agora?",
"copied": "Copiado!",
"copy": "Copiar",
"customHelp": "Criar um dispositivo definido pelo utilizador utilizando o sistema de plugins do evcc.",
"customOption": "Dispositivo definido pelo utilizador",
"delete": "Apagar",
+ "disable": "Desativar",
+ "disabled": "desativado",
"dismiss": "Ignorar",
"docsLink": "Ver documentação.",
"dragHandle": "Arrastar",
"dragItem": "Arrastável: {title}",
"dragList": "Lista reordenável",
+ "enable": "Ativar",
"error": "Erro",
"experimental": "Experimental",
+ "forceEnable": "Ativar na mesma",
"forceSave": "Guardar na mesma",
"fromYamlHint": "Nota: Configurado através do evcc.yaml. Remova a entrada do ficheiro para permitir a edição aqui.",
"hideAdvancedSettings": "Ocultar definições avançadas",
"invalidFileSelected": "Ficheiro selecionado inválido",
"legacy": "histórico",
+ "moreInfo": "Mais informações:",
"noFileSelected": "Nenhum ficheiro selecionado.",
"off": "desligado",
"on": "ligado",
@@ -330,6 +427,7 @@
"saving": "A guardar…",
"selectFile": "Explorar",
"showAdvancedSettings": "Mostrar definições avançadas",
+ "site": "Localização",
"telemetry": "Telemetria",
"templateLoading": "Carregando...",
"title": "Nome",
@@ -421,6 +519,10 @@
"charging": "Carregador guardado. A criar ponto de carregamento…",
"heating": "Dispositivo de aquecimento guardado. A criar o aquecedor…"
},
+ "currentRangeHelp": {
+ "charging": "Valores típicos: 6-16 A para carregadores de 11 kW, 6-32 A para carregadores de 22 kW (trifásicos).",
+ "heating": "Depende da potência nominal do dispositivo de aquecimento (ver ficha técnica)."
+ },
"defaultModeHelp": {
"charging": "Modo de carregamento ao ligar o veículo.",
"heating": "É definido no arranque do sistema."
@@ -470,6 +572,8 @@
"solarBehaviorLabel": "Solar",
"solarModeCustom": "personalizado",
"solarModeMaximum": "máximo solar",
+ "solarShareNote": "O controlo da percentagem de energia solar nas definições do utilizador fica desativado enquanto estiverem definidos limites de potência em watts. Introduza {0} e guarde para o ativar.",
+ "solarShareReset": "Omitir os limites",
"temperatureRangeHelp": "Intervalo de visualização da barra de temperatura. Não afeta o controlo.",
"temperatureRangeTitle": "Intervalo de temperatura",
"thresholdDisableDelayLabel": "Desativar atraso",
@@ -507,6 +611,7 @@
"main": {
"addAdditional": "Adicionar Contador adicional",
"addConsumer": "Adicionar consumidor",
+ "addCurtailer": "Adicionar dispositivo de restrição",
"addGrid": "Adicionar Contador de Rede",
"addLoadpoint": "Adicionar ponto de carregamento ou aquecedor",
"addPvBattery": "Adicionar solar ou bateria",
@@ -607,6 +712,7 @@
"aux": "Adicionar aparelho autorregulável",
"battery": "Adicionar Contador de bateria",
"consumer": "Adicionar consumidor habitual",
+ "grid": "Adicionar contador de rede",
"pv": "Adicionar Contador solar"
},
"save": "Guardar",
@@ -742,7 +848,7 @@
"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. Atualmente, apenas calcula e apresenta os dados. Ainda não controla dispositivos.",
"enable": "Ativar o Otimizador",
- "info": "Pode demorar alguns minutos até que a opção do otimizador no menu fique visível. Em instalações novas, pode demorar até 24 horas até que o evcc tenha recolhido dados suficientes.",
+ "info": "Em instalações novas, pode demorar até 24 horas até que o evcc tenha recolhido dados suficientes.",
"title": "Otimizador"
},
"options": {
@@ -797,6 +903,8 @@
"noClients": "Ainda não há clientes. Ninguém pode ligar-se ainda.",
"password": "Palavra-passe",
"passwordOnce": "Esta palavra-passe é apresentada apenas uma vez. Leia o código QR ou copie-a agora. Não poderá voltar a vê-la.",
+ "privacy": "Ao ativar esta funcionalidade, concorda com os nossos {0}.",
+ "privacyLink": "política de privacidade",
"qrInstall": "Instale a aplicação evcc para {ios} ou {android}.",
"qrScan": "Digitalize o código com a câmara do seu smartphone para se ligar. Clique nele, se já estiver a utilizar o seu smartphone.",
"removeClient": "Remover cliente",
@@ -811,7 +919,7 @@
"grid": "Rede",
"integrations": "Integrações",
"loadpoints": "Pontos de carregamento e aquecedores",
- "meter": "Solar e Bateria",
+ "meter": "Solar e bateria",
"services": "Serviços",
"system": "Sistema",
"vehicles": "Veículos"
@@ -838,6 +946,22 @@
"labelVendorId": "Vendor ID",
"title": "SMA Sunny Home Manager"
},
+ "site": {
+ "country": {
+ "description": "Utilizado para sugestões e valores predefinidos específicos de cada região.",
+ "label": "País"
+ },
+ "currency": {
+ "description": "Utiliza-se para calcular os preços da energia, os custos e as poupanças com base na sua tarifa.",
+ "example": "O preço que lhe foi cobrado foi de {price}. Poupou {amount}.",
+ "label": "Moeda"
+ },
+ "siteTitle": {
+ "description": "Exibido no ecrã principal e no separador do navegador.",
+ "label": "Título"
+ },
+ "title": "Localização"
+ },
"sponsor": {
"addToken": "Insira token",
"changeToken": "Alterar token",
@@ -873,6 +997,8 @@
"confirmationButton": "Reset e reiniciar",
"confirmationText": "Isto irá apagar permanentemente os dados selecionados. Certifique-se de que descarregou primeiro uma cópia de segurança.",
"description": "Está a ter problemas com a configuração e pretende começar de novo? Elimine todos os dados e comece de novo.",
+ "remote": "Acesso remoto",
+ "remoteDescription": "Elimina a configuração e as credenciais.",
"sessions": "Sessões de carregamento",
"sessionsDescription": "Apaga o histórico da sessão de carregamento.",
"settings": "Configuração e parâmetros",
@@ -1004,6 +1130,124 @@
"titleEdit": "Editar Veículo"
}
},
+ "energy": {
+ "battery": {
+ "charged": "Carga",
+ "discharged": "Descarga"
+ },
+ "consumers": {
+ "chart": {
+ "bars": "Barras",
+ "pattern": "Padrão"
+ },
+ "cost": "Custo",
+ "others": "Outros",
+ "source": {
+ "battery": "bateria",
+ "grid": "rede",
+ "pv": "solar"
+ },
+ "title": "Consumidores",
+ "view": {
+ "river": "Rio",
+ "treemap": "Treemap"
+ }
+ },
+ "direction": {
+ "battery": {
+ "energy": "carregado",
+ "returnEnergy": "descarregado"
+ },
+ "grid": {
+ "energy": "importado",
+ "returnEnergy": "exportado"
+ },
+ "meter": {
+ "energy": "Energia",
+ "returnEnergy": "Energia (inversa)"
+ }
+ },
+ "empty": "Não há dados de energia disponíveis para este período.",
+ "flow": {
+ "battery": "Bateria",
+ "batteryCharge": "Carga da bateria",
+ "batteryDischarge": "Descarga da bateria",
+ "charging": "Carregando",
+ "export": "Exportação prá rede",
+ "grid": "Importação da rede",
+ "heating": "Aquecimento",
+ "home": "Consumo",
+ "overview": "Visão geral",
+ "pv": "Produção",
+ "title": "Fluxo de energia",
+ "usage": "Uso",
+ "withdrawal": "Cancelamento"
+ },
+ "grid": {
+ "amount": "Quantia",
+ "cost": "Custo da rede",
+ "energy": "Energia",
+ "export": "Exportação",
+ "exportPrice": "Preço de exportação",
+ "import": "Importação",
+ "importPrice": "Preço de importação",
+ "price": "Preço",
+ "revenue": "Receita de alimentação",
+ "title": "Rede"
+ },
+ "group": {
+ "battery": "Bateria",
+ "consumer": "Consumo",
+ "forecast": "Previsão",
+ "grid": "Rede",
+ "loadpoint": "Carregamento e aquecimento",
+ "meter": "Contadores adicionais",
+ "pv": "Produção"
+ },
+ "production": {
+ "above": "{value} acima",
+ "accuracy": "Precisão da previsão",
+ "below": "{value} abaixo",
+ "deviation": "Desvio",
+ "expectedTomorrow": "esperado amanhã",
+ "exported": "{value} exportado",
+ "produced": "Produzido",
+ "remainingToday": "restante hoje",
+ "selfConsumed": "Autoconsumido",
+ "title": "Produção"
+ },
+ "socTemp": {
+ "charged": "carregado",
+ "soc": "carga",
+ "temperature": "temperatura",
+ "used": "usado"
+ },
+ "sources": {
+ "battery": "Bateria",
+ "cost": "Custo",
+ "energy": "Energia",
+ "grid": "Rede",
+ "hint": "Energia solar e de baterias avaliadas ao preço de injeção na rede.",
+ "price": "Preço/kWh",
+ "pv": "Solar",
+ "share": "Parte",
+ "source": "Fonte",
+ "title": "Fontes",
+ "total": "Total"
+ },
+ "stat": {
+ "autarky": "Autarquia",
+ "co2Saved": "CO₂ economizado",
+ "co2SavedTooltip": "Em comparação com a {energy} consumida da rede elétrica.",
+ "configure": "Configure agora",
+ "noCo2": "Sem dados de CO₂.",
+ "noPrice": "Sem dados de preço.",
+ "savings": "Poupança",
+ "savingsTooltip": "Em comparação com a compra da {energy} consumida da rede elétrica.",
+ "versus": "{value} vs. {reference}"
+ },
+ "title": "Energia"
+ },
"footer": {
"community": {
"greenEnergy": "Solar",
@@ -1069,14 +1313,14 @@
"optInSponsorship": "Requer Patrocínio (Sponsoring)."
},
"version": {
- "availableLong": "nova versão disponível",
+ "availableLong": "atualização disponível",
"community": "comunidade evcc",
"labelEmail": "E-mail",
"labelPhone": "Telefone",
"labelRelease": "Lançamento",
"labelVersion": "Versão",
"labelWebsite": "Website",
- "latestVersion": "versão mais recente",
+ "latestVersion": "atualizado",
"madeByCommunity": "Realizado por {0}.",
"modalCancel": "Cancelar",
"modalDownload": "Download",
@@ -1140,9 +1384,13 @@
}
},
"general": {
+ "back": "Voltar",
+ "cancel": "Cancelar",
"download": "Descarregar",
+ "error": "Erro",
"none": "nenhum",
- "note": "Nota:"
+ "note": "Nota:",
+ "warning": "Aviso"
},
"header": {
"about": "Sobre",
@@ -1175,33 +1423,11 @@
"restartDescription": "Tentou desligar e ligar de novo?",
"secondaryActions": "Ainda não conseguiu resolver o seu problema? Aqui estão algumas sugestões mais complexas."
},
- "energy": {
- "direction": {
- "battery": {
- "energy": "carregado",
- "returnEnergy": "descarregado"
- },
- "grid": {
- "energy": "importado",
- "returnEnergy": "exportado"
- },
- "meter": {
- "energy": "Energia",
- "returnEnergy": "Energia (inversa)"
- }
- },
- "group": {
- "battery": "Bateria",
- "consumer": "Consumo",
- "forecast": "Previsão",
- "grid": "Rede",
- "loadpoint": "Carregamento e aquecimento",
- "meter": "Contadores adicionais",
- "pv": "Produção"
- }
- },
"issue": {
"additional": {
+ "copied": "copiado!",
+ "copy": "copiar",
+ "copySelected": "copiar o selecionado",
"description": "Inclua definições e registos para nos ajudar a reproduzir o problema rapidamente. Recomendamos que partilhe o máximo possível. Geralmente, o estado não é necessário.",
"include": "incluir",
"lines": "linhas",
@@ -1282,6 +1508,15 @@
"title": "Autenticação"
},
"main": {
+ "alwaysCharge": {
+ "description": "sem interrupções",
+ "label": "Carregar sempre",
+ "labelHeating": "Aquecer sempre",
+ "minCurrent": "min {current}",
+ "once": "até ao final da sessão",
+ "onceTip": "Apenas para esta sessão",
+ "permanent": "permanece ligado de forma permanente"
+ },
"chargingPlan": {
"active": "Ativo",
"addRepeatingPlan": "Adicionar planos iguais",
@@ -1298,15 +1533,18 @@
"continuous": "contínuo",
"continuousCo2Description": "Seleciona a série de intervalos mais limpa e não interrompida.",
"continuousPriceDescription": "Seleciona a série de intervalos de tempo não interrompidos mais económica.",
+ "disabledDescription": "Requer uma tarifa dinâmica ou uma previsão de CO₂.",
"label": "Otimização"
},
"planNumber": "Plano {number}",
"precondition": {
"description": "Faz parte do processo de carregamento imediatamente antes da partida: bateria aquecida, menos tempo com um nível de carga elevado.",
+ "disabledDescription": "Todo o carregamento é feito imediatamente antes da partida.",
"label": "Carga tardia",
"optionAll": "tudo",
"optionNo": "não",
- "summary": "{precondition} antes de"
+ "summary": "{precondition} antes de",
+ "summaryAll": "mesmo antes da partida"
},
"remove": "Apagar",
"repeating": "recorrente",
@@ -1334,6 +1572,7 @@
"batteryDischargeHold": "Bateria (suspensa)",
"batteryGridChargeActive": "Carga da rede: ativa",
"batteryGridChargeLimit": "Carga da rede: quando",
+ "batteryStatus": "Estado da bateria",
"batteryTooltip": "{energy} de {total} ({soc})",
"forecast": "Previsões",
"forecastTooltip": "previsão: produção solar restante de hoje",
@@ -1372,6 +1611,7 @@
"finished": "Tempo de finalização",
"last24hEnergy": "Últimas 24 horas",
"last7dEnergy": "Últimos 7 dias",
+ "phasesMismatch": "Estão ativas mais fases do que as configuradas. Verifique a configuração das fases.",
"power": "Potência",
"price": "Custo",
"remaining": "Tempo restante",
@@ -1386,7 +1626,7 @@
"descriptionDisabled": "Selecione um limite para permitir o carregamento rápido da bateria doméstica.",
"disabled": "Desativado",
"label": "Boost bateria",
- "mode": "Disponível apenas nos modos solar e min+solar.",
+ "mode": "Disponível apenas no modo inteligente.",
"once": "Boost ativo para esta sessão de carga.",
"stateActive": "Boost de bateria ativo",
"stateBelowLimit": "Bateria com carga insuficiente para o boost",
@@ -1404,7 +1644,7 @@
"label": "Corrente de carga mínima"
},
"minTemp": {
- "description": "Além disso, a bateria {0} e a energia da rede também são utilizadas no modo solar para manter esta temperatura.",
+ "description": "Quando o nível da bateria é inferior a {0}, recorre-se também à rede elétrica no modo inteligente para manter esta temperatura.",
"label": "Temperatura mínima"
},
"phasesConfigured": {
@@ -1418,13 +1658,33 @@
},
"smartCostCheap": "Carregamento de Rede em tarifa mais económica",
"smartCostClean": "Carregamento de Rede \"Verde\"",
+ "solar": "Excedente solar",
+ "solarShare": {
+ "charging": {
+ "description": "Pelo menos {share} da potência mínima de carregamento deve provir da energia solar.",
+ "descriptionFull": "O carregamento começa assim que o excedente atingir a potência mínima de carregamento.",
+ "descriptionZero": "O carregamento começa assim que houver algum excedente disponível."
+ },
+ "heating": {
+ "description": "Pelo menos {share} da potência mínima de aquecimento deve provir da energia solar.",
+ "descriptionFull": "O aquecimento começa assim que o excedente atingir a potência mínima de aquecimento.",
+ "descriptionZero": "O aquecimento começa assim que houver algum excedente disponível."
+ },
+ "label": "Quota solar",
+ "thresholdsHint": "Os limites de carregamento baseados na potência estão configurados. Remova-os para utilizar este controlo deslizante.",
+ "thresholdsHintYaml": "Os limiares de carregamento baseados na potência estão configurados. Remova os limiares de ativação/desativação deste ponto de carga no ficheiro evcc.yaml para utilizar este controlo deslizante.",
+ "thresholdsLink": "Editar configuração"
+ },
"title": "Configurações {0}",
"vehicle": "Veículo"
},
"mode": {
+ "boost": "Boost",
"minpv": "Min+Solar",
+ "normal": "Normal",
"now": "Rápido",
"off": "Off",
+ "on": "Ligado",
"pv": "Solar",
"smart": "Smart"
},
@@ -1445,6 +1705,7 @@
"nextPlan": "Próximo plano",
"notReachableInTime": "O objetivo será atingido {overrun} mais tarde.",
"onlyInPvMode": "O plano de carregamento só funciona no modo solar.",
+ "onlyInSmartMode": "O plano de carregamento só funciona no modo inteligente.",
"planDuration": "Tempo de carga",
"planPeriodLabel": "Período",
"planPeriodValue": "{start} até {end}",
@@ -1492,7 +1753,7 @@
"vehicleSettings": {
"connectedTo": "conectado a {0}",
"description": "Estas configurações predefinidas são aplicadas quando o veículo se liga a um ponto de carregamento.",
- "editHint": "Adicionar ou editar viaturas em {0}.",
+ "editHint": "Os veículos podem ser editados em {0}.",
"keepAsIs": "Manter tal como está",
"learnMore": "Saiba mais.",
"limitSoc": "Limite padrão",
@@ -1674,6 +1935,13 @@
"vehicle": "Veículo"
},
"settings": {
+ "dateFormat": {
+ "auto": "Automático",
+ "dmy": "DD/MM/AAAA",
+ "label": "Formato da data",
+ "mdy": "MM/DD/AAAA",
+ "ymd": "AAAA-MM-DD"
+ },
"deviceInfo": "As definições efetuadas nesta caixa de diálogo afetam apenas este dispositivo.",
"fullscreen": {
"enter": "Entrar em tela cheia",
@@ -1689,6 +1957,7 @@
"label": "Idioma"
},
"loadpoints": {
+ "disabledDevice": "desativado",
"help": "Alterar a ordem e visibilidade da interface do utilizador.",
"hide": "Ocultar {title}",
"label": "Pontos de carregamento",
@@ -1732,7 +2001,7 @@
"co2Label": "Emissão de CO₂",
"co2Limit": "Limite de CO₂",
"enable": "Ativar limite",
- "loadpointDescription": "Permite carregamento rápido temporário no modo solar.",
+ "loadpointDescription": "Ativa o carregamento rápido temporário no modo inteligente.",
"modalTitle": "Carregamento de Rede Inteligente",
"none": "nenhum",
"priceLabel": "Preço da energia",
@@ -1742,7 +2011,8 @@
"saved": "Guardado."
},
"smartFeedInPriority": {
- "activeHoursLabel": "Tempo de pausa",
+ "activeHoursLabel": "Tempo ativo",
+ "batteryDescription": "Descarrega a bateria doméstica para a rede enquanto a tarifa de injeção for elevada.",
"description": "Faz uma pausa no carregamento quando os preços são elevados para dar prioridade à alimentação rentável da rede.",
"priceLabel": "Taxa de alimentação",
"priceLimit": "Limite de alimentação",
@@ -1766,6 +2036,7 @@
"battery": "Bateria",
"charge": "Carga",
"comingSoon": "Esta página está em construção.",
+ "energy": "Energia",
"forecast": "Previsão",
"more": "Mais",
"sessions": "Sessões"
diff --git a/i18n/sk.json b/i18n/sk.json
index 9bec3a264f..072e37c839 100644
--- a/i18n/sk.json
+++ b/i18n/sk.json
@@ -7,6 +7,18 @@
"authorizationSuccessful": "Autorizácia úspešná",
"buttonConnect": "Pripojte sa k {provider}",
"buttonDisconnect": "Odpojiť",
+ "challenge": {
+ "captcha": {
+ "label": "Zadajte znaky uvedené v obrázku.",
+ "title": "Captcha"
+ },
+ "code": {
+ "label": "Vložte kód alebo adresu stránky, na ktorú ste boli presmerovaní.",
+ "title": "Autorizačný kód"
+ },
+ "openLogin": "Otvorte prihlasovaciu stránku",
+ "submit": "Pokračovať"
+ },
"confirmLogout": "Naozaj chcete odpojiť {title}?",
"connect": "pripojiť",
"disconnect": "odpojiť",
@@ -25,6 +37,7 @@
"discharging": "vybíja",
"energy": "Energia",
"gridCharge": "nabíjanie zo siete",
+ "gridDischarge": "vybíjanie do siete",
"hold": "vybíjanie zablokované",
"holdCharge": "nabíjanie zablokované",
"ofTotal": "z {total}",
@@ -43,6 +56,12 @@
"bufferTitle": "Batéria ako zásobník energie pre nabíjanie",
"discharge": "Zabráňte vybíjaniu domácej batérie v rýchlom režime a počas plánovaného nabíjania.",
"gridDischarge": "Umožniť batérií vybíjanie do siete.",
+ "gridDischargeConfirm": {
+ "country": "Nakonfigurovaná krajina: {country}.",
+ "description": "Možnosť dodávať energiu z batérie do elektrickej siete závisí od krajiny, v ktorej sa nachádzate, a od podmienok vašej zmluvy o dodávke elektriny do siete. Ak sa batéria nabíja aj zo siete, dodávaná energia už nie je výlučne solárneho pôvodu. V rámci mnohých taríf za dodávku elektriny do siete to môže viesť k strate nároku na odmenu alebo byť priam zakázané. Preto si najprv skontrolujte zmluvu o dodávke elektriny do siete a požiadavky prevádzkovateľa elektrickej siete.",
+ "noCountry": "Krajina nie je nakonfigurovaná. Nastavte ju pod {link}.",
+ "title": "Dodávka energie z batérie do siete"
+ },
"priority": "Domáca batéria má prioritu, kým je pod {soc}. Potom nabíjacie stanice dostanú prebytok.",
"priorityNone": "Prebytok solárnej energie ide priamo do nabíjacích staníc. Nastavte hodnotu vyššiu ako {soc}, aby ste uprednostnili domácu batériu.",
"priorityTitle": "Kam ide prebytok ako najprv?"
@@ -144,11 +163,46 @@
"switchsocket": "Používateľom definovaná spínateľná zásuvka"
}
},
+ "circuit": {
+ "addMeter": "Pridať vyhradený merač energie",
+ "custom": "Užívateľom definovaný obvod",
+ "deleteChildrenFirst": "Ak chcete odstrániť tento okruh, najprv odstráňte jeho podradené obvody.",
+ "meterDedicated": "Priradený merač",
+ "meterGrid": "Merač elektrickej siete",
+ "meterHelp": "Merač celkovej spotreby tohto obvodu.",
+ "meterLabel": "Merač energie",
+ "meterLabelAdd": "Pridať merač obvodu",
+ "meterLabelEdit": "Upraviť merač obvodu",
+ "meterNone": "Žiaden merač",
+ "meterSelectionHelpDedicatedMeter": "Pridajte existujúci merač do tohto okruhu nižšie.",
+ "meterSelectionHelpGridMeter": "Používa existujúci merač elektrickej siete vašej inštalácie.",
+ "meterSelectionHelpNoMeter": "Používa sa suma všetkých nabíjacích bodov.",
+ "meterSelectionLabel": "Výber merača",
+ "parentCircuit": "Nadradený obvod",
+ "template": "Obvod",
+ "titleAdd": "Pridať obvod",
+ "titleEdit": "Upraviť obvod",
+ "titleLabel": "Názov"
+ },
"circuits": {
+ "addMainCircuit": "Pridať hlavný obvod",
+ "addSubCircuit": "Pridať podobvod",
+ "chargingPointsNote": "Priraďte nabíjacie body a ohrievače k okruhu v ich vlastných nastaveniach.",
"description": "Zaisťuje, že súčet všetkých zaťažení pripojených k obvodu neprekročí nakonfigurované limity výkonu a prúdu. Obvody je možné vnorovať a vytvárať hierarchiu.",
+ "legacyWarning": "K dispozícii je nová konfigurácia okruhov! Odstráňte a uložte svoju konfiguráciu tu, aby ste mohli využiť nového sprievodcu nastavením.",
+ "meterDedicated": "Merač",
+ "meterGrid": "Sieť",
"title": "Riadenie záťaže",
"usableMeters": "Použiteľné referencie merača"
},
+ "combobox": {
+ "count": "{count} návrhy",
+ "matching": "Zhodujúce",
+ "other": "Iné",
+ "searching": "Vyhľadávanie…",
+ "suggestions": "Návrhy",
+ "used": "už použité"
+ },
"consumer": {
"description": "Sleduje menšie, nekontrolovateľné spotrebiče (chladnička, práčka, ...). Dáva vám lepší prehľad o spotrebe vo vašej domácnosti.",
"titleAdd": "Pridať merač spotrebičov",
@@ -216,6 +270,8 @@
"identifier": "RFID-Identifikátor",
"loginBlocked": "Limit prihlásení dosiahnutý",
"max": "max",
+ "maxCurrent": "Max. prúd",
+ "maxPower": "Max. výkon",
"messengers": "Služby",
"no": "nie",
"odometer": "Najazdené kilometre",
@@ -352,6 +408,7 @@
"enable": "Povoliť",
"error": "Chyba",
"experimental": "Experimentálne",
+ "forceEnable": "Napriek tomu povoliť",
"forceSave": "Napriek tomu uložiť",
"fromYamlHint": "Poznámka: Konfigurované cez evcc.yaml. Odstráňte záznam zo súboru, aby sa dala položka upravovať tu.",
"hideAdvancedSettings": "Skryť pokročilé nastavenia",
@@ -370,6 +427,7 @@
"saving": "Ukladá…",
"selectFile": "Prehliadať",
"showAdvancedSettings": "Zobraziť pokročilé nastavenia",
+ "site": "Miesto",
"telemetry": "Telemetria",
"templateLoading": "Načítava...",
"title": "Názov",
@@ -888,6 +946,22 @@
"labelVendorId": "ID dodávateľa",
"title": "SMA Sunny Home Manager"
},
+ "site": {
+ "country": {
+ "description": "Používa sa na tipy a predvolené hodnoty špecifické pre daný región.",
+ "label": "Krajina"
+ },
+ "currency": {
+ "description": "Slúži na formátovanie cien energií, nákladov a úspor na základe vášho tarifu.",
+ "example": "Cena za nabíjanie bola {price}. Ušetrili ste {amount}.",
+ "label": "Mena"
+ },
+ "siteTitle": {
+ "description": "Zobrazené na hlavnej obrazovke a záložke prehliadača.",
+ "label": "Názov"
+ },
+ "title": "Miesto"
+ },
"sponsor": {
"addToken": "Zadajte token",
"changeToken": "Zmena tokenu",
@@ -1056,6 +1130,124 @@
"titleEdit": "Upraviť vozidlo"
}
},
+ "energy": {
+ "battery": {
+ "charged": "Nabité",
+ "discharged": "Vybité"
+ },
+ "consumers": {
+ "chart": {
+ "bars": "Stĺpik",
+ "pattern": "Vzor"
+ },
+ "cost": "Výdavky",
+ "others": "Ostatné",
+ "source": {
+ "battery": "batéria",
+ "grid": "sieť",
+ "pv": "solár"
+ },
+ "title": "Spotrebiče",
+ "view": {
+ "river": "Rieka",
+ "treemap": "Stromová mapa"
+ }
+ },
+ "direction": {
+ "battery": {
+ "energy": "nabité",
+ "returnEnergy": "vybité"
+ },
+ "grid": {
+ "energy": "importované",
+ "returnEnergy": "exportované"
+ },
+ "meter": {
+ "energy": "Energia",
+ "returnEnergy": "Energia (spätná)"
+ }
+ },
+ "empty": "Pre toto časové obdobie nie sú k dispozícii žiadne údaje.",
+ "flow": {
+ "battery": "Batéria",
+ "batteryCharge": "Nabíjanie batérie",
+ "batteryDischarge": "Vybíjanie batérie",
+ "charging": "Nabíja",
+ "export": "Export do siete",
+ "grid": "Import zo siete",
+ "heating": "Vyhrievanie",
+ "home": "Spotreba",
+ "overview": "Prehľad",
+ "pv": "Výroba",
+ "title": "Tok energie",
+ "usage": "Využitie",
+ "withdrawal": "Výber"
+ },
+ "grid": {
+ "amount": "Množstvo",
+ "cost": "Sieťové náklady",
+ "energy": "Energia",
+ "export": "Exportované",
+ "exportPrice": "Exportná cena",
+ "import": "importované",
+ "importPrice": "Importná cena",
+ "price": "Cena",
+ "revenue": "Výnosy z dodávky elektriny do siete",
+ "title": "Sieť"
+ },
+ "group": {
+ "battery": "Batéria",
+ "consumer": "Spotreba",
+ "forecast": "Predpoveď",
+ "grid": "Sieť",
+ "loadpoint": "Nabíjanie a vykurovanie",
+ "meter": "Dodatočné merače",
+ "pv": "Výroba"
+ },
+ "production": {
+ "above": "{value} nad",
+ "accuracy": "Presnosť predpovede",
+ "below": "{value} pod",
+ "deviation": "Odchýlka",
+ "expectedTomorrow": "očakávané zajtra",
+ "exported": "{value} exportované",
+ "produced": "Vyrobené",
+ "remainingToday": "zostávajúce dnes",
+ "selfConsumed": "Spotrebované",
+ "title": "Produkcia"
+ },
+ "socTemp": {
+ "charged": "nabité",
+ "soc": "stav nabitia",
+ "temperature": "teplota",
+ "used": "použité"
+ },
+ "sources": {
+ "battery": "Batéria",
+ "cost": "Cena",
+ "energy": "Energia",
+ "grid": "Sieť",
+ "hint": "Energia zo soláru a batérií ocenená podľa výkupnej ceny.",
+ "price": "Cena/kWh",
+ "pv": "Solár",
+ "share": "Zdieľané",
+ "source": "Zdroj",
+ "title": "Zdroje",
+ "total": "Celkom"
+ },
+ "stat": {
+ "autarky": "Autarkia",
+ "co2Saved": "CO₂ ušetrené",
+ "co2SavedTooltip": "V porovnaní s odoberaním spotrebovanej {energy} zo siete.",
+ "configure": "Konfigurovať teraz",
+ "noCo2": "Žiadne údaje CO₂.",
+ "noPrice": "Žiadne údaje o cenách.",
+ "savings": "Úspory",
+ "savingsTooltip": "V porovnaní s nákupom spotrebovanej {energy} zo siete.",
+ "versus": "{value} voči {reference}"
+ },
+ "title": "Energia"
+ },
"footer": {
"community": {
"greenEnergy": "Solár",
@@ -1083,7 +1275,7 @@
"savings": "úspora",
"solar": "slnečná energia"
},
- "indicatorLabel": "Informácie o hlavičke",
+ "indicatorLabel": "Informácie z hlavičky",
"modalTitle": "Prehľad energie nabíjania",
"moneySaved": "{value} ušetrené",
"percentGrid": "{grid} kWh sieť",
@@ -1193,6 +1385,7 @@
},
"general": {
"back": "Späť",
+ "cancel": "Zrušiť",
"download": "Stiahnuť",
"error": "Chyba",
"none": "žiadny",
@@ -1230,38 +1423,13 @@
"restartDescription": "Skúsili ste to vypnúť a znova zapnúť?",
"secondaryActions": "Stále neviete vyriešiť svoj problém? Tu je niekoľko ďalších, ale náročnejších možností."
},
- "energy": {
- "direction": {
- "battery": {
- "energy": "nabité",
- "returnEnergy": "vybité"
- },
- "grid": {
- "energy": "importované",
- "returnEnergy": "exportované"
- },
- "meter": {
- "energy": "Energia",
- "returnEnergy": "Energia (spätná)"
- }
- },
- "group": {
- "battery": "Batéria",
- "consumer": "Spotreba",
- "forecast": "Predpoveď",
- "grid": "Sieť",
- "loadpoint": "Nabíjanie a vykurovanie",
- "meter": "Dodatočné merače",
- "pv": "Výroba"
- }
- },
"issue": {
"additional": {
"copied": "skopírované!",
"copy": "skopírovať",
"copySelected": "skopírovať vybrané",
"description": "Priložte konfiguráciu a protokoly, ktoré nám pomôžu problém rýchlo zreprodukovať. Odporúčame zdieľať čo najviac údajov. Stav zvyčajne nie je potrebný.",
- "include": "zahrnúť",
+ "include": "pridať",
"lines": "riadky",
"logs": "Protokoly",
"logsDescription": "Nedávne záznamy v denníku, ktoré môžu pomôcť identifikovať problém.",
@@ -1293,9 +1461,9 @@
"issueDescription": "Popis",
"issueTitle": "Názov",
"stepsToReproduce": "Kroky k reprodukcii",
- "subTitleDiscussion": "Opíšte svoj problém",
- "subTitleEmail": "Opíšte svoj problém",
- "subTitleIssue": "Popíšte problém",
+ "subTitleDiscussion": "Popíšte svoj problém",
+ "subTitleEmail": "Popíšte svoj problém",
+ "subTitleIssue": "Popíšte svoj problém",
"summary": {
"confirmationButtonDiscussion": "Začnite diskusiu na GitHube",
"confirmationButtonIssue": "Vytvoriť problém na GitHube",
@@ -1404,6 +1572,7 @@
"batteryDischargeHold": "Batéria (vybíjanie uzamknuté)",
"batteryGridChargeActive": "Sieťové nabíjanie: aktívne",
"batteryGridChargeLimit": "Sieťové nabíjanie: keď",
+ "batteryStatus": "Stav batérie",
"batteryTooltip": "{energy} z {total} ({soc})",
"forecast": "Predpoveď",
"forecastTooltip": "predpoveď: zostávajúca produkcia solárnej energie dnes",
@@ -1663,7 +1832,7 @@
},
"session": {
"addOdometer": "Pridať hodnotu",
- "addedRange": "Pridaný rozsah",
+ "addedRange": "Pridaný dojazd",
"cancel": "Zrušiť",
"co2": "CO₂",
"date": "Obdobie",
@@ -1679,7 +1848,7 @@
"title": "Nabíjacia relácia"
},
"sessions": {
- "addedRange": "Rozsah",
+ "addedRange": "Dojazd",
"avgPower": "⌀ Výkon",
"avgPrice": "⌀ Cena",
"chargeDuration": "Trvanie",
@@ -1705,7 +1874,7 @@
},
"co2": "⌀ CO2",
"csv": {
- "addedrange": "Pridaný rozsah (km)",
+ "addedrange": "Pridaný dojazd(km)",
"chargedenergy": "Energia (kWh)",
"chargeduration": "Trvanie",
"co2perkwh": "CO₂/kWh",
@@ -1807,9 +1976,9 @@
},
"theme": {
"auto": "systém",
- "dark": "tmavá",
+ "dark": "tmavý",
"label": "Dizajn",
- "light": "svetlá"
+ "light": "svetlý"
},
"time": {
"12h": "12h",
@@ -1868,6 +2037,7 @@
"battery": "Batéria",
"charge": "Stav nabitia",
"comingSoon": "Táto stránka je vo výstavbe.",
+ "energy": "Energia",
"forecast": "Predpoveď",
"more": "Viac",
"sessions": "Relácie"
diff --git a/i18n/tr.json b/i18n/tr.json
index 0eab0ac7a9..7d28f6b966 100644
--- a/i18n/tr.json
+++ b/i18n/tr.json
@@ -7,6 +7,18 @@
"authorizationSuccessful": "Yetkilendirme Başarılı",
"buttonConnect": "{provider}'a bağlan",
"buttonDisconnect": "Bağlantıyı kes",
+ "challenge": {
+ "captcha": {
+ "label": "Resimde gösterilen karakterleri gir.",
+ "title": "Captcha"
+ },
+ "code": {
+ "label": "Kodu veya yönlendirildiğin sayfanın adresini yapıştır.",
+ "title": "Yetkilendirme kodu"
+ },
+ "openLogin": "Giriş sayfasını aç",
+ "submit": "Devam"
+ },
"confirmLogout": "{title} bağlantısını kesmek istediğinden emin misin?",
"connect": "bağlan",
"disconnect": "bağlantıyı kes",
@@ -33,7 +45,7 @@
},
"combined": "Birleşik",
"config": {
- "buffer": "{soc} değerinin üzerindeki ev batarya enerjisi, doldurma noktalarını desteklemek için kullanılabilir ve güneş enerjisi modunda kapanmayı geciktirebilir. Güneş enerjisiyle doldurmayı otomatik olarak başlat {start}.",
+ "buffer": "{soc} üzerindeki ev bataryası enerjisi, doldurma noktalarını desteklemek için kullanılabilir ve akıllı modda kapanmayı geciktirebilir. Güneş enerjisiyle doldurmayı otomatik olarak başlat {start}.",
"bufferNone": "Doldurma noktaları için ev bataryasından enerji kullanılmamaktadır. Desteği etkinleştirmek için {soc} değerinden daha düşük bir değer ayarla.",
"bufferStart": {
"above": "batarya doluluk seviyesi {soc} oranının üzerine çıktığında",
@@ -41,7 +53,14 @@
"never": "sadece yeterli fazlalık olduğunda"
},
"bufferTitle": "Batarya doldurma tamponu olarak",
+ "discharge": "Hızlı doldurma modunda ve planlı doldurma esnasında ev bataryasının boşalmasını önle.",
"gridDischarge": "Ev tipi bataryanın şebekeye deşarj olmasına izin verin.",
+ "gridDischargeConfirm": {
+ "country": "Yapılandırılan ülke: {country}.",
+ "description": "Bataryadan şebekeye elektrik aktarılabilmesi, bulunduğun ülkeye ve besleme sözleşmene bağlıdır. Batarya aynı zamanda şebekeden dolduruluyorsa, aktarılan elektrik artık tamamen güneş enerjisinden elde edilmiş sayılmaz. Birçok besleme tarifesi kapsamında bu durum, ücret hakkını kaybetmene neden olur veya yasaktır. Öncelikle besleme sözleşmeni ve elektrik şirketinin gerekliliklerini gözden geçir.",
+ "noCountry": "Hiçbir ülke ayarlanmamış. {link} altından ayarla.",
+ "title": "Bataryadan şebekeye elektrik aktarımı"
+ },
"priority": "Ev bataryası {soc} seviyesinin altında olduğunda öncelikli olarak doldurulur. Ardından, doldurma noktalarına fazlalık aktarılır.",
"priorityNone": "Güneş enerjisi fazlası doğrudan şarj noktalarına aktarılır. Ev bataryasına öncelik vermek için {soc} değerinin üzerinde bir ayar yapın.",
"priorityTitle": "Fazla akım ilk nereye gitsin?"
@@ -63,6 +82,7 @@
},
"batterySettings": {
"gridChargeTab": "Şebekeden doldurma",
+ "gridDischargeTab": "Şebekeye boşaltma",
"modalTitle": "Ev Bataryası",
"noBattery": "Batarya yapılandırılmamış.",
"usageTab": "Batarya kullanımı"
@@ -142,8 +162,35 @@
"switchsocket": "Kullanıcı tanımlı şalterli priz"
}
},
+ "circuit": {
+ "addMeter": "Özel elektrik sayacı ekle",
+ "custom": "Kullanıcı tanımlı devre",
+ "deleteChildrenFirst": "Bu devreyi silmek için önce alt devrelerini sil.",
+ "meterDedicated": "Özel sayaç",
+ "meterGrid": "Şebeke sayacı",
+ "meterHelp": "Bu devrenin toplam tüketimini ölçen sayaç.",
+ "meterLabel": "Elektrik sayacı",
+ "meterLabelAdd": "Devre Sayacı Ekle",
+ "meterLabelEdit": "Devre Sayacını Düzenle",
+ "meterNone": "Sayaç yok",
+ "meterSelectionHelpDedicatedMeter": "Aşağıdaki devreye mevcut bir sayacı ekle.",
+ "meterSelectionHelpGridMeter": "Tesisatındaki mevcut elektrik sayacını kullanır.",
+ "meterSelectionHelpNoMeter": "Doldurma noktalarının toplamı kullanılır.",
+ "meterSelectionLabel": "Sayaç Seçimi",
+ "parentCircuit": "Ana devre",
+ "template": "Devre",
+ "titleAdd": "Devre ekle",
+ "titleEdit": "Devreyi düzenle",
+ "titleLabel": "Başlık"
+ },
"circuits": {
+ "addMainCircuit": "Ana devreyi ekle",
+ "addSubCircuit": "Alt devre ekle",
+ "chargingPointsNote": "Doldurma noktalarını ve ısıtıcıları, kendi ayarları dahilinde bir devreye ata.",
"description": "Bir devreye bağlı tüm yük noktalarının toplamının, yapılandırılan güç ve akım limitlerini aşmamasını sağlar. Devreler, bir hiyerarşi oluşturacak şekilde iç içe yerleştirilebilir.",
+ "legacyWarning": "Yeni devre yapılandırması kullanıma sunuldu! Yeni adım adım kılavuzlu süreci kullanmak için yapılandırmanı buradan sil ve kaydet.",
+ "meterDedicated": "Sayaç",
+ "meterGrid": "Şebeke",
"title": "Yük yönetimi",
"usableMeters": "Kullanılabilir sayaç referansları"
},
@@ -188,7 +235,7 @@
"chargeStatusC": "doluyor",
"chargeStatusE": "elektrik yok",
"chargeStatusF": "hata",
- "chargedEnergy": "Doldu",
+ "chargedEnergy": "Doldu (oturum)",
"co2": "Şebeke CO₂",
"configured": "Yapılandırıldı",
"connected": "Bağlı",
@@ -214,6 +261,8 @@
"identifier": "RFID tanımlayıcısı",
"loginBlocked": "Giriş sınırına ulaşıldı",
"max": "azami",
+ "maxCurrent": "Azami akım",
+ "maxPower": "Azami güç",
"messengers": "Hizmetler",
"no": "hayır",
"odometer": "Kilometre Sayacı",
@@ -237,6 +286,31 @@
"temp": "Isı",
"topic": "Konu",
"url": "URL",
+ "usage": {
+ "aux": {
+ "energy": "Tüketildi",
+ "returnEnergy": "Üretildi"
+ },
+ "battery": {
+ "energy": "Boşaltıldı",
+ "returnEnergy": "Dolduruldu"
+ },
+ "charge": {
+ "energy": "Dolduruldu",
+ "returnEnergy": "Boşaltıldı"
+ },
+ "consumer": {
+ "energy": "Tüketildi",
+ "returnEnergy": "Üretildi"
+ },
+ "grid": {
+ "energy": "Alınan",
+ "returnEnergy": "Satılan"
+ },
+ "pv": {
+ "energy": "Üretilen"
+ }
+ },
"vehicleLimitSoc": "Doldurma sınırı",
"yes": "evet"
},
@@ -271,8 +345,10 @@
"port": "Port",
"portHelp": "Kullanılacak port.",
"qr": "Eşleştirme kodu",
+ "qrExplain": "Kurulum sırasında başka bir EEBus cihazını tarayarak, SKI kodunu girmeden otomatik olarak eşleştir.",
"removeConfirm": "Tüm EEBus yapılandırmaları kaldırılacak. Bir sonraki başlatmada yeni sertifikalar ve tanımlayıcılar oluşturulacak. Emin misin?",
"removePairing": "Eşleştirmeyi kaldır",
+ "removePairingConfirm": "Cihazın bağlantısı kesilecek ve yeniden eşleştirilmesi gerekecek. Emin misin?",
"shipid": "SHIP-ID",
"shipidExplain": "EEBus ağında tanımlama için kalıcı cihaz tanımlayıcı.",
"shipidHelp": "Bu SHIP-ID, aşağıdaki sertifikalarla bağlantılı.",
@@ -305,6 +381,7 @@
"change": "Değiştir",
"clear": "Temizle",
"close": "Kapat",
+ "confirmDisable": "Bu cihazı devre dışı mı kalsın? Bir sonraki yeniden başlatmanın ardından bu ayar göz ardı edilecek.",
"confirmEnable": "Bu cihaz tekrar etkinleştirilsin mi?",
"confirmSave": "Kaydedilmemiş değişiklikler var. Şimdi kaydedilsin mi?",
"copied": "Kopyalandı!",
@@ -322,11 +399,13 @@
"enable": "Etkinleştir",
"error": "Hata",
"experimental": "Deneysel",
+ "forceEnable": "Yine de etkinleştir",
"forceSave": "Yine de kaydet",
"fromYamlHint": "Not: evcc.yaml aracılığıyla yapılandırıldı. Burada düzenlemeyi etkinleştirmek için dosyadan öğeyi kaldır.",
"hideAdvancedSettings": "“Gelişmiş ayarları gizle”",
"invalidFileSelected": "Geçersiz dosya seçildi",
"legacy": "eskimiş",
+ "moreInfo": "Daha fazla bilgi:",
"noFileSelected": "Seçili dosya yok.",
"off": "kapalı",
"on": "açık",
@@ -346,7 +425,7 @@
"validateSave": "“Doğrula ve kaydet”"
},
"grid": {
- "title": "Elektrik sayacı",
+ "title": "Şebeke sayacı",
"titleAdd": "Elektrik Sayacı Ekle",
"titleEdit": "Elektrik Sayacını Düzenle"
},
@@ -934,7 +1013,7 @@
"greenShareSub2": "tarafından sağlanan enerji",
"power": "Doldurma gücü",
"powerSub1": "{totalClients} katılımcıdan {activeClients} katılımcı",
- "powerSub2": "dolduruyor…",
+ "powerSub2": "şu an dolduruyor.",
"tabTitle": "Canlı topluluk"
},
"savings": {
@@ -1009,7 +1088,7 @@
"modalUpdateStatusStart": "Kurulum başladı:",
"modalViewOnGitHub": "GitHub'da görüntüle",
"openSource": "açık kaynak",
- "poweredBy": "powered by"
+ "poweredBy": "katkı sunanlar"
}
},
"forecast": {
@@ -1242,7 +1321,7 @@
"waitForVehicle": "Isıtmaya hazır…"
},
"hemsWarning": {
- "title": "Harici sınır:"
+ "title": "Harici sınırlama etkin"
},
"loadpoint": {
"avgPrice": "⌀ Fiyat",
diff --git a/meter/fritz/smarthome/service.go b/meter/fritz/smarthome/service.go
index 367ae88854..41b8d09ed9 100644
--- a/meter/fritz/smarthome/service.go
+++ b/meter/fritz/smarthome/service.go
@@ -1,6 +1,7 @@
package smarthome
import (
+ "cmp"
"encoding/json"
"errors"
"fmt"
@@ -13,7 +14,6 @@ import (
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
- "github.com/samber/lo"
)
func init() {
@@ -63,13 +63,36 @@ func getDevices(w http.ResponseWriter, r *http.Request) {
return
}
- ains := lo.Map(devices, func(d Device, _ int) string {
- return d.AIN
- })
- slices.Sort(ains)
-
w.Header().Set("Cache-control", "max-age=60")
- jsonWrite(w, ains)
+ jsonWrite(w, deviceOptions(devices))
+}
+
+func deviceOptions(devices []Device) []service.Option {
+ res := make([]service.Option, 0, len(devices))
+ for _, d := range devices {
+ // every device is a valid choice, a single one auto-fills the ain
+ res = append(res, service.Option{
+ Value: d.AIN,
+ Label: strings.TrimSpace(d.Name),
+ Hint: strings.TrimSpace(d.ProductName),
+ Match: true,
+ })
+ }
+ // connected devices first, then by ain
+ connected := func(o service.Option) bool {
+ i := slices.IndexFunc(devices, func(d Device) bool { return d.AIN == o.Value })
+ return devices[i].IsConnected
+ }
+ slices.SortFunc(res, func(a, b service.Option) int {
+ if ca, cb := connected(a), connected(b); ca != cb {
+ if ca {
+ return -1
+ }
+ return 1
+ }
+ return cmp.Compare(a.Value, b.Value)
+ })
+ return res
}
func jsonWrite(w http.ResponseWriter, data any) {
diff --git a/meter/fritz/smarthome/service_test.go b/meter/fritz/smarthome/service_test.go
new file mode 100644
index 0000000000..ee75f83858
--- /dev/null
+++ b/meter/fritz/smarthome/service_test.go
@@ -0,0 +1,25 @@
+package smarthome
+
+import (
+ "testing"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDeviceOptions(t *testing.T) {
+ devices := []Device{
+ {AIN: "11657 0123456", Name: "Wallbox", ProductName: " FRITZ!DECT 210 ", IsConnected: true},
+ {AIN: "08761 0000001", Name: "Garage", ProductName: "", IsConnected: false},
+ {AIN: "09995 0000002", Name: "Pump", IsConnected: true},
+ }
+
+ // connected devices first, then by ain
+ assert.Equal(t, []service.Option{
+ {Value: "09995 0000002", Label: "Pump", Match: true},
+ {Value: "11657 0123456", Label: "Wallbox", Hint: "FRITZ!DECT 210", Match: true},
+ {Value: "08761 0000001", Label: "Garage", Match: true},
+ }, deviceOptions(devices))
+
+ assert.Empty(t, deviceOptions(nil))
+}
diff --git a/meter/shelly/connection.go b/meter/shelly/connection.go
index c30323a632..f4211c2f90 100644
--- a/meter/shelly/connection.go
+++ b/meter/shelly/connection.go
@@ -43,8 +43,38 @@ func (c *Connection) SignedPower() bool {
// NewConnection creates a new Shelly device connection.
func NewConnection(uri, user, password string, channel int, cache time.Duration) (*Connection, error) {
+ log := util.NewLogger("shelly")
+
+ client, uri, resp, err := newClient(log, uri, user, password)
+ if err != nil {
+ return nil, err
+ }
+
+ model, _, _ := strings.Cut(resp.Type+resp.Model, "-")
+
+ var gen Generation
+ if resp.Gen < 2 {
+ // Shelly GEN 1 API
+ // https://shelly-api-docs.shelly.cloud/gen1/#shelly-family-overview
+ gen = newGen1(client, uri, model, channel, cache)
+ } else {
+ // Shelly GEN 2+ API
+ // https://shelly-api-docs.shelly.cloud/gen2/
+ gen, err = newGen2(client, uri, model, channel, cache)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ conn := &Connection{Generation: gen, gen: resp.Gen}
+
+ return conn, nil
+}
+
+// newClient probes the /shelly endpoint and returns an authenticated client, the normalized uri and the device info
+func newClient(log *util.Logger, uri, user, password string) (*request.Helper, string, DeviceInfo, error) {
if uri == "" {
- return nil, errors.New("missing uri")
+ return nil, "", DeviceInfo{}, errors.New("missing uri")
}
for _, suffix := range []string{"/", "/rcp", "/shelly"} {
@@ -52,43 +82,30 @@ func NewConnection(uri, user, password string, channel int, cache time.Duration)
}
uri = util.DefaultScheme(uri, "http")
- log := util.NewLogger("shelly")
client := request.NewHelper(log)
// Shelly Gen1 and Gen2 families expose the /shelly endpoint
var resp DeviceInfo
if err := client.GetJSON(fmt.Sprintf("%s/shelly", uri), &resp); err != nil {
- return nil, err
+ return nil, "", DeviceInfo{}, err
}
if (resp.Auth || resp.AuthEn) && (user == "" || password == "") {
- return nil, fmt.Errorf("%s (%s) missing user/password", resp.Model, resp.Mac)
+ return nil, "", DeviceInfo{}, fmt.Errorf("%s (%s) missing user/password", resp.Model, resp.Mac)
}
- model, _, _ := strings.Cut(resp.Type+resp.Model, "-")
-
client.Transport = request.NewTripper(log, transport.Insecure())
- var gen Generation
- if resp.Gen < 2 {
- // Shelly GEN 1 API
- // https://shelly-api-docs.shelly.cloud/gen1/#shelly-family-overview
- if user != "" {
+ if user != "" {
+ if resp.Gen < 2 {
log.Redact(transport.BasicAuthHeader(user, password))
- }
- gen = newGen1(client, uri, model, channel, user, password, cache)
- } else {
- // Shelly GEN 2+ API
- // https://shelly-api-docs.shelly.cloud/gen2/
-
- var err error
- gen, err = newGen2(client, uri, model, channel, user, password, cache)
- if err != nil {
- return nil, err
+ client.Transport = transport.BasicAuth(user, password, client.Transport)
+ } else {
+ // Shelly gen 2 rfc7616 authentication
+ // https://shelly-api-docs.shelly.cloud/gen2/General/Authentication
+ client.Transport = transport.Digest(user, password, client.Transport)
}
}
- conn := &Connection{Generation: gen, gen: resp.Gen}
-
- return conn, nil
+ return client, uri, resp, nil
}
diff --git a/meter/shelly/gen1.go b/meter/shelly/gen1.go
index 3606015e27..d916e43a2f 100644
--- a/meter/shelly/gen1.go
+++ b/meter/shelly/gen1.go
@@ -8,7 +8,6 @@ import (
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
- "github.com/evcc-io/evcc/util/transport"
)
// Gen1API endpoint reference: https://shelly-api-docs.shelly.cloud/gen1/#shelly-family-overview
@@ -18,6 +17,9 @@ type Gen1SwitchResponse struct {
}
type Gen1Status struct {
+ Relays []struct {
+ Ison bool
+ }
Meters []struct {
Power float64
Current float64
@@ -46,11 +48,7 @@ type gen1 struct {
}
// newGen1 initializes the connection to the shelly gen1 api and sets up the cached gen1Status
-func newGen1(client *request.Helper, uri, model string, channel int, user, password string, cache time.Duration) *gen1 {
- if user != "" {
- client.Transport = transport.BasicAuth(user, password, client.Transport)
- }
-
+func newGen1(client *request.Helper, uri, model string, channel int, cache time.Duration) *gen1 {
c := &gen1{
Helper: client,
uri: uri,
diff --git a/meter/shelly/gen2.go b/meter/shelly/gen2.go
index 2349c1c715..f6a95f07ba 100644
--- a/meter/shelly/gen2.go
+++ b/meter/shelly/gen2.go
@@ -8,7 +8,6 @@ import (
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
- "github.com/evcc-io/evcc/util/transport"
"github.com/samber/lo"
)
@@ -121,7 +120,7 @@ func (c *gen2) apiCall[T any](id int, method string) func() (T, error) {
}
// gen2InitApi initializes the connection to the shelly gen2+ api and sets up the cached gen2SwitchStatus, gen2EM1Status and gen2EMStatus
-func newGen2(helper *request.Helper, uri, model string, channel int, user, password string, cache time.Duration) (*gen2, error) {
+func newGen2(helper *request.Helper, uri, model string, channel int, cache time.Duration) (*gen2, error) {
// Shelly GEN 2+ API
// https://shelly-api-docs.shelly.cloud/gen2/
c := &gen2{
@@ -131,12 +130,6 @@ func newGen2(helper *request.Helper, uri, model string, channel int, user, passw
model: model,
}
- // Shelly gen 2 rfc7616 authentication
- // https://shelly-api-docs.shelly.cloud/gen2/General/Authentication
- if user != "" {
- c.Client.Transport = transport.Digest(user, password, c.Client.Transport)
- }
-
var res Gen2Methods
if err := c.execCmd(channel, "Shelly.ListMethods", &res); err != nil {
return nil, err
diff --git a/meter/shelly/service.go b/meter/shelly/service.go
new file mode 100644
index 0000000000..89ee9d11d8
--- /dev/null
+++ b/meter/shelly/service.go
@@ -0,0 +1,181 @@
+package shelly
+
+import (
+ "cmp"
+ "encoding/json"
+ "fmt"
+ "maps"
+ "net/http"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/evcc-io/evcc/util"
+ "github.com/samber/lo"
+)
+
+var (
+ log = util.NewLogger("shelly")
+ redactPassword = log.RotatingSlot()
+)
+
+func init() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /channels", getChannels)
+
+ service.Register("shelly", mux)
+}
+
+// channel is a relay/meter slot of a device, merged across components sharing the same id
+type channel struct {
+ id int
+ name string
+ relay bool
+ metered bool // has a power meter
+ power float64 // W, valid if metered
+}
+
+// gen2Components are the Shelly.GetStatus components that make up a channel
+var gen2Components = []string{"switch", "pm1", "em1", "em"}
+
+// channelOptions maps channels to options. With metered only channels with a power
+// meter match (meter templates), otherwise only relays do (charger templates).
+func channelOptions(channels []channel, metered bool) []service.Option {
+ res := make([]service.Option, 0, len(channels))
+ for _, ch := range channels {
+ kind := "Meter"
+ if ch.relay {
+ kind = "Switch"
+ }
+
+ o := service.Option{
+ Value: strconv.Itoa(ch.id),
+ Label: cmp.Or(strings.TrimSpace(ch.name), fmt.Sprintf("%s %d", kind, ch.id+1)),
+ Match: ch.relay,
+ }
+ if metered {
+ o.Match = ch.metered
+ }
+ if ch.metered {
+ o.Hint = fmt.Sprintf("%.0f W", ch.power)
+ }
+ res = append(res, o)
+ }
+
+ return res
+}
+
+// gen1Channels lists the relays and (e)meters of a /status response by index
+func gen1Channels(res Gen1Status) []channel {
+ var channels []channel
+ for i := range max(len(res.Relays), len(res.Meters), len(res.EMeters)) {
+ ch := channel{id: i, relay: i < len(res.Relays)}
+ switch {
+ case i < len(res.Meters):
+ ch.metered, ch.power = true, res.Meters[i].Power
+ case i < len(res.EMeters):
+ ch.metered, ch.power = true, res.EMeters[i].Power
+ }
+ channels = append(channels, ch)
+ }
+ return channels
+}
+
+// gen2Channels merges the switch/pm1/em1/em components of Shelly.GetStatus and
+// Shelly.GetConfig by id. Switches without power metering omit apower.
+func gen2Channels(status, config map[string]json.RawMessage) []channel {
+ byId := make(map[int]channel)
+
+ // sorted keys keep the merged name deterministic (em1:0 before switch:0)
+ for _, key := range slices.Sorted(maps.Keys(status)) {
+ typ, idStr, ok := strings.Cut(key, ":")
+ if !ok {
+ continue
+ }
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ continue
+ }
+
+ if !slices.Contains(gen2Components, typ) {
+ continue
+ }
+
+ var res struct {
+ Apower *float64 `json:"apower"`
+ ActPower *float64 `json:"act_power"`
+ TotalActPower *float64 `json:"total_act_power"`
+ }
+ var cfg struct {
+ Name string `json:"name"`
+ }
+ _ = json.Unmarshal(status[key], &res)
+ _ = json.Unmarshal(config[key], &cfg)
+
+ ch := byId[id]
+ ch.id = id
+ ch.relay = ch.relay || typ == "switch"
+ if power := cmp.Or(res.Apower, res.ActPower, res.TotalActPower); power != nil {
+ ch.metered, ch.power = true, *power
+ }
+ if strings.TrimSpace(ch.name) == "" {
+ ch.name = cfg.Name
+ }
+ byId[id] = ch
+ }
+
+ res := lo.Values(byId)
+ slices.SortFunc(res, func(a, b channel) int { return a.id - b.id })
+ return res
+}
+
+func getChannels(w http.ResponseWriter, req *http.Request) {
+ q := req.URL.Query()
+ password := q.Get("password")
+ metered, _ := strconv.ParseBool(q.Get("metered"))
+
+ // the logger is shared, a rotating slot keeps the redaction list from growing while typing
+ redactPassword(password)
+
+ res, err := deviceChannels(q.Get("uri"), q.Get("user"), password)
+ if err != nil {
+ // form incomplete or device unreachable, the ui polls while typing
+ log.DEBUG.Printf("channels: %v", err)
+ res = nil
+ }
+
+ // short cache: hints carry live values, the form refetches on every change
+ w.Header().Set("Cache-control", "max-age=30")
+ jsonWrite(w, channelOptions(res, metered))
+}
+
+func deviceChannels(uri, user, password string) ([]channel, error) {
+ client, uri, info, err := newClient(log, uri, user, password)
+ if err != nil {
+ return nil, err
+ }
+
+ if info.Gen < 2 {
+ var res Gen1Status
+ if err := client.GetJSON(uri+"/status", &res); err != nil {
+ return nil, err
+ }
+ return gen1Channels(res), nil
+ }
+
+ var status, config map[string]json.RawMessage
+ if err := client.GetJSON(uri+"/rpc/Shelly.GetStatus", &status); err != nil {
+ return nil, err
+ }
+ if err := client.GetJSON(uri+"/rpc/Shelly.GetConfig", &config); err != nil {
+ return nil, err
+ }
+
+ return gen2Channels(status, config), nil
+}
+
+func jsonWrite(w http.ResponseWriter, data any) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(data)
+}
diff --git a/meter/shelly/service_test.go b/meter/shelly/service_test.go
new file mode 100644
index 0000000000..ac77334f3c
--- /dev/null
+++ b/meter/shelly/service_test.go
@@ -0,0 +1,141 @@
+package shelly
+
+import (
+ "encoding/json"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestChannelOptions(t *testing.T) {
+ channels := []channel{
+ {id: 0, name: " Garden ", relay: true, metered: true, power: 635.4},
+ {id: 1, metered: true, power: 12.6},
+ {id: 2, relay: true},
+ }
+
+ // charger: relays match
+ assert.Equal(t, []service.Option{
+ {Value: "0", Label: "Garden", Hint: "635 W", Match: true},
+ {Value: "1", Label: "Meter 2", Hint: "13 W"},
+ {Value: "2", Label: "Switch 3", Match: true},
+ }, channelOptions(channels, false))
+
+ // meter: metered channels match
+ assert.Equal(t, []service.Option{
+ {Value: "0", Label: "Garden", Hint: "635 W", Match: true},
+ {Value: "1", Label: "Meter 2", Hint: "13 W", Match: true},
+ {Value: "2", Label: "Switch 3"},
+ }, channelOptions(channels, true))
+
+ assert.Empty(t, channelOptions(nil, true))
+}
+
+func TestGen1Channels(t *testing.T) {
+ var res Gen1Status
+
+ // Shelly EM: one relay, two emeters
+ require.NoError(t, json.Unmarshal([]byte(`{"relays":[{"ison":true}],"emeters":[{"power":100.5},{"power":-20}]}`), &res))
+ assert.Equal(t, []channel{
+ {id: 0, relay: true, metered: true, power: 100.5},
+ {id: 1, metered: true, power: -20.0},
+ }, gen1Channels(res))
+
+ // Shelly 1: relay without meter
+ res = Gen1Status{}
+ require.NoError(t, json.Unmarshal([]byte(`{"relays":[{"ison":false}]}`), &res))
+ assert.Equal(t, []channel{{id: 0, relay: true}}, gen1Channels(res))
+}
+
+func TestGen2Channels(t *testing.T) {
+ tc := []struct {
+ name string
+ status, config string
+ expected []channel
+ }{
+ {
+ "pro 4pm, switch 1 unnamed",
+ `{"sys":{},"switch:0":{"id":0,"output":true,"apower":635.4},"switch:1":{"id":1,"output":false,"apower":0}}`,
+ `{"switch:0":{"id":0,"name":"Garden"},"switch:1":{"id":1,"name":null}}`,
+ []channel{
+ {id: 0, name: "Garden", relay: true, metered: true, power: 635.4},
+ {id: 1, relay: true, metered: true, power: 0.0},
+ },
+ },
+ {
+ "plus 1, no power metering",
+ `{"switch:0":{"id":0,"output":true}}`,
+ `{"switch:0":{"id":0,"name":"Pump"}}`,
+ []channel{{id: 0, name: "Pump", relay: true}},
+ },
+ {
+ "pro em, switch and em1 share id 0",
+ `{"switch:0":{"id":0,"output":true},"em1:0":{"id":0,"act_power":-12.3},"em1:1":{"id":1,"act_power":4.5}}`,
+ `{"switch:0":{"id":0,"name":"Relay"},"em1:0":{"id":0,"name":"Grid"},"em1:1":{"id":1,"name":"PV"}}`,
+ []channel{
+ {id: 0, name: "Grid", relay: true, metered: true, power: -12.3},
+ {id: 1, name: "PV", metered: true, power: 4.5},
+ },
+ },
+ {
+ "pro 3em triphase",
+ `{"em:0":{"id":0,"total_act_power":1500}}`,
+ `{"em:0":{"id":0,"name":null}}`,
+ []channel{{id: 0, metered: true, power: 1500.0}},
+ },
+ {
+ "pm mini",
+ `{"pm1:0":{"id":0,"apower":42}}`,
+ `{"pm1:0":{"id":0,"name":"Heater"}}`,
+ []channel{{id: 0, name: "Heater", metered: true, power: 42.0}},
+ },
+ }
+
+ for _, tc := range tc {
+ t.Run(tc.name, func(t *testing.T) {
+ var status, config map[string]json.RawMessage
+ require.NoError(t, json.Unmarshal([]byte(tc.status), &status))
+ require.NoError(t, json.Unmarshal([]byte(tc.config), &config))
+
+ assert.Equal(t, tc.expected, gen2Channels(status, config))
+ })
+ }
+}
+
+func TestGetChannels(t *testing.T) {
+ srv := shellyServer(t, map[string]string{
+ "Shelly.GetStatus": `{"sys":{},"switch:0":{"id":0,"output":true,"apower":635.4},"switch:1":{"id":1,"output":false}}`,
+ "Shelly.GetConfig": `{"switch:0":{"id":0,"name":" Garden "},"switch:1":{"id":1,"name":null}}`,
+ })
+ defer srv.Close()
+
+ get := func(query string) []service.Option {
+ w := httptest.NewRecorder()
+ getChannels(w, httptest.NewRequest("GET", "/channels?"+query, nil))
+ require.Equal(t, 200, w.Code)
+
+ var res []service.Option
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res))
+ return res
+ }
+
+ uri := "uri=" + url.QueryEscape(srv.URL)
+
+ assert.Equal(t, []service.Option{
+ {Value: "0", Label: "Garden", Hint: "635 W", Match: true},
+ {Value: "1", Label: "Switch 2", Match: true},
+ }, get(uri))
+
+ assert.Equal(t, []service.Option{
+ {Value: "0", Label: "Garden", Hint: "635 W", Match: true},
+ {Value: "1", Label: "Switch 2"},
+ }, get(uri+"&metered=1"))
+
+ // incomplete form and unreachable device yield an empty list
+ assert.Empty(t, get(""))
+ assert.Empty(t, get("uri=http://127.0.0.1:1"))
+}
diff --git a/meter/tasmota/service.go b/meter/tasmota/service.go
new file mode 100644
index 0000000000..901aa006e2
--- /dev/null
+++ b/meter/tasmota/service.go
@@ -0,0 +1,117 @@
+package tasmota
+
+import (
+ "cmp"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/evcc-io/evcc/util"
+)
+
+func init() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /channels", getChannels)
+
+ service.Register("tasmota", mux)
+}
+
+// statusResponse combines the Status and StatusSTS parts of the Status 0 response
+type statusResponse struct {
+ StatusResponse
+ StatusSTSResponse
+}
+
+// relays returns the state of relays 1..8, empty when the relay does not exist
+func (res StatusSTSResponse) relays() [8]string {
+ sts := res.StatusSTS
+ return [8]string{cmp.Or(sts.Power1, sts.Power), sts.Power2, sts.Power3, sts.Power4, sts.Power5, sts.Power6, sts.Power7, sts.Power8}
+}
+
+// channelOptions lists relay and energy channels. Relays match for chargers, channels with energy readings for meters.
+func channelOptions(st statusResponse, sns StatusSNSResponse, meter bool) []service.Option {
+ relays := st.relays()
+ power := sns.StatusSNS.Energy.Power
+
+ var res []service.Option
+ for ch := 1; ch <= 8; ch++ {
+ relay := relays[ch-1] != ""
+ energy := ch <= len(power)
+ if !relay && !energy {
+ continue
+ }
+
+ match := relay
+ if meter {
+ match = energy
+ }
+
+ o := service.Option{
+ Value: strconv.Itoa(ch),
+ Label: fmt.Sprintf("Channel %d", ch),
+ Match: match,
+ }
+ if names := st.Status.FriendlyName; ch <= len(names) {
+ o.Label = cmp.Or(strings.TrimSpace(names[ch-1]), o.Label)
+ }
+ if energy {
+ o.Hint = fmt.Sprintf("%.0f W", power[ch-1])
+ }
+
+ res = append(res, o)
+ }
+
+ return res
+}
+
+// channels queries the device; unreachable or unauthorized devices yield no options
+func channels(uri, user, password string, meter bool) []service.Option {
+ // NewConnection redacts the credentials on the shared logger
+ log := util.NewLogger("tasmota")
+
+ // channel is irrelevant for discovery
+ conn, err := NewConnection(uri, user, password, []int{1}, 0)
+ if err != nil {
+ log.DEBUG.Println(err)
+ return nil
+ }
+
+ var st statusResponse
+ if err := conn.cmd("Status 0", &st); err != nil {
+ log.DEBUG.Println(err)
+ return nil
+ }
+
+ var sns StatusSNSResponse
+ if err := conn.cmd("Status 8", &sns); err != nil {
+ log.DEBUG.Println(err)
+ return nil
+ }
+
+ return channelOptions(st, sns, meter)
+}
+
+func getChannels(w http.ResponseWriter, req *http.Request) {
+ q := req.URL.Query()
+ meter, _ := strconv.ParseBool(q.Get("meter"))
+
+ var res []service.Option
+ if uri := q.Get("uri"); uri != "" {
+ res = channels(uri, q.Get("user"), q.Get("password"), meter)
+ }
+ if res == nil {
+ res = []service.Option{}
+ }
+
+ // short cache: hints carry live values, the form refetches on every change
+ w.Header().Set("Cache-control", "max-age=30")
+ jsonWrite(w, res)
+}
+
+func jsonWrite(w http.ResponseWriter, data any) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(data)
+}
diff --git a/meter/tasmota/service_test.go b/meter/tasmota/service_test.go
new file mode 100644
index 0000000000..314b268a0d
--- /dev/null
+++ b/meter/tasmota/service_test.go
@@ -0,0 +1,111 @@
+package tasmota
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestChannelOptions(t *testing.T) {
+ tc := []struct {
+ name string
+ status string
+ sns string
+ meter bool
+ expect []service.Option
+ }{
+ {
+ name: "single relay charger",
+ status: `{"Status":{"FriendlyName":["Pump"]},"StatusSTS":{"POWER":"ON"}}`,
+ sns: `{"StatusSNS":{"Time":"2023-02-05T20:31:48"}}`,
+ expect: []service.Option{{Value: "1", Label: "Pump", Match: true}},
+ },
+ {
+ name: "single relay meter without energy",
+ status: `{"Status":{"FriendlyName":["Pump"]},"StatusSTS":{"POWER":"ON"}}`,
+ sns: `{"StatusSNS":{"Time":"2023-02-05T20:31:48"}}`,
+ meter: true,
+ expect: []service.Option{{Value: "1", Label: "Pump"}},
+ },
+ {
+ name: "dual relay with energy",
+ status: `{"Status":{"FriendlyName":["Pump"," "]},"StatusSTS":{"POWER1":"ON","POWER2":"OFF"}}`,
+ sns: `{"StatusSNS":{"ENERGY":{"Power":[8.15,0]}}}`,
+ meter: true,
+ expect: []service.Option{
+ {Value: "1", Label: "Pump", Hint: "8 W", Match: true},
+ {Value: "2", Label: "Channel 2", Hint: "0 W", Match: true},
+ },
+ },
+ {
+ name: "energy only meter",
+ status: `{"Status":{"FriendlyName":["Meter"]},"StatusSTS":{}}`,
+ sns: `{"StatusSNS":{"ENERGY":{"Power":[100,200,300]}}}`,
+ meter: true,
+ expect: []service.Option{
+ {Value: "1", Label: "Meter", Hint: "100 W", Match: true},
+ {Value: "2", Label: "Channel 2", Hint: "200 W", Match: true},
+ {Value: "3", Label: "Channel 3", Hint: "300 W", Match: true},
+ },
+ },
+ {
+ name: "no channels",
+ status: `{"Status":{},"StatusSTS":{}}`,
+ sns: `{"StatusSNS":{}}`,
+ },
+ }
+
+ for _, tc := range tc {
+ t.Run(tc.name, func(t *testing.T) {
+ var st statusResponse
+ require.NoError(t, json.Unmarshal([]byte(tc.status), &st))
+
+ var sns StatusSNSResponse
+ require.NoError(t, json.Unmarshal([]byte(tc.sns), &sns))
+
+ assert.Equal(t, tc.expect, channelOptions(st, sns, tc.meter))
+ })
+ }
+}
+
+func TestGetChannels(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ if q.Get("user") != "admin" || q.Get("password") != "secret" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ switch q.Get("cmnd") {
+ case "Status 0":
+ fmt.Fprint(w, `{"Status":{"FriendlyName":["Pump"]},"StatusSTS":{"POWER":"ON"}}`)
+ case "Status 8":
+ fmt.Fprint(w, `{"StatusSNS":{"ENERGY":{"Power":47.11}}}`)
+ default:
+ w.WriteHeader(http.StatusBadRequest)
+ }
+ }))
+ defer srv.Close()
+
+ get := func(query string) []service.Option {
+ w := httptest.NewRecorder()
+ getChannels(w, httptest.NewRequest("GET", "/channels?"+query, nil))
+ require.Equal(t, http.StatusOK, w.Code)
+
+ var res []service.Option
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res))
+ return res
+ }
+
+ assert.Equal(t, []service.Option{{Value: "1", Label: "Pump", Hint: "47 W", Match: true}},
+ get("uri="+srv.URL+"&user=admin&password=secret"))
+
+ assert.Empty(t, get("uri="+srv.URL+"&user=admin&password=wrong"))
+ assert.Empty(t, get("uri=http://127.0.0.1:1"))
+ assert.Empty(t, get(""))
+}
diff --git a/package-lock.json b/package-lock.json
index 7d23342cac..b7e1e36d8d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,6 +32,7 @@
},
"devDependencies": {
"@playwright/test": "^1.63.0",
+ "@redocly/cli": "^2.62.0",
"@redocly/openapi-core": "^2.53.3",
"@storybook/vue3": "^10.6.0",
"@storybook/vue3-vite": "^10.6.0",
@@ -4007,6 +4008,21 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/@redocly/cli": {
+ "version": "2.62.0",
+ "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.62.0.tgz",
+ "integrity": "sha512-vB6H/UegNoFSz960eJ1QoGo7BaJC2i3kzCrbi8CHTOAItyUtO2HlobOPLx56WJ+ioY7njPOO1mlIR3QCt5eoVA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "openapi": "bin/cli.js",
+ "redocly": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=22.12.0 || >=20.19.0 <21.0.0",
+ "npm": ">=10"
+ }
+ },
"node_modules/@redocly/config": {
"version": "0.56.0",
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.56.0.tgz",
@@ -9907,9 +9923,9 @@
}
},
"node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
diff --git a/package.json b/package.json
index 6e6b60ef85..27a1eba2a5 100644
--- a/package.json
+++ b/package.json
@@ -55,6 +55,7 @@
"repository": "github:evcc-io/evcc",
"devDependencies": {
"@playwright/test": "^1.63.0",
+ "@redocly/cli": "^2.62.0",
"@redocly/openapi-core": "^2.53.3",
"@storybook/vue3": "^10.6.0",
"@storybook/vue3-vite": "^10.6.0",
diff --git a/plugin/sunspec.go b/plugin/sunspec.go
index a3a4ea8537..20c177baae 100644
--- a/plugin/sunspec.go
+++ b/plugin/sunspec.go
@@ -24,6 +24,7 @@ type ModbusSunspec struct {
op modbus.SunSpecOperation
scale float64
mask uint64
+ nan bool
}
func init() {
@@ -37,6 +38,7 @@ func NewModbusSunspecFromConfig(ctx context.Context, other map[string]any) (Plug
Value []string
Scale float64
BitMask string
+ Nan bool // report the not-implemented sentinel as 0, like the modbus *nan encodings
ConnectDelay time.Duration
}{
Scale: 1,
@@ -110,6 +112,7 @@ func NewModbusSunspecFromConfig(ctx context.Context, other map[string]any) (Plug
device: device,
scale: cc.Scale,
mask: mask,
+ nan: cc.Nan,
}
device.mu.Lock()
@@ -145,8 +148,12 @@ func (m *ModbusSunspec) floatGetter() (f float64, err error) {
m.op.Point,
)
if err != nil {
- // not implemented sentinel: report missing instead of zero so energy totals are not rebased
+ // not implemented sentinel: report missing so energy totals are not rebased,
+ // unless the template opted into zero (e.g. an unused mppt string)
if errors.Is(err, meters.ErrNaN) {
+ if m.nan {
+ return 0, nil
+ }
return 0, api.ErrNotAvailable
}
return 0, fmt.Errorf("model %d block %d point %s: %w", m.op.Model, m.op.Block, m.op.Point, err)
diff --git a/plugin/sunspec_test.go b/plugin/sunspec_test.go
index 1056289f1f..108edc1d45 100644
--- a/plugin/sunspec_test.go
+++ b/plugin/sunspec_test.go
@@ -140,6 +140,12 @@ func TestSunspecFloatGetterNaN(t *testing.T) {
require.NoError(t, block.Write(model704.WMaxLimPct))
_, err = mb.floatGetter()
require.ErrorIs(t, err, api.ErrNotAvailable)
+
+ // nan option reports the sentinel as 0, as the modbus *nan encodings do
+ mb.nan = true
+ res, err = mb.floatGetter()
+ require.NoError(t, err)
+ require.Equal(t, 0.0, res)
}
// TestSunspecConcurrentSharedDevice reads different points of one shared device
diff --git a/scripts/state-schema/schemas.ts b/scripts/state-schema/schemas.ts
index 3c2f07deea..2d289bb173 100644
--- a/scripts/state-schema/schemas.ts
+++ b/scripts/state-schema/schemas.ts
@@ -10,6 +10,7 @@ export interface StateSchemas {
// enums use SCREAMING_SNAKE in the frontend, schema names follow openapi PascalCase convention
const RENAME: Record = {
CHARGE_MODE: "ChargeMode",
+ OPTIMIZER_AUTOMATIC: "OptimizerAutomatic",
ALWAYS_CHARGE: "AlwaysCharge",
BATTERY_MODE: "BatteryMode",
CURRENCY: "Currency",
diff --git a/server/eebus/eebus.go b/server/eebus/eebus.go
index 72ab8b9741..ebf391aec0 100644
--- a/server/eebus/eebus.go
+++ b/server/eebus/eebus.go
@@ -470,6 +470,13 @@ func (c *EEBus) clientsFor(ski string) []Device {
return c.clients[ski]
}
+// HasDevices reports whether any configured device uses the given ski
+func (c *EEBus) HasDevices(ski string) bool {
+ c.mux.Lock()
+ defer c.mux.Unlock()
+ return len(c.clientsFor(ski)) > 0
+}
+
// RegisterDevice subscribes a device to the remote service with given ski.
// An empty ski subscribes to the device paired via the SHIP Pairing Service.
func (c *EEBus) RegisterDevice(ski, ip string, device Device) error {
diff --git a/server/eebus/service.go b/server/eebus/service.go
index 06594ed2ae..1d86e8e1b1 100644
--- a/server/eebus/service.go
+++ b/server/eebus/service.go
@@ -1,9 +1,13 @@
package eebus
import (
+ "cmp"
"encoding/json"
"net/http"
+ "slices"
+ "strings"
+ shipapi "github.com/enbility/ship-go/api"
"github.com/evcc-io/evcc/server/service"
)
@@ -16,12 +20,26 @@ func init() {
service.Register("eebus", mux)
}
+// deviceOptions maps discovered services to suggestions, used marks skis already configured
+func deviceOptions(services []shipapi.RemoteMdnsService, used func(ski string) bool) []service.Option {
+ res := make([]service.Option, 0, len(services))
+ for _, s := range services {
+ res = append(res, service.Option{
+ Value: s.Ski,
+ Label: cmp.Or(strings.TrimSpace(s.Brand+" "+s.Model), s.Name, s.ShipID),
+ Hint: cmp.Or(s.Serial, s.ShipID),
+ Match: true, // match keeps the single-device auto-fill
+ Used: used(s.Ski),
+ })
+ }
+ slices.SortFunc(res, func(a, b service.Option) int { return cmp.Compare(a.Value, b.Value) })
+ return res
+}
+
func getServices(w http.ResponseWriter, req *http.Request) {
- var res []string
+ res := []service.Option{}
if instance != nil {
- for _, s := range instance.RemoteServices() {
- res = append(res, s.Ski)
- }
+ res = deviceOptions(instance.RemoteServices(), instance.HasDevices)
}
json.NewEncoder(w).Encode(res)
}
diff --git a/server/eebus/service_test.go b/server/eebus/service_test.go
new file mode 100644
index 0000000000..812d9f2808
--- /dev/null
+++ b/server/eebus/service_test.go
@@ -0,0 +1,26 @@
+package eebus
+
+import (
+ "testing"
+
+ shipapi "github.com/enbility/ship-go/api"
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDeviceOptions(t *testing.T) {
+ services := []shipapi.RemoteMdnsService{
+ {Ski: "bbb", Name: "Wallbox", ShipID: "Wallbox-2", Brand: "Elli", Model: "Charger Pro", Serial: "12345"},
+ {Ski: "aaa", Name: "Steuerbox", ShipID: "FNN-1"},
+ {Ski: "ccc", ShipID: "Heatpump-3", Brand: "Vaillant"},
+ }
+ used := func(ski string) bool { return ski == "bbb" }
+
+ assert.Equal(t, []service.Option{
+ {Value: "aaa", Label: "Steuerbox", Hint: "FNN-1", Match: true},
+ {Value: "bbb", Label: "Elli Charger Pro", Hint: "12345", Match: true, Used: true},
+ {Value: "ccc", Label: "Vaillant", Hint: "Heatpump-3", Match: true},
+ }, deviceOptions(services, used))
+
+ assert.Equal(t, []service.Option{}, deviceOptions(nil, used))
+}
diff --git a/server/http.go b/server/http.go
index ff3d599ae4..093e386bdb 100644
--- a/server/http.go
+++ b/server/http.go
@@ -174,11 +174,11 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
))
// site api
- smartCostLimit := func(lp loadpoint.API, limit *float64) {
- lp.SetSmartCostLimit(limit)
+ smartCostLimit := func(lp loadpoint.API, limit *float64) error {
+ return lp.SetSmartCostLimit(limit)
}
- smartFeedInPriorityLimit := func(lp loadpoint.API, limit *float64) {
- lp.SetSmartFeedInPriorityLimit(limit)
+ smartFeedInPriorityLimit := func(lp loadpoint.API, limit *float64) error {
+ return lp.SetSmartFeedInPriorityLimit(limit)
}
routes := map[string]route{
@@ -267,11 +267,11 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
"enableDelay": {"POST", "/enable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetEnableDelay), lp.GetEnableDelay)},
"disableThreshold": {"POST", "/disable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetDisableThreshold), lp.GetDisableThreshold)},
"disableDelay": {"POST", "/disable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetDisableDelay), lp.GetDisableDelay)},
- "smartCost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
- "smartCostDelete": {"DELETE", "/smartcostlimit", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
+ "smartCost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatPtrHandler(lp.SetSmartCostLimit, lp.GetSmartCostLimit)},
+ "smartCostDelete": {"DELETE", "/smartcostlimit", floatPtrHandler(lp.SetSmartCostLimit, lp.GetSmartCostLimit)},
"solarShare": {"POST", "/solarshare/{value:-?[0-9.]+}", floatHandler(pass(lp.SetSolarShare), lp.GetSolarShare)},
- "smartFeedInPriority": {"POST", "/smartfeedinprioritylimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartFeedInPriorityLimit), lp.GetSmartFeedInPriorityLimit)},
- "smartFeedInPriorityDelete": {"DELETE", "/smartfeedinprioritylimit", floatPtrHandler(pass(lp.SetSmartFeedInPriorityLimit), lp.GetSmartFeedInPriorityLimit)},
+ "smartFeedInPriority": {"POST", "/smartfeedinprioritylimit/{value:-?[0-9.]+}", floatPtrHandler(lp.SetSmartFeedInPriorityLimit, lp.GetSmartFeedInPriorityLimit)},
+ "smartFeedInPriorityDelete": {"DELETE", "/smartfeedinprioritylimit", floatPtrHandler(lp.SetSmartFeedInPriorityLimit, lp.GetSmartFeedInPriorityLimit)},
"priority": {"POST", "/priority/{value:[0-9]+}", intHandler(pass(lp.SetPriority), lp.GetPriority)},
"batteryBoost": {"POST", "/batteryboost/{value:[01truefalse]+}", boolHandler(lp.SetBatteryBoost, func() bool { return lp.GetBatteryBoost() > 0 })},
"batteryBoostLimit": {"POST", "/batteryboostlimit/{value:[0-9]+}", intHandler(pass(lp.SetBatteryBoostLimit), lp.GetBatteryBoostLimit)},
@@ -361,6 +361,7 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
"deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler(pub)},
"experimental": {"POST", "/experimental/{value:[01truefalse]+}", boolHandler(setExperimental(pub), getExperimental)},
"optimizer": {"POST", "/optimizer/{value:[01truefalse]+}", boolHandler(setOptimizer(pub), getOptimizer)},
+ "optimizerAutomatic": {"POST", "/optimizerautomatic/{value:[a-z]+}", stringHandler(setOptimizerAutomatic(pub, site), core.OptimizerAutomatic)},
"remote": {"POST", "/remote/{value:[01truefalse]+}", boolHandler(remoteAccess.Enable, remoteAccess.Enabled)},
"remoteclients": {"GET", "/remote/clients", remoteClientsHandler(remoteAccess)},
"createremoteclient": {"POST", "/remote/clients", createRemoteClientHandler(remoteAccess)},
diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go
index 581c2b8c85..67c4d26bbb 100644
--- a/server/http_config_loadpoint_handler.go
+++ b/server/http_config_loadpoint_handler.go
@@ -233,7 +233,7 @@ func newLoadpointHandler() http.HandlerFunc {
dev := config.NewConfigurableDevice[loadpoint.API](&conf, instance)
if err := dynamic.Apply(instance); err != nil {
conf.Delete()
- jsonError(w, http.StatusBadRequest, err)
+ jsonError(w, errorStatus(err), err)
return
}
@@ -323,7 +323,7 @@ func updateLoadpointHandler() http.HandlerFunc {
// dynamic; instance is nil for a disabled loadpoint, takes effect on next restart
if instance != nil {
if err := dynamic.Apply(instance); err != nil {
- jsonError(w, http.StatusBadRequest, err)
+ jsonError(w, errorStatus(err), err)
return
}
}
diff --git a/server/http_config_site_other_handler.go b/server/http_config_site_other_handler.go
index dfb69b902e..d1390cce98 100644
--- a/server/http_config_site_other_handler.go
+++ b/server/http_config_site_other_handler.go
@@ -2,9 +2,12 @@ package server
import (
"encoding/json"
+ "fmt"
"net/http"
+ "slices"
"github.com/evcc-io/evcc/api/globalconfig"
+ "github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/db/settings"
"github.com/evcc-io/evcc/util/sponsor"
@@ -14,6 +17,11 @@ func setOptimizer(pub publisher) func(bool) error {
return func(b bool) error {
settings.SetBool(keys.Optimizer, b)
pub(keys.Optimizer, b)
+ if !b {
+ // automatic mode cannot outlive the optimizer
+ settings.SetString(keys.OptimizerAutomatic, core.OptimizerAutomaticOff)
+ pub(keys.OptimizerAutomatic, core.OptimizerAutomaticOff)
+ }
return nil
}
}
@@ -23,6 +31,22 @@ func getOptimizer() bool {
return b
}
+func setOptimizerAutomatic(pub publisher, site *core.Site) func(string) error {
+ return func(level string) error {
+ if !slices.Contains(core.OptimizerAutomaticLevels, level) {
+ return fmt.Errorf("invalid optimizer automatic level: %s", level)
+ }
+
+ settings.SetString(keys.OptimizerAutomatic, level)
+ pub(keys.OptimizerAutomatic, level)
+
+ // suggestions become control decisions, don't wait for the next slot
+ site.Optimize()
+
+ return nil
+ }
+}
+
func setExperimental(pub publisher) func(bool) error {
return func(b bool) error {
settings.SetBool(keys.Experimental, b)
diff --git a/server/http_loadpoint_handler.go b/server/http_loadpoint_handler.go
index 4fe82defbb..5ef5dcfa00 100644
--- a/server/http_loadpoint_handler.go
+++ b/server/http_loadpoint_handler.go
@@ -9,6 +9,7 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
+ "github.com/evcc-io/evcc/core/planner"
"github.com/evcc-io/evcc/core/site"
"github.com/gorilla/mux"
)
@@ -19,6 +20,8 @@ type PlanResponse struct {
Duration int64 `json:"duration"`
Plan api.Rates `json:"plan"`
Power float64 `json:"power"`
+ // Optimizer indicates that the optimizer created the plan
+ Optimizer bool `json:"optimizer"`
}
type PlanPreviewResponse struct {
@@ -37,15 +40,19 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
goal, _ := lp.GetPlanGoal()
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
- strategy := lp.EffectivePlanStrategy()
- plan := lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous)
+
+ plan, power, optimizer := lp.EffectivePlan(planTime, requiredDuration, lp.EffectivePlanStrategy())
+ if optimizer {
+ maxPower, requiredDuration = power, planner.Duration(plan)
+ }
res := PlanResponse{
- PlanId: id,
- PlanTime: planTime,
- Duration: int64(requiredDuration.Seconds()),
- Plan: plan,
- Power: maxPower,
+ PlanId: id,
+ PlanTime: planTime,
+ Duration: int64(requiredDuration.Seconds()),
+ Plan: plan,
+ Power: maxPower,
+ Optimizer: optimizer,
}
jsonWrite(w, res)
diff --git a/server/http_site_handler.go b/server/http_site_handler.go
index 5a226f75f1..0fde443741 100644
--- a/server/http_site_handler.go
+++ b/server/http_site_handler.go
@@ -16,6 +16,7 @@ import (
"time"
"github.com/evcc-io/evcc/api"
+ "github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/site"
@@ -164,7 +165,7 @@ func handler[T any](conv func(string) (T, error), set func(T) error, get func()
}
if err != nil {
- jsonError(w, http.StatusBadRequest, err)
+ jsonError(w, errorStatus(err), err)
return
}
@@ -172,6 +173,14 @@ func handler[T any](conv func(string) (T, error), set func(T) error, get func()
}
}
+// errorStatus maps a setting rejected by the current state to conflict
+func errorStatus(err error) int {
+ if errors.Is(err, core.ErrOptimizerAutomatic) {
+ return http.StatusConflict
+ }
+ return http.StatusBadRequest
+}
+
// ptrHandler updates pointer api
func ptrHandler[T any](conv func(string) (T, error), set func(*T) error, get func() *T) http.HandlerFunc {
return handler(func(s string) (*T, error) {
@@ -249,8 +258,9 @@ func callHandler(fun func()) http.HandlerFunc {
}
}
-// updateSmartCostLimit sets the smart cost limit globally
-func updateSmartCostLimit(site site.API, setLimit func(loadpoint.API, *float64)) http.HandlerFunc {
+// updateSmartCostLimit sets the smart cost limit globally. Loadpoints under
+// optimizer control are skipped, the request fails only if none accepted it.
+func updateSmartCostLimit(site site.API, setLimit func(loadpoint.API, *float64) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
var val *float64
@@ -265,14 +275,35 @@ func updateSmartCostLimit(site site.API, setLimit func(loadpoint.API, *float64))
val = &f
}
- for _, lp := range site.ActiveLoadpoints() {
- setLimit(lp, val)
+ if err := setLoadpointsLimit(site, setLimit, val); err != nil {
+ jsonError(w, errorStatus(err), err)
+ return
}
jsonWrite(w, val)
}
}
+// setLoadpointsLimit applies a limit to all loadpoints that accept it
+func setLoadpointsLimit(site site.API, setLimit func(loadpoint.API, *float64) error, val *float64) error {
+ var applied bool
+ var lastErr error
+
+ for _, lp := range site.ActiveLoadpoints() {
+ if err := setLimit(lp, val); err != nil {
+ lastErr = err
+ } else {
+ applied = true
+ }
+ }
+
+ if applied {
+ return nil
+ }
+
+ return lastErr
+}
+
// updateBatteryMode sets the external battery mode
func updateBatteryMode(site site.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
diff --git a/server/http_site_handler_test.go b/server/http_site_handler_test.go
new file mode 100644
index 0000000000..254505241d
--- /dev/null
+++ b/server/http_site_handler_test.go
@@ -0,0 +1,18 @@
+package server
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/evcc-io/evcc/core"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestErrorStatus(t *testing.T) {
+ // settings the optimizer decides on are rejected as conflict, not bad request
+ assert.Equal(t, http.StatusConflict, errorStatus(core.ErrOptimizerAutomatic))
+ assert.Equal(t, http.StatusConflict, errorStatus(fmt.Errorf("smart cost limit: %w", core.ErrOptimizerAutomatic)))
+ assert.Equal(t, http.StatusBadRequest, errorStatus(errors.New("boom")))
+}
diff --git a/server/http_vehicle_handler.go b/server/http_vehicle_handler.go
index 1d63cd43ae..2f8bd1a901 100644
--- a/server/http_vehicle_handler.go
+++ b/server/http_vehicle_handler.go
@@ -150,6 +150,8 @@ func planSocHandler(site site.API) http.HandlerFunc {
return
}
+ site.Optimize()
+
ts, soc = v.GetPlanSoc()
res := struct {
@@ -216,6 +218,8 @@ func addRepeatingPlansHandler(site site.API) http.HandlerFunc {
return
}
+ site.Optimize()
+
jsonWrite(w, res)
}
}
@@ -236,6 +240,8 @@ func planSocRemoveHandler(site site.API) http.HandlerFunc {
return
}
+ site.Optimize()
+
jsonWrite(w, struct{}{})
}
}
diff --git a/server/influxdb_test.go b/server/influxdb_test.go
index 5e09ebbde1..190b58638b 100644
--- a/server/influxdb_test.go
+++ b/server/influxdb_test.go
@@ -87,8 +87,12 @@ func (w *influxSuite) TestSlice() {
w.Len(w.p, 0)
}
+// only set numeric fields are written, so adding a field does not change the points
func (w *influxSuite) TestMeasurement() {
- w.WriteParam(util.Param{Key: "battery", Val: types.Measurement{Power: 1, Soc: new(10.0)}})
+ w.WriteParam(util.Param{Key: "battery", Val: types.Measurement{
+ Power: 1, Soc: new(10.0),
+ Title: "home", Controllable: new(true), Modes: []string{"normal", "discharge"},
+ }})
w.Equal([]*write.Point{
inf2.NewPoint("batteryPower", nil, map[string]any{"value": 1.0}, w.clock.Now()),
inf2.NewPoint("batterySoc", nil, map[string]any{"value": 10.0}, w.clock.Now()),
diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json
index e8b71d07d8..548d94c638 100644
--- a/server/mcp/openapi.json
+++ b/server/mcp/openapi.json
@@ -1417,6 +1417,45 @@
}
}
},
+ "/config/optimizerautomatic/{value}": {
+ "post": {
+ "operationId": "setOptimizerAutomatic",
+ "summary": "Set optimizer automatic level",
+ "description": "Let the optimizer directly control the home battery (`battery`) or home battery and loadpoints (`full`) instead of only advising (`off`). Requires a sponsor token.",
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "tags": [
+ "experimental"
+ ],
+ "parameters": [
+ {
+ "name": "value",
+ "description": "What the optimizer controls.",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "off",
+ "battery",
+ "full"
+ ]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/StringResult"
+ }
+ }
+ }
+ },
"/smartfeedinprioritylimit": {
"delete": {
"operationId": "removeGlobalSmartFeedInPriorityLimit",
@@ -3521,6 +3560,10 @@
"description": "Recommended discharge power in W.",
"type": "number"
},
+ "grid": {
+ "description": "Planned site grid flow of the slot in W, positive means import.",
+ "type": "number"
+ },
"actionable": {
"description": "Suggestion differs from the current operating mode.",
"type": "boolean"
@@ -3794,6 +3837,10 @@
"description": "Current offered to the vehicle in A.",
"type": "number"
},
+ "optimizerControlled": {
+ "description": "Optimizer decides start/stop for this loadpoint, replacing the price limits.",
+ "type": "boolean"
+ },
"phaseAction": {
"$ref": "#/components/schemas/PhaseAction",
"description": "Pending phase switching action in solar mode."
@@ -4043,6 +4090,7 @@
"minSocNotReached",
"mode",
"offeredCurrent",
+ "optimizerControlled",
"phaseAction",
"phaseRemaining",
"phasesActive",
@@ -4289,6 +4337,10 @@
"description": "Recommended discharge power in W.",
"type": "number"
},
+ "grid": {
+ "description": "Planned site grid flow of the slot in W, positive means import.",
+ "type": "number"
+ },
"actionable": {
"description": "Suggestion differs from the current operating mode.",
"type": "boolean"
@@ -4348,6 +4400,13 @@
"description": "Battery supports external control of its operation mode.",
"type": "boolean"
},
+ "modes": {
+ "description": "Battery modes supported by the device.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"capacity": {
"description": "Battery capacity in kWh. Zero when not specified.",
"type": "number"
@@ -5505,6 +5564,14 @@
}
}
},
+ "OptimizerAutomatic": {
+ "type": "string",
+ "enum": [
+ "off",
+ "battery",
+ "full"
+ ]
+ },
"State": {
"description": "Complete system state as returned by /api/state and pushed via websocket and MQTT. This structure mirrors the internal UI state and carries no compatibility promise. Fields may change or disappear between releases.",
"type": "object",
@@ -5872,6 +5939,10 @@
"description": "Battery optimizer is enabled.",
"type": "boolean"
},
+ "optimizerAutomatic": {
+ "$ref": "#/components/schemas/OptimizerAutomatic",
+ "description": "What the optimizer controls instead of only advising: nothing, the home battery, or battery and loadpoints."
+ },
"optimizerChargingStrategy": {
"description": "Selected battery optimizer charging strategy.",
"type": "string"
@@ -6211,6 +6282,16 @@
}
}
},
+ "StringResult": {
+ "description": "Success - String result",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
"NullResult": {
"description": "Success - Null result",
"content": {
diff --git a/server/modbus/handler.go b/server/modbus/handler.go
index a2ffac6836..1c8336c872 100644
--- a/server/modbus/handler.go
+++ b/server/modbus/handler.go
@@ -4,6 +4,7 @@ import (
"encoding/binary"
"errors"
"math/bits"
+ "net"
"github.com/andig/mbserver"
"github.com/evcc-io/evcc/util"
@@ -41,14 +42,21 @@ func (h *handler) logResult(op string, b []byte, err error) {
}
}
-func (h *handler) exceptionToUint16AndError(op string, b []byte, err error) ([]uint16, error) {
- h.logResult(op, b, err)
-
+// mapError passes device exceptions through and reports upstream timeouts as gateway target failure (0x0B)
+// instead of letting mbserver map them to server device failure (0x04)
+func mapError(err error) error {
if me, ok := errors.AsType[*gridx.Error](err); ok {
- err = mbserver.MapExceptionCodeToError(me.ExceptionCode)
+ return mbserver.MapExceptionCodeToError(me.ExceptionCode)
}
+ if ne, ok := errors.AsType[net.Error](err); ok && ne.Timeout() {
+ return mbserver.ErrGWTargetFailedToRespond
+ }
+ return err
+}
- return bytesAsUint16(b), err
+func (h *handler) exceptionToUint16AndError(op string, b []byte, err error) ([]uint16, error) {
+ h.logResult(op, b, err)
+ return bytesAsUint16(b), mapError(err)
}
func coilsToBytes(b []bool) []byte {
@@ -73,10 +81,7 @@ func coilsToBytes(b []bool) []byte {
func (h *handler) bytesToBoolResult(op string, qty uint16, b []byte, err error) ([]bool, error) {
h.logResult(op, b, err)
-
- if me, ok := errors.AsType[*gridx.Error](err); ok {
- err = mbserver.MapExceptionCodeToError(me.ExceptionCode)
- }
+ err = mapError(err)
var res []bool
diff --git a/server/modbus/proxy_test.go b/server/modbus/proxy_test.go
index 50e77c1675..62b216eac8 100644
--- a/server/modbus/proxy_test.go
+++ b/server/modbus/proxy_test.go
@@ -2,6 +2,7 @@ package modbus
import (
"encoding/binary"
+ "errors"
"math/rand/v2"
"net"
"sync"
@@ -11,6 +12,7 @@ import (
"github.com/andig/mbserver"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/modbus"
+ gridx "github.com/grid-x/modbus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -114,6 +116,55 @@ func TestReadCoils(t *testing.T) {
}
}
+func TestUpstreamTimeout(t *testing.T) {
+ // downstream server responding slower than the proxy timeout
+ l, err := net.Listen("tcp", "localhost:0")
+ require.NoError(t, err)
+ defer l.Close()
+
+ srv, _ := mbserver.New(&slowHandler{
+ delay: 500 * time.Millisecond,
+ RequestHandler: new(mbserver.DummyHandler),
+ })
+ require.NoError(t, srv.Start(l))
+ defer func() { _ = srv.Stop() }()
+
+ // proxy server
+ pl, err := net.Listen("tcp", "localhost:0")
+ require.NoError(t, err)
+ defer pl.Close()
+
+ downstreamConn, err := modbus.Settings{URI: l.Addr().String(), ID: 1, Timeout: 100 * time.Millisecond}.Connection(t.Context())
+ require.NoError(t, err)
+
+ proxy, _ := mbserver.New(&handler{
+ log: util.NewLogger("foo"),
+ conn: downstreamConn,
+ })
+ require.NoError(t, proxy.Start(pl))
+ defer func() { _ = proxy.Stop() }()
+
+ // test client
+ conn, err := modbus.NewConnection(t.Context(), pl.Addr().String(), "", "", 0, modbus.Tcp, 1)
+ require.NoError(t, err)
+ conn.Timeout(5 * time.Second)
+
+ _, err = conn.WriteMultipleRegisters(1, 1, []byte{0, 1})
+ me, ok := errors.AsType[*gridx.Error](err)
+ require.True(t, ok, "expected modbus exception, got %v", err)
+ assert.Equal(t, uint8(gridx.ExceptionCodeGatewayTargetDeviceFailedToRespond), me.ExceptionCode)
+}
+
+type slowHandler struct {
+ delay time.Duration
+ mbserver.RequestHandler
+}
+
+func (h *slowHandler) HandleHoldingRegisters(req *mbserver.HoldingRegistersRequest) ([]uint16, error) {
+ time.Sleep(h.delay)
+ return req.Args, nil
+}
+
type echoHandler struct {
id int
mbserver.RequestHandler
diff --git a/server/mqtt.go b/server/mqtt.go
index 703b8fdfa3..556642fe32 100644
--- a/server/mqtt.go
+++ b/server/mqtt.go
@@ -233,16 +233,12 @@ func (m *MQTT) listenSiteSetters(topic string, site site.API) error {
{"residualPower", floatSetter(site.SetResidualPower)},
{"gridExportLimit", floatSetter(site.SetGridExportLimit)},
{"solarAdjusted", boolSetter(pass(site.SetSolarAdjusted))},
- {"smartCostLimit", floatPtrSetter(pass(func(limit *float64) {
- for _, lp := range site.ActiveLoadpoints() {
- lp.SetSmartCostLimit(limit)
- }
- }))},
- {"smartFeedInPriorityLimit", floatPtrSetter(pass(func(limit *float64) {
- for _, lp := range site.ActiveLoadpoints() {
- lp.SetSmartFeedInPriorityLimit(limit)
- }
- }))},
+ {"smartCostLimit", floatPtrSetter(func(limit *float64) error {
+ return setLoadpointsLimit(site, loadpoint.API.SetSmartCostLimit, limit)
+ })},
+ {"smartFeedInPriorityLimit", floatPtrSetter(func(limit *float64) error {
+ return setLoadpointsLimit(site, loadpoint.API.SetSmartFeedInPriorityLimit, limit)
+ })},
{"batteryGridChargeLimit", floatPtrSetter(site.SetBatteryGridChargeLimit)},
{"batteryGridDischargeLimit", floatPtrSetter(site.SetBatteryGridDischargeLimit)},
{"batteryMode", ptrSetter(api.BatteryModeString, func(m *api.BatteryMode) error {
@@ -275,9 +271,9 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint.
{"disableThreshold", floatSetter(pass(lp.SetDisableThreshold))},
{"enableDelay", durationSetter(pass(lp.SetEnableDelay))},
{"disableDelay", durationSetter(pass(lp.SetDisableDelay))},
- {"smartCostLimit", floatPtrSetter(pass(lp.SetSmartCostLimit))},
+ {"smartCostLimit", floatPtrSetter(lp.SetSmartCostLimit)},
{"solarShare", floatSetter(pass(lp.SetSolarShare))},
- {"smartFeedInPriorityLimit", floatPtrSetter(pass(lp.SetSmartFeedInPriorityLimit))},
+ {"smartFeedInPriorityLimit", floatPtrSetter(lp.SetSmartFeedInPriorityLimit)},
{"batteryBoost", boolSetter(lp.SetBatteryBoost)},
{"batteryBoostLimit", intSetter(pass(lp.SetBatteryBoostLimit))},
{"planStrategy", planStrategySetter(lp.SetPlanStrategy)},
diff --git a/server/mqtt_test.go b/server/mqtt_test.go
index 24bc0487e9..902411dd2f 100644
--- a/server/mqtt_test.go
+++ b/server/mqtt_test.go
@@ -10,7 +10,6 @@ import (
"github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/util"
- "github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
@@ -126,39 +125,44 @@ func (suite *mqttSuite) TestNilInterface() {
suite.Equal([]string{""}, suite.payloads, "payloads")
}
-func (suite *mqttSuite) TestMeasurement() {
- topics := lo.Map([]string{
- "name", "title", "icon", "power", "energy", "returnEnergy", "powers", "currents", "excessDCPower", "capacity", "soc", "controllable", "suggestion",
- }, func(s string, _ int) string {
- return "test/" + s
- })
+// published returns the last payload per topic
+func (suite *mqttSuite) published() map[string]string {
+ res := make(map[string]string, len(suite.topics))
+ for i, topic := range suite.topics {
+ res[topic] = suite.payloads[i]
+ }
+ return res
+}
+// TestMeasurement checks the fields under test only, so adding a field does not break it
+func (suite *mqttSuite) TestMeasurement() {
suite.publish("test", false, types.Measurement{})
- suite.Equal(topics, suite.topics, "topics")
- suite.Equal([]string{"", "", "", "0", "", "", "", "", "", "", "", "", ""}, suite.payloads, "empty payloads")
+ for topic, payload := range suite.published() {
+ if topic != "test/power" {
+ suite.Empty(payload, "unset field must clear retained topic %s", topic)
+ }
+ }
+ suite.Equal("0", suite.published()["test/power"])
suite.publish("test", false, types.Measurement{Energy: new(1.0)})
- suite.Equal(topics, suite.topics, "topics")
- suite.Equal([]string{"", "", "", "0", "1", "", "", "", "", "", "", "", ""}, suite.payloads, "energy payloads")
+ suite.Equal("1", suite.published()["test/energy"])
suite.publish("test", false, types.Measurement{Controllable: new(false)})
- suite.Equal(topics, suite.topics, "topics")
- suite.Equal([]string{"", "", "", "0", "", "", "", "", "", "", "", "false", ""}, suite.payloads, "controllable payloads")
+ suite.Equal("false", suite.published()["test/controllable"])
+ suite.Empty(suite.published()["test/energy"], "energy cleared")
suite.publish("test", false, types.Measurement{Currents: []float64{1, 2, 3}})
- suite.Equal(append(topics, "test/currents/1", "test/currents/2", "test/currents/3"), suite.topics, "currents topics")
- suite.Equal([]string{"", "", "", "0", "", "", "", "3", "", "", "", "", "", "1", "2", "3"}, suite.payloads, "currents payloads")
-}
+ suite.Subset(suite.published(), map[string]string{
+ "test/currents": "3", "test/currents/1": "1", "test/currents/2": "2", "test/currents/3": "3",
+ })
-func (suite *mqttSuite) TestBatteryState() {
- topics := lo.Map([]string{
- "power", "energy", "returnEnergy", "capacity", "soc",
- "devices", "devices/1/name", "devices/1/title", "devices/1/icon", "devices/1/power", "devices/1/energy", "devices/1/returnEnergy", "devices/1/powers", "devices/1/currents", "devices/1/excessDCPower", "devices/1/capacity", "devices/1/soc", "devices/1/controllable", "devices/1/suggestion",
- "forecast",
- }, func(s string, _ int) string {
- return "test/" + s
+ suite.publish("test", false, types.Measurement{Modes: []string{"normal", "discharge"}})
+ suite.Subset(suite.published(), map[string]string{
+ "test/modes": "2", "test/modes/1": "normal", "test/modes/2": "discharge",
})
+}
+func (suite *mqttSuite) TestBatteryState() {
suite.publish("test", false, types.BatteryState{
Power: 2,
Soc: 20.0,
@@ -168,8 +172,10 @@ func (suite *mqttSuite) TestBatteryState() {
}},
})
- suite.Equal(topics, suite.topics, "topics")
- suite.Equal([]string{"2", "", "", "", "20", "1", "", "", "", "1", "", "", "", "", "", "", "10", "", "", ""}, suite.payloads, "payloads")
+ suite.Subset(suite.published(), map[string]string{
+ "test/power": "2", "test/soc": "20", "test/forecast": "",
+ "test/devices": "1", "test/devices/1/power": "1", "test/devices/1/soc": "10",
+ })
}
func (suite *mqttSuite) TestMapKeys() {
diff --git a/server/openapi.state.yaml b/server/openapi.state.yaml
index 36ba59ad07..3ba957570f 100644
--- a/server/openapi.state.yaml
+++ b/server/openapi.state.yaml
@@ -275,6 +275,9 @@ components:
optimizer:
description: Battery optimizer is enabled.
type: boolean
+ optimizerAutomatic:
+ $ref: "#/components/schemas/OptimizerAutomatic"
+ description: "What the optimizer controls instead of only advising: nothing, the home battery, or battery and loadpoints."
optimizerChargingStrategy:
description: Selected battery optimizer charging strategy.
type: string
@@ -409,6 +412,11 @@ components:
controllable:
description: Battery supports external control of its operation mode.
type: boolean
+ modes:
+ description: Battery modes supported by the device.
+ type: array
+ items:
+ type: string
capacity:
description: Battery capacity in kWh. Zero when not specified.
type: number
@@ -449,6 +457,9 @@ components:
discharge:
description: Recommended discharge power in W.
type: number
+ grid:
+ description: Planned site grid flow of the slot in W, positive means import.
+ type: number
actionable:
description: Suggestion differs from the current operating mode.
type: boolean
@@ -942,6 +953,9 @@ components:
offeredCurrent:
description: Current offered to the vehicle in A.
type: number
+ optimizerControlled:
+ description: Optimizer decides start/stop for this loadpoint, replacing the price limits.
+ type: boolean
phaseAction:
$ref: "#/components/schemas/PhaseAction"
description: Pending phase switching action in solar mode.
@@ -1143,6 +1157,7 @@ components:
- minSocNotReached
- mode
- offeredCurrent
+ - optimizerControlled
- phaseAction
- phaseRemaining
- phasesActive
@@ -1197,6 +1212,9 @@ components:
discharge:
description: Recommended discharge power in W.
type: number
+ grid:
+ description: Planned site grid flow of the slot in W, positive means import.
+ type: number
actionable:
description: Suggestion differs from the current operating mode.
type: boolean
@@ -1542,6 +1560,12 @@ components:
$ref: "#/components/schemas/OcppStationStatus"
required:
- stations
+ OptimizerAutomatic:
+ type: string
+ enum:
+ - off
+ - battery
+ - full
PhaseAction:
description: Pending phase switching action in solar mode.
type: string
diff --git a/server/openapi.yaml b/server/openapi.yaml
index b2db36c7c2..d455479b00 100644
--- a/server/openapi.yaml
+++ b/server/openapi.yaml
@@ -850,6 +850,30 @@ paths:
responses:
"200":
$ref: "#/components/responses/BooleanResult"
+ /config/optimizerautomatic/{value}:
+ post:
+ operationId: setOptimizerAutomatic
+ summary: Set optimizer automatic level
+ description: "Let the optimizer directly control the home battery (`battery`) or home battery and loadpoints (`full`) instead of only advising (`off`). Requires a sponsor token."
+ security:
+ - cookieAuth: []
+ - bearerAuth: []
+ tags:
+ - experimental
+ parameters:
+ - name: value
+ description: What the optimizer controls.
+ in: path
+ required: true
+ schema:
+ type: string
+ enum:
+ - "off"
+ - "battery"
+ - "full"
+ responses:
+ "200":
+ $ref: "#/components/responses/StringResult"
/smartfeedinprioritylimit:
delete:
operationId: removeGlobalSmartFeedInPriorityLimit
@@ -2339,6 +2363,12 @@ components:
application/json:
schema:
type: boolean
+ StringResult:
+ description: Success - String result
+ content:
+ application/json:
+ schema:
+ type: string
NullResult:
description: Success - Null result
content:
diff --git a/server/service/registry.go b/server/service/registry.go
index 6997da72cf..c1972cb5c8 100644
--- a/server/service/registry.go
+++ b/server/service/registry.go
@@ -5,6 +5,15 @@ import (
"sync"
)
+// Option is a service suggestion with context
+type Option struct {
+ Value string `json:"value"`
+ Label string `json:"label,omitempty"`
+ Hint string `json:"hint,omitempty"`
+ Match bool `json:"match,omitempty"`
+ Used bool `json:"used,omitempty"`
+}
+
var (
mu sync.Mutex
registry = make(map[string]http.Handler)
diff --git a/tariff/ostrom/api.go b/tariff/ostrom/api.go
index e487f2279e..386c525eac 100644
--- a/tariff/ostrom/api.go
+++ b/tariff/ostrom/api.go
@@ -66,27 +66,27 @@ type CityId []struct {
type Tariffs struct {
Ostrom []struct {
ProductCode string `json:"productCode"`
- Tariff int `json:"tariff"`
- BasicFee int `json:"basicFee"`
+ Tariff float64 `json:"tariff"`
+ BasicFee float64 `json:"basicFee"`
NetworkFee float64 `json:"networkFee"`
UnitPricePerkWH float64 `json:"unitPricePerkWH"`
- TariffWithStormPreisBremse int `json:"tariffWithStormPreisBremse"`
- StromPreisBremseUnitPrice int `json:"stromPreisBremseUnitPrice"`
+ TariffWithStormPreisBremse float64 `json:"tariffWithStormPreisBremse"`
+ StromPreisBremseUnitPrice float64 `json:"stromPreisBremseUnitPrice"`
AccumulatedUnitPriceWithStromPreisBremse float64 `json:"accumulatedUnitPriceWithStromPreisBremse"`
UnitPrice float64 `json:"unitPrice"`
- EnergyConsumption int `json:"energyConsumption"`
+ EnergyConsumption float64 `json:"energyConsumption"`
BasePriceBrutto float64 `json:"basePriceBrutto"`
WorkingPriceBrutto float64 `json:"workingPriceBrutto"`
WorkingPriceNetto float64 `json:"workingPriceNetto"`
- MeterChargeBrutto int `json:"meterChargeBrutto"`
+ MeterChargeBrutto float64 `json:"meterChargeBrutto"`
WorkingPricePowerTax float64 `json:"workingPricePowerTax"`
AverageHourlyPriceToday float64 `json:"averageHourlyPriceToday,omitempty"`
MinHourlyPriceToday float64 `json:"minHourlyPriceToday,omitempty"`
MaxHourlyPriceToday float64 `json:"maxHourlyPriceToday,omitempty"`
} `json:"ostrom"`
Footprint struct {
- Usage int `json:"usage"`
- KgCO2Emissions int `json:"kgCO2Emissions"`
+ Usage float64 `json:"usage"`
+ KgCO2Emissions float64 `json:"kgCO2Emissions"`
} `json:"footprint"`
IsPendingApplicationAllowed bool `json:"isPendingApplicationAllowed"`
Status string `json:"status"`
diff --git a/tariff/tibber.go b/tariff/tibber.go
index 630c1923b3..e07400fe37 100644
--- a/tariff/tibber.go
+++ b/tariff/tibber.go
@@ -112,7 +112,7 @@ func (t *Tibber) rates(pi []tibber.Price) api.Rates {
data := make(api.Rates, 0, len(pi))
for _, r := range pi {
price := r.Total
- if t.Charges != 0 || t.Tax != 0 || t.Formula != "" {
+ if t.Charges != 0 || len(t.chargesZones) > 0 || t.Tax != 0 || t.Formula != "" {
price = t.totalPrice(r.Energy, r.StartsAt)
}
ar := api.Rate{
diff --git a/tariff/tibber_test.go b/tariff/tibber_test.go
new file mode 100644
index 0000000000..72e3e7ee7f
--- /dev/null
+++ b/tariff/tibber_test.go
@@ -0,0 +1,26 @@
+package tariff
+
+import (
+ "testing"
+ "time"
+
+ "github.com/evcc-io/evcc/meter/tibber"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestTibberChargesZones guards that charge zones alone switch to the energy price base (#34558)
+func TestTibberChargesZones(t *testing.T) {
+ ts := time.Date(2026, 1, 15, 2, 0, 0, 0, time.Local)
+ prices := []tibber.Price{{StartsAt: ts, Total: 0.30, Energy: 0.20}}
+
+ tb := &Tibber{embed: &embed{}}
+ require.NoError(t, tb.init())
+ assert.InDelta(t, 0.30, tb.rates(prices)[0].Value, 1e-9, "total without charges")
+
+ tb = &Tibber{embed: &embed{
+ ChargesZones_: []chargesZoneConfig{{Charges: -0.07, Hours: "00:00-03:00"}},
+ }}
+ require.NoError(t, tb.init())
+ assert.InDelta(t, 0.20-0.07, tb.rates(prices)[0].Value, 1e-9, "energy with zone charges")
+}
diff --git a/templates/README.md b/templates/README.md
index 147f24f698..7a06f93454 100644
--- a/templates/README.md
+++ b/templates/README.md
@@ -363,7 +363,7 @@ auth:
**Format**: `service-name/endpoint` or `service-name/endpoint?param1={param1}¶m2={param2}`
-Parameters from other params can be referenced using `{param-name}` syntax, which will be replaced with the user's input for that parameter. `{template}` is the name of the selected template. The endpoint will only be called once the user has entered values for all referenced parameters. The endpoint is called every time a referenced parameter value changes.
+Parameters from other params can be referenced using `{param-name}` syntax, which will be replaced with the user's input for that parameter. `{template}` is the name of the selected template. The endpoint is only called once all referenced required parameters have a value. Referenced optional parameters, e.g. `user` or `password`, are replaced with an empty string while empty, so the service must handle missing values. The endpoint is called every time a referenced parameter value changes.
**Response formats**:
diff --git a/templates/definition/charger/homeassistant-switch.yaml b/templates/definition/charger/homeassistant-switch.yaml
index 5f8148e318..783cc96ae5 100644
--- a/templates/definition/charger/homeassistant-switch.yaml
+++ b/templates/definition/charger/homeassistant-switch.yaml
@@ -39,7 +39,7 @@ params:
description:
de: Entity ID für Leistungsmessung
en: Entity ID for power measurement
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=W,kW
example: sensor.smartsocket_power
# custom: power drawn when on, see charger/switchsocket_lm.go
- name: ratedpower
@@ -59,7 +59,7 @@ params:
help:
de: Nur für Heizgeräte
en: Heating devices only
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=°C,°F,K
example: sensor.heatpump_temperature
advanced: true
- name: limittemp
@@ -69,7 +69,7 @@ params:
help:
de: Im Gerät konfigurierte Begrenzung, nur für Heizgeräte
en: Limit configured in the device, heating devices only
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=°C,°F,K
example: sensor.heatpump_target_temperature
advanced: true
- preset: switchsocket
diff --git a/templates/definition/charger/homeassistant.yaml b/templates/definition/charger/homeassistant.yaml
index d582c55ed2..cd411f100f 100644
--- a/templates/definition/charger/homeassistant.yaml
+++ b/templates/definition/charger/homeassistant.yaml
@@ -85,7 +85,7 @@ params:
description:
de: Maximale Stromstärke-Entität [A]
en: Maximum current entity [A]
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=number,input_number
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=number,input_number&unit=A
example: "number.charger_max_current"
required: true
help:
@@ -104,7 +104,7 @@ params:
description:
de: Leistungsentität
en: Power entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=W,kW
example: "sensor.charger_power"
advanced: true
help:
@@ -114,17 +114,27 @@ params:
description:
de: Energieentität
en: Energy entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=Wh,kWh,MWh
example: "sensor.charger_energy"
advanced: true
help:
en: Entity ID for cumulative energy measurement in kWh
de: Entitäts-ID für kumulative Energiemessung in kWh
+ - name: temp
+ description:
+ de: Temperaturentität
+ en: Temperature entity
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor,input_number&unit=°C,°F,K
+ example: "sensor.boiler_temperature"
+ advanced: true
+ help:
+ en: Entity ID for temperature measurement in °C (heating devices)
+ de: Entitäts-ID für Temperaturmessung in °C (Heizgeräte)
- name: currentL1
description:
de: L1 Stromentität
en: L1 current entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=A
example: "sensor.charger_current_l1"
advanced: true
help:
@@ -134,7 +144,7 @@ params:
description:
de: L2 Stromentität
en: L2 current entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=A
example: "sensor.charger_current_l2"
advanced: true
help:
@@ -144,7 +154,7 @@ params:
description:
de: L3 Stromentität
en: L3 current entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=A
example: "sensor.charger_current_l3"
advanced: true
help:
@@ -154,7 +164,7 @@ params:
description:
de: L1 Spannungsentität
en: L1 voltage entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=V
example: "sensor.charger_voltage_l1"
advanced: true
help:
@@ -164,7 +174,7 @@ params:
description:
de: L2 Spannungsentität
en: L2 voltage entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=V
example: "sensor.charger_voltage_l2"
advanced: true
help:
@@ -174,7 +184,7 @@ params:
description:
de: L3 Spannungsentität
en: L3 voltage entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=V
example: "sensor.charger_voltage_l3"
advanced: true
help:
@@ -210,6 +220,7 @@ render: |
milliamps: {{ .milliamps }}
power: {{ .power }}
energy: {{ .energy }}
+ temp: {{ .temp }}
{{- if and .currentL1 .currentL2 .currentL3 }}
currents:
- {{ .currentL1 }}
diff --git a/templates/definition/charger/shelly.yaml b/templates/definition/charger/shelly.yaml
index 7227f46fae..95da9a6f0e 100644
--- a/templates/definition/charger/shelly.yaml
+++ b/templates/definition/charger/shelly.yaml
@@ -17,6 +17,7 @@ params:
- name: password
- name: channel
default: 0
+ service: shelly/channels?uri={host}&user={user}&password={password}
- preset: switchsocket
render: |
type: shelly
diff --git a/templates/definition/charger/tasmota.yaml b/templates/definition/charger/tasmota.yaml
index 9c5eb01925..698b925a2c 100644
--- a/templates/definition/charger/tasmota.yaml
+++ b/templates/definition/charger/tasmota.yaml
@@ -21,6 +21,7 @@ params:
description:
de: Schaltkanal (1-8)
en: Relay channel (1-8)
+ service: tasmota/channels?uri={host}&user={user}&password={password}
- preset: switchsocket
render: |
type: tasmota
diff --git a/templates/definition/circuit/static.yaml b/templates/definition/circuit/static.yaml
index 68904e59ae..0e4c93d97a 100644
--- a/templates/definition/circuit/static.yaml
+++ b/templates/definition/circuit/static.yaml
@@ -16,7 +16,6 @@ params:
de: Maximal erlaubte Gesamtleistung in diesem Stromkreis.
en: Maximum total power allowed in this circuit.
- name: meter
- # service: TODO
- name: parent
description:
de: Übergeordneter Stromkreis
diff --git a/templates/definition/meter/enphase-modbus.yaml b/templates/definition/meter/enphase-modbus.yaml
index 25f46b7fe3..ecb58d33d9 100644
--- a/templates/definition/meter/enphase-modbus.yaml
+++ b/templates/definition/meter/enphase-modbus.yaml
@@ -38,22 +38,28 @@ render: |
- source: sunspec
{{- include "modbus" . | indent 4 }}
value: 701:AL1
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value: 701:AL2
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value: 701:AL3
+ nan: true
voltages:
- source: sunspec
{{- include "modbus" . | indent 4 }}
value: 701:VL1
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value: 701:VL2
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value: 701:VL3
+ nan: true
curtail:
source: sequence
set:
diff --git a/templates/definition/meter/fronius-gen24.yaml b/templates/definition/meter/fronius-gen24.yaml
index a8d6b9d92d..c680301f39 100644
--- a/templates/definition/meter/fronius-gen24.yaml
+++ b/templates/definition/meter/fronius-gen24.yaml
@@ -69,6 +69,7 @@ render: |
- 211:AphA
- 203:AphA
- 213:AphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -76,6 +77,7 @@ render: |
- 211:AphB
- 203:AphB
- 213:AphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -83,6 +85,7 @@ render: |
- 211:AphC
- 203:AphC
- 213:AphC
+ nan: true
voltages:
- source: sunspec
{{- include "modbus" . | indent 4 }}
@@ -91,6 +94,7 @@ render: |
- 211:PhVphA
- 203:PhVphA
- 213:PhVphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -98,6 +102,7 @@ render: |
- 211:PhVphB
- 203:PhVphB
- 213:PhVphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -105,6 +110,7 @@ render: |
- 211:PhVphC
- 203:PhVphC
- 213:PhVphC
+ nan: true
powers:
- source: sunspec
{{- include "modbus" . | indent 4 }}
@@ -113,6 +119,7 @@ render: |
- 211:WphA
- 203:WphA
- 213:WphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -120,6 +127,7 @@ render: |
- 211:WphB
- 203:WphB
- 213:WphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -127,6 +135,7 @@ render: |
- 211:WphC
- 203:WphC
- 213:WphC
+ nan: true
{{- end }}
{{- if eq .usage "pv" }}
power:
@@ -136,10 +145,12 @@ render: |
{{- include "modbus-connection" . | indent 4 }}
id: 1
value: 160:1:DCW # mppt 1
+ nan: true
- source: sunspec
{{- include "modbus-connection" . | indent 4 }}
id: 1
value: 160:2:DCW # mppt 2
+ nan: true
energy:
source: calc
add:
@@ -212,11 +223,13 @@ render: |
{{- include "modbus-connection" . | indent 4 }}
id: 1
value: 160:3:DCW # mppt 3 charge
+ nan: true
scale: -1
- source: sunspec
{{- include "modbus-connection" . | indent 4 }}
id: 1
value: 160:4:DCW # mppt 4 discharge
+ nan: true
energy:
source: sunspec
{{- include "modbus-connection" . | indent 2 }}
@@ -255,6 +268,13 @@ render: |
{{- include "modbus-connection" . | indent 10 }}
id: 1
value: 124:0:OutWRte
+ - source: const
+ value: 100 # %
+ set:
+ source: sunspec
+ uri: {{ joinHostPort .host .port }}
+ id: 1
+ value: 124:0:InWRte
- case: 2 # hold
set:
source: sequence
@@ -316,5 +336,37 @@ render: |
{{- include "modbus-connection" . | indent 10 }}
id: 1
value: 124:0:InWRte
+ - case: 5 # discharge
+ set:
+ source: sequence
+ set:
+ - source: const
+ value: 0
+ set:
+ source: sunspec
+ uri: {{ joinHostPort .host .port }}
+ id: 1
+ value: 124:0:StorCtl_Mod
+ - source: const
+ value: -100 # %
+ set:
+ source: sunspec
+ uri: {{ joinHostPort .host .port }}
+ id: 1
+ value: 124:0:InWRte
+ - source: const
+ value: 100 # %
+ set:
+ source: sunspec
+ uri: {{ joinHostPort .host .port }}
+ id: 1
+ value: 124:0:OutWRte
+ - source: const
+ value: 3
+ set:
+ source: sunspec
+ uri: {{ joinHostPort .host .port }}
+ id: 1
+ value: 124:0:StorCtl_Mod
{{- include "battery-params" . }}
{{- end }}
diff --git a/templates/definition/meter/fronius-vertoplus.yaml b/templates/definition/meter/fronius-vertoplus.yaml
index f37d13fbff..2e6af64a05 100644
--- a/templates/definition/meter/fronius-vertoplus.yaml
+++ b/templates/definition/meter/fronius-vertoplus.yaml
@@ -60,6 +60,7 @@ render: |
- 211:AphA
- 203:AphA
- 213:AphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -67,6 +68,7 @@ render: |
- 211:AphB
- 203:AphB
- 213:AphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -74,6 +76,7 @@ render: |
- 211:AphC
- 203:AphC
- 213:AphC
+ nan: true
voltages:
- source: sunspec
{{- include "modbus" . | indent 4 }}
@@ -82,6 +85,7 @@ render: |
- 211:PhVphA
- 203:PhVphA
- 213:PhVphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -89,6 +93,7 @@ render: |
- 211:PhVphB
- 203:PhVphB
- 213:PhVphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -96,6 +101,7 @@ render: |
- 211:PhVphC
- 203:PhVphC
- 213:PhVphC
+ nan: true
powers:
- source: sunspec
{{- include "modbus" . | indent 4 }}
@@ -104,6 +110,7 @@ render: |
- 211:WphA
- 203:WphA
- 213:WphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -111,6 +118,7 @@ render: |
- 211:WphB
- 203:WphB
- 213:WphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -118,6 +126,7 @@ render: |
- 211:WphC
- 203:WphC
- 213:WphC
+ nan: true
{{- end }}
{{- if eq .usage "pv" }}
power:
@@ -127,14 +136,17 @@ render: |
{{- include "modbus-connection" . | indent 4 }}
id: 1
value: 160:1:DCW # mppt 1
+ nan: true
- source: sunspec
{{- include "modbus-connection" . | indent 4 }}
id: 1
value: 160:2:DCW # mppt 2
+ nan: true
- source: sunspec
{{- include "modbus-connection" . | indent 4 }}
id: 1
value: 160:3:DCW # mppt 3
+ nan: true
energy:
source: calc
add:
@@ -212,11 +224,13 @@ render: |
{{- include "modbus-connection" . | indent 4 }}
id: 1
value: 160:4:DCW # mppt 4 charge
+ nan: true
scale: -1
- source: sunspec
{{- include "modbus-connection" . | indent 4 }}
id: 1
value: 160:5:DCW # mppt 5 discharge
+ nan: true
energy:
source: sunspec
{{- include "modbus-connection" . | indent 2 }}
diff --git a/templates/definition/meter/homeassistant.yaml b/templates/definition/meter/homeassistant.yaml
index b3a474680b..7b96098ea4 100644
--- a/templates/definition/meter/homeassistant.yaml
+++ b/templates/definition/meter/homeassistant.yaml
@@ -31,7 +31,7 @@ params:
de: Leistungsentität
en: Power Entity
required: true
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=W,kW
example: "sensor.house_power"
help:
en: Entity ID for instantaneous power measurement in watts. The entity must provide numeric values only (e.g., "1234", not "1234 W").
@@ -40,7 +40,7 @@ params:
description:
de: Energieentität
en: Energy Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=Wh,kWh,MWh
example: "sensor.house_energy"
advanced: true
help:
@@ -50,7 +50,7 @@ params:
description:
de: Energieentität (Gegenrichtung)
en: Return Energy Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=Wh,kWh,MWh
example: "sensor.house_return_energy"
advanced: true
help:
@@ -60,7 +60,7 @@ params:
description:
de: L1 Stromentität
en: L1 Current Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=A
example: "sensor.house_current_l1"
advanced: true
help:
@@ -70,7 +70,7 @@ params:
description:
de: L2 Stromentität
en: L2 Current Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=A
example: "sensor.house_current_l2"
advanced: true
help:
@@ -80,7 +80,7 @@ params:
description:
de: L3 Stromentität
en: L3 Current Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=A
example: "sensor.house_current_l3"
advanced: true
help:
@@ -90,7 +90,7 @@ params:
description:
de: L1 Spannungsentität
en: L1 Voltage Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=V
example: "sensor.house_voltage_l1"
advanced: true
help:
@@ -100,7 +100,7 @@ params:
description:
de: L2 Spannungsentität
en: L2 Voltage Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=V
example: "sensor.house_voltage_l2"
advanced: true
help:
@@ -110,7 +110,7 @@ params:
description:
de: L3 Spannungsentität
en: L3 Voltage Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=V
example: "sensor.house_voltage_l3"
advanced: true
help:
@@ -121,7 +121,7 @@ params:
description:
de: Batterieladestand
en: Battery State of Charge
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=%25
example: "sensor.battery_soc"
advanced: true
help:
@@ -165,7 +165,7 @@ params:
description:
de: Minimaler Ladestand (Entität)
en: Minimum SoC Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor,number,input_number
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor,number,input_number&unit=%25
example: "number.battery_min_soc"
advanced: true
help:
@@ -176,7 +176,7 @@ params:
description:
de: Maximaler Ladestand (Entität)
en: Maximum SoC Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor,number,input_number
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor,number,input_number&unit=%25
example: "number.battery_max_soc"
advanced: true
help:
diff --git a/templates/definition/meter/iobroker.yaml b/templates/definition/meter/iobroker.yaml
index ef07bbb697..7d759c0d3a 100644
--- a/templates/definition/meter/iobroker.yaml
+++ b/templates/definition/meter/iobroker.yaml
@@ -69,14 +69,13 @@ params:
type: string
advanced: true
- preset: battery-params
- - name: capacity
- - name: minsoc
- - name: maxsoc
- - name: maxchargepower
- - name: maxdischargepower
- name: maxacpower
render: |
type: custom
+ maxacpower: {{ .maxacpower }} # W
+ {{- if eq .usage "battery" }}
+ {{- include "battery-params" . }}
+ {{- end }}
{{- if .power }}
power:
source: http
diff --git a/templates/definition/meter/keba-kecontact.yaml b/templates/definition/meter/keba-kecontact.yaml
index d2b8692696..14c456353d 100644
--- a/templates/definition/meter/keba-kecontact.yaml
+++ b/templates/definition/meter/keba-kecontact.yaml
@@ -41,45 +41,54 @@ render: |
value:
- 203:AphA
- 213:AphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:AphB
- 213:AphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:AphC
- 213:AphC
+ nan: true
voltages:
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:PhVphA
- 213:PhVphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:PhVphB
- 213:PhVphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:PhVphC
- 213:PhVphC
+ nan: true
powers:
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:WphA
- 213:WphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:WphB
- 213:WphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:WphC
- 213:WphC
+ nan: true
diff --git a/templates/definition/meter/kostal-ksem.yaml b/templates/definition/meter/kostal-ksem.yaml
index e5522a8631..f8aef3a29b 100644
--- a/templates/definition/meter/kostal-ksem.yaml
+++ b/templates/definition/meter/kostal-ksem.yaml
@@ -40,45 +40,54 @@ render: |
value:
- 203:AphA
- 213:AphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:AphB
- 213:AphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:AphC
- 213:AphC
+ nan: true
voltages:
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:PhVphA
- 213:PhVphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:PhVphB
- 213:PhVphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:PhVphC
- 213:PhVphC
+ nan: true
powers:
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:WphA
- 213:WphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:WphB
- 213:WphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:WphC
- 213:WphC
+ nan: true
diff --git a/templates/definition/meter/kostal-plenticore-gen2.yaml b/templates/definition/meter/kostal-plenticore-gen2.yaml
index 1b95e4e6cc..0e66e7143e 100644
--- a/templates/definition/meter/kostal-plenticore-gen2.yaml
+++ b/templates/definition/meter/kostal-plenticore-gen2.yaml
@@ -37,7 +37,7 @@ params:
default: little
advanced: true
- name: maxacpower
- deprecated: true # read from nameplate
+ service: modbus/read?address=531&type=holding&encoding=uint16&{modbus}
- preset: battery-params
- name: capacity
service: modbus/read?address=1068&type=holding&encoding=float32s&scale=0.001&{modbus}
@@ -62,12 +62,15 @@ render: |
- source: sunspec
{{- include "modbus" . | indent 4 }}
value: 160:1:DCW # string 1
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value: 160:2:DCW # string 2
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value: 160:3:DCW # string 3
+ nan: true
energy:
source: modbus
{{- include "modbus" . | indent 2 }}
@@ -76,7 +79,7 @@ render: |
type: holding
encoding: {{ if (eq .endianness "big") }}float32{{ else }}float32s{{ end }}
scale: 0.001 # Wh -> kWh
- {{- include "sunspec-maxacpower" . }}
+ maxacpower: {{ .maxacpower }} # W
# feed-in curtailment via active power limit (SunSpec model 123, WMaxLimPct @ 40217).
# written as a single register (fn 06); the Plenticore rejects write multiple (fn 16) here.
# WMaxLim_Ena (register 40221) is enabled by default and left to the inverter
diff --git a/templates/definition/meter/marstek-batterymode.tpl b/templates/definition/meter/marstek-batterymode.tpl
index 02ef38a45a..f39a19fecd 100644
--- a/templates/definition/meter/marstek-batterymode.tpl
+++ b/templates/definition/meter/marstek-batterymode.tpl
@@ -16,6 +16,16 @@ batterymode:
address: 44002 # Max Charge Power
type: writesingle
decode: uint16
+ # Restore Max Discharge Power
+ - source: const
+ value: {{ .maxdischargepower }}
+ set:
+ source: modbus
+ {{- include "modbus" . | indent 10 }}
+ register:
+ address: 44003 # Max Discharge Power
+ type: writesingle
+ decode: uint16
# Enable RS485 Control Mode
- source: const
value: 21930
@@ -46,10 +56,30 @@ batterymode:
address: 42000 # RS485 Control Mode = Disabled
type: writesingle
decode: uint16
- - case: 2 # hold
+ - case: 2 # hold -> no discharging, charging allowed
set:
source: sequence
set:
+ # Restore Max Charge Power
+ - source: const
+ value: {{ .maxchargepower }}
+ set:
+ source: modbus
+ {{- include "modbus" . | indent 10 }}
+ register:
+ address: 44002 # Max Charge Power
+ type: writesingle
+ decode: uint16
+ # Set Max Discharge Power to 0 to prevent discharging
+ - source: const
+ value: 0
+ set:
+ source: modbus
+ {{- include "modbus" . | indent 10 }}
+ register:
+ address: 44003 # Max Discharge Power
+ type: writesingle
+ decode: uint16
# Enable RS485 Control Mode
- source: const
value: 21930
@@ -60,21 +90,40 @@ batterymode:
address: 42000 # RS485 Control Mode = Enabled
type: writesingle
decode: uint16
- # Set Force Charge/Discharge to Stop
+ # Set User Work Mode
- source: const
- value: 0
+ value: {{ .work_mode_normal }}
set:
source: modbus
{{- include "modbus" . | indent 10 }}
register:
- address: 42010 # Force Charge/Discharge = Stop
+ address: 43000 # User Work Mode
+ type: writesingle
+ decode: uint16
+ # Disable RS485 Control Mode
+ - source: const
+ value: 21947
+ set:
+ source: modbus
+ {{- include "modbus" . | indent 10 }}
+ register:
+ address: 42000 # RS485 Control Mode = Disabled
type: writesingle
decode: uint16
- # Do not disable RS485 Control Mode because it will reset the device and let it charge/discharge again
- case: 3 # charge
set:
source: sequence
set:
+ # Restore Max Charge Power
+ - source: const
+ value: {{ .maxchargepower }}
+ set:
+ source: modbus
+ {{- include "modbus" . | indent 10 }}
+ register:
+ address: 44002 # Max Charge Power
+ type: writesingle
+ decode: uint16
# Enable RS485 Control Mode
- source: const
value: 21930
@@ -120,6 +169,16 @@ batterymode:
address: 44002 # Max Charge Power
type: writesingle
decode: uint16
+ # Restore Max Discharge Power
+ - source: const
+ value: {{ .maxdischargepower }}
+ set:
+ source: modbus
+ {{- include "modbus" . | indent 10 }}
+ register:
+ address: 44003 # Max Discharge Power
+ type: writesingle
+ decode: uint16
# Enable RS485 Control Mode
- source: const
value: 21930
@@ -155,6 +214,16 @@ batterymode:
set:
source: sequence
set:
+ # Restore Max Discharge Power
+ - source: const
+ value: {{ .maxdischargepower }}
+ set:
+ source: modbus
+ {{- include "modbus" . | indent 10 }}
+ register:
+ address: 44003 # Max Discharge Power
+ type: writesingle
+ decode: uint16
# Enable RS485 Control Mode
- source: const
value: 21930
diff --git a/templates/definition/meter/senec-connect.yaml b/templates/definition/meter/senec-connect.yaml
index ffad1bf651..0cc04d06e7 100644
--- a/templates/definition/meter/senec-connect.yaml
+++ b/templates/definition/meter/senec-connect.yaml
@@ -3,6 +3,9 @@ products:
- brand: SENEC
description:
generic: .Connect (Cloud)
+ - brand: SENEC
+ description:
+ generic: .Home V4
- brand: SENEC
description:
generic: .Home P4
diff --git a/templates/definition/meter/sessy-smart-battery.yaml b/templates/definition/meter/sessy-smart-battery.yaml
index c70fc6af18..2369a7041b 100644
--- a/templates/definition/meter/sessy-smart-battery.yaml
+++ b/templates/definition/meter/sessy-smart-battery.yaml
@@ -158,4 +158,4 @@ render: |
headers:
- content-type: application/json
body: '{"setpoint": -2200}'
- capacity: {{ .capacity }}
+ {{- include "battery-params" . }}
diff --git a/templates/definition/meter/shelly-1pm.yaml b/templates/definition/meter/shelly-1pm.yaml
index f36a109159..9f8be855ac 100644
--- a/templates/definition/meter/shelly-1pm.yaml
+++ b/templates/definition/meter/shelly-1pm.yaml
@@ -21,6 +21,7 @@ params:
- name: user
- name: password
- name: channel
+ service: shelly/channels?uri={host}&user={user}&password={password}&metered=1
render: |
type: shelly
uri: http://{{ .host }} # shelly device ip address (local)
diff --git a/templates/definition/meter/solaredge-hybrid.yaml b/templates/definition/meter/solaredge-hybrid.yaml
index 5a2a7ae31c..72188aee7a 100644
--- a/templates/definition/meter/solaredge-hybrid.yaml
+++ b/templates/definition/meter/solaredge-hybrid.yaml
@@ -60,42 +60,51 @@ render: |
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:AphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:AphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:AphC
+ nan: true
voltages:
- source: sunspec
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:PhVphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:PhVphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:PhVphC
+ nan: true
powers:
- source: sunspec
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:WphA
+ nan: true
scale: -1
- source: sunspec
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:WphB
+ nan: true
scale: -1
- source: sunspec
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:WphC
+ nan: true
scale: -1
{{- end }}
{{- if eq .usage "pv" }}
diff --git a/templates/definition/meter/solaredge-inverter.yaml b/templates/definition/meter/solaredge-inverter.yaml
index eff5909576..dc6b7b7640 100644
--- a/templates/definition/meter/solaredge-inverter.yaml
+++ b/templates/definition/meter/solaredge-inverter.yaml
@@ -48,42 +48,51 @@ render: |
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:AphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:AphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:AphC
+ nan: true
voltages:
- source: sunspec
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:PhVphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:PhVphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:PhVphC
+ nan: true
powers:
- source: sunspec
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:WphA
+ nan: true
scale: -1
- source: sunspec
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:WphB
+ nan: true
scale: -1
- source: sunspec
{{- include "modbus" . | indent 4 }}
subdevice: 1 # Metering device
value: 203:WphC
+ nan: true
scale: -1
{{- end }}
{{- if eq .usage "pv" }}
@@ -106,16 +115,19 @@ render: |
value:
- 101:AphA
- 103:AphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 101:AphB
- 103:AphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 101:AphC
- 103:AphC
+ nan: true
curtail:
source: sequence
set:
diff --git a/templates/definition/meter/sunspec-hybrid-curtailable.yaml b/templates/definition/meter/sunspec-hybrid-curtailable.yaml
index a4a8cdc25a..7df813c899 100644
--- a/templates/definition/meter/sunspec-hybrid-curtailable.yaml
+++ b/templates/definition/meter/sunspec-hybrid-curtailable.yaml
@@ -21,9 +21,11 @@ render: |
- source: sunspec
{{- include "modbus" . | indent 6 }}
value: 160:1:DCW # mppt 1
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 6 }}
value: 160:2:DCW # mppt 2
+ nan: true
energy:
source: calc
add:
diff --git a/templates/definition/meter/sunspec-hybrid.yaml b/templates/definition/meter/sunspec-hybrid.yaml
index beff620862..f3a0ae1615 100644
--- a/templates/definition/meter/sunspec-hybrid.yaml
+++ b/templates/definition/meter/sunspec-hybrid.yaml
@@ -43,48 +43,57 @@ render: |
value:
- 203:AphA
- 213:AphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:AphB
- 213:AphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:AphC
- 213:AphC
+ nan: true
voltages:
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:PhVphA
- 213:PhVphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:PhVphB
- 213:PhVphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:PhVphC
- 213:PhVphC
+ nan: true
powers:
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:WphA
- 213:WphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:WphB
- 213:WphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
- 203:WphC
- 213:WphC
+ nan: true
{{- end }}
{{- if eq .usage "pv" }}
power:
@@ -93,9 +102,11 @@ render: |
- source: sunspec
{{- include "modbus" . | indent 6 }}
value: 160:1:DCW # mppt 1
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 6 }}
value: 160:2:DCW # mppt 2
+ nan: true
energy:
source: calc
add:
@@ -116,10 +127,12 @@ render: |
- source: sunspec
{{- include "modbus" . | indent 6 }}
value: 160:3:DCW # mppt 3 (charge)
+ nan: true
scale: -1
- source: sunspec
{{- include "modbus" . | indent 6 }}
value: 160:4:DCW # mppt 4 (discharge)
+ nan: true
energy:
source: sunspec
{{- include "modbus" . | indent 2 }}
diff --git a/templates/definition/meter/sunspec-inverter-control.yaml b/templates/definition/meter/sunspec-inverter-control.yaml
index 5238bd4436..6120da560f 100644
--- a/templates/definition/meter/sunspec-inverter-control.yaml
+++ b/templates/definition/meter/sunspec-inverter-control.yaml
@@ -21,10 +21,12 @@ render: |
- source: sunspec
{{- include "modbus" . | indent 4 }}
value: 160:3:DCW # mppt 3 (charge)
+ nan: true
scale: -1
- source: sunspec
{{- include "modbus" . | indent 4 }}
value: 160:4:DCW # mppt 4 (discharge)
+ nan: true
energy:
source: sunspec
{{- include "modbus" . | indent 2 }}
diff --git a/templates/definition/meter/sunspec-inverter.yaml b/templates/definition/meter/sunspec-inverter.yaml
index 44b57f50cc..c764c515b1 100644
--- a/templates/definition/meter/sunspec-inverter.yaml
+++ b/templates/definition/meter/sunspec-inverter.yaml
@@ -56,6 +56,7 @@ render: |
- 212:AphA
- 203:AphA
- 213:AphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -65,6 +66,7 @@ render: |
- 212:AphB
- 203:AphB
- 213:AphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -74,6 +76,7 @@ render: |
- 212:AphC
- 203:AphC
- 213:AphC
+ nan: true
voltages:
- source: sunspec
{{- include "modbus" . | indent 4 }}
@@ -84,6 +87,7 @@ render: |
- 212:PhVphA
- 203:PhVphA
- 213:PhVphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -93,6 +97,7 @@ render: |
- 212:PhVphB
- 203:PhVphB
- 213:PhVphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -102,6 +107,7 @@ render: |
- 212:PhVphC
- 203:PhVphC
- 213:PhVphC
+ nan: true
powers:
- source: sunspec
{{- include "modbus" . | indent 4 }}
@@ -112,6 +118,7 @@ render: |
- 212:WphA
- 203:WphA
- 213:WphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -121,6 +128,7 @@ render: |
- 212:WphB
- 203:WphB
- 213:WphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -130,6 +138,7 @@ render: |
- 212:WphC
- 203:WphC
- 213:WphC
+ nan: true
{{- end }}
{{- if eq .usage "pv" }}
power:
diff --git a/templates/definition/meter/sunspec-meter.yaml b/templates/definition/meter/sunspec-meter.yaml
index af0411d9a6..60cb417d63 100644
--- a/templates/definition/meter/sunspec-meter.yaml
+++ b/templates/definition/meter/sunspec-meter.yaml
@@ -61,6 +61,7 @@ render: |
- 212:AphA
- 203:AphA
- 213:AphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -70,6 +71,7 @@ render: |
- 212:AphB
- 203:AphB
- 213:AphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -79,6 +81,7 @@ render: |
- 212:AphC
- 203:AphC
- 213:AphC
+ nan: true
voltages:
- source: sunspec
{{- include "modbus" . | indent 4 }}
@@ -89,6 +92,7 @@ render: |
- 212:PhVphA
- 203:PhVphA
- 213:PhVphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -98,6 +102,7 @@ render: |
- 212:PhVphB
- 203:PhVphB
- 213:PhVphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -107,6 +112,7 @@ render: |
- 212:PhVphC
- 203:PhVphC
- 213:PhVphC
+ nan: true
powers:
- source: sunspec
{{- include "modbus" . | indent 4 }}
@@ -117,6 +123,7 @@ render: |
- 212:WphA
- 203:WphA
- 213:WphA
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -126,6 +133,7 @@ render: |
- 212:WphB
- 203:WphB
- 213:WphB
+ nan: true
- source: sunspec
{{- include "modbus" . | indent 4 }}
value:
@@ -135,6 +143,7 @@ render: |
- 212:WphC
- 203:WphC
- 213:WphC
+ nan: true
{{- end }}
{{- if eq .usage "pv" }}
power:
diff --git a/templates/definition/meter/tasmota.yaml b/templates/definition/meter/tasmota.yaml
index 844e9e5526..7fe46b29b6 100644
--- a/templates/definition/meter/tasmota.yaml
+++ b/templates/definition/meter/tasmota.yaml
@@ -21,6 +21,7 @@ params:
description:
de: Messkanal (1-8)
en: Meter channel (1-8)
+ service: tasmota/channels?uri={host}&user={user}&password={password}&meter=true
render: |
type: tasmota
uri: http://{{ .host }}
diff --git a/templates/definition/meter/victron-vrm.yaml b/templates/definition/meter/victron-vrm.yaml
index 072a3eb8d1..bf1ff0e20f 100644
--- a/templates/definition/meter/victron-vrm.yaml
+++ b/templates/definition/meter/victron-vrm.yaml
@@ -51,3 +51,4 @@ render: |
- content-type: application/json
- x-authorization: Token {{ .token }}
jq: .records.data["51"].valueFloat
+ {{- include "battery-params" . }}
diff --git a/templates/definition/tariff/amber.yaml b/templates/definition/tariff/amber.yaml
index a20c6e3ee6..cf9c17c627 100644
--- a/templates/definition/tariff/amber.yaml
+++ b/templates/definition/tariff/amber.yaml
@@ -8,8 +8,6 @@ params:
- name: token
required: true
- name: siteid
- description:
- generic: Site ID
required: true
- name: channel
type: choice
diff --git a/templates/definition/tariff/pvnode-v2-temperature.yaml b/templates/definition/tariff/pvnode-v2-temperature.yaml
new file mode 100644
index 0000000000..e52f4ef6a4
--- /dev/null
+++ b/templates/definition/tariff/pvnode-v2-temperature.yaml
@@ -0,0 +1,62 @@
+template: pvnode-v2-temperature
+link: https://pvnode.com
+products:
+ - brand: pvnode V2 API Temperature
+requirements:
+ description:
+ en: |
+ [pvnode V2 API](https://pvnode.com) provides outdoor temperature forecasts in 15-minute intervals via REST API Version 2 with site ID and easy configuration via web app.
+ An API key is required (free plan available).
+ **Attention**: The free plan only allows one fixed site with forecasts for today & tomorrow and only one update per day and 250 cached queries.
+ de: |
+ [pvnode V2 API](https://pvnode.com) liefert Außentemperaturdaten in 15-Minuten-Intervallen per REST API Version 2 mit Standort-ID und leichter Konfiguration über Web-App
+ Ein API-Key ist erforderlich (kostenloser Plan möglich)
+ **Achtung**: Mit dem kostenlosen Plan ist nur ein Standort (fest) möglich mit einer Prognose für Heute & Morgen und nur einem Update pro Tag und 250 gecached Abfragen.
+ evcc: ["skiptest"]
+group: temperature
+
+params:
+ - name: apikey
+ required: true
+ - name: siteid
+ required: true
+ service: http/get?uri=https://api.pvnode.com/v2/sites/&token={apikey}&jq=.&value=.id&label=.name
+ - name: forecast_days
+ description:
+ en: Forecast days
+ de: Vorhersagetage
+ help:
+ en: The free plan of pvnode only allows 1 forecast day (1 = today + tomorrow).
+ de: Der kostenlose Plan von pvnode erlaubt nur 1 Vorhersagetag (1 = heute + morgen).
+ type: int
+ default: 1
+ advanced: true
+ - name: interval
+ default: 24h
+ advanced: true
+
+render: |
+ type: custom
+ tariff: temperature
+ features: ["cacheable"]
+ forecast:
+ source: http
+ uri: https://api.pvnode.com/v2/forecast/{{ .siteid }}?include=weather&forecast_days={{ .forecast_days }}&past_days=0&timezone=utc
+ auth:
+ type: bearer
+ token: {{ .apikey }}
+ jq: |
+ # values are the average temperature, measured 2 meters above ground, of the
+ # 15min interval ending at the timestamp,
+ # per https://github.com/evcc-io/evcc/pull/33102#issuecomment-5392639519.
+ # evcc expects the temperature at the timestamp, which is the mean of the intervals
+ # meeting there- the trailing interval is dropped as its successor is unknown.
+ [.values[] | {t: (.timestamp | fromdateiso8601), v: .temp}] as $s
+ | [ range(0; ($s|length)-1) |
+ {
+ start: ($s[.].t | todateiso8601),
+ end: ($s[.].t + 900 | todateiso8601),
+ value: (($s[.].v + $s[.+1].v) / 2)
+ }
+ ] | tostring
+ interval: {{ .interval }}
diff --git a/templates/definition/tariff/pvnode-v2.yaml b/templates/definition/tariff/pvnode-v2.yaml
index f7df4cc6e1..737b88e854 100644
--- a/templates/definition/tariff/pvnode-v2.yaml
+++ b/templates/definition/tariff/pvnode-v2.yaml
@@ -26,7 +26,7 @@ params:
en: Site ID
de: Standort-ID
required: true
- service: http/get?uri=https://api.pvnode.com/v2/sites/&token={apikey}&jq=[.[].id]
+ service: http/get?uri=https://api.pvnode.com/v2/sites/&token={apikey}&jq=.&value=.id&label=.name
- name: forecast_days
description:
en: Forecast days
diff --git a/templates/definition/vehicle/homeassistant.yaml b/templates/definition/vehicle/homeassistant.yaml
index 779447468c..b55d750dd0 100644
--- a/templates/definition/vehicle/homeassistant.yaml
+++ b/templates/definition/vehicle/homeassistant.yaml
@@ -30,7 +30,7 @@ params:
description:
de: Ladezustand [%]
en: State of charge [%]
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=%25
example: "sensor.vehicle_soc"
required: true
help:
@@ -40,7 +40,7 @@ params:
description:
de: Restreichweite [km]
en: Remaining range [km]
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=km
example: "sensor.vehicle_range"
help:
en: Entity ID for the vehicle's remaining range in kilometers
@@ -85,7 +85,7 @@ params:
description:
de: Ziel-Ladezustand [%]
en: Target state of charge [%]
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=number,input_number
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=number,input_number&unit=%25
example: "number.vehicle_target_state_of_charge"
help:
en: Entity ID for the vehicle's target state of charge in percent (`number` or `input_number` entity)
@@ -94,7 +94,7 @@ params:
description:
de: Kilometerstand [km]
en: Odometer [km]
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=km
example: "sensor.vehicle_odometer"
help:
en: Entity ID for the vehicle's odometer reading in kilometers
@@ -141,7 +141,7 @@ params:
description:
de: Ladestromstärke setzen [A]
en: Set charging current [A]
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=number,input_number
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=number,input_number&unit=A
example: "number.vehicle_charging_current"
help:
en: Entity ID for setting the maximum charging current in amperes (`number` or `input_number` entity)
diff --git a/tests/battery-multi.evcc.yaml b/tests/battery-multi.evcc.yaml
index 9bd0dfb94a..e09fd9ff0b 100644
--- a/tests/battery-multi.evcc.yaml
+++ b/tests/battery-multi.evcc.yaml
@@ -29,7 +29,7 @@ meters:
source: const
value: 76
capacity: 13.5
- batterymodes: ["normal", "hold", "charge", "holdcharge"]
+ batterymodes: ["normal", "hold", "charge", "holdcharge", "discharge"]
batterymode:
source: js
vm: shared
diff --git a/tests/battery-settings.spec.ts b/tests/battery-settings.spec.ts
index 733c8ccdcf..ace6c1286e 100644
--- a/tests/battery-settings.spec.ts
+++ b/tests/battery-settings.spec.ts
@@ -87,6 +87,56 @@ test.describe("battery settings", async () => {
).toBeVisible();
});
+ test("optimizer automatic mode", async ({ page }) => {
+ await page.goto("/#/battery");
+
+ const enableLimit = page.getByRole("switch", { name: "Enable limit" });
+ const priceLimit = page.getByRole("combobox", { name: "Price limit" });
+ const dischargeControl = page.getByRole("switch", { name: /Prevent home battery discharge/ });
+ const optimizerHints = page.getByText(
+ "Disabled by automatic control. The optimizer decides instead.",
+ {
+ exact: true,
+ }
+ );
+
+ await expect(enableLimit).toBeEnabled();
+ await expect(priceLimit).toBeEnabled();
+ await expect(dischargeControl).toBeEnabled();
+ await expect(optimizerHints).toHaveCount(0);
+
+ await expect(await page.request.post("/api/config/optimizerautomatic/full")).toBeOK();
+
+ await expect(enableLimit).toBeDisabled();
+ await expect(priceLimit).toBeDisabled();
+ await expect(dischargeControl).toBeDisabled();
+ await expect(optimizerHints).toHaveCount(2);
+ await expect(optimizerHints.first()).toBeVisible();
+ await expect(optimizerHints.last()).toBeVisible();
+ await expect(page.getByTestId("battery-priority").getByRole("combobox")).toBeEnabled();
+ await expect(page.getByTestId("battery-buffer").getByRole("combobox").first()).toBeEnabled();
+
+ // battery only level takes over the battery settings as well
+ await expect(await page.request.post("/api/config/optimizerautomatic/battery")).toBeOK();
+ await expect(enableLimit).toBeDisabled();
+ await expect(dischargeControl).toBeDisabled();
+ await expect(optimizerHints).toHaveCount(2);
+
+ await expect(await page.request.post("/api/config/optimizerautomatic/off")).toBeOK();
+
+ await expect(enableLimit).toBeEnabled();
+ await expect(priceLimit).toBeEnabled();
+ await expect(dischargeControl).toBeEnabled();
+ await expect(optimizerHints).toHaveCount(0);
+
+ // disabling the optimizer releases automatic mode
+ await expect(await page.request.post("/api/config/optimizerautomatic/full")).toBeOK();
+ await expect(enableLimit).toBeDisabled();
+ await expect(await page.request.post("/api/config/optimizer/false")).toBeOK();
+ await expect(enableLimit).toBeEnabled();
+ await expect(optimizerHints).toHaveCount(0);
+ });
+
test("hold mode display", async ({ page }) => {
await page.goto("/");
await page.getByTestId("energyflow").click();
diff --git a/tests/config-loadpoint.spec.ts b/tests/config-loadpoint.spec.ts
index a2d346eb3d..9f444aa81f 100644
--- a/tests/config-loadpoint.spec.ts
+++ b/tests/config-loadpoint.spec.ts
@@ -53,6 +53,7 @@ test.describe("charging loadpoint", async () => {
await lpModal.getByRole("link", { name: "Advanced configuration" }).click();
await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport");
+ await expect(lpModal.getByLabel("Title")).not.toHaveAttribute("placeholder");
// verify defaults
await expect(lpModal.getByLabel("Default mode")).toHaveValue("");
// mode
diff --git a/tests/config-param-service-demo.tpl.yaml b/tests/config-param-service-demo.tpl.yaml
index 3e92678e28..9f970e9ea2 100644
--- a/tests/config-param-service-demo.tpl.yaml
+++ b/tests/config-param-service-demo.tpl.yaml
@@ -32,6 +32,12 @@ params:
generic: Required address
required: true
service: demo/options
+ - name: user
+ - name: credentials
+ description:
+ generic: Credentials
+ required: true
+ service: demo/echo?user={user}
render: |
type: custom
diff --git a/tests/config-param-service.spec.ts b/tests/config-param-service.spec.ts
index 0a054e5b4f..08e5f4146c 100644
--- a/tests/config-param-service.spec.ts
+++ b/tests/config-param-service.spec.ts
@@ -65,6 +65,18 @@ test.describe("config param service", async () => {
await expectDatalistOptions(city, []);
});
+ test("optional placeholders", async ({ page }) => {
+ const meterModal = await openMeterModal(page);
+ const credentials = meterModal.getByLabel("Credentials");
+
+ // empty optional params are sent as empty strings
+ await expect(credentials).toHaveValue("user=");
+
+ await credentials.fill("");
+ await meterModal.getByLabel("Username").fill("admin");
+ await expect(credentials).toHaveValue("user=admin");
+ });
+
test("auto-apply single service value", async ({ page }) => {
const meterModal = await openMeterModal(page);
diff --git a/tests/optimize.spec.ts b/tests/optimize.spec.ts
index 337b24229b..a89c6441a5 100644
--- a/tests/optimize.spec.ts
+++ b/tests/optimize.spec.ts
@@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
-import { openMoreMenu } from "./utils";
+import { expectModalVisible, openMoreMenu } from "./utils";
const CONFIG = "basics.evcc.yaml";
const SQL = "optimize.sql";
@@ -30,4 +30,17 @@ test.describe("optimize page", async () => {
await page.getByRole("link", { name: "Check logs" }).click();
await expect(page.getByTestId("log-search")).toHaveValue("optimizer");
});
+
+ test("disabled state", async ({ page }) => {
+ await page.goto("/#/optimize");
+ await expect(page.getByTestId("optimize-empty")).toBeVisible();
+
+ await expect(await page.request.post("/api/config/optimizer/false")).toBeOK();
+ await expect(page.getByText("The optimizer is disabled.")).toBeVisible();
+
+ // settings open in place, the page stays reachable without its menu entry
+ await page.getByRole("button", { name: "Open settings" }).click();
+ await expectModalVisible(page.getByTestId("optimizer-modal"));
+ await expect(page).toHaveURL("/#/optimize");
+ });
});
diff --git a/util/homeassistant/connection.go b/util/homeassistant/connection.go
index cac309d35f..553061acae 100644
--- a/util/homeassistant/connection.go
+++ b/util/homeassistant/connection.go
@@ -135,6 +135,27 @@ func (c *Connection) GetFloatState(entity string) (float64, error) {
return scale * value, nil
}
+// GetTemperatureState retrieves the state of an entity as temperature in °C.
+// Fahrenheit and Kelvin readings are converted according to the entity's unit.
+func (c *Connection) GetTemperatureState(entity string) (float64, error) {
+ state, err := c.GetState(entity)
+ if err != nil {
+ return 0, err
+ }
+
+ value, err := strconv.ParseFloat(state.State, 64)
+ if err != nil {
+ return 0, fmt.Errorf("invalid numeric state '%s' for entity %s: %w", state.State, entity, err)
+ }
+
+ res, err := toCelsius(value, state.Attributes.UnitOfMeasurement)
+ if err != nil {
+ return 0, fmt.Errorf("%w for entity %s", err, entity)
+ }
+
+ return res, nil
+}
+
// GetBoolState retrieves the state of an entity as boolean
func (c *Connection) GetBoolState(entity string) (bool, error) {
state, err := c.GetState(entity)
diff --git a/util/homeassistant/service.go b/util/homeassistant/service.go
index c93f879f7b..59f7212ebe 100644
--- a/util/homeassistant/service.go
+++ b/util/homeassistant/service.go
@@ -1,6 +1,7 @@
package homeassistant
import (
+ "cmp"
"encoding/json"
"errors"
"maps"
@@ -28,7 +29,34 @@ func getInstances(w http.ResponseWriter, req *http.Request) {
mu.Lock()
defer mu.Unlock()
- jsonWrite(w, slices.Sorted(maps.Values(instances)))
+ res := make([]service.Option, 0, len(instances))
+ for name, uri := range instances {
+ // match keeps the single-instance auto-fill
+ res = append(res, service.Option{Value: uri, Label: name, Match: true})
+ }
+
+ slices.SortFunc(res, func(a, b service.Option) int { return cmp.Compare(a.Value, b.Value) })
+ jsonWrite(w, res)
+}
+
+func entityOptions(states []StateResponse, domains, units []string) []service.Option {
+ var res []service.Option
+ for _, e := range states {
+ if !matchesDomains(e.EntityId, domains) {
+ continue
+ }
+ o := service.Option{
+ Value: e.EntityId,
+ Label: e.Attributes.FriendlyName,
+ Match: slices.Contains(units, e.Attributes.UnitOfMeasurement),
+ }
+ if e.State != "unavailable" && e.State != "unknown" {
+ o.Hint = strings.TrimSpace(e.State + " " + e.Attributes.UnitOfMeasurement)
+ }
+ res = append(res, o)
+ }
+ slices.SortFunc(res, func(a, b service.Option) int { return cmp.Compare(a.Value, b.Value) })
+ return res
}
func connectionFromRequest(req *http.Request) (*Connection, error) {
@@ -46,10 +74,10 @@ func connectionFromRequest(req *http.Request) (*Connection, error) {
return NewConnection(log, uri, "", insecure)
}
-// domainsFromRequest parses the comma-separated "domain" query parameter.
-func domainsFromRequest(req *http.Request) []string {
- if domain := req.URL.Query().Get("domain"); domain != "" {
- return strings.Split(domain, ",")
+// queryList parses a comma-separated query parameter.
+func queryList(req *http.Request, key string) []string {
+ if v := req.URL.Query().Get(key); v != "" {
+ return strings.Split(v, ",")
}
return nil
}
@@ -81,17 +109,9 @@ func getEntities(w http.ResponseWriter, req *http.Request) {
return
}
- domains := domainsFromRequest(req)
-
- var result []string
- for _, e := range states {
- if matchesDomains(e.EntityId, domains) {
- result = append(result, e.EntityId)
- }
- }
-
- w.Header().Set("Cache-control", "max-age=300")
- jsonWrite(w, result)
+ // short cache: hints carry live values, the form refetches on every change
+ w.Header().Set("Cache-control", "max-age=30")
+ jsonWrite(w, entityOptions(states, queryList(req, "domain"), queryList(req, "unit")))
}
func getServices(w http.ResponseWriter, req *http.Request) {
@@ -101,7 +121,7 @@ func getServices(w http.ResponseWriter, req *http.Request) {
return
}
- domains := domainsFromRequest(req)
+ domains := queryList(req, "domain")
seen := make(map[string]struct{})
diff --git a/util/homeassistant/service_test.go b/util/homeassistant/service_test.go
new file mode 100644
index 0000000000..98ae7a6c2b
--- /dev/null
+++ b/util/homeassistant/service_test.go
@@ -0,0 +1,41 @@
+package homeassistant
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/stretchr/testify/assert"
+)
+
+func state(id, state, unit, name string) StateResponse {
+ var s StateResponse
+ s.EntityId = id
+ s.State = state
+ s.Attributes.UnitOfMeasurement = unit
+ s.Attributes.FriendlyName = name
+ return s
+}
+
+func TestEntityOptions(t *testing.T) {
+ states := []StateResponse{
+ state("sensor.grid_power", "1234", "W", "Grid Power"),
+ state("switch.garden", "on", "", "Garden"),
+ state("sensor.backup", "unavailable", "", "Backup"),
+ }
+
+ assert.Equal(t, []service.Option{
+ {Value: "sensor.backup", Label: "Backup"},
+ {Value: "sensor.grid_power", Label: "Grid Power", Hint: "1234 W", Match: true},
+ }, entityOptions(states, []string{"sensor"}, []string{"W", "kW"}))
+
+ assert.Len(t, entityOptions(states, nil, nil), 3)
+ assert.Empty(t, entityOptions(states, []string{"light"}, nil))
+}
+
+func TestQueryList(t *testing.T) {
+ req := httptest.NewRequest("GET", "/entities?domain=sensor,number&unit=%25", nil)
+ assert.Equal(t, []string{"sensor", "number"}, queryList(req, "domain"))
+ assert.Equal(t, []string{"%"}, queryList(req, "unit"))
+ assert.Nil(t, queryList(req, "missing"))
+}
diff --git a/util/homeassistant/types.go b/util/homeassistant/types.go
index 9ec56eafef..fe8d200175 100644
--- a/util/homeassistant/types.go
+++ b/util/homeassistant/types.go
@@ -42,3 +42,17 @@ func (state StateResponse) scale() (float64, error) {
}
return 1, nil
}
+
+// toCelsius converts a temperature value with the given Home Assistant unit to °C
+func toCelsius(value float64, unit string) (float64, error) {
+ switch unit {
+ case "", "°C":
+ return value, nil
+ case "°F":
+ return (value - 32) * 5 / 9, nil
+ case "K":
+ return value - 273.15, nil
+ default:
+ return 0, fmt.Errorf("invalid temperature unit '%s'", unit)
+ }
+}
diff --git a/util/homeassistant/types_test.go b/util/homeassistant/types_test.go
new file mode 100644
index 0000000000..2cff772f20
--- /dev/null
+++ b/util/homeassistant/types_test.go
@@ -0,0 +1,31 @@
+package homeassistant
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestToCelsius(t *testing.T) {
+ for _, tc := range []struct {
+ value float64
+ unit string
+ want float64
+ err bool
+ }{
+ {21.5, "°C", 21.5, false},
+ {21.5, "", 21.5, false},
+ {32, "°F", 0, false},
+ {212, "°F", 100, false},
+ {273.15, "K", 0, false},
+ {50, "%", 0, true},
+ } {
+ got, err := toCelsius(tc.value, tc.unit)
+ if tc.err {
+ assert.Error(t, err, tc.unit)
+ continue
+ }
+ assert.NoError(t, err, tc.unit)
+ assert.InDelta(t, tc.want, got, 0.001, tc.unit)
+ }
+}
diff --git a/util/service/demo.go b/util/service/demo.go
index c5a2e03232..95aaf85da7 100644
--- a/util/service/demo.go
+++ b/util/service/demo.go
@@ -15,6 +15,7 @@ func init() {
mux.HandleFunc("GET /modbus", getModbus)
mux.HandleFunc("GET /insecure", getInsecure)
mux.HandleFunc("GET /options", getOptions)
+ mux.HandleFunc("GET /echo", getEcho)
service.Register("demo", mux)
}
@@ -42,7 +43,7 @@ func getCity(w http.ResponseWriter, req *http.Request) {
}
func getOptions(w http.ResponseWriter, req *http.Request) {
- json.NewEncoder(w).Encode([]Option{
+ json.NewEncoder(w).Encode([]service.Option{
{Value: "192.0.2.10", Label: "alpha", Hint: "Vendor A", Match: true, Used: true},
{Value: "192.0.2.20", Label: "beta", Hint: "Vendor B"},
{Value: "192.0.2.30", Label: "gamma", Hint: "Vendor C", Match: true},
@@ -55,6 +56,10 @@ func getInsecure(w http.ResponseWriter, req *http.Request) {
json.NewEncoder(w).Encode([]string{"insecure=" + req.URL.Query().Get("insecure")})
}
+func getEcho(w http.ResponseWriter, req *http.Request) {
+ json.NewEncoder(w).Encode([]string{req.URL.RawQuery})
+}
+
func getModbus(w http.ResponseWriter, req *http.Request) {
// Verify that either uri or device is provided (mimics modbus connection params)
uri := req.URL.Query().Get("uri")
diff --git a/util/service/hardware.go b/util/service/hardware.go
index 3a63b13d14..f836ebb5e1 100644
--- a/util/service/hardware.go
+++ b/util/service/hardware.go
@@ -20,17 +20,32 @@ func init() {
var (
once sync.Once
- ports []string
+ ports []service.Option
)
func getSerialPorts(w http.ResponseWriter, req *http.Request) {
once.Do(func() {
if env := os.Getenv("EVCC_SERIAL_PORTS"); env != "" {
- ports = strings.Split(env, ",")
+ ports = plainOptions(strings.Split(env, ","))
} else {
- ports, _ = serialports.GetPortsList()
+ ports = serialPortOptions()
}
})
json.NewEncoder(w).Encode(ports)
}
+
+// plainOptions wraps port names as options without details
+func plainOptions(names []string) []service.Option {
+ res := make([]service.Option, 0, len(names))
+ for _, name := range names {
+ res = append(res, service.Option{Value: name, Match: true})
+ }
+ return res
+}
+
+// plainSerialPorts lists port names without USB details
+func plainSerialPorts() []service.Option {
+ names, _ := serialports.GetPortsList()
+ return plainOptions(names)
+}
diff --git a/util/service/hardware_darwin.go b/util/service/hardware_darwin.go
new file mode 100644
index 0000000000..328b661b49
--- /dev/null
+++ b/util/service/hardware_darwin.go
@@ -0,0 +1,10 @@
+//go:build darwin && !cgo
+
+package service
+
+import "github.com/evcc-io/evcc/server/service"
+
+// serialPortOptions falls back to plain port names: the darwin enumerator needs IOKit via cgo
+func serialPortOptions() []service.Option {
+ return plainSerialPorts()
+}
diff --git a/util/service/hardware_enumerator.go b/util/service/hardware_enumerator.go
new file mode 100644
index 0000000000..fd0fe7c504
--- /dev/null
+++ b/util/service/hardware_enumerator.go
@@ -0,0 +1,29 @@
+//go:build !darwin || cgo
+
+package service
+
+import (
+ "strings"
+
+ "github.com/evcc-io/evcc/server/service"
+ "go.bug.st/serial/enumerator"
+)
+
+// serialPortOptions lists ports with USB product, vid:pid and serial number
+func serialPortOptions() []service.Option {
+ details, err := enumerator.GetDetailedPortsList()
+ if err != nil {
+ return plainSerialPorts()
+ }
+
+ res := make([]service.Option, 0, len(details))
+ for _, d := range details {
+ // match on every port so a single port still auto-fills
+ o := service.Option{Value: d.Name, Label: d.Product, Match: true}
+ if d.VID != "" {
+ o.Hint = strings.TrimSpace(d.VID + ":" + d.PID + " " + d.SerialNumber)
+ }
+ res = append(res, o)
+ }
+ return res
+}
diff --git a/util/service/helper.go b/util/service/helper.go
index 92db757469..e3cc9f6356 100644
--- a/util/service/helper.go
+++ b/util/service/helper.go
@@ -10,15 +10,6 @@ import (
"github.com/spf13/cast"
)
-// Option is a service suggestion with context
-type Option struct {
- Value string `json:"value"`
- Label string `json:"label,omitempty"`
- Hint string `json:"hint,omitempty"`
- Match bool `json:"match,omitempty"`
- Used bool `json:"used,omitempty"`
-}
-
// toString converts to canonical string representation
func toString(value any, castType string) string {
res := value
diff --git a/util/service/http.go b/util/service/http.go
index 1bef604486..f7b0d9fb94 100644
--- a/util/service/http.go
+++ b/util/service/http.go
@@ -1,6 +1,8 @@
package service
import (
+ "cmp"
+ "context"
"errors"
"net/http"
@@ -20,9 +22,32 @@ func init() {
service.Register("http", mux)
}
-// httpGet reads uri and extracts the choices using the jq query, driving
-// selection of remote resources in templates. The config UI polls while the
-// form is filled, hence a rejected request yields an empty list.
+// parseQuery compiles a jq expression, an empty expression yields nil
+func parseQuery(expr string) (*gojq.Query, error) {
+ if expr == "" {
+ return nil, nil
+ }
+ return gojq.Parse(expr)
+}
+
+// first returns the first result of query applied to v, or nil. The request
+// context bounds the evaluation like the jq query itself.
+func first(ctx context.Context, query *gojq.Query, v any) any {
+ if query == nil {
+ return nil
+ }
+ res, ok := query.RunWithContext(ctx, v).Next()
+ if !ok {
+ return nil
+ }
+ if _, ok := res.(error); ok {
+ return nil
+ }
+ return res
+}
+
+// httpGet reads uri and maps the jq result elements to options via the optional
+// value, label and hint expressions. The config UI polls, hence errors yield an empty list.
func httpGet(w http.ResponseWriter, req *http.Request) {
q := req.URL.Query()
@@ -32,16 +57,20 @@ func httpGet(w http.ResponseWriter, req *http.Request) {
return
}
- expr := q.Get("jq")
- if expr == "" {
- expr = "."
- }
-
- query, err := gojq.Parse(expr)
- if err != nil {
- jsonError(w, http.StatusBadRequest, err)
- return
+ var queries [4]*gojq.Query
+ for i, expr := range []string{
+ cmp.Or(q.Get("jq"), "."),
+ cmp.Or(q.Get("value"), "."),
+ q.Get("label"),
+ q.Get("hint"),
+ } {
+ var err error
+ if queries[i], err = parseQuery(expr); err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
}
+ query, value, label, hint := queries[0], queries[1], queries[2], queries[3]
log := util.NewLogger("http")
client := request.NewHelper(log)
@@ -51,7 +80,7 @@ func httpGet(w http.ResponseWriter, req *http.Request) {
client.Transport = transport.BearerAuth(token, client.Transport)
}
- res := []string{}
+ res := []service.Option{}
defer func() { jsonWrite(w, res) }()
body, err := client.GetBody(uri)
@@ -59,17 +88,28 @@ func httpGet(w http.ResponseWriter, req *http.Request) {
return
}
- val, err := jq.Query(query, body)
+ val, err := jq.QueryContext(req.Context(), query, body)
if err != nil {
return
}
- if slice, ok := val.([]any); ok {
- for _, v := range slice {
- res = append(res, cast.ToString(v))
- }
- return
+ slice, ok := val.([]any)
+ if !ok {
+ slice = []any{val}
}
- res = append(res, cast.ToString(val))
+ for _, v := range slice {
+ val := first(req.Context(), value, v)
+ if val == nil {
+ continue
+ }
+
+ // match keeps the single-value auto-fill
+ res = append(res, service.Option{
+ Value: cast.ToString(val),
+ Label: cast.ToString(first(req.Context(), label, v)),
+ Hint: cast.ToString(first(req.Context(), hint, v)),
+ Match: true,
+ })
+ }
}
diff --git a/util/service/http_test.go b/util/service/http_test.go
new file mode 100644
index 0000000000..f420368ad8
--- /dev/null
+++ b/util/service/http_test.go
@@ -0,0 +1,62 @@
+package service
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHttpGet(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"sn":"ABC","sites":[{"id":"s1","name":"Home","kw":9.9},{"id":"s2","name":null},{"name":"no id"}]}`))
+ }))
+ defer srv.Close()
+
+ tc := []struct {
+ name string
+ params url.Values
+ status int
+ res []service.Option
+ }{
+ {"list of scalars", url.Values{"jq": {"[.sites[].id]"}}, 200, []service.Option{
+ {Value: "s1", Match: true}, {Value: "s2", Match: true},
+ }},
+ {"single scalar", url.Values{"jq": {".sn"}}, 200, []service.Option{
+ {Value: "ABC", Match: true},
+ }},
+ {"label and hint", url.Values{"jq": {".sites"}, "value": {".id"}, "label": {".name"}, "hint": {".kw"}}, 200, []service.Option{
+ {Value: "s1", Label: "Home", Hint: "9.9", Match: true}, {Value: "s2", Match: true},
+ }},
+ {"jq error", url.Values{"jq": {".missing[]"}}, 200, []service.Option{}},
+ {"invalid jq", url.Values{"jq": {"["}}, 400, nil},
+ {"invalid label", url.Values{"label": {"["}}, 400, nil},
+ {"missing uri", url.Values{"uri": {""}}, 400, nil},
+ }
+
+ for _, tc := range tc {
+ t.Run(tc.name, func(t *testing.T) {
+ if _, ok := tc.params["uri"]; !ok {
+ tc.params.Set("uri", srv.URL)
+ }
+
+ w := httptest.NewRecorder()
+ httpGet(w, httptest.NewRequest("GET", "/get?"+tc.params.Encode(), nil))
+
+ require.Equal(t, tc.status, w.Code, w.Body.String())
+ if tc.res == nil {
+ return
+ }
+
+ var res []service.Option
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res))
+ assert.Equal(t, tc.res, res)
+ })
+ }
+}
diff --git a/util/service/network.go b/util/service/network.go
index d460314f41..d9d83023ca 100644
--- a/util/service/network.go
+++ b/util/service/network.go
@@ -149,13 +149,13 @@ func getHosts(w http.ResponseWriter, req *http.Request) {
used := usedHosts(allDeviceConfigs())
- res := make([]Option, 0)
+ res := make([]service.Option, 0)
for _, h := range hosts(w, req, mdnsTypes(all)) {
match := func(d templates.Discovery) bool {
return d.Match(h.Names(), h.MAC, h.Services)
}
- o := Option{
+ o := service.Option{
Value: h.IP,
Label: h.Hostname,
Hint: discovery.Vendor(h.MAC),
diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml
index c5917b29fa..ab7b381361 100644
--- a/util/templates/defaults.yaml
+++ b/util/templates/defaults.yaml
@@ -527,6 +527,9 @@ params:
description:
generic: API Key
mask: true
+ - name: siteid
+ description:
+ generic: Site ID
- name: tempsource
description:
de: Temperaturquelle
diff --git a/vehicle/mercedes/api.go b/vehicle/mercedes/api.go
index 3e847fb39d..be20141468 100644
--- a/vehicle/mercedes/api.go
+++ b/vehicle/mercedes/api.go
@@ -46,8 +46,13 @@ func (v *API) Vehicles() ([]string, error) {
return nil, err
}
+ all := res.AssignedVehicles
+ for _, f := range res.Fleets {
+ all = append(all, f.BookedVehicles...)
+ }
+
var vehicles []string
- for _, v := range res.AssignedVehicles {
+ for _, v := range all {
if len(v.Vin) > 0 {
vehicles = append(vehicles, v.Vin)
} else {
diff --git a/vehicle/mercedes/types.go b/vehicle/mercedes/types.go
index 2014ad151e..87510c9110 100644
--- a/vehicle/mercedes/types.go
+++ b/vehicle/mercedes/types.go
@@ -23,6 +23,9 @@ type PinResponse struct {
type VehiclesResponse struct {
AssignedVehicles []Vehicle
+ Fleets []struct {
+ BookedVehicles []Vehicle
+ }
}
type Vehicle struct {
diff --git a/vehicle/myskoda/provider.go b/vehicle/myskoda/provider.go
index 8dae597a3a..9b582ab138 100644
--- a/vehicle/myskoda/provider.go
+++ b/vehicle/myskoda/provider.go
@@ -161,3 +161,11 @@ func (v *Provider) ChargeEnable(enable bool) error {
}
return v.action(action)
}
+
+var _ api.Resurrector = (*Provider)(nil)
+
+// WakeUp implements the api.Resurrector interface
+// the api has no wake-up call, starting charging wakes the vehicle
+func (v *Provider) WakeUp() error {
+ return v.action(ActionStart)
+}
diff --git a/vehicle/octopuskraken/service.go b/vehicle/octopuskraken/service.go
index 75203cc255..f5b8af1c58 100644
--- a/vehicle/octopuskraken/service.go
+++ b/vehicle/octopuskraken/service.go
@@ -1,9 +1,11 @@
package octopuskraken
import (
+ "cmp"
"encoding/json"
"net/http"
"slices"
+ "strings"
"github.com/evcc-io/evcc/server/service"
"github.com/evcc-io/evcc/util"
@@ -24,7 +26,29 @@ func register(name, baseURI string) {
service.Register(name, mux)
}
-// getDevices lists the ids of the SmartFlex devices in the account, driving
+// deviceTypes maps the Kraken device type enum to a readable hint.
+var deviceTypes = map[string]string{
+ "ELECTRIC_VEHICLES": "Vehicle",
+ "CHARGE_POINTS": "Charge point",
+}
+
+func deviceOptions(devices []Device) []service.Option {
+ res := make([]service.Option, 0, len(devices))
+ for _, d := range devices {
+ parts := []string{cmp.Or(deviceTypes[d.DeviceType], d.DeviceType), d.Provider}
+ res = append(res, service.Option{
+ Value: d.ID,
+ Label: d.Name,
+ Hint: strings.Join(slices.DeleteFunc(parts, func(s string) bool { return s == "" }), " · "),
+ // vehicle template: charge points are listed but don't match
+ Match: d.DeviceType == "ELECTRIC_VEHICLES",
+ })
+ }
+ slices.SortFunc(res, func(a, b service.Option) int { return cmp.Compare(a.Value, b.Value) })
+ return res
+}
+
+// getDevices lists the SmartFlex devices in the account, driving
// device selection in the template.
func getDevices(w http.ResponseWriter, req *http.Request, name, baseURI string) {
w.Header().Set("Content-Type", "application/json")
@@ -32,8 +56,8 @@ func getDevices(w http.ResponseWriter, req *http.Request, name, baseURI string)
q := req.URL.Query()
email, password, account := q.Get("email"), q.Get("password"), q.Get("accountnumber")
- ids := []string{}
- defer func() { _ = json.NewEncoder(w).Encode(ids) }()
+ res := []service.Option{}
+ defer func() { _ = json.NewEncoder(w).Encode(res) }()
if email == "" || password == "" {
return
@@ -58,8 +82,5 @@ func getDevices(w http.ResponseWriter, req *http.Request, name, baseURI string)
return
}
- for _, d := range devices {
- ids = append(ids, d.ID)
- }
- slices.Sort(ids)
+ res = deviceOptions(devices)
}
diff --git a/vehicle/octopuskraken/service_test.go b/vehicle/octopuskraken/service_test.go
new file mode 100644
index 0000000000..30ea676b11
--- /dev/null
+++ b/vehicle/octopuskraken/service_test.go
@@ -0,0 +1,38 @@
+package octopuskraken
+
+import (
+ "testing"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/stretchr/testify/assert"
+)
+
+func device(id, name, typ, provider string) Device {
+ return Device{ID: id, Name: name, DeviceType: typ, Provider: provider}
+}
+
+func TestDeviceOptions(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ devices []Device
+ want []service.Option
+ }{
+ {"empty", nil, []service.Option{}},
+ {"sorted by id, vehicles match", []Device{
+ device("dev-2", "Wallbox", "CHARGE_POINTS", "OCPP"),
+ device("dev-1", "My Car", "ELECTRIC_VEHICLES", "TESLA"),
+ }, []service.Option{
+ {Value: "dev-1", Label: "My Car", Hint: "Vehicle · TESLA", Match: true},
+ {Value: "dev-2", Label: "Wallbox", Hint: "Charge point · OCPP"},
+ }},
+ {"unknown type and missing provider", []Device{
+ device("dev-3", "Heat Pump", "HEAT_PUMPS", ""),
+ }, []service.Option{
+ {Value: "dev-3", Label: "Heat Pump", Hint: "HEAT_PUMPS"},
+ }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, deviceOptions(tc.devices))
+ })
+ }
+}
diff --git a/vehicle/skoda.go b/vehicle/skoda.go
index 76b280d694..4fc2d77c99 100644
--- a/vehicle/skoda.go
+++ b/vehicle/skoda.go
@@ -46,7 +46,6 @@ func NewSkodaFromConfig(other map[string]any) (api.Vehicle, error) {
embed: &cc.embed,
}
- var err error
log := util.NewLogger("skoda").Redact(cc.User, cc.Password, cc.VIN)
// use Skoda api to resolve list of vehicles
@@ -64,22 +63,16 @@ func NewSkodaFromConfig(other map[string]any) (api.Vehicle, error) {
return v.VIN, nil
},
)
-
- if err == nil {
- vehicle, err = api.VehicleDetails(vehicle.VIN)
+ if err != nil {
+ return nil, err
}
- if err == nil {
- v.fromVehicle(vehicle.Name, float64(vehicle.Specification.Battery.CapacityInKWh))
+ if vehicle, err = api.VehicleDetails(vehicle.VIN); err != nil {
+ return nil, err
}
- // reuse tokenService to build provider
- if err == nil {
- api := skoda.NewAPI(log, ts)
- api.Client.Timeout = cc.Timeout
-
- v.Provider = skoda.NewProvider(api, vehicle.VIN, cc.Cache)
- }
+ v.fromVehicle(vehicle.Name, float64(vehicle.Specification.Battery.CapacityInKWh))
+ v.Provider = skoda.NewProvider(api, vehicle.VIN, cc.Cache)
- return v, err
+ return v, nil
}
diff --git a/vehicle/tibber/service.go b/vehicle/tibber/service.go
index b5c5fbc2fd..230a3aab29 100644
--- a/vehicle/tibber/service.go
+++ b/vehicle/tibber/service.go
@@ -1,10 +1,12 @@
package tibber
import (
+ "cmp"
"context"
"encoding/json"
"net/http"
"slices"
+ "strings"
"github.com/evcc-io/evcc/server/service"
"github.com/evcc-io/evcc/util"
@@ -17,17 +19,35 @@ func init() {
service.Register("tibber", mux)
}
-// getVehicles lists the external ids of the vehicles in the account, driving
-// vehicle selection in the template. It reuses the OAuth instance created for
-// the same client, so results appear once the user has authorized via the UI.
+// vehicleOptions maps the device list to suggestions keyed by VIN
+func vehicleOptions(vehicles []Device) []service.Option {
+ res := make([]service.Option, 0, len(vehicles))
+ for _, v := range vehicles {
+ model := strings.TrimSpace(v.Info.Brand + " " + v.Info.Model)
+ // every vehicle is a valid choice, a single one auto-fills the vin
+ o := service.Option{Value: v.VIN(), Label: strings.TrimSpace(v.Info.Name), Match: true}
+ if o.Label == "" {
+ o.Label = model
+ } else {
+ o.Hint = model
+ }
+ res = append(res, o)
+ }
+ slices.SortFunc(res, func(a, b service.Option) int { return cmp.Compare(a.Value, b.Value) })
+ return res
+}
+
+// getVehicles lists the vehicles in the account, driving vehicle selection in
+// the template. It reuses the OAuth instance created for the same client, so
+// results appear once the user has authorized via the UI.
func getVehicles(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
q := req.URL.Query()
clientID, clientSecret, redirectURI := q.Get("clientid"), q.Get("clientsecret"), q.Get("redirecturi")
- ids := []string{}
- defer func() { _ = json.NewEncoder(w).Encode(ids) }()
+ res := []service.Option{}
+ defer func() { _ = json.NewEncoder(w).Encode(res) }()
if clientID == "" || clientSecret == "" {
return
@@ -46,8 +66,5 @@ func getVehicles(w http.ResponseWriter, req *http.Request) {
return
}
- for _, v := range vehicles {
- ids = append(ids, v.VIN())
- }
- slices.Sort(ids)
+ res = vehicleOptions(vehicles)
}
diff --git a/vehicle/tibber/service_test.go b/vehicle/tibber/service_test.go
new file mode 100644
index 0000000000..942bfaaab0
--- /dev/null
+++ b/vehicle/tibber/service_test.go
@@ -0,0 +1,24 @@
+package tibber
+
+import (
+ "testing"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestVehicleOptions(t *testing.T) {
+ vehicles := []Device{
+ {ExternalID: "tesla:5YJSA1E26MF1234567", Info: DeviceInfo{Name: "Model S", Brand: "Tesla", Model: "Model S"}},
+ {ExternalID: "vw:WVWZZZ1KZAW000001", Info: DeviceInfo{Name: " ", Brand: "Volkswagen", Model: "ID.3"}},
+ {ExternalID: "skoda:TMBJJ7NX0L0000001"},
+ }
+
+ assert.Equal(t, []service.Option{
+ {Value: "5YJSA1E26MF1234567", Label: "Model S", Hint: "Tesla Model S", Match: true},
+ {Value: "TMBJJ7NX0L0000001", Match: true},
+ {Value: "WVWZZZ1KZAW000001", Label: "Volkswagen ID.3", Match: true},
+ }, vehicleOptions(vehicles))
+
+ assert.Empty(t, vehicleOptions(nil))
+}