From 2a5b778eef47ef829015c5ba426ccaf48ddda1d7 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 9 Oct 2026 12:06:58 +0200 Subject: [PATCH 01/61] Issue: hint to search issues and try nightly before reporting a bug (#34572) --- assets/js/views/Issue.vue | 27 +++++++++++++++++++++++++++ i18n/de.json | 4 ++++ i18n/en.json | 4 ++++ 3 files changed, 35 insertions(+) diff --git a/assets/js/views/Issue.vue b/assets/js/views/Issue.vue index 20d52e63fd..7a987bdc66 100644 --- a/assets/js/views/Issue.vue +++ b/assets/js/views/Issue.vue @@ -69,6 +69,26 @@ 🇬🇧 Please write your issue in English so everyone can participate.

+
+ + + + + + +
+
@@ -392,6 +412,7 @@ import { import type { HelpType, IssueData, Sections } from "@/components/Issue/types"; import type { State } from "@/types/evcc"; import { docsPrefix } from "@/i18n"; +import { isNightly } from "@/utils/version"; // Keys that should be expanded (1-level expansion for arrays and objects) const EXPAND_KEYS = [ @@ -476,6 +497,12 @@ export default defineComponent({ versionString(): string { return `v${store.state.version || ""}`; }, + showBugHints(): boolean { + return !this.emailMode && this.helpType === "issue"; + }, + nightly(): boolean { + return isNightly(store.state.version || ""); + }, systemString(): string { return store.state.system || ""; }, diff --git a/i18n/de.json b/i18n/de.json index d0c3c716f1..a47a0deebc 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1443,6 +1443,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", diff --git a/i18n/en.json b/i18n/en.json index 4cde16ec17..ef6bbd7c34 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1443,6 +1443,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", From 34297ed048ee618c035a1f8cbae57dabbea19a6c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:20:52 +0200 Subject: [PATCH 02/61] Vehicle (Mercedes): discover fleet vehicles (#34556) --- vehicle/mercedes/api.go | 7 ++++++- vehicle/mercedes/types.go | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) 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 { From fff1bd9e76d0d820fa0707ee5b644781e13fb7f4 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 9 Oct 2026 14:23:54 +0200 Subject: [PATCH 03/61] CI: read build-toolchain node version from .node-version (#34585) --- .github/actions/build-toolchain/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/build-toolchain/action.yml b/.github/actions/build-toolchain/action.yml index bef7f0dc72..cc113c8754 100644 --- a/.github/actions/build-toolchain/action.yml +++ b/.github/actions/build-toolchain/action.yml @@ -20,7 +20,7 @@ runs: - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: - node-version: "26" + node-version-file: ".node-version" cache: true # Writes are namespaced by cache-scope so a PR build cannot poison the From bbb6fd1bac23bdb001768c0972573e065baebd74 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 9 Oct 2026 14:25:21 +0200 Subject: [PATCH 04/61] CI: run kin-openapi validator as go tool (#34583) --- .github/workflows/openapi-validate.yml | 3 ++- go.mod | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/openapi-validate.yml b/.github/workflows/openapi-validate.yml index bac1be56a5..ba135277a6 100644 --- a/.github/workflows/openapi-validate.yml +++ b/.github/workflows/openapi-validate.yml @@ -10,6 +10,7 @@ on: - "server/openapi.yaml" - "server/openapi.state.yaml" - ".github/workflows/openapi-validate.yml" + - "go.mod" # validator version follows go.mod jobs: validate-openapi: @@ -23,4 +24,4 @@ jobs: with: go-version-file: go.mod - name: Validate OpenAPI spec - run: go run github.com/getkin/kin-openapi/cmd/validate@v0.133.0 -ext -- server/openapi.yaml + run: go tool validate -ext -- server/openapi.yaml 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 From ac4a32e07f7ad44245604ede2ea4a870964e0c71 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 9 Oct 2026 14:26:06 +0200 Subject: [PATCH 05/61] CI: bump Cloudsmith CLI to 1.28.0, managed by dependabot (#34575) --- .github/dependabot.yml | 6 ++++++ .github/requirements.txt | 1 + .github/workflows/nightly.yml | 2 +- .github/workflows/release.yml | 2 +- 4 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .github/requirements.txt diff --git a/.github/dependabot.yml b/.github/dependabot.yml index abfd0dafcb..38ee3c5f9d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -27,3 +27,9 @@ updates: open-pull-requests-limit: 0 labels: - "infrastructure" + - package-ecosystem: "pip" + directory: "/.github" + schedule: + interval: "monthly" + labels: + - "infrastructure" diff --git a/.github/requirements.txt b/.github/requirements.txt new file mode 100644 index 0000000000..d5dff9d1f9 --- /dev/null +++ b/.github/requirements.txt @@ -0,0 +1 @@ +cloudsmith-cli==1.28.0 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 8c2c1bc251..9d0cdf9850 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -137,7 +137,7 @@ jobs: python-version: 3.12 - name: Install Cloudsmith CLI - run: pip install cloudsmith-cli==1.16.0 + run: pip install -r .github/requirements.txt - name: Publish .deb to Cloudsmith env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2923d776d9..4f15c7b6f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -156,7 +156,7 @@ jobs: python-version: 3.12 - name: Install Cloudsmith CLI - run: pip install cloudsmith-cli==1.16.0 + run: pip install -r .github/requirements.txt - name: Publish .deb to Cloudsmith env: From 359a4725cb9aadd40be46ea66b44ac37fbfad82e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:33:48 +0200 Subject: [PATCH 06/61] Bump docker/setup-buildx-action from 4.2.0 to 4.4.1 (#34591) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/command-pr-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/command-pr-build.yml b/.github/workflows/command-pr-build.yml index bf2bf91047..c68bbcf1db 100644 --- a/.github/workflows/command-pr-build.yml +++ b/.github/workflows/command-pr-build.yml @@ -130,7 +130,7 @@ jobs: - name: Setup Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 - name: Restore build cache uses: ./.github/actions/docker-cache From b38be088dd3d904fce1bde4a84a511d1ad444d9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:34:14 +0200 Subject: [PATCH 07/61] Bump actions/checkout from 7.0.0 to 7.0.1 (#34589) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/claude-code-review-run.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-code-review-run.yml b/.github/workflows/claude-code-review-run.yml index 01a4bdd32f..121c4168f2 100644 --- a/.github/workflows/claude-code-review-run.yml +++ b/.github/workflows/claude-code-review-run.yml @@ -93,7 +93,7 @@ jobs: # Default checkout (base ref) only — do NOT check out the untrusted PR head. - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 From 14a64ddd8e08de8e7fb16f672c72931af8416531 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:34:33 +0200 Subject: [PATCH 08/61] Bump anthropics/claude-code-action from 1.0.236 to 1.0.244 (#34588) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/claude-code-review-run.yml | 2 +- .github/workflows/claude-issue-agent-run.yml | 2 +- .github/workflows/claude-issue-agent.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-code-review-run.yml b/.github/workflows/claude-code-review-run.yml index 121c4168f2..f82902d32d 100644 --- a/.github/workflows/claude-code-review-run.yml +++ b/.github/workflows/claude-code-review-run.yml @@ -102,7 +102,7 @@ jobs: continue-on-error: true # always report status below, even on failure # pinned: v1 (Claude Code 2.1.216) fails every Bash call with # "bwrap: Can't create file at /home/.mcp.json: Permission denied" - uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1 @ 2.1.215 + uses: anthropics/claude-code-action@58985842b834ed26087302ba27d07bc24ca8697a # v1 @ 2.1.215 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # Provided explicitly so the review can post on fork PRs; also required diff --git a/.github/workflows/claude-issue-agent-run.yml b/.github/workflows/claude-issue-agent-run.yml index f9d7394277..8e57ef9815 100644 --- a/.github/workflows/claude-issue-agent-run.yml +++ b/.github/workflows/claude-issue-agent-run.yml @@ -74,7 +74,7 @@ jobs: - name: Run Claude # pinned: v1 (Claude Code 2.1.216) fails every Bash call with # "bwrap: Can't create file at /home/.mcp.json: Permission denied" - uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1 @ 2.1.215 + uses: anthropics/claude-code-action@58985842b834ed26087302ba27d07bc24ca8697a # v1 @ 2.1.215 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/claude-issue-agent.yml b/.github/workflows/claude-issue-agent.yml index 5c3713497f..7cba28aa12 100644 --- a/.github/workflows/claude-issue-agent.yml +++ b/.github/workflows/claude-issue-agent.yml @@ -158,7 +158,7 @@ jobs: - name: Run Claude PR Labeler # pinned: v1 (Claude Code 2.1.216) fails every Bash call with # "bwrap: Can't create file at /home/.mcp.json: Permission denied" - uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1 @ 2.1.215 + uses: anthropics/claude-code-action@58985842b834ed26087302ba27d07bc24ca8697a # v1 @ 2.1.215 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }} From 6873579040beedb39091c9d07c570dc56f5f50ce Mon Sep 17 00:00:00 2001 From: FrankvdAa Date: Fri, 9 Oct 2026 14:36:53 +0200 Subject: [PATCH 09/61] Tariffs: pvnode-v2 temperature forecast (#34571) --- templates/definition/tariff/amber.yaml | 2 - .../tariff/pvnode-v2-temperature.yaml | 62 +++++++++++++++++++ util/templates/defaults.yaml | 3 + 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 templates/definition/tariff/pvnode-v2-temperature.yaml 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..4de1b7e4aa --- /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=[.[].id] + - 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/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 From ce66fb021a62911095b92ae890041707847682d4 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 9 Oct 2026 15:33:00 +0200 Subject: [PATCH 10/61] CI: copy issue labels onto agent-created PRs (#34592) --- .github/workflows/claude-issue-agent-run.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-issue-agent-run.yml b/.github/workflows/claude-issue-agent-run.yml index 8e57ef9815..4887b1d502 100644 --- a/.github/workflows/claude-issue-agent-run.yml +++ b/.github/workflows/claude-issue-agent-run.yml @@ -399,7 +399,8 @@ jobs: if [ "$MODE" = triage ]; then args+=(--draft) fi - if [ "$(gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE" --jq '.type.name // empty')" = Bug ]; then - args+=(--label bug) - fi + # carry the triaged issue labels over: the PR labeling job skips bot authors + mapfile -t labels < <(gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE" \ + --jq '[.labels[].name] + (if .type.name == "Bug" then ["bug"] else [] end) | unique | .[]') + for l in "${labels[@]}"; do args+=(--label "$l"); done gh pr create "${args[@]}" From f8561aec9f7048568384e5419f354096e36a5d69 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:18:05 +0200 Subject: [PATCH 11/61] Bump source-map-js from 1.2.1 to 1.2.2 (#34590) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7d23342cac..0dc84159a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9907,9 +9907,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" From 2e6e6d0138c335e5d636eeafb0e4fec087344cdd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:19:00 +0200 Subject: [PATCH 12/61] Bump voidzero-dev/setup-vp from 1.15.0 to 1.21.1 (#34587) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cli-docs.yml | 2 +- .github/workflows/default.yml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cli-docs.yml b/.github/workflows/cli-docs.yml index 97309b9787..8de0c9fa8a 100644 --- a/.github/workflows/cli-docs.yml +++ b/.github/workflows/cli-docs.yml @@ -26,7 +26,7 @@ jobs: with: go-version-file: go.mod - - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + - uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 with: node-version-file: ".node-version" cache: true diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 38f9f2d862..0b4d486ec4 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -57,7 +57,7 @@ jobs: go-version-file: go.mod id: go - - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + - uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 with: node-version-file: ".node-version" cache: true @@ -127,7 +127,7 @@ jobs: with: persist-credentials: false - - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + - uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 with: node-version-file: ".node-version" cache: true @@ -210,7 +210,7 @@ jobs: go-version-file: go.mod id: go - - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + - uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 with: node-version-file: ".node-version" cache: true @@ -332,7 +332,7 @@ jobs: with: persist-credentials: false - - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + - uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 with: node-version-file: ".node-version" cache: true From 3110416c3ef694d83b3a5fbcf204c2b518d5cc9a Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 9 Oct 2026 16:19:47 +0200 Subject: [PATCH 13/61] CI: pin Redocly CLI as devDependency (#34584) Co-authored-by: Claude Opus 5.5 --- .github/workflows/documentation.yml | 10 +++++++++- package-lock.json | 16 ++++++++++++++++ package.json | 1 + 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index a1009589e4..fe754bbd55 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -55,10 +55,18 @@ jobs: with: persist-credentials: false + - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + with: + node-version-file: ".node-version" + cache: true + + - name: Install + run: make install-ui + - name: Prepare OpenAPI spec run: | mkdir -p ./openapi-deploy - npx --yes @redocly/cli@2 bundle ./server/openapi.yaml -o ./openapi-deploy/openapi.yaml + npx redocly bundle ./server/openapi.yaml -o ./openapi-deploy/openapi.yaml - name: Deploy OpenAPI spec to docs repo uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4 diff --git a/package-lock.json b/package-lock.json index 0dc84159a1..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", 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", From 88e18688640ca41672fbf1bb36633f1ed164fba2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:20:04 +0200 Subject: [PATCH 14/61] Bump voidzero-dev/vite-plus from 1.0.0 to 1.1.0 (#34586) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7aaecbaaef..1d73811514 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # STEP 1 build ui -FROM --platform=$BUILDPLATFORM ghcr.io/voidzero-dev/vite-plus:1.0.0@sha256:8d788678fb9783ba8994c4fe5652af3316bfc414f7ef6ead0136932ee6075b8b AS node +FROM --platform=$BUILDPLATFORM ghcr.io/voidzero-dev/vite-plus:1.1.0@sha256:15dedad6ab1f100c096aaebac6b0fb40ccf1f3dd0dd0169141e2a56588489b60 AS node USER root From 4d8b6496e9b78b5700b931bac9d76346a6dc5196 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:36:32 +0000 Subject: [PATCH 15/61] Tariffs (Ostrom): accept fractional numbers in static price response (#34599) --- tariff/ostrom/api.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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"` From 8629bb9d8882e99e650548530e2436d1f6b5c46f Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 9 Oct 2026 16:37:38 +0200 Subject: [PATCH 16/61] Marstek: allow charging in hold mode (#34497) --- .../definition/meter/marstek-batterymode.tpl | 79 +++++++++++++++++-- 1 file changed, 74 insertions(+), 5 deletions(-) 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 From 6834c99fb18051314d62e982373e612e339570fb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:47:28 +0000 Subject: [PATCH 17/61] Config UI: fix enabling disabled temperature forecast (#34595) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- assets/js/views/Config.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index 2346400ee5..5decb7bea8 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -306,6 +306,7 @@ id: temperatureTariff.id, }) " + @enable="handleDisable('tariff', temperatureTariff.id, false)" /> Date: Sat, 10 Oct 2026 09:25:27 +0200 Subject: [PATCH 18/61] Energy: build overview slots from all series (#34618) --- assets/js/views/Energy.vue | 16 ++++++++++++---- tests/energy.spec.ts | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/assets/js/views/Energy.vue b/assets/js/views/Energy.vue index 2c71b58fd2..1c4e16d01d 100644 --- a/assets/js/views/Energy.vue +++ b/assets/js/views/Energy.vue @@ -422,7 +422,7 @@ import SourcesModal from "../components/Energy/SourcesModal.vue"; import ConsumerRiver, { type RiverLayer } from "../components/Energy/ConsumerRiver.vue"; import TreemapIcon from "../components/MaterialIcon/Treemap.vue"; import StreamIcon from "../components/MaterialIcon/Stream.vue"; -import type { HistorySeries } from "../components/Energy/GroupChart.vue"; +import type { HistorySeries, HistorySlot } from "../components/Energy/GroupChart.vue"; import colors, { batteryColor, darken, deviceColorMap, resolveColors } from "../colors"; import { PERIODS } from "../components/Sessions/types"; import { @@ -672,9 +672,17 @@ export default defineComponent({ overviewTitle(): string { return this.overviewOptions.find((o) => o.value === this.overview)?.name || ""; }, - // the buckets every stack is built on - flowSlots() { - return this.series.find((s) => s.group === "grid" || s.group === "pv")?.data || []; + // the buckets every stack is built on: union of all series, a newly added + // meter alone would only cover the slots since it was added + flowSlots(): HistorySlot[] { + const slots = new Map(); + for (const s of this.series) { + if (s.group === "forecast") continue; + for (const slot of s.data) { + if (!slots.has(slot.start)) slots.set(slot.start, slot); + } + } + return [...slots.values()].sort((a, b) => a.start.localeCompare(b.start)); }, stackSeries(): HistorySeries[] { return this.overview === OVERVIEW_VIEW.USAGE ? this.usageSeries : this.flowSeries; diff --git a/tests/energy.spec.ts b/tests/energy.spec.ts index ea28201e93..94639a839b 100644 --- a/tests/energy.spec.ts +++ b/tests/energy.spec.ts @@ -138,6 +138,22 @@ test.describe("page", () => { await expect(page.getByTestId("energy-stat-co2")).toContainText("1 kg"); }); + // the overview grid must cover every series, not only the slots of the + // first grid/pv series. Solar sorts first and has no rows at night and on + // days 16 and 18, where grid import and battery discharge happen + test("overview covers slots of every series", async ({ page }) => { + const legend = (label: string) => + page.getByTestId("energy-flow").getByRole("listitem").filter({ hasText: label }); + + await page.goto("/#/energy?year=2026&month=9&day=15"); + await expect(legend("Grid import")).toContainText("2.0 kWh"); + await expect(legend("Battery discharge")).toContainText("1.0 kWh"); + + await page.goto("/#/energy?period=month&year=2026&month=9"); + await expect(legend("Grid import")).toContainText("3.0 kWh"); + await expect(legend("Battery discharge")).toContainText("2.0 kWh"); + }); + // plot area spans the chart width minus the 36px axes, 96 slots a day const slotX = (width: number, slot: number, left = 0) => left + ((width - left - 36) * (slot + 0.5)) / 96; From 06d084484cee2256a7102d291d47232d43f3a970 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 10 Oct 2026 12:30:04 +0200 Subject: [PATCH 19/61] SunSpec: nan option to substitute the not-implemented sentinel (#34370) --- plugin/sunspec.go | 9 ++++++++- plugin/sunspec_test.go | 6 ++++++ templates/definition/meter/enphase-modbus.yaml | 6 ++++++ templates/definition/meter/fronius-gen24.yaml | 13 +++++++++++++ templates/definition/meter/fronius-vertoplus.yaml | 14 ++++++++++++++ templates/definition/meter/keba-kecontact.yaml | 9 +++++++++ templates/definition/meter/kostal-ksem.yaml | 9 +++++++++ .../definition/meter/kostal-plenticore-gen2.yaml | 3 +++ templates/definition/meter/solaredge-hybrid.yaml | 9 +++++++++ templates/definition/meter/solaredge-inverter.yaml | 12 ++++++++++++ .../meter/sunspec-hybrid-curtailable.yaml | 2 ++ templates/definition/meter/sunspec-hybrid.yaml | 13 +++++++++++++ .../definition/meter/sunspec-inverter-control.yaml | 2 ++ templates/definition/meter/sunspec-inverter.yaml | 9 +++++++++ templates/definition/meter/sunspec-meter.yaml | 9 +++++++++ 15 files changed, 124 insertions(+), 1 deletion(-) 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/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..281783301b 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 }} 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/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..bb4ee23700 100644 --- a/templates/definition/meter/kostal-plenticore-gen2.yaml +++ b/templates/definition/meter/kostal-plenticore-gen2.yaml @@ -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 }} 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: From daa6fa54a1c3c27088146feafc8314af2466c4a3 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sat, 10 Oct 2026 12:31:50 +0200 Subject: [PATCH 20/61] Planner: only skip short slot if more slots follow (#34604) --- core/loadpoint_plan.go | 4 ++-- core/planner/helper.go | 14 +++++++++----- core/planner/helper_test.go | 24 ++++++++++++------------ 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/core/loadpoint_plan.go b/core/loadpoint_plan.go index eabb4d0cb3..932607ac3c 100644 --- a/core/loadpoint_plan.go +++ b/core/loadpoint_plan.go @@ -209,8 +209,8 @@ func (lp *Loadpoint) plannerActive() (active bool) { active = !activeSlot.End.IsZero() 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/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) { From ebe213ef469befdfec2c00e720518e04c96911d5 Mon Sep 17 00:00:00 2001 From: Ingo <71161062+iseeberg79@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:57:23 +0200 Subject: [PATCH 21/61] Kostal Plenticore: restore manual maxacpower configuration (#34133) --- templates/definition/meter/kostal-plenticore-gen2.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/definition/meter/kostal-plenticore-gen2.yaml b/templates/definition/meter/kostal-plenticore-gen2.yaml index bb4ee23700..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} @@ -79,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 From 8630dcb13e259b84a91cb37135665dc4ca84b1b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustavo=20Cha=C3=ADn?= Date: Sat, 10 Oct 2026 12:57:27 +0200 Subject: [PATCH 22/61] chore: simplify error handling (#34615) --- vehicle/skoda.go | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) 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 } From 942890d1e62490e6a10841a2c59e1d17b2e0bce1 Mon Sep 17 00:00:00 2001 From: CDank Date: Sat, 10 Oct 2026 13:07:32 +0200 Subject: [PATCH 23/61] Home Assistant charger: add temperature entity for heating devices (#34614) --- charger/homeassistant.go | 6 ++++ .../definition/charger/homeassistant.yaml | 11 +++++++ util/homeassistant/connection.go | 21 +++++++++++++ util/homeassistant/types.go | 14 +++++++++ util/homeassistant/types_test.go | 31 +++++++++++++++++++ 5 files changed, 83 insertions(+) create mode 100644 util/homeassistant/types_test.go 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/templates/definition/charger/homeassistant.yaml b/templates/definition/charger/homeassistant.yaml index d582c55ed2..fb5f8ec765 100644 --- a/templates/definition/charger/homeassistant.yaml +++ b/templates/definition/charger/homeassistant.yaml @@ -120,6 +120,16 @@ params: 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 + 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 @@ -210,6 +220,7 @@ render: | milliamps: {{ .milliamps }} power: {{ .power }} energy: {{ .energy }} + temp: {{ .temp }} {{- if and .currentL1 .currentL2 .currentL3 }} currents: - {{ .currentL1 }} 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/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) + } +} From f399a40b63a990cf4154d943ea926285b60ddfea Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 10 Oct 2026 13:14:58 +0200 Subject: [PATCH 24/61] Tibber: apply charge zones without other charges (#34643) --- tariff/tibber.go | 2 +- tariff/tibber_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tariff/tibber_test.go 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") +} From d3ab6d36256a239265c8c315bde5562d922b3a10 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 10 Oct 2026 13:26:31 +0200 Subject: [PATCH 25/61] Translations update from Hosted Weblate (#34076) Co-authored-by: FraBoCH <19388397+FraBoCH@users.noreply.github.com> Co-authored-by: Filipe Lima Co-authored-by: shpokas Co-authored-by: aerucu Co-authored-by: amtssp Co-authored-by: markus-steffen Co-authored-by: Martin Schmiedel Dopke Co-authored-by: aaronchase64 Co-authored-by: Clark2309 Co-authored-by: Nicola Corna Co-authored-by: Richard E Co-authored-by: Frank van der Aa Co-authored-by: RTTTC <94727758+RTTTC@users.noreply.github.com> Co-authored-by: andig --- i18n/da.json | 66 +++++ i18n/de.json | 8 +- i18n/es.json | 723 +++++++++++++++++++++++++++++++++++++++------------ i18n/fr.json | 238 ++++++++++++++--- i18n/it.json | 50 ++++ i18n/lb.json | 203 +++++++++++++-- i18n/lt.json | 226 ++++++++++++++-- i18n/lv.json | 228 +++++++++++++--- i18n/nl.json | 194 +++++++++++--- i18n/pt.json | 347 +++++++++++++++++++++--- i18n/sk.json | 240 ++++++++++++++--- i18n/tr.json | 91 ++++++- 12 files changed, 2207 insertions(+), 407 deletions(-) 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 a47a0deebc..21f690f43f 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", @@ -788,7 +788,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.", @@ -913,7 +913,7 @@ "grid": "Netzanschluss", "integrations": "Integrationen", "loadpoints": "Ladepunkte & Heizungen", - "meter": "PV & Batterie", + "meter": "PV & Speicher", "services": "Dienste", "system": "System", "vehicles": "Fahrzeuge" 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", From ced79e331a346fb10468f5527f5bc7fea0e999a6 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 11 Oct 2026 11:40:46 +0200 Subject: [PATCH 26/61] Optimizer: energy plan goal from remaining plan energy (#34644) --- core/loadpoint/api.go | 2 ++ core/loadpoint/mock.go | 14 ++++++++++++++ core/loadpoint_plan.go | 8 ++++++++ core/site_optimizer.go | 3 ++- core/site_optimizer_test.go | 23 +++++++++++++++++++++++ 5 files changed, 49 insertions(+), 1 deletion(-) diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index 9a7f13c9fc..74dcee503a 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 diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index d2204ff3ce..931de437ef 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -559,6 +559,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() diff --git a/core/loadpoint_plan.go b/core/loadpoint_plan.go index 932607ac3c..613e729a73 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() diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 835c6fded3..dd0fa05c2d 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -1299,7 +1299,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_test.go b/core/site_optimizer_test.go index 85ee48b74c..d14afd572b 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 From f0569795076fdc3101fde13194e363d6d8cb19b1 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 11 Oct 2026 11:57:56 +0200 Subject: [PATCH 27/61] Optimizer: add automatic mode (#32881) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Michael Geers Co-authored-by: Fabian Pittroff <39737699+cp-fabian-pittroff@users.noreply.github.com> Co-authored-by: Alexandre JARDON <28548335+webalexeu@users.noreply.github.com> Co-authored-by: Ingo <71161062+iseeberg79@users.noreply.github.com> --- assets/css/app.css | 6 + .../components/Battery/BatteryConfigCard.vue | 70 ++- .../ChargingPlans/ChargingPlanModal.vue | 1 + .../ChargingPlans/PlansSettings.vue | 15 +- .../ChargingPlans/Preview.stories.ts | 9 + .../js/components/ChargingPlans/Preview.vue | 18 +- .../js/components/Config/OptimizerModal.vue | 59 +- assets/js/components/Loadpoints/Loadpoint.vue | 1 + .../components/Loadpoints/SettingsModal.vue | 26 +- .../components/MaterialIcon/OptimizerAuto.vue | 19 + .../Optimize/AutomaticModeStrip.vue | 67 +++ .../js/components/Tariff/SmartCostLimit.vue | 2 + .../components/Tariff/SmartFeedInPriority.vue | 2 + .../js/components/Tariff/SmartTariffBase.vue | 164 +++--- assets/js/components/Vehicles/Status.vue | 20 +- assets/js/components/Vehicles/Vehicle.vue | 1 + assets/js/types/evcc.ts | 15 + assets/js/views/Battery.vue | 20 +- assets/js/views/Optimize.vue | 87 ++- cmd/root.go | 1 + core/keys/global.go | 1 + core/keys/loadpoint.go | 2 + core/loadpoint.go | 41 +- core/loadpoint/api.go | 6 +- core/loadpoint/config.go | 12 +- core/loadpoint/mock.go | 28 +- core/loadpoint_api.go | 57 +- core/loadpoint_boost_test.go | 9 + core/loadpoint_optimizer.go | 186 ++++++ core/loadpoint_plan.go | 28 +- core/loadpoint_test.go | 3 +- core/optimizer_automatic_test.go | 551 ++++++++++++++++++ core/optimizer_schedule_test.go | 23 + core/site.go | 40 +- core/site/api.go | 4 + core/site_api.go | 45 +- core/site_battery.go | 55 ++ core/site_optimizer.go | 119 +++- core/site_optimizer_test.go | 52 +- core/types/types.go | 1 + i18n/de.json | 25 +- i18n/en.json | 25 +- scripts/state-schema/schemas.ts | 1 + server/http.go | 17 +- server/http_config_loadpoint_handler.go | 4 +- server/http_config_site_other_handler.go | 24 + server/http_loadpoint_handler.go | 21 +- server/http_site_handler.go | 41 +- server/http_site_handler_test.go | 18 + server/http_vehicle_handler.go | 6 + server/mcp/openapi.json | 74 +++ server/mqtt.go | 20 +- server/openapi.state.yaml | 19 + server/openapi.yaml | 30 + templates/definition/meter/fronius-gen24.yaml | 39 ++ tests/battery-settings.spec.ts | 50 ++ tests/optimize.spec.ts | 15 +- 57 files changed, 2083 insertions(+), 212 deletions(-) create mode 100644 assets/js/components/MaterialIcon/OptimizerAuto.vue create mode 100644 assets/js/components/Optimize/AutomaticModeStrip.vue create mode 100644 core/loadpoint_optimizer.go create mode 100644 core/optimizer_automatic_test.go create mode 100644 server/http_site_handler_test.go diff --git a/assets/css/app.css b/assets/css/app.css index 5b502a7353..b8c86a04c9 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -96,6 +96,9 @@ "Noto Color Emoji"; --bs-success: var(--evcc-primary); --bs-success-rgb: var(--bs-primary-rgb); + --evcc-success-bg: oklch(from var(--evcc-darker-green) 96% 0.035 h); + --evcc-success-text: oklch(from var(--evcc-darker-green) 45% 0.11 h); + --evcc-success-text-secondary: oklch(from var(--evcc-darker-green) 52% 0.08 h); --bs-code-color: var(--evcc-darkest-green); --evcc-backdrop: #93949eaa; /* --bs-gray-medium + transparent */ @@ -127,6 +130,9 @@ --bs-border-color-translucent: rgba(255, 255, 255, 0.08); --bs-code-color: var(--evcc-dark-green); --evcc-backdrop: #000000aa; /* black + transparent */ + --evcc-success-bg: oklch(from var(--evcc-darker-green) 30% 0.06 h); + --evcc-success-text: oklch(from var(--evcc-darker-green) 82% 0.17 h); + --evcc-success-text-secondary: oklch(from var(--evcc-darker-green) 80% 0.07 h); --tab-bar-background: var(--evcc-box); color-scheme: dark; } diff --git a/assets/js/components/Battery/BatteryConfigCard.vue b/assets/js/components/Battery/BatteryConfigCard.vue index d4d05a936d..6586ff413f 100644 --- a/assets/js/components/Battery/BatteryConfigCard.vue +++ b/assets/js/components/Battery/BatteryConfigCard.vue @@ -6,7 +6,9 @@ class="text-primary flex-shrink-0 mt-1" >
-
{{ $t("battery.config.priorityTitle") }}
+
+ {{ $t("battery.config.priorityTitle") }} +
-
{{ $t("battery.config.bufferTitle") }}
+
+ {{ $t("battery.config.bufferTitle") }} +
+ + + + + +

+ @@ -40,17 +54,19 @@ import { defineComponent, type PropType } from "vue"; import formatter from "@/mixins/formatter"; import minuteTicker from "@/mixins/minuteTicker"; import TariffChart from "../Tariff/TariffChart.vue"; +import OptimizerAuto from "../MaterialIcon/OptimizerAuto.vue"; import { SMART_COST_TYPE, type CURRENCY, type Rate, type Slot } from "@/types/evcc"; export default defineComponent({ name: "ChargingPlanPreview", - components: { TariffChart }, + components: { TariffChart, OptimizerAuto }, mixins: [formatter, minuteTicker], props: { duration: Number, power: Number, rates: Array as PropType, plan: Array as PropType, + optimizer: Boolean, smartCostType: String as PropType, targetTime: [Date, null], currency: String as PropType, diff --git a/assets/js/components/Config/OptimizerModal.vue b/assets/js/components/Config/OptimizerModal.vue index 757710ffda..b136d28d04 100644 --- a/assets/js/components/Config/OptimizerModal.vue +++ b/assets/js/components/Config/OptimizerModal.vue @@ -27,6 +27,41 @@
+
+ +
+ +
+ + +
+ {{ $t(`config.optimizer.automaticLevel.${automatic}.description`) }} +
+
+
+

{{ $t("config.optimizer.info") }}

@@ -41,6 +76,7 @@ import SponsorTokenRequired from "./DeviceModal/SponsorTokenRequired.vue"; import api from "@/api"; import store from "@/store"; import { docsPrefix } from "@/i18n"; +import { OPTIMIZER_AUTOMATIC } from "@/types/evcc"; import type { AxiosError } from "axios"; export default defineComponent({ @@ -52,12 +88,22 @@ export default defineComponent({ data() { return { error: null as string | null, + levels: [OPTIMIZER_AUTOMATIC.BATTERY, OPTIMIZER_AUTOMATIC.FULL], }; }, computed: { enabled(): boolean { return !!store.state?.optimizer; }, + automatic(): OPTIMIZER_AUTOMATIC { + return store.state?.optimizerAutomatic || OPTIMIZER_AUTOMATIC.OFF; + }, + automaticEnabled(): boolean { + return this.automatic !== OPTIMIZER_AUTOMATIC.OFF; + }, + level(): OPTIMIZER_AUTOMATIC { + return this.automaticEnabled ? this.automatic : OPTIMIZER_AUTOMATIC.BATTERY; + }, hasEvopt(): boolean { return !!store.state?.evopt; }, @@ -67,9 +113,20 @@ export default defineComponent({ }, methods: { async change(e: Event) { + await this.post(`config/optimizer/${(e.target as HTMLInputElement).checked}`); + }, + async changeAutomatic(e: Event) { + const { checked } = e.target as HTMLInputElement; + const level = checked ? OPTIMIZER_AUTOMATIC.BATTERY : OPTIMIZER_AUTOMATIC.OFF; + await this.post(`config/optimizerautomatic/${level}`); + }, + async changeLevel(e: Event) { + await this.post(`config/optimizerautomatic/${(e.target as HTMLSelectElement).value}`); + }, + async post(url: string) { try { this.error = null; - await api.post(`config/optimizer/${(e.target as HTMLInputElement).checked}`); + await api.post(url); } catch (err) { const e = err as AxiosError<{ error: string }>; this.error = e.response?.data?.error || e.message; diff --git a/assets/js/components/Loadpoints/Loadpoint.vue b/assets/js/components/Loadpoints/Loadpoint.vue index ef41641d11..9dd49e37e7 100644 --- a/assets/js/components/Loadpoints/Loadpoint.vue +++ b/assets/js/components/Loadpoints/Loadpoint.vue @@ -250,6 +250,7 @@ export default defineComponent({ smartFeedInPriorityAvailable: Boolean, smartFeedInPriorityActive: Boolean, smartFeedInPriorityNextStart: String as PropType, + optimizerControlled: Boolean, suggestion: Object as PropType, tariffGrid: Number, tariffFeedIn: Number, diff --git a/assets/js/components/Loadpoints/SettingsModal.vue b/assets/js/components/Loadpoints/SettingsModal.vue index bae9de9196..5597268498 100644 --- a/assets/js/components/Loadpoints/SettingsModal.vue +++ b/assets/js/components/Loadpoints/SettingsModal.vue @@ -18,6 +18,7 @@ :loadpoint-id="id" :multiple-loadpoints="multipleLoadpoints" :possible="smartCostAvailable" + :disabled-hint="optimizerHint" :tariff="forecast?.planner" class="mt-2 mb-4" /> @@ -29,13 +30,14 @@ is-loadpoint :multiple-loadpoints="multipleLoadpoints" :possible="smartFeedInPriorityAvailable" + :disabled-hint="optimizerHint" :tariff="forecast?.feedin" class="mt-2 mb-4" />
{{ $t("main.loadpointSettings.solar") }}
-
+
@@ -44,7 +46,7 @@ v-model="selectedSolarShare" class="col-sm-8 col-lg-4 pe-0" :class="{ 'opacity-50': thresholdsConfigured }" - :disabled="thresholdsConfigured" + :disabled="thresholdsConfigured || !!optimizerHint" @change="setSolarShare" />
@@ -62,6 +64,16 @@
+

+ + + + +

, default: () => [] }, + loadpoints: { + type: Array as PropType, + default: () => [], + }, batteryConfigured: Boolean, smartCostType: String as PropType, smartCostAvailable: Boolean, @@ -256,6 +273,9 @@ export default defineComponent({ loadpoint() { return this.loadpoints.find((loadpoint) => loadpoint.id === this.id); }, + optimizerHint(): string { + return this.loadpoint?.optimizerControlled ? "config.optimizer.controlled" : ""; + }, maxCurrent() { return this.loadpoint?.maxCurrent; }, diff --git a/assets/js/components/MaterialIcon/OptimizerAuto.vue b/assets/js/components/MaterialIcon/OptimizerAuto.vue new file mode 100644 index 0000000000..403cf18e4c --- /dev/null +++ b/assets/js/components/MaterialIcon/OptimizerAuto.vue @@ -0,0 +1,19 @@ + + + diff --git a/assets/js/components/Optimize/AutomaticModeStrip.vue b/assets/js/components/Optimize/AutomaticModeStrip.vue new file mode 100644 index 0000000000..d71ac70756 --- /dev/null +++ b/assets/js/components/Optimize/AutomaticModeStrip.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/assets/js/components/Tariff/SmartCostLimit.vue b/assets/js/components/Tariff/SmartCostLimit.vue index bfc3898354..516759ebc5 100644 --- a/assets/js/components/Tariff/SmartCostLimit.vue +++ b/assets/js/components/Tariff/SmartCostLimit.vue @@ -7,6 +7,7 @@ :currency="currency" :apply-all="multipleLoadpoints && isLoadpoint" :possible="possible" + :disabled-hint="disabledHint" :tariff="tariff" :form-id="formId" :is-slot-active="isSlotActive" @@ -40,6 +41,7 @@ export default defineComponent({ isLoadpoint: Boolean, loadpointId: String, possible: Boolean, + disabledHint: String, lastLimit: Number, tariff: Array as PropType, }, diff --git a/assets/js/components/Tariff/SmartFeedInPriority.vue b/assets/js/components/Tariff/SmartFeedInPriority.vue index 9c4bc7fc95..c941a17713 100644 --- a/assets/js/components/Tariff/SmartFeedInPriority.vue +++ b/assets/js/components/Tariff/SmartFeedInPriority.vue @@ -6,6 +6,7 @@ :currency="currency" :apply-all="isLoadpoint && multipleLoadpoints" :possible="possible" + :disabled-hint="disabledHint" :tariff="tariff" :form-id="formId" :is-slot-active="isSlotActive" @@ -41,6 +42,7 @@ export default defineComponent({ isLoadpoint: Boolean, multipleLoadpoints: Boolean, possible: Boolean, + disabledHint: String, tariff: Array, }, computed: { diff --git a/assets/js/components/Tariff/SmartTariffBase.vue b/assets/js/components/Tariff/SmartTariffBase.vue index 6cd7bc02c5..3a3fe3c055 100644 --- a/assets/js/components/Tariff/SmartTariffBase.vue +++ b/assets/js/components/Tariff/SmartTariffBase.vue @@ -1,84 +1,106 @@

-
+
+ d.modes?.includes("discharge") + ); + return this.experimental && (supported || this.batteryGridDischarge); + }, controlledTitleList(): string { return new Intl.ListFormat(this.$i18n?.locale).format(this.optimizerControlledTitles); }, diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 4458bd90dc..c7875b11a5 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -1241,6 +1241,8 @@ export interface BatteryMeter extends Meter { soc: number; /** Battery supports external control of its operation mode. */ controllable: boolean; + /** Battery modes supported by the device. */ + modes?: string[]; /** Battery capacity in kWh. Zero when not specified. */ capacity: number; /** Battery optimizer suggestion. */ diff --git a/core/site.go b/core/site.go index 26043e43c8..621ae18770 100644 --- a/core/site.go +++ b/core/site.go @@ -825,8 +825,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 diff --git a/core/types/types.go b/core/types/types.go index c7959556dd..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"` } 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 61be8eabc8..548d94c638 100644 --- a/server/mcp/openapi.json +++ b/server/mcp/openapi.json @@ -4400,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" 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 3daca0b28a..3ba957570f 100644 --- a/server/openapi.state.yaml +++ b/server/openapi.state.yaml @@ -412,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 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 From 0306287bf2e92868df9d4b09d6580c28f49f79a6 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 11 Oct 2026 13:22:46 +0200 Subject: [PATCH 32/61] SENEC Connect: add .Home V4 product (#34688) --- templates/definition/meter/senec-connect.yaml | 3 +++ 1 file changed, 3 insertions(+) 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 From f28efb90ab53d12e229a8c58fc31553f8a9a6cf6 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 11 Oct 2026 14:24:47 +0200 Subject: [PATCH 33/61] Optimizer: ignore prioritySoc on loadpoints it controls (#34689) --- core/optimizer_automatic_test.go | 38 +++++++++++++++++++++++++++++++ core/site.go | 39 +++++++++++++++++++++----------- docs/agents/core-domain.md | 2 ++ 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/core/optimizer_automatic_test.go b/core/optimizer_automatic_test.go index 6a4b7333ea..927bf9ba00 100644 --- a/core/optimizer_automatic_test.go +++ b/core/optimizer_automatic_test.go @@ -225,6 +225,44 @@ func TestOptimizerStopFollowsSurplus(t *testing.T) { 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) { diff --git a/core/site.go b/core/site.go index 621ae18770..8a77a62065 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) @@ -1152,7 +1153,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 } @@ -1251,6 +1252,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 { @@ -1374,19 +1398,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), ) 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 ``` From 63544a3240ed0b08ceca23b70fcfe2a4e5a90015 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sun, 11 Oct 2026 14:34:04 +0200 Subject: [PATCH 34/61] Config: fix "false" placeholder on fields without default value (#34655) --- assets/js/components/Config/PropertyEntry.vue | 2 +- assets/js/components/Config/PropertyField.vue | 2 +- tests/config-loadpoint.spec.ts | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/assets/js/components/Config/PropertyEntry.vue b/assets/js/components/Config/PropertyEntry.vue index 3bcccaa63f..28b7393369 100644 --- a/assets/js/components/Config/PropertyEntry.vue +++ b/assets/js/components/Config/PropertyEntry.vue @@ -45,7 +45,7 @@ export default { Description: String, Help: String, Example: String, - Default: [String, Number, Boolean], + Default: { type: [String, Number, Boolean], default: undefined }, Type: String, Unit: String, Mask: Boolean, diff --git a/assets/js/components/Config/PropertyField.vue b/assets/js/components/Config/PropertyField.vue index 3a035cbb72..f5f1f68d75 100644 --- a/assets/js/components/Config/PropertyField.vue +++ b/assets/js/components/Config/PropertyField.vue @@ -186,7 +186,7 @@ export default { property: String, masked: Boolean, placeholder: String, - defaultValue: [String, Number, Boolean], + defaultValue: { type: [String, Number, Boolean], default: undefined }, type: String, unit: String, // transitional: emit ns numbers, remove once all callers accept duration strings (loadpoint follow-up) 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 From dfc0468af4f845e2575d7186f8299e107826b66d Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sun, 11 Oct 2026 14:34:55 +0200 Subject: [PATCH 35/61] Home Assistant: improved entity suggestions (#34654) --- .../js/components/Helper/Combobox.stories.ts | 17 ++++++ assets/js/components/Helper/Combobox.vue | 46 ++++++---------- server/service/registry.go | 9 ++++ .../charger/homeassistant-switch.yaml | 6 +-- .../definition/charger/homeassistant.yaml | 20 +++---- templates/definition/meter/homeassistant.yaml | 24 ++++----- .../definition/vehicle/homeassistant.yaml | 10 ++-- util/homeassistant/service.go | 54 +++++++++++++------ util/homeassistant/service_test.go | 41 ++++++++++++++ util/service/demo.go | 2 +- util/service/helper.go | 9 ---- util/service/network.go | 4 +- 12 files changed, 154 insertions(+), 88 deletions(-) create mode 100644 util/homeassistant/service_test.go diff --git a/assets/js/components/Helper/Combobox.stories.ts b/assets/js/components/Helper/Combobox.stories.ts index 4a6302b778..6860f44f5e 100644 --- a/assets/js/components/Helper/Combobox.stories.ts +++ b/assets/js/components/Helper/Combobox.stories.ts @@ -37,6 +37,23 @@ Matching.args = { options: options.map((o) => ({ ...o, match: o.hint?.startsWith("Fronius") })), }; +const entities = [ + { value: "sensor.grid_power", label: "Grid Power", hint: "-25 W", match: true }, + { value: "sensor.home_power", label: "Home Power", hint: "635 W", match: true, used: true }, + { + value: "sensor.backup_next_scheduled_automatic_backup", + label: "Backup Next scheduled automatic backup", + hint: "2026-10-11T03:42:17+00:00", + }, + { value: "sensor.backup_manager_state", label: "Backup Manager state", hint: "idle" }, + { value: "sensor.battery_soc", label: "Battery", hint: "87 %" }, + { value: "sensor.outdoor_temperature", label: "Outdoor", hint: "12.4 °C" }, + { value: "sensor.unavailable_device", label: "Unavailable device" }, +]; + +export const Entities = Template.bind({}); +Entities.args = { id: "power", options: entities }; + export const Selected = Template.bind({}); Selected.args = { ...Default.args, modelValue: "192.168.31.21" }; diff --git a/assets/js/components/Helper/Combobox.vue b/assets/js/components/Helper/Combobox.vue index 4729df5645..78244f2991 100644 --- a/assets/js/components/Helper/Combobox.vue +++ b/assets/js/components/Helper/Combobox.vue @@ -66,18 +66,20 @@ :aria-selected="option.value === activeValue" @click="select(option)" > -
{{ option.value }}
+
+
{{ option.value }}
+ + {{ $t("config.combobox.used") }} + +
- {{ option.label }} -
- - {{ $t("config.combobox.used") }} - -
- {{ option.hint }} +
{{ option.label }}
+
+ {{ option.hint }} +
@@ -266,26 +268,13 @@ export default defineComponent({ .options { max-height: 20rem; } -/* address and name left, badge and brand right, brand is cut first */ -.option { - display: grid; - grid-template-columns: minmax(0, 1fr) fit-content(50%); - column-gap: 1rem; - align-items: center; -} .option-value { - grid-area: 1 / 1; -} -.option-label { - grid-area: 2 / 1; -} -.option-badge { - grid-area: 1 / 2; - justify-self: end; + font-size: var(--bs-body-font-size); } +/* label is cut before the hint, hint never takes more than half the row */ .option-hint { - grid-area: 2 / 2; - text-align: end; + flex-shrink: 0; + max-width: 50%; } .searching { border-top: var(--bs-dropdown-border-width) solid var(--bs-dropdown-divider-bg); @@ -298,7 +287,6 @@ export default defineComponent({ cursor: pointer; } .option--active { - color: var(--bs-dropdown-link-hover-color); - background-color: var(--bs-dropdown-link-hover-bg); + background-color: var(--evcc-gray-25); } 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/templates/definition/charger/homeassistant-switch.yaml b/templates/definition/charger/homeassistant-switch.yaml index e28b94802d..3ba747186e 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 - name: temp description: @@ -48,7 +48,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 @@ -58,7 +58,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 fb5f8ec765..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,7 +114,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.charger_energy" advanced: true help: @@ -124,7 +124,7 @@ params: description: de: Temperaturentität en: Temperature entity - service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor,input_number + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor,input_number&unit=°C,°F,K example: "sensor.boiler_temperature" advanced: true help: @@ -134,7 +134,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.charger_current_l1" advanced: true help: @@ -144,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: @@ -154,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: @@ -164,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: @@ -174,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: @@ -184,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: 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/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/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/service/demo.go b/util/service/demo.go index c5a2e03232..2c2981b11d 100644 --- a/util/service/demo.go +++ b/util/service/demo.go @@ -42,7 +42,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}, 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/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), From 470fd6a1db0a95b716c41c399b917c9100a494da Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 11 Oct 2026 14:57:41 +0200 Subject: [PATCH 36/61] Tibber: rich vehicle suggestions (#34693) --- vehicle/tibber/service.go | 35 +++++++++++++++++++++++++--------- vehicle/tibber/service_test.go | 24 +++++++++++++++++++++++ 2 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 vehicle/tibber/service_test.go 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)) +} From bc539ee237a0b17532d2a77994c0f78b5afa2f52 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 11 Oct 2026 14:58:19 +0200 Subject: [PATCH 37/61] Octopus: rich device suggestions (#34694) --- vehicle/octopuskraken/service.go | 35 +++++++++++++++++++----- vehicle/octopuskraken/service_test.go | 38 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 vehicle/octopuskraken/service_test.go 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)) + }) + } +} From f15588c34f407f79b5cfc9ac7f8685b7c0df916a Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 11 Oct 2026 14:58:45 +0200 Subject: [PATCH 38/61] Circuit: drop stale service todo on meter param (#34695) --- templates/definition/circuit/static.yaml | 1 - 1 file changed, 1 deletion(-) 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 From 2919f74d8a74617b131eb0ef714125b203509811 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 11 Oct 2026 14:59:27 +0200 Subject: [PATCH 39/61] EEBus: rich device suggestions for SKI (#34696) --- server/eebus/eebus.go | 7 +++++++ server/eebus/service.go | 26 ++++++++++++++++++++++---- server/eebus/service_test.go | 26 ++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 server/eebus/service_test.go 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)) +} From 90d83663e075ee67de248e90b7837905b3b1e1c7 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 11 Oct 2026 15:02:21 +0200 Subject: [PATCH 40/61] Viessmann: rich equipment suggestions (#34697) --- charger/viessmann/api.go | 15 +++++++++-- charger/viessmann/api_test.go | 9 ++++--- charger/viessmann/service.go | 43 ++++++++++++++++++++++--------- charger/viessmann/service_test.go | 42 +++++++++++++++++++++++++----- 4 files changed, 85 insertions(+), 24 deletions(-) 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 From af62776af253405478e435b396ee2caefcca4cee Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 11 Oct 2026 15:05:04 +0200 Subject: [PATCH 41/61] Fritz!DECT: rich device suggestions (#34692) --- meter/fritz/smarthome/service.go | 37 ++++++++++++++++++++++----- meter/fritz/smarthome/service_test.go | 25 ++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 meter/fritz/smarthome/service_test.go 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)) +} From 12bc7eeea472843150e588ef9604b0ebdcf453d1 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 11 Oct 2026 15:11:46 +0200 Subject: [PATCH 42/61] Tasmota: channel suggestions (#34703) --- meter/tasmota/service.go | 117 ++++++++++++++++++++++ meter/tasmota/service_test.go | 111 ++++++++++++++++++++ templates/definition/charger/tasmota.yaml | 1 + templates/definition/meter/tasmota.yaml | 1 + 4 files changed, 230 insertions(+) create mode 100644 meter/tasmota/service.go create mode 100644 meter/tasmota/service_test.go 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/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/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 }} From 78ae0eebae2ec37625effbc9059afbdb73aa6f10 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 11 Oct 2026 15:12:50 +0200 Subject: [PATCH 43/61] Shelly: channel suggestions (#34701) --- meter/shelly/connection.go | 65 +++++--- meter/shelly/gen1.go | 10 +- meter/shelly/gen2.go | 9 +- meter/shelly/service.go | 181 +++++++++++++++++++++ meter/shelly/service_test.go | 141 ++++++++++++++++ templates/definition/charger/shelly.yaml | 1 + templates/definition/meter/shelly-1pm.yaml | 1 + 7 files changed, 370 insertions(+), 38 deletions(-) create mode 100644 meter/shelly/service.go create mode 100644 meter/shelly/service_test.go 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/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/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) From c27b74deb761c259f869fc5a0d234387afef140a Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 11 Oct 2026 15:16:35 +0200 Subject: [PATCH 44/61] Modbus: rich serial port suggestions (#34700) --- .../components/Config/DeviceModal/Modbus.vue | 9 +++--- util/service/hardware.go | 21 ++++++++++++-- util/service/hardware_darwin.go | 10 +++++++ util/service/hardware_enumerator.go | 29 +++++++++++++++++++ 4 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 util/service/hardware_darwin.go create mode 100644 util/service/hardware_enumerator.go diff --git a/assets/js/components/Config/DeviceModal/Modbus.vue b/assets/js/components/Config/DeviceModal/Modbus.vue index 2cfe8486f8..11317e0513 100644 --- a/assets/js/components/Config/DeviceModal/Modbus.vue +++ b/assets/js/components/Config/DeviceModal/Modbus.vue @@ -217,7 +217,7 @@ export default defineComponent({ protocol: MODBUS_PROTOCOL.TCP as MODBUS_PROTOCOL, MODBUS_PROTOCOL, MODBUS_CONNECTION, - deviceServiceValues: [] as string[], + deviceServiceValues: [] as ServiceValue[], localDevice: undefined as string | undefined, }; }, @@ -333,17 +333,18 @@ export default defineComponent({ return `${name}-${this.componentId}`; }, async updateServiceValues() { - this.deviceServiceValues = serviceDefaults(await loadServiceValues("hardware/serial")); + this.deviceServiceValues = await loadServiceValues("hardware/serial"); this.applyServiceDefault(); }, applyServiceDefault() { // auto-apply device value if it's needed and exactly one option exists + const defaults = serviceDefaults(this.deviceServiceValues); if ( this.connection === MODBUS_CONNECTION.SERIAL && - this.deviceServiceValues.length === 1 && + defaults.length === 1 && !this.device ) { - this.deviceModel = this.deviceServiceValues[0]; + this.deviceModel = defaults[0]; } }, }, 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 +} From 08bf5fd2339bde35d833d81aaa971a6269f12dcb Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 11 Oct 2026 15:17:34 +0200 Subject: [PATCH 45/61] Service: http/get label and hint for rich suggestions (#34699) --- .../tariff/pvnode-v2-temperature.yaml | 2 +- templates/definition/tariff/pvnode-v2.yaml | 2 +- util/service/http.go | 80 ++++++++++++++----- util/service/http_test.go | 62 ++++++++++++++ 4 files changed, 124 insertions(+), 22 deletions(-) create mode 100644 util/service/http_test.go diff --git a/templates/definition/tariff/pvnode-v2-temperature.yaml b/templates/definition/tariff/pvnode-v2-temperature.yaml index 4de1b7e4aa..e52f4ef6a4 100644 --- a/templates/definition/tariff/pvnode-v2-temperature.yaml +++ b/templates/definition/tariff/pvnode-v2-temperature.yaml @@ -20,7 +20,7 @@ params: required: true - name: siteid 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/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/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) + }) + } +} From fff5c5dfe3f3f8d737438b840e013d2b6a15b842 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 11 Oct 2026 15:29:44 +0200 Subject: [PATCH 46/61] Modbus proxy: report upstream timeouts as gateway target failure (#34705) --- server/modbus/handler.go | 23 ++++++++++------- server/modbus/proxy_test.go | 51 +++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 9 deletions(-) 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 From 1a8eeaf4c9fc8a541f1a434d581116558dfec661 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 12 Oct 2026 09:36:17 +0200 Subject: [PATCH 47/61] Easee: lock 3p at charger level instead of switching to auto (#34640) --- charger/easee.go | 13 +++------ charger/easee_test.go | 48 +++++++++++++++++++++++++++++++ docs/agents/easee-architecture.md | 2 +- 3 files changed, 53 insertions(+), 10 deletions(-) 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/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`) From 95680c40533949f57bac1361bae821bb4c0573f2 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 12 Oct 2026 11:03:25 +0200 Subject: [PATCH 48/61] Config UI: fill empty optional params in service urls (#34702) Co-authored-by: Claude Fable 5.1 Co-authored-by: Michael Geers --- .../Config/DeviceModal/index.test.ts | 25 ++++++++++++++++--- .../js/components/Config/DeviceModal/index.ts | 9 +++++-- templates/README.md | 2 +- tests/config-param-service-demo.tpl.yaml | 6 +++++ tests/config-param-service.spec.ts | 12 +++++++++ util/service/demo.go | 5 ++++ 6 files changed, 52 insertions(+), 7 deletions(-) diff --git a/assets/js/components/Config/DeviceModal/index.test.ts b/assets/js/components/Config/DeviceModal/index.test.ts index f228eea9b6..fe64b19057 100644 --- a/assets/js/components/Config/DeviceModal/index.test.ts +++ b/assets/js/components/Config/DeviceModal/index.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; import { applyDefaultsFromTemplate, createServiceEndpoints, type TemplateParam } from "./index"; -const buildParam = (name: string, service?: string): TemplateParam => ({ +const buildParam = (name: string, service?: string, required = false): TemplateParam => ({ Name: name, - Required: false, + Required: required, Advanced: false, Deprecated: false, Service: service, @@ -17,7 +17,7 @@ describe("createServiceEndpoints", () => { }); it("replaces single placeholder", () => { - const params = [buildParam("home", "homes"), buildParam("power", "homes/{home}/sensors")]; + const params = [buildParam("home", "homes", true), buildParam("power", "homes/{home}/sensors")]; const endpoints = createServiceEndpoints(params); const homeEndpoint = endpoints.find(({ name }) => name === "home")!; const powerEndpoint = endpoints.find(({ name }) => name === "power")!; @@ -38,7 +38,7 @@ describe("createServiceEndpoints", () => { }); it("encodes replacements", () => { - const params = [buildParam("token", "homes/{home}/sensors/{sensor}?token={token}")]; + const params = [buildParam("token", "homes/{home}/sensors/{sensor}?token={token}", true)]; const endpoints = createServiceEndpoints(params); const tokenEndpoint = endpoints[0]!; expect(tokenEndpoint.url({ home: "hq", sensor: "bat/tery", token: "a+b c" })).toBe( @@ -99,6 +99,23 @@ describe("createServiceEndpoints", () => { // Non-empty value should replace placeholder expect(endpoints[0]!.url({ home: "main" })).toBe("homes/main/sensors"); }); + + it("fills empty optional params, required ones keep blocking", () => { + const params = [ + buildParam("host", undefined, true), + buildParam("user"), + buildParam("password"), + buildParam("channel", "shelly/channels?uri={host}&user={user}&password={password}"), + ]; + const endpoints = createServiceEndpoints(params); + const channel = endpoints.find(({ name }) => name === "channel")!; + + expect(channel.url({ host: "shelly" })).toBe("shelly/channels?uri=shelly&user=&password="); + expect(channel.url({ host: "shelly", user: "admin", password: "" })).toBe( + "shelly/channels?uri=shelly&user=admin&password=" + ); + expect(channel.url({ user: "admin" })).toBe("shelly/channels?uri={host}&user=admin&password="); + }); }); describe("applyDefaultsFromTemplate", () => { diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts index 8c29d3f041..c33082d1d7 100644 --- a/assets/js/components/Config/DeviceModal/index.ts +++ b/assets/js/components/Config/DeviceModal/index.ts @@ -175,7 +175,8 @@ export async function loadServiceValues(path: string, retry?: (seconds: number) }); const retryAfter = Number(response.headers["retry-after"]); if (retryAfter > 0) retry?.(retryAfter); - return (response.data as ServiceValue[]) || []; + // 4xx bodies (e.g. a 404 for a path with an empty optional param) are not suggestions + return Array.isArray(response.data) ? (response.data as ServiceValue[]) : []; } catch { return []; } @@ -209,6 +210,10 @@ export const createServiceEndpoints = (params: TemplateParam[]): ParamService[] if (!param.Service) { return null; } + // empty optional params (e.g. user/password) must not block the fetch + const optional = extractPlaceholders(param.Service).filter((name) => + params.some((p) => p.Name === name && name !== "modbus" && !p.Required) + ); const stringValues = (values: Record): Record => Object.entries(values).reduce( (acc, [key, val]) => { @@ -216,7 +221,7 @@ export const createServiceEndpoints = (params: TemplateParam[]): ParamService[] acc[key] = String(val); return acc; }, - {} as Record + Object.fromEntries(optional.map((name) => [name, ""])) as Record ); return { 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/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/util/service/demo.go b/util/service/demo.go index 2c2981b11d..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) } @@ -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") From c4ff64b4dee9dfe811fa4c8ee4daf9d645484489 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 12 Oct 2026 12:47:11 +0200 Subject: [PATCH 49/61] Optimizer: hourly slots beyond 48 hours (#34738) --- assets/js/components/Optimize/ChargeChart.vue | 32 +++++--- assets/js/components/Optimize/chart.test.ts | 18 ++++- assets/js/components/Optimize/chart.ts | 18 +++++ core/site_optimizer.go | 75 ++++++++++++++++++- core/site_optimizer_test.go | 52 +++++++++++++ 5 files changed, 183 insertions(+), 12 deletions(-) diff --git a/assets/js/components/Optimize/ChargeChart.vue b/assets/js/components/Optimize/ChargeChart.vue index 18f1964f1f..798440afe3 100644 --- a/assets/js/components/Optimize/ChargeChart.vue +++ b/assets/js/components/Optimize/ChargeChart.vue @@ -29,6 +29,8 @@ import type { Legend } from "../Sessions/types"; import { slotTimes, slotXAxis, + slotParents, + splitSlots, dayBoundarySeries, formatSlotRange, whToKW, @@ -97,19 +99,26 @@ export default defineComponent({ ...details.map((d, i) => ({ label: demandTitle(d, this.consumptionLabel), color: this.demandColors[i] || this.consumptionColor, - data: d.values.map(this.toKW), + data: this.split(d.values).map(this.toKW), id: d.title ? loadpointTitle(d) : undefined, })), ...this.batteryEntries("vehicle"), ...this.batteryEntries("battery"), ]; }, + // request slots beyond 48h may be clock hours, the chart shows them as quarters + parents(): number[] { + return slotParents(this.evopt.req.time_series.dt); + }, + dt(): number[] { + return this.split(this.evopt.req.time_series.dt); + }, times(): number[] { - return slotTimes(this.timestamp, this.evopt.req.time_series.dt); + return slotTimes(this.timestamp, this.dt); }, gridPower(): number[] { - const gridImport = this.evopt.res.grid_import || []; - const gridExport = this.evopt.res.grid_export || []; + const gridImport = this.split(this.evopt.res.grid_import || []); + const gridExport = this.split(this.evopt.res.grid_export || []); return gridImport.map((imp, i) => { const importKW = this.toKW(imp, i); const exportKW = this.toKW(gridExport[i] || 0, i); @@ -138,7 +147,7 @@ export default defineComponent({ name: SOLAR_LABEL, type: "line", z: 4, - data: this.evopt.req.time_series.ft.map(this.toKW), + data: this.split(this.evopt.req.time_series.ft).map(this.toKW), smooth: 0.2, ...transientHoverDot(colors.forecast || ""), lineStyle: { color: colors.forecast || "", ...lineDefaults }, @@ -212,8 +221,11 @@ export default defineComponent({ }, }, methods: { + split(values: number[]): number[] { + return splitSlots(values, this.parents); + }, toKW(wh: number, index: number): number { - return whToKW(wh, this.evopt.req.time_series.dt[index] || 0); + return whToKW(wh, this.dt[index] || 0); }, formatValue(value: number): string { return this.fmtW(value * 1000, POWER_UNIT.AUTO); @@ -223,9 +235,10 @@ export default defineComponent({ const battery = this.evopt.res.batteries[index]; if (detail.type !== type || !battery) return []; // charging positive, discharging negative; one of both is always zero - const data = battery.charging_power.map((charging, i) => { + const discharging = this.split(battery.discharging_power); + const data = this.split(battery.charging_power).map((charging, i) => { const chargingKW = this.toKW(charging, i); - const dischargingKW = this.toKW(battery.discharging_power[i] || 0, i); + const dischargingKW = this.toKW(discharging[i] || 0, i); return chargingKW > 0 ? chargingKW : -dischargingKW; }); return [ @@ -243,8 +256,7 @@ export default defineComponent({ ): string { const arr = Array.isArray(params) ? params : [params]; if (!arr.length) return ""; - const dt = this.evopt.req.time_series.dt; - const head = formatSlotRange(this.times, dt, arr[0]!.dataIndex); + const head = formatSlotRange(this.times, this.dt, arr[0]!.dataIndex); const rows: TooltipRow[] = arr .filter((p) => p.value != null && !Number.isNaN(p.value)) .filter((p) => !p.seriesName?.endsWith("-casing")) diff --git a/assets/js/components/Optimize/chart.test.ts b/assets/js/components/Optimize/chart.test.ts index 320582512c..29c7d6ae67 100644 --- a/assets/js/components/Optimize/chart.test.ts +++ b/assets/js/components/Optimize/chart.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { dayBoundarySeries, isMidnight, slotXAxis } from "./chart"; +import { dayBoundarySeries, isMidnight, slotParents, slotXAxis, splitSlots } from "./chart"; // 15min slots starting at the given local hour function times(startHour: number, count: number): number[] { @@ -32,3 +32,19 @@ describe("slotXAxis", () => { expect(dayBoundarySeries(times(0, 4)).markLine.data).toEqual([]); }); }); + +describe("splitSlots", () => { + // partial first slot, a quarter, a clock hour, a half hour cut by a goal + const dt = [420, 900, 3600, 1800]; + const parents = slotParents(dt); + + test("shows hours as quarters", () => { + expect(parents).toEqual([0, 1, 2, 2, 2, 2, 3, 3]); + expect(splitSlots(dt, parents)).toEqual([420, 900, 900, 900, 900, 900, 900, 900]); + }); + + test("shares energy evenly, power per slot unchanged", () => { + expect(splitSlots([7, 10, 400, 100], parents)).toEqual([7, 10, 100, 100, 100, 100, 50, 50]); + expect(splitSlots([], parents)).toEqual([]); + }); +}); diff --git a/assets/js/components/Optimize/chart.ts b/assets/js/components/Optimize/chart.ts index ddff4a19d6..a3e198e202 100644 --- a/assets/js/components/Optimize/chart.ts +++ b/assets/js/components/Optimize/chart.ts @@ -56,6 +56,24 @@ export function slotXAxis(times: number[], weekdayShort: (d: Date) => string) { }; } +const QUARTER_SECONDS = 15 * 60; + +// source slot index per chart slot: a slot longer than a quarter hour is shown as equal +// quarters, so the category axis keeps one width per slot when the request merges its +// horizon tail into clock hours +export function slotParents(dt: number[]): number[] { + return dt.flatMap((s, i) => Array(Math.max(1, Math.round(s / QUARTER_SECONDS))).fill(i)); +} + +// values per chart slot, a split slot shares its value evenly among its quarters: energies +// and dt both shrink, power per slot stays +export function splitSlots(values: number[], parents: number[]): number[] { + if (!values.length) return []; + const count: number[] = []; + for (const p of parents) count[p] = (count[p] ?? 0) + 1; + return parents.map((p) => (values[p] ?? 0) / (count[p] ?? 1)); +} + export function formatSlotRange(times: number[], dt: number[], index: number): string { const start = new Date(times[index] ?? 0); const end = new Date(start.getTime() + (dt[index] || 0) * 1000); diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 4b8d0336dd..db105bc3ab 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -507,7 +507,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) } @@ -669,6 +670,10 @@ func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.Optim details.DemandDetails = nil } + if hosted { + coarsenHorizon(&req, &details) + } + return req, details, nil } @@ -1355,6 +1360,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) diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go index dfeef80033..1a3975e30c 100644 --- a/core/site_optimizer_test.go +++ b/core/site_optimizer_test.go @@ -187,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) From bbdccca85b0fd8e445289c79b7e34d275925d2ec Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:38:39 +0000 Subject: [PATCH 50/61] Battery UI: replace German "Speicher" with "Hausbatterie"/"Ladestand" (#34717) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- i18n/de.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/i18n/de.json b/i18n/de.json index ab53b77cbd..0b2386232c 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -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", From 4bee189ae74ce65cb78389a2aca357291e4d0456 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:45:56 +0000 Subject: [PATCH 51/61] Config UI: keep auth provider action visible for long titles (#34737) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Michael Geers --- .../Config/AuthProvidersCard.stories.ts | 41 +++++++++++++++++++ .../components/Config/AuthProvidersCard.vue | 12 +++--- 2 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 assets/js/components/Config/AuthProvidersCard.stories.ts diff --git a/assets/js/components/Config/AuthProvidersCard.stories.ts b/assets/js/components/Config/AuthProvidersCard.stories.ts new file mode 100644 index 0000000000..c0d2e5e207 --- /dev/null +++ b/assets/js/components/Config/AuthProvidersCard.stories.ts @@ -0,0 +1,41 @@ +import AuthProvidersCard from "./AuthProvidersCard.vue"; +import type { Meta, StoryFn } from "@storybook/vue3"; + +export default { + title: "Config/AuthProvidersCard", + component: AuthProvidersCard, + parameters: { + layout: "centered", + }, +} as Meta; + +// narrow: column width in the config device grid +const Narrow: StoryFn = (args) => ({ + components: { AuthProvidersCard }, + setup() { + return { args }; + }, + template: '
', +}); + +export const Mixed = Narrow.bind({}); +Mixed.args = { + providers: { + "Tesla (Model Y)": { id: "tesla", authenticated: true }, + "Home Assistant (homeassistant.local)": { id: "ha", authenticated: false }, + }, +}; + +export const LongTitles = Narrow.bind({}); +LongTitles.args = { + providers: { + "Home Assistant (https://homeassistant.example.duckdns.org)": { + id: "ha1", + authenticated: true, + }, + "Home Assistant (https://homeassistant.another-very-long-domain.duckdns.org)": { + id: "ha2", + authenticated: false, + }, + }, +}; diff --git a/assets/js/components/Config/AuthProvidersCard.vue b/assets/js/components/Config/AuthProvidersCard.vue index 39a91ae790..879a6a2abb 100644 --- a/assets/js/components/Config/AuthProvidersCard.vue +++ b/assets/js/components/Config/AuthProvidersCard.vue @@ -14,18 +14,20 @@
-
+
- {{ provider.title }} + {{ + provider.title + }}