Compare commits

..

No commits in common. "master" and "0.314.0" have entirely different histories.

544 changed files with 3808 additions and 13031 deletions

View file

@ -1,73 +0,0 @@
name: Build
# Forgejo bevorzugt .forgejo/workflows gegenüber .github/workflows. Die 23
# Upstream-Workflows laufen hier also nicht mit — die verlangen ohnehin
# GitHub-spezifische Runner-Label (depot-ubuntu-24.04-arm) und Secrets.
on:
push:
branches: [master]
workflow_dispatch:
jobs:
verify:
name: Go build & test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# main.go bindet dist/ per //go:embed ein. Das Verzeichnis wird vom
# Frontend-Build erzeugt und ist gitignored — für den reinen Compile-Check
# reicht ein Platzhalter, go:embed braucht nur mindestens eine Datei.
- name: Stub embedded assets
run: mkdir -p dist && echo stub > dist/index.html
- name: Build
run: go build ./...
- name: Vet
run: go vet ./util/sponsor/... ./charger/... ./core/... ./server/...
- name: Test
run: CGO_ENABLED=0 go test ./...
binary:
name: evcc binary
runs-on: ubuntu-latest
needs: verify
steps:
# fetch-depth 0: das Makefile leitet VERSION aus git describe --tags ab
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v4
with:
node-version: 26
cache: npm
- name: Install UI deps
run: npm ci
- name: Build UI
run: make ui
- name: Build binary
run: make build
- uses: actions/upload-artifact@v3
with:
name: evcc
path: evcc

View file

@ -13,7 +13,7 @@ inputs:
runs:
using: composite
steps:
- uses: actions/setup-go@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: false # managed explicitly below to keep the scoped namespaces

View file

@ -32,12 +32,6 @@ runs:
${{ runner.os }}-docker-mounts-${{ inputs.cache-scope }}-
${{ runner.os }}-docker-mounts-main-
# the go module cache restores read-only, which fails the dance's cleanup
# of the source directory after injection
- name: Make cache mounts writable
shell: bash
run: test -d .docker-cache && chmod -R u+w .docker-cache || true
- name: Inject cache mounts
uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0
with:

View file

@ -3,9 +3,7 @@
#
# Feature releases (patch level 0) must be tagged on master. Bugfix releases may
# be tagged on any branch so an older release line can be serviced without
# shipping everything that landed on master since. Either way the tag must build
# on the previous release of its own line, so a tag pushed from the wrong branch
# cannot ship an older commit under a newer version.
# shipping everything that landed on master since.
#
# Prints `latest=<true|false>` for consumption as a GitHub step output. Only the
# newest release may move the `latest` pointers (docker tag, homebrew formula,
@ -29,43 +27,13 @@ validate() {
return 1
fi
local line="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
if [[ ${BASH_REMATCH[3]} == 0 ]] && ! git merge-base --is-ancestor "$tag" "$MASTER_REF"; then
echo "::error::feature release '$tag' must be tagged on master" >&2
return 1
fi
local releases
releases=$(git tag --list | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort --version-sort) || true
# a release must build on its own line. a tag pushed from the wrong branch
# would otherwise ship an older commit under a newer version.
local earlier
# awk must not exit early here, that would SIGPIPE the grep it reads from
earlier=$(printf '%s\n' "$releases" | grep -E "^${line//./\\.}\." | awk -v t="$tag" '$0 == t { seen = 1 } !seen')
if [[ -n $earlier ]]; then
# the newest predecessor the tag really builds on. a tag from the wrong
# branch has none, a repeated one has it at the very same commit.
local prev
prev=$(while read -r rel; do
if git merge-base --is-ancestor "$rel" "$tag"; then echo "$rel"; fi
done <<<"$earlier" | tail -1)
if [[ -z $prev ]]; then
echo "::error::release '$tag' does not build on $(tail -1 <<<"$earlier"), it was tagged on the wrong branch" >&2
return 1
fi
if [[ $(git rev-parse "$tag^{commit}") == $(git rev-parse "$prev^{commit}") ]]; then
echo "::error::release '$tag' points at the same commit as '$prev'" >&2
return 1
fi
fi
local newest
newest=$(printf '%s\n' "$releases" | tail -1)
newest=$(git tag --list | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort --version-sort | tail -1) || true
if [[ $newest == "$tag" ]]; then
echo "latest=true"
@ -87,12 +55,10 @@ self_test() {
git checkout --quiet -b fix
commit two
git tag 0.1.1 # bugfix release off master
git tag 0.1.2 # same commit as its predecessor
git tag 0.2.0 # feature release off master
git checkout --quiet master
commit three
git tag 0.3.0
git tag 0.2.1 # tagged on the wrong branch, does not contain 0.2.0
git tag 9.9.9-fork # tags from forks must not count as a release
MASTER_REF=master
@ -110,8 +76,6 @@ self_test() {
expect 0.1.0 latest=false # feature release on master, superseded
expect 0.1.1 latest=false # bugfix release off master, older line
expect 0.3.0 latest=true # newest release
expect 0.1.2 FAIL # same commit as its predecessor
expect 0.2.1 FAIL # tagged on the wrong branch
expect 0.2.0 FAIL # feature release not on master
expect 0.1 FAIL # not MAJOR.MINOR.PATCH
expect v0.1.0 FAIL # no v prefix allowed

View file

@ -57,33 +57,11 @@ jobs:
core.setFailed(message);
};
// the command is usually posted right before hitting merge, so give the
// pull request time to land instead of failing on the race
const deadline = Date.now() + 5 * 60 * 1000;
let pr;
for (;;) {
try {
({ data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: context.payload.issue.number,
}));
// a closed pull request will never merge, no point in waiting it out
if (pr.merged || pr.state === 'closed') {
break;
}
} catch (err) {
// a blip while polling must not fail the command, but a request that
// is still failing when the time is up is a real error
if (Date.now() >= deadline) {
throw err;
}
}
if (Date.now() >= deadline) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 15000));
}
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: context.payload.issue.number,
});
if (!pr.merged) {
fail(`pull request #${pr.number} is not merged`);
return;
@ -179,15 +157,18 @@ jobs:
TITLE: ${{ steps.pr.outputs.title }}
ACTOR: ${{ github.event.comment.user.login }}
run: |
# the release line the branch was cut from, 0.313 for release/0.313.1
line=$(echo "${TARGET#release/}" | cut -d. -f1,2)
# the pull request is authored by the deploy token, so assign the
# requester. the body stays empty, squashing it would carry the text
# into the release branch as the commit description.
# the title stays untouched so the changelog filters and groups still
# see the original prefix, the backport label identifies the pull request
# the marker trails the title so the changelog filters and groups still
# see the original prefix, goreleaser strips it from the release notes
url=$(gh pr create \
--base "$TARGET" \
--head "$BRANCH" \
--title "$TITLE" \
--title "$TITLE [$line backport]" \
--label backport \
--assignee "$ACTOR" \
--body "")

View file

@ -60,7 +60,6 @@ jobs:
permissions:
contents: read
actions: read
id-token: write # depot OIDC
secrets: inherit
report:

View file

@ -13,7 +13,6 @@ jobs:
permissions:
contents: read
id-token: write # depot OIDC
steps:
- uses: actions/checkout@v7
@ -28,10 +27,14 @@ jobs:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
# depot builders keep the Dockerfile's cache mounts on their own volumes,
# so they need neither buildx nor the cache dance
- name: Setup Depot
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
- name: Setup Buildx
id: buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Restore build cache
uses: ./.github/actions/docker-cache
with:
builder: ${{ steps.buildx.outputs.name }}
- name: Define tags
id: meta
@ -43,9 +46,8 @@ jobs:
type=raw,value=nightly.{{date 'YYYYMMDD'}}-{{sha}}
- name: Publish
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
project: ${{ secrets.DEPOT_PROJECT_ID }}
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v6
push: true

View file

@ -48,7 +48,6 @@ jobs:
permissions:
contents: read
actions: read
id-token: write # depot OIDC
secrets: inherit
hassio:

View file

@ -46,7 +46,6 @@ jobs:
runs-on: depot-ubuntu-24.04-arm
permissions:
contents: read
id-token: write # depot OIDC
steps:
- uses: actions/checkout@v7
@ -60,10 +59,14 @@ jobs:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
# depot builders keep the Dockerfile's cache mounts on their own volumes,
# so they need neither buildx nor the cache dance
- name: Setup Depot
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
- name: Setup Buildx
id: buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Restore build cache
uses: ./.github/actions/docker-cache
with:
builder: ${{ steps.buildx.outputs.name }}
- name: Meta
id: meta
@ -76,9 +79,8 @@ jobs:
latest=${{ needs.guard.outputs.latest }}
- name: Publish
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
project: ${{ secrets.DEPOT_PROJECT_ID }}
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v6
push: true
@ -144,12 +146,6 @@ jobs:
# a bugfix release of an older line must not move the homebrew formula
# or the Github latest release marker
MAKE_LATEST: ${{ needs.guard.outputs.latest }}
# macOS code signing and notarization
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
- uses: actions/setup-python@v6
with:

View file

@ -23,7 +23,6 @@ linters:
settings:
modernize:
disable:
- embedlit
- stringsbuilder
staticcheck:
checks:

View file

@ -11,7 +11,8 @@ builds:
- -trimpath
- -tags=release
ldflags:
- -X github.com/evcc-io/evcc/util.Version={{ .Version }} -X github.com/evcc-io/evcc/util.Commit={{ .ShortCommit }} -s -w
# the package version carries a timestamp for ordering, the reported version does not
- -X github.com/evcc-io/evcc/util.Version={{ if .IsSnapshot }}{{ incminor .Tag }}-dev+{{ .ShortCommit }}{{ else }}{{ .Version }}{{ end }} -X github.com/evcc-io/evcc/util.Commit={{ .ShortCommit }} -s -w
env:
- CGO_ENABLED=0
goos:
@ -56,9 +57,10 @@ checksum:
name_template: "checksums.txt"
snapshot:
# semver pre-release of the upcoming version, the timestamp keeps the package
# version increasing monotonically for apt
version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}"
# semver pre-release of the upcoming version. The timestamp makes the package
# version increase monotonically: apt compares it numerically, while the commit
# alone would sort randomly.
version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}+{{ .ShortCommit }}"
changelog:
sort: asc

View file

@ -16,7 +16,8 @@ builds:
- -trimpath
- -tags=release
ldflags:
- -X github.com/evcc-io/evcc/util.Version={{ .Version }} -s -w
# the package version carries a timestamp for ordering, the reported version does not
- -X github.com/evcc-io/evcc/util.Version={{ if .IsSnapshot }}{{ incminor .Tag }}-dev+{{ .ShortCommit }}{{ else }}{{ .Version }}{{ end }} -s -w
env:
- CGO_ENABLED=0
goos:
@ -59,29 +60,19 @@ archives:
universal_binaries:
- replace: true
notarize:
macos:
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
sign:
certificate: "{{ .Env.MACOS_SIGN_P12 }}"
password: "{{ .Env.MACOS_SIGN_PASSWORD }}"
notarize:
issuer_id: "{{ .Env.MACOS_NOTARY_ISSUER_ID }}"
key_id: "{{ .Env.MACOS_NOTARY_KEY_ID }}"
key: "{{ .Env.MACOS_NOTARY_KEY }}"
wait: true
timeout: 20m
checksum:
name_template: "checksums.txt"
snapshot:
# semver pre-release of the upcoming version, the timestamp keeps the package
# version increasing monotonically for apt
version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}"
# semver pre-release of the upcoming version. The timestamp makes the package
# version increase monotonically: apt compares it numerically, while the commit
# alone would sort randomly.
version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}+{{ .ShortCommit }}"
changelog:
sort: asc
# drop the marker the backport command appends to the pull request title
format: '{{ .SHA }} {{ reReplaceAll ` \[\d+\.\d+ backport\]` .Message "" }}'
filters:
exclude:
- "^chore"
@ -135,7 +126,7 @@ nfpms:
preremove: ./packaging/scripts/preremove.sh
postremove: ./packaging/scripts/postremove.sh
homebrew_casks:
brews:
- skip_upload: '{{ if eq (envOrDefault "MAKE_LATEST" "true") "true" }}false{{ else }}true{{ end }}'
repository:
owner: evcc-io
@ -143,7 +134,15 @@ homebrew_casks:
commit_author:
name: andig
email: info@evcc.io
directory: Formula
homepage: "https://evcc.io"
description: "Sonne tanken ☀️🚘"
binaries:
- evcc
license: "MIT"
test: |
system "#{bin}/evcc --version"
service: |
run [opt_bin/"evcc"]
working_dir HOMEBREW_PREFIX
keep_alive true
log_path var/"log/evcc.log"
error_log_path var/"log/evcc.log"

View file

@ -1,11 +1,4 @@
import { StorybookConfig } from "@storybook/vue3-vite";
import { createReadStream } from "node:fs";
// serve test logos so the custom branding stories can render them
const customLogos = {
"/custom-logo-light": "tests/custom-logo-light.svg",
"/custom-logo-dark": "tests/custom-logo-dark.svg",
};
export default {
stories: ["../assets/js/**/*.stories.@(js|ts)"],
@ -17,21 +10,4 @@ export default {
core: {
disableTelemetry: true,
},
viteFinal: (config) => ({
...config,
plugins: [
...(config.plugins ?? []),
{
name: "evcc-custom-logos",
configureServer(server) {
server.middlewares.use((req, res, next) => {
const file = customLogos[req.url?.split("?")[0] as keyof typeof customLogos];
if (!file) return next();
res.setHeader("Content-Type", "image/svg+xml");
createReadStream(file).pipe(res);
});
},
},
],
}),
} satisfies StorybookConfig;

View file

@ -2,7 +2,6 @@ import { type Preview, setup } from "@storybook/vue3";
import "bootstrap/dist/css/bootstrap.min.css";
import smoothscroll from "smoothscroll-polyfill";
import setupI18n from "../assets/js/i18n";
import tooltip from "../assets/js/directives/tooltip";
import "../assets/css/app.css";
import { watchThemeChanges } from "../assets/js/theme";
@ -36,7 +35,6 @@ export default {
setup((app) => {
app.use(setupI18n());
app.directive("tooltip", tooltip);
// Mock router-link for Storybook
app.component("router-link", {

View file

@ -200,7 +200,6 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w
### Styling & Internationalization
- Use CSS Custom Properties for theming (semantic names: `--evcc-green`, `--evcc-battery`)
- No inline `style` attributes; use Bootstrap utility classes or scoped CSS (inline only for truly dynamic values)
- Use existing custom media queries for responsive breakpoints
- Use `$t()` function for all user-facing strings
- Update both `i18n/en.json` and `i18n/de.json` for new strings

View file

@ -3,9 +3,8 @@ FROM --platform=$BUILDPLATFORM node:26-alpine AS node
RUN apk update && apk add --no-cache make curl bash && curl -fsSL https://vite.plus | bash
# the installer only wires vp into interactive shell rc files, which RUN steps don't source.
# newer vite+ releases dropped the split directory layout, so accept both bin locations
ENV PATH="/root/.vite-plus/bin:/root/.local/share/vite-plus/bin:${PATH}"
# the installer only wires vp into interactive shell rc files, which RUN steps don't source
ENV PATH="/root/.vite-plus/bin:${PATH}"
WORKDIR /build
@ -25,7 +24,7 @@ RUN make ui
# STEP 2 build executable binary
FROM --platform=$BUILDPLATFORM golang:1.27.0-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS builder
# Install git + SSL ca certificates.
# Git is required for fetching the dependencies.

54
FORK.md
View file

@ -1,54 +0,0 @@
# Fork-Hinweise
Fork von [evcc-io/evcc](https://github.com/evcc-io/evcc).
## Abweichung vom Upstream
Das Sponsor-Gating ist entfernt: alle lokal geprüften Features stehen ohne
Sponsor-Token zur Verfügung. Der Eingriff sitzt an einer einzigen Stelle in
`util/sponsor/auth.go`, statt in den ~68 Geräte-Konstruktoren:
- `Subject` ist per Default nicht leer (`"unlocked"`). Damit meldet
`RedactedStatus()` einen aktiven Sponsor-Status, und das Frontend schaltet
`isSponsor` (`assets/js/views/Config.vue`) frei — Geräte-Dialoge zeigen kein
„Sponsor-Token erforderlich" mehr.
- `IsAuthorized()` liefert immer `true`. Das öffnet alle Aufrufer, u. a. die
Charger-/Vehicle-Konstruktoren, `server/modbus/proxy.go` und den
Optimizer-Gate in `core/site_optimizer.go`.
- `ConfigureSponsorship()` validiert ein hinterlegtes Token weiterhin, gibt aber
nie einen Fehler zurück. Ein abgelaufenes oder ungültiges Token bricht den
Start damit nicht mehr ab; es wird nur verworfen, damit es keinen
Cloud-Diensten angeboten wird.
Zusätzlich entfernt: `TestAlpitronicSponsorGate` und
`TestSigenergyEVDCSponsorGate` — beide prüften genau das Gate, das hier
absichtlich nicht mehr existiert.
## Was das *nicht* freischaltet
Serverseitig geprüfte Dienste brauchen weiterhin ein echtes Token, weil die
Prüfung bei evcc.io stattfindet und nicht im Client:
- Optimizer-API (`core/site_optimizer.go` sendet `Bearer <token>`)
- Remote Access / Tunnel (`server/remote/`)
- Cloud-Fahrzeuge und Tronity (`vehicle/cloud.go`, `vehicle/tronity.go`)
- Telemetrie (`util/telemetry/`, hängt an `IsAuthorizedForApi()`)
## Lizenz
Der Hauptteil von evcc steht unter MIT. `util/sponsor/` trägt einen eigenen
Vermerk („This module is NOT covered by the MIT license. All rights reserved.").
Diese Änderung betrifft genau dieses Modul.
evcc finanziert sich über Sponsoring: <https://sponsor.evcc.io>
## Upstream-Sync
```
git remote add upstream https://github.com/evcc-io/evcc.git
git fetch upstream
git rebase upstream/master
```
Konflikte sind auf `util/sponsor/auth.go` beschränkt; `FORK.md` liegt bewusst
außerhalb der Upstream-Dateien.

View file

@ -6,16 +6,15 @@ GIT_TAG := $(shell test -d .git && git describe --abbrev=0 --tags)
# commits since the latest tag, empty or 0 means the commit is tagged
GIT_DIST := $(shell test -n "$(GIT_TAG)" && git rev-list --count $(GIT_TAG)..HEAD)
NEXT_TAG = $(shell echo $(GIT_TAG) | awk -F. -v b='$(BUMP)' '{ if (b == "patch") { m = $$2; p = $$3 + 1 } else { m = $$2 + 1; p = 0 }; print $$1 "." m "." p }')
# untagged builds are semver pre-releases of the upcoming version, the build
# timestamp keeps them increasing monotonically
BUILD_TIMESTAMP := $(shell date -u '+%s')
TAG_NAME ?= $(if $(filter-out 0,$(GIT_DIST)),$(NEXT_TAG)-dev.$(BUILD_TIMESTAMP),$(GIT_TAG))
# untagged builds are semver pre-releases of the upcoming version
TAG_NAME ?= $(if $(filter-out 0,$(GIT_DIST)),$(NEXT_TAG)-dev+$(SHA),$(GIT_TAG))
COMMIT := $(SHA)
# hide commit for releases
ifeq ($(RELEASE),1)
COMMIT :=
endif
VERSION := $(if $(TAG_NAME),$(TAG_NAME),$(SHA))
BUILD_DATE := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
BUILD_TAGS := -tags=release
LD_FLAGS := -X github.com/evcc-io/evcc/util.Version=$(VERSION) -X github.com/evcc-io/evcc/util.Commit=$(COMMIT) -s -w
BUILD_ARGS := -trimpath -ldflags='$(LD_FLAGS)'
@ -34,7 +33,7 @@ IMAGE_FILE := evcc_$(TAG_NAME).img
PACKAGES = ./release
# asn1-patch
GOROOT = $(shell go env GOROOT)
GOROOT := $(shell go env GOROOT)
CURRDIR := $(shell pwd)
default:: ui build
@ -100,7 +99,7 @@ porcelain::
test -z "$$(git status --porcelain)" || (git status; git diff; false)
build::
@echo Version: $(VERSION) $(SHA)
@echo Version: $(VERSION) $(SHA) $(BUILD_DATE)
CGO_ENABLED=0 go build -v $(BUILD_TAGS) $(BUILD_ARGS)
snapshot::
@ -110,15 +109,15 @@ release::
goreleaser --clean
docker::
@echo Version: $(VERSION) $(SHA)
@echo Version: $(VERSION) $(SHA) $(BUILD_DATE)
docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):$(DOCKER_TAG) --push .
publish-nightly::
@echo Version: $(VERSION) $(SHA)
@echo Version: $(VERSION) $(SHA) $(BUILD_DATE)
docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):nightly --push .
publish-release::
@echo Version: $(VERSION) $(SHA)
@echo Version: $(VERSION) $(SHA) $(BUILD_DATE)
docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):latest --tag $(DOCKER_IMAGE):$(VERSION) --build-arg RELEASE=1 --push .
apt-nightly::
@ -154,7 +153,7 @@ gok-update::
${GOK} update yes
soc::
@echo Version: $(VERSION) $(SHA)
@echo Version: $(VERSION) $(SHA) $(BUILD_DATE)
go build $(BUILD_TAGS) $(BUILD_ARGS) github.com/evcc-io/evcc/cmd/soc
# patch asn1.go to allow Elli buggy certificates to be accepted with EEBUS

View file

@ -130,10 +130,9 @@ type ChargeRater interface {
ChargedEnergy() (float64, error)
}
// Identifier identifies a vehicle and is implemented by the charger.
// A charger may know more than one identity, e.g. an RFID tag and a vehicle id.
// Identifier identifies a vehicle and is implemented by the charger
type Identifier interface {
Identify() ([]string, error)
Identify() (string, error)
}
// Authorizer authorizes a charging session by supplying RFID credentials

View file

@ -7,28 +7,23 @@ import (
"github.com/cenkalti/backoff/v4"
)
// permanentError is a sentinel error that signals permanence to backoff while
// remaining distinguishable from the other permanent sentinels.
// permanentError is a sentinel error that keeps matching errors.Is after
// backoff has stripped the backoff.Permanent wrapper.
type permanentError struct {
msg string
}
func (e *permanentError) Error() string { return e.msg }
// As signals permanence to backoff. Wrapping the sentinel in backoff.Permanent
// instead would make errors.Is match any other permanent error, since
// backoff.PermanentError.Is matches by type rather than identity.
func (e *permanentError) As(target any) bool {
if p, ok := target.(**backoff.PermanentError); ok {
*p = &backoff.PermanentError{Err: e}
return true
}
return false
// Is matches the wrapped sentinel, too
func (e *permanentError) Is(target error) bool {
var t *permanentError
return errors.As(target, &t) && t == e
}
// permanent creates a permanent sentinel error
func permanent(msg string) error {
return &permanentError{msg}
return backoff.Permanent(&permanentError{msg})
}
// ErrNotAvailable indicates that a feature is not available

View file

@ -8,47 +8,20 @@ import (
"github.com/stretchr/testify/assert"
)
// permanentSentinels are all errors created by permanent()
var permanentSentinels = []error{
ErrNotAvailable,
ErrUnsupportedPlatform,
ErrSponsorRequired,
ErrMissingCredentials,
ErrMissingToken,
}
// Backoff returns permanent errors unwrapped. These must still match the sentinel.
func TestPermanentSentinels(t *testing.T) {
for _, err := range permanentSentinels {
for _, tc := range []struct{ err, other error }{
{ErrNotAvailable, ErrUnsupportedPlatform},
{ErrUnsupportedPlatform, ErrNotAvailable},
{ErrMissingCredentials, ErrMissingToken},
{ErrMissingToken, ErrMissingCredentials},
} {
_, unwrapped := backoff.RetryWithData(func() (int, error) {
return 0, err
return 0, tc.err
}, &backoff.StopBackOff{})
assert.ErrorIs(t, unwrapped, err)
assert.ErrorIs(t, fmt.Errorf("wrapped: %w", unwrapped), err)
assert.ErrorIs(t, fmt.Errorf("wrapped: %w", err), err)
assert.ErrorIs(t, unwrapped, tc.err)
assert.ErrorIs(t, fmt.Errorf("wrapped: %w", unwrapped), tc.err)
assert.NotErrorIs(t, unwrapped, tc.other)
}
}
// Permanent sentinels must not match each other, whether returned directly or
// unwrapped by backoff.
func TestPermanentSentinelIdentity(t *testing.T) {
for _, err := range permanentSentinels {
_, unwrapped := backoff.RetryWithData(func() (int, error) {
return 0, err
}, &backoff.StopBackOff{})
for _, other := range permanentSentinels {
if other == err {
continue
}
assert.NotErrorIs(t, err, other)
assert.NotErrorIs(t, unwrapped, other)
assert.NotErrorIs(t, fmt.Errorf("wrapped: %w", err), other)
}
}
// login required is permanent, too
assert.NotErrorIs(t, LoginRequiredError("foo"), ErrNotAvailable)
}

View file

@ -64,7 +64,6 @@ type All struct {
Site map[string]any
Loadpoints []config.Named
Circuits []config.Named
Curtailers []config.Named
}
type Javascript struct {

View file

@ -228,7 +228,7 @@ func (i *iDimmer) Dimmed() (bool, error) {
return i.dimmer1()
}
func Identifier(identifier0 func() ([]string, error)) api.Identifier {
func Identifier(identifier0 func() (string, error)) api.Identifier {
if identifier0 == nil {
return nil
}
@ -236,10 +236,10 @@ func Identifier(identifier0 func() ([]string, error)) api.Identifier {
}
type iIdentifier struct {
identifier0 func() ([]string, error)
identifier0 func() (string, error)
}
func (i *iIdentifier) Identify() ([]string, error) {
func (i *iIdentifier) Identify() (string, error) {
return i.identifier0()
}
@ -378,21 +378,6 @@ func (i *iPhaseVoltages) Voltages() (float64, float64, float64, error) {
return i.phaseVoltages0()
}
func PowerLimiter(powerLimiter0 func() (float64, float64, error)) api.PowerLimiter {
if powerLimiter0 == nil {
return nil
}
return &iPowerLimiter{powerLimiter0}
}
type iPowerLimiter struct {
powerLimiter0 func() (float64, float64, error)
}
func (i *iPowerLimiter) GetMinMaxPower() (float64, float64, error) {
return i.powerLimiter0()
}
func Resurrector(resurrector0 func() error) api.Resurrector {
if resurrector0 == nil {
return nil

View file

@ -396,10 +396,10 @@ func (m *MockIdentifier) EXPECT() *MockIdentifierMockRecorder {
}
// Identify mocks base method.
func (m *MockIdentifier) Identify() ([]string, error) {
func (m *MockIdentifier) Identify() (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Identify")
ret0, _ := ret[0].([]string)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}

View file

@ -50,7 +50,6 @@
--evcc-gray: var(--bs-gray-medium);
--evcc-gray-50: #93949e80;
--evcc-gray-25: #93949e40;
--evcc-gray-15: #93949e26;
--evcc-gray-10: #93949e20;
--evcc-accent1: var(--evcc-dark-yellow);
--evcc-accent2: var(--evcc-darker-green);
@ -952,9 +951,3 @@ input::-webkit-date-and-time-value {
.sticky-bottom {
bottom: calc(var(--tab-bar-height) + 0.25rem);
}
[_echarts_instance_] {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}

View file

@ -6,7 +6,6 @@ import { VueHeadMixin, createHead } from "@unhead/vue/client";
import App from "./views/App.vue";
import setupRouter from "./router.ts";
import setupI18n from "./i18n.ts";
import tooltip from "./directives/tooltip.ts";
import { watchThemeChanges } from "./theme.ts";
import { applyUrlSettings } from "./urlSettings.ts";
import { appDetection, sendToApp } from "./utils/native";
@ -100,7 +99,6 @@ app.use(i18n);
app.use(router);
app.use(head);
app.mixin(VueHeadMixin);
app.directive("tooltip", tooltip);
window.app = app.mount("#app");
watchThemeChanges();

View file

@ -52,11 +52,6 @@ export default {
hasUpdater: { control: "boolean" },
uploadMessage: { control: "text" },
uploadProgress: { control: "number" },
customLogo: { control: "boolean" },
customBrand: { control: "text" },
customWebsite: { control: "text" },
customEmail: { control: "text" },
customPhone: { control: "text" },
},
} as Meta<typeof AboutModal>;
@ -99,22 +94,10 @@ StableUpdateWithUpdater.args = {
export const Nightly = Template.bind({});
Nightly.args = {
installed: "0.304.0-dev.1712345678",
commit: "5ce7be4",
installed: "0.304.0-dev+5ce7be4",
};
export const DevBuild = Template.bind({});
DevBuild.args = {
installed: "0.0.0",
};
export const CustomBranding = Template.bind({});
CustomBranding.args = {
installed: "0.303.1",
availableVersion: "0.303.1",
customLogo: true,
customBrand: "G1GA HEMS",
customWebsite: "https://example.com/hems",
customEmail: "support@example.com",
customPhone: "+49 123 456789",
};

View file

@ -1,5 +1,5 @@
<template>
<GenericModal id="aboutModal" @opened="acknowledge">
<GenericModal id="aboutModal" :size="modalSize" @opened="acknowledge">
<template #title>
<a :href="websiteUrl" target="_blank" rel="noopener noreferrer">
<template v-if="customLogo">
@ -44,12 +44,14 @@
>
v{{ installed }}
</a>
<Badge v-if="newVersionAvailable">
{{ $t("footer.version.availableLong") }}
</Badge>
<Badge v-else-if="!nightly" variant="muted">
{{ $t("footer.version.latestVersion") }}
</Badge>
<span
v-if="!nightly && !newVersionAvailable"
class="text-muted text-nowrap"
>{{ $t("footer.version.latestVersion") }}</span
>
<span v-if="newVersionAvailable" class="text-nowrap">{{
$t("footer.version.availableLong")
}}</span>
</div>
</td>
</tr>
@ -113,22 +115,8 @@
<!-- open source -->
<hr />
<p
class="mb-0 d-flex justify-content-between align-items-center flex-wrap column-gap-2 row-gap-1"
>
<span v-if="customLogo" class="d-flex align-items-center gap-2">
<span>{{ $t("footer.version.poweredBy") }}</span>
<a
:href="evccWebsiteUrl"
target="_blank"
rel="noopener noreferrer"
class="d-flex"
aria-label="evcc"
>
<Logo class="footer-logo" />
</a>
</span>
<i18n-t v-else keypath="footer.version.madeByCommunity" tag="span">
<p class="mb-0 small d-flex flex-wrap column-gap-1">
<i18n-t keypath="footer.version.madeByCommunity" tag="span">
<a
:href="githubRepoUrl"
target="_blank"
@ -137,13 +125,15 @@
>{{ $t("footer.version.community") }}</a
>
</i18n-t>
<a
class="evcc-gray"
:href="githubDependenciesUrl"
target="_blank"
rel="noopener noreferrer"
>{{ $t("footer.version.openSource") }}</a
>
<i18n-t keypath="footer.version.poweredByOpenSource" tag="span" class="d-inline">
<a
class="text-muted"
:href="githubDependenciesUrl"
target="_blank"
rel="noopener noreferrer"
>{{ $t("footer.version.openSource") }}</a
>
</i18n-t>
</p>
</div>
</GenericModal>
@ -154,11 +144,16 @@ import GenericModal from "./Helper/GenericModal.vue";
import "@h2d2/shopicons/es/regular/gift";
import "@h2d2/shopicons/es/filled/heart";
import Logo from "./Footer/Logo.vue";
import Badge from "./Helper/Badge.vue";
import api from "@/api";
import settings from "@/settings";
import { extractDomain } from "@/utils/extractDomain";
import { isDevelopment, isNightly, getReleaseName, isNewVersionAvailable } from "@/utils/version";
import {
isDevelopment,
isNightly,
getReleaseName,
commitFromVersion,
isNewVersionAvailable,
} from "@/utils/version";
import { defineComponent } from "vue";
const GITHUB_REPO = "https://github.com/evcc-io/evcc";
@ -166,10 +161,9 @@ const EVCC_WEBSITE = "https://evcc.io/";
export default defineComponent({
name: "AboutModal",
components: { GenericModal, Logo, Badge },
components: { GenericModal, Logo },
props: {
installed: { type: String, default: "" },
commit: { type: String, default: "" },
availableVersion: String,
releaseNotes: String,
hasUpdater: Boolean,
@ -210,14 +204,14 @@ export default defineComponent({
githubRepoUrl() {
return GITHUB_REPO;
},
evccWebsiteUrl() {
return EVCC_WEBSITE;
},
githubDependenciesUrl() {
return `${GITHUB_REPO}/network/dependencies`;
},
githubCommitUrl() {
return `${GITHUB_REPO}/commit/${this.commit}`;
return `${GITHUB_REPO}/commit/${commitFromVersion(this.installed)}`;
},
modalSize() {
return this.newVersionAvailable ? undefined : "sm";
},
cleanedReleaseNotes() {
if (!this.releaseNotes) return "";
@ -252,9 +246,6 @@ export default defineComponent({
.about-logo {
height: 2.5rem;
}
.footer-logo {
height: 1rem;
}
.custom-logo {
height: 4rem;
width: auto;

View file

@ -61,7 +61,7 @@ export default defineComponent({
},
computed: {
resetUrl() {
return `${docsPrefix()}/faq#password-reset`;
return `${docsPrefix()}/docs/faq#password-reset`;
},
modalSize() {
return this.demoMode ? "md" : "sm";

View file

@ -1,7 +1,13 @@
<template>
<div data-testid="plan-strategy" class="mb-5 mb-lg-4">
<div :class="{ 'mb-2': open }">
<div v-if="disabled">
<span class="fw-bold evcc-gray">{{ $t("main.chargingPlan.strategy.label") }}:</span>
{{ " " }}
<span class="small">{{ summary }}</span>
</div>
<button
v-else
type="button"
class="btn btn-link btn-sm text-gray p-0 border-0 d-flex align-items-center text-start"
:class="{ 'text-primary': open }"
@ -16,7 +22,13 @@
</div>
<div class="collapsible-wrapper" :class="{ open }">
<div class="collapsible-content ring-space">
<div class="row">
<div v-if="disabled" class="row mb-4">
<div class="small text-muted">
<strong class="text-primary">{{ $t("general.note") }}</strong>
{{ $t("main.chargingPlan.strategyDisabledDescription") }}
</div>
</div>
<div v-else class="row">
<div class="col-12 col-lg-6 mb-3">
<div class="row">
<label
@ -27,15 +39,6 @@
</label>
<div class="col-7 col-lg-12">
<select
v-if="disabled"
:id="formId('continuous')"
class="form-select"
disabled
>
<option>{{ $t("general.none") }}</option>
</select>
<select
v-else
:id="formId('continuous')"
v-model="localContinuous"
class="form-select"
@ -64,17 +67,6 @@
</label>
<div class="col-7 col-lg-12">
<select
v-if="disabled"
:id="formId('precondition')"
class="form-select"
disabled
>
<option>
{{ $t("main.chargingPlan.precondition.optionAll") }}
</option>
</select>
<select
v-else
:id="formId('precondition')"
v-model="localPrecondition"
class="form-select"
@ -94,11 +86,7 @@
</div>
</div>
<div class="small text-muted mt-1">
{{
$t(
`main.chargingPlan.precondition.${disabled ? "disabledDescription" : "description"}`
)
}}
{{ $t("main.chargingPlan.precondition.description") }}
</div>
</div>
</div>
@ -113,13 +101,6 @@ import formatter from "@/mixins/formatter";
import { SMART_COST_TYPE, type PlanStrategy } from "@/types/evcc";
import DropdownIcon from "../MaterialIcon/Dropdown.vue";
const HOUR = 60 * 60;
const QUARTER_HOUR = 0.25 * HOUR;
const HALF_HOUR = 0.5 * HOUR;
const ONE_HOUR = 1 * HOUR;
const TWO_HOURS = 2 * HOUR;
const EVERYTHING = 7 * 24 * HOUR;
export default defineComponent({
name: "ChargingPlanStrategy",
components: { DropdownIcon },
@ -128,6 +109,7 @@ export default defineComponent({
id: [String, Number],
precondition: { type: Number, default: 0 },
continuous: { type: Boolean, default: false },
disabled: Boolean,
smartCostType: String as PropType<SMART_COST_TYPE>,
},
emits: ["update"],
@ -139,10 +121,6 @@ export default defineComponent({
};
},
computed: {
disabled(): boolean {
// options only make sense with a dynamic planner tariff
return !this.smartCostType || this.smartCostType === SMART_COST_TYPE.PRICE_STATIC;
},
isCo2(): boolean {
return this.smartCostType === SMART_COST_TYPE.CO2;
},
@ -151,16 +129,14 @@ export default defineComponent({
},
summary(): string {
if (this.disabled) {
return this.$t("main.chargingPlan.precondition.summaryAll");
return this.$t("main.chargingPlan.strategyDisabledDescription");
}
const parts = [
this.$t(
`main.chargingPlan.optimization.${this.continuous ? "continuous" : this.cheapestKey}`
),
];
if (this.precondition >= EVERYTHING) {
parts.push(this.$t("main.chargingPlan.precondition.summaryAll"));
} else if (this.precondition) {
if (this.precondition) {
parts.push(
this.$t("main.chargingPlan.precondition.summary", {
precondition: this.fmtDurationLong(this.precondition),
@ -170,9 +146,6 @@ export default defineComponent({
return parts.join(", ");
},
optimizationDescription(): string {
if (this.disabled) {
return this.$t("main.chargingPlan.optimization.disabledDescription");
}
const variant = this.localContinuous
? this.isCo2
? "continuousCo2"
@ -181,6 +154,13 @@ export default defineComponent({
return this.$t(`main.chargingPlan.optimization.${variant}Description`);
},
preconditionOptions() {
const HOUR = 60 * 60;
const QUARTER_HOUR = 0.25 * HOUR;
const HALF_HOUR = 0.5 * HOUR;
const ONE_HOUR = 1 * HOUR;
const TWO_HOURS = 2 * HOUR;
const EVERYTHING = 7 * 24 * HOUR;
const options = [QUARTER_HOUR, HALF_HOUR, ONE_HOUR, TWO_HOURS, EVERYTHING];
// support custom values (via API)

View file

@ -36,6 +36,7 @@
:id="id"
:precondition="effectivePlanStrategy?.precondition"
:continuous="effectivePlanStrategy?.continuous"
:disabled="strategyDisabled"
:smart-cost-type="smartCostType"
@update="updatePlanStrategy"
/>
@ -161,6 +162,13 @@ export default defineComponent({
nextPlanTitle(): string {
return `${this.$t("main.targetCharge.nextPlan")}: #${this.nextPlanId}`;
},
strategyDisabled(): boolean {
// options only make sense if there are variable prices
// TODO: make this logic more robust (api fails, missing data)
const slots = this.forecast?.planner || [];
const values = new Set(slots.map(({ value }) => value));
return values.size <= 1;
},
},
watch: {
effectivePlanTime(newValue: string) {

View file

@ -1,18 +1,18 @@
<template>
<p class="mb-0 root" data-testid="plan-warnings">
<span v-if="targetIsAboveLimit" class="d-block evcc-gray mt-1">
<p class="mb-3 root" data-testid="plan-warnings">
<span v-if="targetIsAboveLimit" class="d-block evcc-gray mb-1">
{{ $t("main.targetCharge.targetIsAboveLimit", { limit: limitFmt }) }}
</span>
<span v-if="mode && ['off', 'now'].includes(mode)" class="d-block text-warning mt-1">
<span v-if="mode && ['off', 'now'].includes(mode)" class="d-block text-warning mb-1">
{{ $t("main.targetCharge.onlyInPvMode") }}
</span>
<span v-if="timeTooFarInTheFuture" class="d-block evcc-gray mt-1">
<span v-if="timeTooFarInTheFuture" class="d-block evcc-gray mb-1">
{{ $t("main.targetCharge.targetIsTooFarInTheFuture") }}
</span>
<span v-if="notReachableInTime" class="d-block text-warning mt-1">
<span v-if="notReachableInTime" class="d-block text-warning mb-1">
{{ $t("main.targetCharge.notReachableInTime", { overrun: overrunFmt }) }}
</span>
<span v-if="targetIsAboveVehicleLimit" class="d-block text-warning mt-1">
<span v-if="targetIsAboveVehicleLimit" class="d-block text-warning mb-1">
{{ $t("main.targetCharge.targetIsAboveVehicleLimit") }}
</span>
</p>

View file

@ -97,31 +97,11 @@
</small>
</label>
</div>
<div class="d-flex mb-1">
<input
id="resetRemote"
v-model="selectedReset.remote"
class="form-check-input"
type="checkbox"
/>
<label class="form-check-label ms-2" for="resetRemote">
<span>{{ $t("config.system.backupRestore.reset.remote") }}</span>
<br />
<small>
{{ $t("config.system.backupRestore.reset.remoteDescription") }}
</small>
</label>
</div>
</div>
<button
class="btn btn-outline-danger mt-3"
:disabled="
!selectedReset.sessions &&
!selectedReset.settings &&
!selectedReset.remote
"
:disabled="!selectedReset.sessions && !selectedReset.settings"
>
{{ $t("config.system.backupRestore.reset.action") }}
</button>
@ -212,7 +192,6 @@ export default defineComponent({
selectedReset: {
sessions: false,
settings: false,
remote: false,
},
file: null as File | null,
confirmType: "" as "backup" | "restore" | "reset" | "",
@ -249,7 +228,6 @@ export default defineComponent({
this.selectedReset = {
sessions: false,
settings: false,
remote: false,
};
this.file = null;
this.navigateHomeAfterRestart = false;

View file

@ -3,7 +3,7 @@
name="circuits"
:title="$t('config.circuits.title')"
:description="$t('config.circuits.description')"
docs="/features/loadmanagement"
docs="/docs/features/loadmanagement"
:defaultYaml="defaultYaml"
removeKey="circuits"
endpoint="/config/circuits"

View file

@ -15,7 +15,7 @@
:label="$t('config.control.labelInterval')"
:help="$t('config.control.descriptionInterval')"
example="30 s"
docsLink="/reference/configuration/interval"
docsLink="/docs/reference/configuration/interval"
>
<div class="input-group input-width">
<input
@ -37,7 +37,7 @@
:label="$t('config.control.labelResidualPower')"
:help="$t('config.control.descriptionResidualPower')"
example="100 W"
docsLink="/reference/configuration/site#residualpower"
docsLink="/docs/reference/configuration/site#residualpower"
>
<div class="input-group input-width">
<input

View file

@ -1,97 +0,0 @@
<template>
<DeviceModalBase
:id="id"
name="curtailer"
device-type="curtailer"
:modal-title="$t(`config.curtailer.${isNew ? 'titleAdd' : 'titleEdit'}`)"
:provide-template-options="provideTemplateOptions"
:initial-values="initialValues"
:on-template-change="handleTemplateChange"
:preserve-on-template-change="preserveFields"
@added="(name) => $emit('changed', { action: 'added', name })"
@updated="$emit('changed', { action: 'updated' })"
@removed="$emit('changed', { action: 'removed' })"
>
<template #description>
<p class="mt-0 mb-4">{{ $t("config.curtailer.description") }}</p>
</template>
<template #before-template="{ values }">
<FormRow id="curtailerParamDeviceTitle" :label="$t('config.general.title')">
<PropertyField
id="curtailerParamDeviceTitle"
v-model.trim="values.deviceTitle"
type="String"
size="w-100"
class="me-2"
required
/>
</FormRow>
</template>
</DeviceModalBase>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import DeviceModalBase from "./DeviceModal/DeviceModalBase.vue";
import FormRow from "./FormRow.vue";
import PropertyField from "./PropertyField.vue";
import type { DeviceValues, Product } from "./DeviceModal";
import { type TemplateGroup, customTemplateOption } from "./DeviceModal/TemplateSelector.vue";
import { ConfigType } from "@/types/evcc";
import defaultCurtailerYaml from "./defaultYaml/curtailer.yaml?raw";
import { getModal } from "@/configModal";
const initialValues = {
type: ConfigType.Template,
deviceTitle: "",
template: null,
};
export default defineComponent({
name: "CurtailerModal",
components: {
DeviceModalBase,
FormRow,
PropertyField,
},
emits: ["changed"],
data() {
return {
initialValues,
preserveFields: ["deviceTitle"],
};
},
computed: {
id(): number | undefined {
return getModal("curtailer")?.id;
},
isNew(): boolean {
return this.id === undefined;
},
},
methods: {
provideTemplateOptions(products: Product[]): TemplateGroup[] {
return [
{
label: "generic",
options: [
...products.filter((p: Product) => p.group === "generic"),
customTemplateOption(this.$t("config.general.customOption")),
],
},
{
label: "specific",
options: products.filter((p: Product) => p.group !== "generic"),
},
];
},
handleTemplateChange(value: string, values: DeviceValues) {
if (value === ConfigType.Custom) {
values.type = ConfigType.Custom;
values.yaml = defaultCurtailerYaml;
}
},
},
});
</script>

View file

@ -34,7 +34,7 @@ export default defineComponent({
},
computed: {
docsLink() {
return `${docsPrefix()}/user-defined-devices#${this.type}`;
return `${docsPrefix()}/docs/devices/plugins#${this.type}`;
},
},
watch: {

View file

@ -3,7 +3,7 @@
name="eebus"
:title="$t('config.eebus.title')"
:description="$t('config.eebus.description')"
docs="/reference/configuration/eebus"
docs="/docs/reference/configuration/eebus"
endpoint="/config/eebus"
state-key="eebus.config"
:no-buttons="fromYaml"

View file

@ -208,7 +208,7 @@ export default defineComponent({
return (this as any).fmtTimeAgo(ms - Date.now());
},
docsLink(): string {
return `${docsPrefix()}/external-limit`;
return `${docsPrefix()}/docs/external-limit`;
},
experimental(): boolean {
return store.state?.experimental === true;

View file

@ -3,7 +3,7 @@
name="influx"
:title="$t('config.influx.title')"
:description="$t('config.influx.description')"
docs="/reference/configuration/influx"
docs="/docs/reference/configuration/influx"
endpoint="/config/influx"
state-key="influx"
@changed="$emit('changed')"

View file

@ -54,7 +54,7 @@ export default defineComponent({
return `claude mcp add --transport http evcc ${this.mcpUrl}`;
},
docsLink(): string {
return `${docsPrefix()}/integrations/mcp`;
return `${docsPrefix()}/docs/integrations/mcp`;
},
},
});

View file

@ -55,7 +55,7 @@
/>
<div class="text-end small text-gray mt-1">
<a
:href="`${docsPrefix()}/notifications#placeholders`"
:href="`${docsPrefix()}/docs/notifications#placeholders`"
target="_blank"
class="text-gray"
>

View file

@ -4,7 +4,7 @@
name="messaginglegacy"
:title="`${$t('config.messaging.title')} (${$t('config.general.legacy')})`"
:description="$t('config.messaging.description')"
docs="/reference/configuration/messaging"
docs="/docs/reference/configuration/messaging"
:defaultYaml="defaultYaml"
endpoint="/config/messaging"
removeKey="messaging"

View file

@ -4,7 +4,7 @@
name="messaging"
:title="$t('config.messaging.title')"
:description="$t('config.messaging.description')"
docs="/notifications"
docs="/docs/notifications"
endpoint="/config/messagingEvents"
state-key="messagingEvents"
data-testid="messaging-modal"

View file

@ -3,7 +3,7 @@
name="modbusproxy"
:title="$t('config.modbusproxy.title')"
:description="$t('config.modbusproxy.description')"
docs="/reference/configuration/modbusproxy"
docs="/docs/reference/configuration/modbusproxy"
endpoint="/config/modbusproxy"
state-key="modbusproxy"
store-values-in-array

View file

@ -3,7 +3,7 @@
name="mqtt"
:title="$t('config.mqtt.title')"
:description="$t('config.mqtt.description')"
docs="/reference/configuration/mqtt"
docs="/docs/reference/configuration/mqtt"
endpoint="/config/mqtt"
state-key="mqtt"
@changed="$emit('changed')"

View file

@ -4,7 +4,7 @@
tabindex="0"
@click="$emit('click')"
>
<shopicon-regular-plus class="me-1 flex-shrink-0"></shopicon-regular-plus>
<shopicon-regular-plus class="me-1"></shopicon-regular-plus>
<span class="text-start">{{ title }}</span>
</button>
</template>

View file

@ -62,7 +62,7 @@ export default defineComponent({
return !!store.state?.evopt;
},
docsLink(): string {
return `${docsPrefix()}/features/optimizer`;
return `${docsPrefix()}/docs/features/optimizer`;
},
},
methods: {

View file

@ -6,18 +6,15 @@
data-testid="remote-modal"
@open="onOpen"
>
<div class="alert alert-warning">
Development preview. Not ready for general use. Use with caution and monitor your system
closely. Feedback welcome!
</div>
<SponsorTokenRequired v-if="!isSponsor" feature class="mt-0" />
<ErrorMessage :error="error" />
<template v-if="view === 'list'">
<p>
{{ $t("config.remote.description") }}
<i18n-t tag="span" keypath="config.remote.privacy" scope="global">
<a :href="privacyPolicyLink" target="_blank">
{{ $t("config.remote.privacyLink") }}
</a>
</i18n-t>
</p>
<p>{{ $t("config.remote.description") }}</p>
<div class="form-check form-switch my-3">
<input
id="remoteEnabled"
@ -141,10 +138,6 @@ export default defineComponent({
status(): RemoteStatus {
return this.remote?.status ?? { connected: false, loginBlocked: false };
},
privacyPolicyLink(): string {
const path = this.$i18n.locale === "de" ? "" : "/en";
return `https://evcc.io${path}/datenschutz/`;
},
modalTitle(): string {
switch (this.view) {
case "create":
@ -152,7 +145,7 @@ export default defineComponent({
case "reveal":
return this.$t("config.remote.clientCreated");
default:
return this.$t("config.remote.title");
return `${this.$t("config.remote.title")} 🧪`;
}
},
},

View file

@ -127,9 +127,9 @@ export default defineComponent({
},
curlExample(): string {
const key = this.revealedKey ?? "";
const url = `${window.location.origin}/api/db/backup`;
const url = `${window.location.origin}/api/system/backup`;
return [
`curl -X GET ${url} \\`,
`curl -X POST ${url} \\`,
` -H "Authorization: Bearer ${key}" \\`,
` -o evcc-backup.db`,
].join("\n");

View file

@ -3,7 +3,7 @@
name="shm"
:title="$t('config.shm.title')"
:description="$t('config.shm.description')"
docs="/reference/configuration/hems"
docs="/docs/reference/configuration/hems"
endpoint="/config/shm"
state-key="shm"
disable-remove

View file

@ -128,7 +128,7 @@ export default {
return this.token && !this.fromYaml;
},
trialTokenLink() {
return `${docsPrefix()}/sponsorship#trial`;
return `${docsPrefix()}/docs/sponsorship#trial`;
},
},
methods: {

View file

@ -1,9 +1,11 @@
<template>
<span
v-tooltip="tooltip"
ref="root"
class="d-flex align-items-center gap-2 text-nowrap evcc-gray"
:role="tooltip ? 'img' : undefined"
:aria-label="tooltip || undefined"
:title="tooltip || undefined"
:data-bs-toggle="tooltip ? 'tooltip' : undefined"
>
<span class="d-inline-block rounded-circle status-dot" :class="dotClass"></span>
<slot />
@ -12,6 +14,7 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import Tooltip from "bootstrap/js/dist/tooltip";
type Variant = "success" | "warning" | "danger" | "muted";
@ -21,6 +24,9 @@ export default defineComponent({
variant: { type: String as PropType<Variant>, default: "muted" },
tooltip: { type: String, default: "" },
},
data() {
return { tooltipInstance: null as Tooltip | null };
},
computed: {
dotClass(): string {
switch (this.variant) {
@ -35,6 +41,30 @@ export default defineComponent({
}
},
},
watch: {
tooltip() {
this.initTooltip();
},
},
mounted() {
this.initTooltip();
},
beforeUnmount() {
this.tooltipInstance?.dispose();
},
methods: {
initTooltip() {
this.$nextTick(() => {
this.tooltipInstance?.dispose();
this.tooltipInstance = null;
const el = this.$refs["root"] as Element | undefined;
if (el && this.tooltip) {
// explicit title: bootstrap clears the attr, so :title alone goes stale
this.tooltipInstance = new Tooltip(el, { title: this.tooltip });
}
});
},
},
});
</script>

View file

@ -3,7 +3,7 @@
name="tariffsLegacy"
:title="`${$t('config.tariff.title')} (${$t('config.general.legacy')})`"
:description="$t('config.tariff.description')"
docs="/tariffs"
docs="/docs/tariffs"
:defaultYaml="defaultYaml"
removeKey="tariffs"
endpoint="/config/tariffs"

View file

@ -1,28 +0,0 @@
## required attributes
# feed-in limit in percent of nominal production power (0..100, 100 = uncurtailed)
curtail: # set feed-in limit
source: js
script: console.log(curtail)
curtailed: # current feed-in limit
source: const
value: 100
## alternative examples
#curtail:
# source: http
# uri: http://inverter.local/api/limit?percent={{ .curtail }}
# method: POST
#curtailed:
# source: http
# uri: http://inverter.local/api/limit
# jq: .percent
#curtail:
# source: modbus
# uri: 192.168.179.200:502
# id: 1
# register: { type: writesingle, address: 40016, encoding: uint16 }
#curtailed:
# source: error
# error: ErrNotAvailable

View file

@ -16,9 +16,6 @@ power: # current power
#maxpower: # maximum AC power in W (for hybrid pv)
# source: const
# value: 5000
#curtailed: # current feed-in limit in % (0..100, 100 = uncurtailed)
# source: const
# value: 100
#soc: # state of charge in % (for battery)
# source: const
# value: 75
@ -53,6 +50,3 @@ power: # current power
#batterymode: # set charging mode directly (1: normal, 2: hold, 3: charge) (for battery)
# source: js
# script: console.log(batterymode)
#curtail: # set feed-in limit in % (0..100, 100 = uncurtailed)
# source: js
# script: console.log(curtail)

View file

@ -656,7 +656,7 @@ export default defineComponent({
height: 0;
opacity: 0;
transform: scale(0.98);
overflow: hidden;
overflow: visible;
transition-property: height, opacity, transform;
transition-duration: 0;
transition-timing-function: cubic-bezier(0.5, 0.5, 0.5, 1.15);

View file

@ -236,8 +236,6 @@ export default defineComponent({
display: flex;
overflow: hidden;
margin-right: 1.2rem;
/* Safari composites the width-animated bars without subpixel antialiasing, blurring labels on 1x displays */
-webkit-font-smoothing: antialiased;
}
.label-scale-name {
color: var(--evcc-gray);

View file

@ -2,4 +2,7 @@
overflow-x: auto;
padding-bottom: 4px;
margin-bottom: 0.5rem;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}

View file

@ -59,19 +59,6 @@
equal-width
/>
</FormRow>
<FormRow id="settingsDateFormat" :label="$t('settings.dateFormat.label')">
<select
id="settingsDateFormat"
v-model="dateFormat"
class="form-select form-select-sm w-75"
>
<option value="">{{ $t("settings.dateFormat.auto") }}</option>
<option value="dmy">{{ $t("settings.dateFormat.dmy") }}</option>
<option value="mdy">{{ $t("settings.dateFormat.mdy") }}</option>
<option value="ymd">{{ $t("settings.dateFormat.ymd") }}</option>
</select>
<div class="form-text evcc-gray">{{ dateFormatExample }}</div>
</FormRow>
<FormRow v-if="loadpoints.length" :label="$t('settings.loadpoints.label')">
<LoadpointOrderSettings :loadpoints="loadpoints" />
</FormRow>
@ -104,24 +91,14 @@ import {
removeLocalePreference,
} from "@/i18n.ts";
import { getThemePreference, setThemePreference } from "@/theme.ts";
import {
getUnits,
setUnits,
is12hFormat,
set12hFormat,
getDateFormat,
setDateFormat,
} from "@/units";
import { getUnits, setUnits, is12hFormat, set12hFormat } from "@/units";
import { isApp } from "@/utils/native";
import { defineComponent, type PropType } from "vue";
import { LENGTH_UNIT, THEME, TIME_FORMAT, type UiLoadpoint } from "@/types/evcc";
import formatter from "@/mixins/formatter";
import type { DateFormat } from "@/settings";
export default defineComponent({
name: "UserInterfaceSettings",
components: { FormRow, SelectGroup, LoadpointOrderSettings },
mixins: [formatter],
props: {
loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
},
@ -138,17 +115,6 @@ export default defineComponent({
};
},
computed: {
dateFormat: {
get(): DateFormat {
return getDateFormat();
},
set(value: DateFormat) {
setDateFormat(value);
},
},
dateFormatExample(): string {
return this.fmtFullDateTime(new Date(2015, 9, 21, 16, 29));
},
languageOptions: () => {
const locales = Object.entries(LOCALES).map(([key, value]) => {
return { value: key, name: value[1] };

View file

@ -1,6 +1,6 @@
import Loadpoints from "./Loadpoints.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
import { CHARGE_MODE, CURRENCY, SMART_COST_TYPE } from "@/types/evcc";
import { CURRENCY, SMART_COST_TYPE } from "@/types/evcc";
// Create LoadpointCompact structure for the Loadpoints component
const createLoadpoint = (opts: any = {}) => {
@ -117,18 +117,11 @@ TwoLoadpoints.args = {
index: 0,
vehicleName: "vehicle_3",
charging: true,
chargePower: 1380,
power: 1380,
chargePower: 8050,
power: 8050,
connected: true,
soc: 66,
vehicleSoc: 66,
mode: CHARGE_MODE.PV,
phasesConfigured: 3,
phasesActive: 1,
offeredCurrent: 9,
chargeCurrents: [6, 0, 0],
minCurrent: 6,
maxCurrent: 16,
}),
createLoadpoint({
title: "Charging point with a very very very long title that tests layout compatibility!!!",

View file

@ -1,6 +1,5 @@
import Phases from "./Phases.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
import { ref, onMounted, onUnmounted } from "vue";
export default {
title: "Loadpoints/Phases",
@ -10,7 +9,6 @@ export default {
},
argTypes: {
phasesActive: { control: { type: "number" } },
phasesConfigured: { control: { type: "number" } },
minCurrent: { control: { type: "number" } },
maxCurrent: { control: { type: "number" } },
offeredCurrent: { control: { type: "number" } },
@ -26,117 +24,72 @@ const Template: StoryFn<typeof Phases> = (args) => ({
template: '<Phases v-bind="args" />',
});
const base = {
phasesConfigured: 3,
export const OnePhase = Template.bind({});
OnePhase.args = {
phasesActive: 1,
minCurrent: 6,
maxCurrent: 16,
offeredCurrent: 8,
chargeCurrents: undefined,
};
// --- expected phases only (no per-phase measurement) ---
export const NoData = Template.bind({});
NoData.args = { ...base, phasesActive: undefined, offeredCurrent: 0 };
export const Expected1p = Template.bind({});
Expected1p.args = { ...base, phasesActive: 1 };
export const Expected2p = Template.bind({});
Expected2p.args = { ...base, phasesActive: 2 };
export const Expected3p = Template.bind({});
Expected3p.args = { ...base, phasesActive: 3 };
// --- measured per-phase currents ---
export const Measured1p = Template.bind({});
Measured1p.args = { ...base, phasesActive: 1, offeredCurrent: 12, chargeCurrents: [6, 0.2, 0] };
export const Measured2p = Template.bind({});
Measured2p.args = { ...base, phasesActive: 2, offeredCurrent: 16, chargeCurrents: [16, 16, 0.3] };
export const Measured3p = Template.bind({});
Measured3p.args = { ...base, phasesActive: 3, offeredCurrent: 13, chargeCurrents: [11, 9, 12] };
export const Asymmetric = Template.bind({});
Asymmetric.args = { ...base, phasesActive: 2, offeredCurrent: 16, chargeCurrents: [8, 0.9, 14] };
export const OnlyL2 = Template.bind({});
OnlyL2.args = { ...base, phasesActive: 1, offeredCurrent: 13, chargeCurrents: [0, 13, 0] };
export const MainlyL3 = Template.bind({});
MainlyL3.args = {
...base,
phasesActive: 1,
offeredCurrent: 20,
maxCurrent: 20,
chargeCurrents: [0.007, 0.009, 5.945],
export const TwoPhase = Template.bind({});
TwoPhase.args = {
...OnePhase.args,
phasesActive: 2,
};
// 1p device but 3 phases measured: configuration error, warning color + tooltip
export const Mismatch1p = Template.bind({});
Mismatch1p.args = {
...base,
phasesConfigured: 1,
export const ThreePhase = Template.bind({});
ThreePhase.args = {
...OnePhase.args,
phasesActive: 3,
offeredCurrent: 10,
chargeCurrents: [10, 10, 10],
};
// cycles 1p → 3p → 1p every few seconds to inspect the transition
export const Switching: StoryFn<typeof Phases> = (args) => ({
components: { Phases },
setup() {
const phasesActive = ref(1);
let timer: ReturnType<typeof setInterval>;
onMounted(() => {
timer = setInterval(() => {
phasesActive.value = phasesActive.value === 1 ? 3 : 1;
}, 3000);
});
onUnmounted(() => clearInterval(timer));
return { args, phasesActive };
},
template: '<Phases v-bind="args" :phasesActive="phasesActive" />',
});
Switching.args = { ...base, phasesConfigured: 0, offeredCurrent: 12 };
export const RealCurrents = Template.bind({});
RealCurrents.args = {
...OnePhase.args,
phasesActive: 3,
offeredCurrent: 13,
chargeCurrents: [11, 9, 12],
};
// --- overview: rows = scenario, columns = device phase configuration ---
export const OnePhaseMoreAvailable = Template.bind({});
OnePhaseMoreAvailable.args = {
...OnePhase.args,
offeredCurrent: 12,
chargeCurrents: [6, 0.2, 0],
};
const scenarios = [
{ name: "NoData", args: NoData.args },
{ name: "Expected1p", args: Expected1p.args },
{ name: "Expected2p", args: Expected2p.args },
{ name: "Expected3p", args: Expected3p.args },
{ name: "Measured1p", args: Measured1p.args },
{ name: "Measured2p", args: Measured2p.args },
{ name: "Measured3p", args: Measured3p.args },
{ name: "Asymmetric", args: Asymmetric.args },
{ name: "OnlyL2", args: OnlyL2.args },
{ name: "MainlyL3", args: MainlyL3.args },
];
export const TwoPhasesActive = Template.bind({});
TwoPhasesActive.args = {
...OnePhase.args,
phasesActive: 2,
offeredCurrent: 16,
chargeCurrents: [16, 16, 0.3],
};
// 1p3p auto (0) behaves like 3p
const configs = [
{ name: "1p device", phasesConfigured: 1 },
{ name: "3p/auto", phasesConfigured: 3 },
];
export const AsymetricPhases = Template.bind({});
AsymetricPhases.args = {
...OnePhase.args,
phasesActive: 2,
offeredCurrent: 16,
chargeCurrents: [8, 0.9, 14],
};
export const Overview: StoryFn<typeof Phases> = () => ({
components: { Phases },
setup() {
return { scenarios, configs };
},
template: `
<div style="display: grid; grid-template-columns: 140px repeat(2, 120px); gap: 0.75rem 1.5rem; align-items: center; padding: 1rem; background: var(--evcc-box); border-radius: 0.5rem; color: var(--evcc-default-text); font-size: 0.85rem;">
<div></div>
<div v-for="c in configs" :key="c.name" style="opacity: 0.7;">{{ c.name }}</div>
<template v-for="s in scenarios" :key="s.name">
<div style="opacity: 0.7;">{{ s.name }}</div>
<Phases v-for="c in configs" :key="c.name" v-bind="{ ...s.args, phasesConfigured: c.phasesConfigured }" />
</template>
</div>
`,
});
Overview.parameters = { controls: { disable: true } };
export const OnlySecondPhase = Template.bind({});
OnlySecondPhase.args = {
...OnePhase.args,
phasesActive: 1,
offeredCurrent: 13,
chargeCurrents: [0, 13, 0],
};
export const MainlyThirdPhase = Template.bind({});
MainlyThirdPhase.args = {
...OnePhase.args,
phasesActive: 1,
offeredCurrent: 10,
chargeCurrents: [0.007, 0.009, 5.945],
minCurrent: 6,
maxCurrent: 20,
};

View file

@ -1,23 +1,12 @@
<template>
<div
v-tooltip="phasesMismatch ? $t('main.loadpoint.phasesMismatch') : undefined"
class="phases d-flex"
:class="{ 'phases-warning': phasesMismatch }"
>
<div :class="`phases d-flex justify-content-between`">
<div
v-for="num in [1, 2, 3]"
:key="num"
class="phase me-1"
:class="{
'phase-inactive': !isPhaseActive(num),
'phase-hidden': !isPhaseActive(num) && (maxPhases === 1 || num > maxPhases),
}"
:class="{ 'phase-inactive': !isPhaseActive(num) }"
>
<div
v-show="targetWidth() > 0"
class="target"
:style="{ width: `${targetWidth()}%` }"
></div>
<div class="target" :style="{ width: `${targetWidth()}%` }"></div>
<div class="real" :style="{ width: `${realWidth(num)}%` }"></div>
</div>
</div>
@ -34,7 +23,6 @@ export default defineComponent({
offeredCurrent: { type: Number, default: 0 },
chargeCurrents: { type: Array as PropType<number[]> },
phasesActive: { type: Number as PropType<PHASES> },
phasesConfigured: { type: Number, default: 3 },
minCurrent: { type: Number, default: 6 },
maxCurrent: { type: Number, default: 16 },
},
@ -43,17 +31,9 @@ export default defineComponent({
if (!this.chargeCurrents) return false;
return this.chargeCurrents.filter((c) => c >= MIN_ACTIVE_CURRENT).length > 0;
},
maxPhases() {
// 0 = automatic 1p3p switching
return this.phasesConfigured || 3;
},
phasesMismatch() {
return [1, 2, 3].filter((num) => this.isPhaseActive(num)).length > this.maxPhases;
},
},
methods: {
targetWidth() {
if (this.offeredCurrent <= 0) return 0;
const current = Math.min(
Math.max(this.minCurrent, this.offeredCurrent),
this.maxCurrent
@ -72,8 +52,7 @@ export default defineComponent({
const current = this.chargeCurrents[num - 1];
return current !== undefined && current >= MIN_ACTIVE_CURRENT;
}
// unknown active phases: assume all
return num <= (this.phasesActive || this.maxPhases);
return num <= (this.phasesActive || 0);
},
},
});
@ -84,14 +63,15 @@ export default defineComponent({
width: 73px;
}
.phase {
background-color: var(--evcc-gray-15);
background-color: var(--bs-gray-bright);
height: 4px;
flex: 1 1 0;
flex-grow: 1;
position: relative;
border-radius: 2px;
border-radius: 1px;
overflow: hidden;
flex-basis: 100%;
opacity: 1;
transition-property: flex-basis, flex-grow, margin, opacity;
transition-property: flex-basis, margin, opacity;
transition-duration: var(--evcc-transition-slow);
transition-timing-function: ease-in;
}
@ -99,13 +79,6 @@ html.dark .phase {
background-color: var(--bs-gray-bright);
}
.phase-inactive {
flex: 0 0 4px;
}
.phase-inactive .target,
.phase-inactive .real {
opacity: 0;
}
.phase-hidden {
flex-basis: 0;
margin-right: 0 !important;
opacity: 0;
@ -123,15 +96,9 @@ html.dark .phase {
opacity: 1;
}
.target {
background-color: color-mix(in srgb, var(--evcc-dark-green) 40%, white);
background-color: var(--evcc-green);
}
.real {
background-color: var(--evcc-dark-green);
}
.phases-warning .target {
background-color: color-mix(in srgb, var(--bs-warning) 40%, white);
}
.phases-warning .real {
background-color: var(--bs-warning);
}
</style>

View file

@ -1,18 +0,0 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12.825 15.8q.75-.35 1.113-1.2t-.163-1.6q-1.1-1.575-2.212-3.062T9.3 6.95q-.275-.35-.687-.162t-.313.637q.4 1.825.85 3.625t1 3.65q.25.875 1.038 1.163t1.637-.063m-7.7 4.2q-.525 0-1.012-.238t-.738-.712q-.7-1.15-1.025-2.437T2.025 14q0-1.725.588-3.463t1.862-3.112q.375-.425.8-.412t.7.262t.35.625t-.225.725q-.95 1.05-1.512 2.475T4.025 14q0 1.025.263 2.038T5.125 18H18.9q.525-.9.813-1.925T20 14q0-1.875-.812-3.475T17.025 7.8q-1.05-.875-2.375-1.35T11.95 6q-.4 0-.712-.262t-.363-.663t.15-.725t.6-.35q1.825-.075 3.563.525T18.35 6.3q1.675 1.4 2.663 3.375T22 14q0 1.4-.35 2.663t-1 2.387q-.275.475-.75.713t-1 .237zm6.9-6.025"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "Curtail",
mixins: [icon],
});
</script>

View file

@ -146,7 +146,7 @@ export default defineComponent({
...tooltipStyle(colors.text || ""),
formatter: this.tooltipFormatter,
},
xAxis: slotXAxis(this.times, this.weekdayShort),
xAxis: slotXAxis(this.times),
yAxis: forecastYAxis({
min: undefined,
position: "right",

View file

@ -1,35 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { isMidnight, slotXAxis } from "./chart";
// 15min slots starting at the given local hour
function times(startHour: number, count: number): number[] {
const start = new Date(2025, 0, 1, startHour).getTime();
return Array.from({ length: count }, (_, i) => start + i * 15 * 60 * 1000);
}
describe("slotXAxis", () => {
const weekdayShort = () => "Thu";
// 20:00 to 08:00, midnight at index 16
const slots = times(20, 48);
const axis = slotXAxis(slots, weekdayShort);
const label = (i: number) => axis.axisLabel.formatter(String(slots[i]));
test("labels full hours only", () => {
expect(label(0)).toBe("20"); // 20:00
expect(label(1)).toBe(""); // 20:15
expect(label(4)).toBe(""); // 21:00, not a step hour
expect(label(32)).toBe("4"); // 04:00
});
test("shows the weekday at midnight", () => {
expect(label(16)).toBe("0\nThu");
});
test("draws a split line at day boundaries only", () => {
expect(isMidnight(slots[16])).toBe(true);
expect(axis.splitLine.interval(16)).toBe(true);
expect(axis.splitLine.interval(15)).toBe(false);
// no line at the axis start, even if the first slot is midnight
expect(slotXAxis(times(0, 4), weekdayShort).splitLine.interval(0)).toBe(false);
});
});

View file

@ -1,5 +1,4 @@
import { xAxisLabelStyle } from "../Forecast/echarts";
import colors from "@/colors";
import type { BatteryDetail } from "@/types/evcc";
// loadpoint part of a vehicle entry title: "Carport (blue e-Golf)" → "Carport"
@ -20,35 +19,22 @@ export function slotTimes(timestamp: string, dt: number[]): number[] {
return res;
}
export function isMidnight(time?: number): boolean {
if (time === undefined) return false;
const d = new Date(time);
return d.getHours() === 0 && d.getMinutes() === 0;
}
// category x axis over slot times, labels at full hours every 4h (6h on mobile),
// day boundaries as split line with the weekday shown at 00:00
export function slotXAxis(times: number[], weekdayShort: (d: Date) => string) {
// category x axis over slot times, labels at full hours every 4h (6h on mobile)
export function slotXAxis(times: number[]) {
const step = window.innerWidth < 576 ? 6 : 4;
return {
type: "category",
data: times.map(String),
axisLine: { show: false },
axisTick: { show: false },
splitLine: {
show: true,
// split lines sit at the left edge of their slot
interval: (index: number) => index > 0 && isMidnight(times[index]),
lineStyle: { color: colors.muted || "", type: "solid" },
},
splitLine: { show: false },
axisLabel: {
...xAxisLabelStyle(),
interval: 0,
formatter: (value: string) => {
const d = new Date(Number(value));
if (d.getMinutes() !== 0 || d.getHours() % step !== 0) return "";
const label = String(d.getHours());
return d.getHours() === 0 ? `${label}\n${weekdayShort(d)}` : label;
return String(d.getHours());
},
},
};

View file

@ -60,11 +60,11 @@
</th>
<td>
<template v-if="session.created">
{{ fmtFullDateTime(new Date(session.created)) }}
{{ fmtFullDateTime(new Date(session.created), false) }}
</template>
<br />
<template v-if="session.finished">
{{ fmtFullDateTime(new Date(session.finished)) }}
{{ fmtFullDateTime(new Date(session.finished), false) }}
</template>
</td>
</tr>

View file

@ -133,7 +133,7 @@
@click="showDetails(session.id)"
>
<td class="ps-0 tabular">
{{ fmtWeekdayDayTime(new Date(session.created)) }}
{{ fmtFullDateTime(new Date(session.created), true) }}
</td>
<td class="d-none d-md-table-cell">
{{ session.loadpoint }}

View file

@ -47,7 +47,7 @@ export default defineComponent({
},
computed: {
docsLink() {
return `${docsPrefix()}/faq#telemetry`;
return `${docsPrefix()}/docs/faq#telemetry`;
},
},
methods: {

View file

@ -16,91 +16,85 @@
</a>
</p>
<div
v-for="vehicle in vehicles"
v-for="(vehicle, index) in vehicles"
:key="vehicle.name"
role="group"
:aria-label="vehicle.title"
class="d-flex gap-2 mb-3"
:class="{ 'border-top pt-4': index > 0 }"
class="mb-4"
>
<VehicleIcon :name="vehicle.icon" class="flex-shrink-0" />
<div class="flex-grow-1 overflow-hidden ring-space">
<div class="d-flex flex-wrap align-items-center column-gap-2 row-gap-1 mb-3">
<strong class="text-truncate">{{ vehicle.title }}</strong>
<Badge v-if="connectedLoadpoint(vehicle)" class="flex-shrink-0">
{{
$t("main.vehicleSettings.connectedTo", [
connectedLoadpoint(vehicle)?.title ||
$t("main.loadpoint.fallbackName"),
])
}}
</Badge>
<Badge v-else variant="muted" class="flex-shrink-0">
{{ $t("main.vehicleSettings.notConnected") }}
</Badge>
</div>
<div class="row">
<div class="col-6 col-lg-3 mb-3">
<div class="small text-uppercase evcc-gray text-truncate">
{{ $t("main.vehicleSettings.mode") }}
</div>
<CustomSelect
:id="fieldId(vehicle, 'mode')"
inline
:options="modeOptions"
:selected="vehicle.mode ?? ''"
:ariaLabel="$t('main.vehicleSettings.mode')"
@change="changeMode(vehicle, $event)"
>
<span class="text-decoration-underline text-primary">
{{ optionName(modeOptions, vehicle.mode ?? "") }}
</span>
</CustomSelect>
</div>
<template v-if="socSupported(vehicle)">
<div class="col-6 col-lg-3 mb-3">
<div class="small text-uppercase evcc-gray text-truncate">
{{ $t("main.vehicleSettings.minSoc") }}
</div>
<CustomSelect
:id="fieldId(vehicle, 'minSoc')"
inline
:options="socOptions(vehicle, true)"
:selected="vehicle.minSoc ?? 0"
:ariaLabel="$t('main.vehicleSettings.minSoc')"
@change="changeMinSoc(vehicle, $event)"
>
<span class="text-decoration-underline text-primary">
{{
optionName(
socOptions(vehicle, true),
vehicle.minSoc ?? 0
)
}}
</span>
</CustomSelect>
</div>
<div class="col-6 col-lg-3 mb-3">
<div class="small text-uppercase evcc-gray text-truncate">
{{ $t("main.vehicleSettings.limitSoc") }}
</div>
<CustomSelect
:id="fieldId(vehicle, 'limitSoc')"
inline
:options="socOptions(vehicle)"
:selected="vehicle.limitSoc || 100"
:ariaLabel="$t('main.vehicleSettings.limitSoc')"
@change="changeLimitSoc(vehicle, $event)"
>
<span class="text-decoration-underline text-primary">
{{
optionName(socOptions(vehicle), vehicle.limitSoc || 100)
}}
</span>
</CustomSelect>
</div>
</template>
</div>
<div class="d-flex flex-wrap align-items-center column-gap-2 row-gap-1 mb-3">
<VehicleIcon :name="vehicle.icon" class="flex-shrink-0" />
<strong class="text-truncate">{{ vehicle.title }}</strong>
<Badge v-if="connectedLoadpoint(vehicle)" class="flex-shrink-0">
{{
$t("main.vehicleSettings.connectedTo", [
connectedLoadpoint(vehicle)?.title ||
$t("main.loadpoint.fallbackName"),
])
}}
</Badge>
<Badge v-else variant="muted" class="flex-shrink-0">
{{ $t("main.vehicleSettings.notConnected") }}
</Badge>
</div>
<SettingsFormRow
:id="fieldId(vehicle, 'mode')"
:label="$t('main.vehicleSettings.mode')"
>
<select
:id="fieldId(vehicle, 'mode')"
class="form-select form-select-sm"
:value="vehicle.mode ?? ''"
@change="changeMode(vehicle, $event)"
>
<option v-for="opt in modeOptions" :key="opt.value" :value="opt.value">
{{ opt.name }}
</option>
</select>
</SettingsFormRow>
<template v-if="socSupported(vehicle)">
<SettingsFormRow
:id="fieldId(vehicle, 'limitSoc')"
:label="$t('main.vehicleSettings.limitSoc')"
:description="$t('main.vehicleSettings.limitSocDescription')"
>
<select
:id="fieldId(vehicle, 'limitSoc')"
class="form-select form-select-sm"
:value="vehicle.limitSoc || 100"
@change="changeLimitSoc(vehicle, $event)"
>
<option
v-for="opt in socOptions(vehicle)"
:key="opt.value"
:value="opt.value"
>
{{ opt.name }}
</option>
</select>
</SettingsFormRow>
<SettingsFormRow
:id="fieldId(vehicle, 'minSoc')"
:label="$t('main.vehicleSettings.minSoc')"
:description="$t('main.vehicleSettings.minSocDescription')"
>
<select
:id="fieldId(vehicle, 'minSoc')"
class="form-select form-select-sm"
:value="vehicle.minSoc ?? 0"
@change="changeMinSoc(vehicle, $event)"
>
<option
v-for="opt in socOptions(vehicle, true)"
:key="opt.value"
:value="opt.value"
>
{{ opt.name }}
</option>
</select>
</SettingsFormRow>
</template>
</div>
<p class="mb-0 border-top pt-4">
<i18n-t keypath="main.vehicleSettings.editHint" tag="span" scope="global">
@ -116,7 +110,7 @@
<script lang="ts">
import Badge from "../Helper/Badge.vue";
import GenericModal from "../Helper/GenericModal.vue";
import CustomSelect from "../Helper/CustomSelect.vue";
import SettingsFormRow from "../Helper/SettingsFormRow.vue";
import VehicleIcon from "../VehicleIcon";
import api from "@/api";
import formatter from "@/mixins/formatter";
@ -130,7 +124,7 @@ const { OFF, PV, MINPV, NOW } = CHARGE_MODE;
export default defineComponent({
name: "VehicleSettingsModal",
components: { Badge, CustomSelect, GenericModal, VehicleIcon },
components: { Badge, GenericModal, SettingsFormRow, VehicleIcon },
mixins: [formatter],
props: {
vehicles: { type: Array as PropType<Vehicle[]>, default: () => [] },
@ -143,7 +137,7 @@ export default defineComponent({
},
computed: {
docsUrl(): string {
return `${docsPrefix()}/features/limits`;
return `${docsPrefix()}/docs/features/limits`;
},
modeOptions(): SelectOption<string>[] {
return [
@ -194,9 +188,6 @@ export default defineComponent({
};
});
},
optionName(options: SelectOption<string | number>[], value: string | number): string {
return options.find((o) => o.value === value)?.name ?? String(value);
},
selectValue(event: Event): string {
return (event.target as HTMLSelectElement).value;
},

View file

@ -1,25 +0,0 @@
import Tooltip from "bootstrap/js/dist/tooltip";
import type { Directive, DirectiveBinding } from "vue";
function update(el: HTMLElement, { value, oldValue }: DirectiveBinding<string | undefined>) {
if (value === oldValue) return;
const instance = Tooltip.getInstance(el);
if (!value) {
instance?.dispose();
} else if (instance) {
instance.setContent({ ".tooltip-inner": value });
} else {
new Tooltip(el, { title: value });
}
}
// v-tooltip="text": bootstrap tooltip bound to text, removed when text is empty
const tooltip: Directive<HTMLElement, string | undefined> = {
mounted: update,
updated: update,
beforeUnmount(el) {
Tooltip.getInstance(el)?.dispose();
},
};
export default tooltip;

View file

@ -110,6 +110,7 @@ export async function ensureCurrentLocaleMessages(i18n: VueI18nInstance) {
}
export function docsPrefix() {
const lang = getLocale() === "de" ? "de" : "en";
return `https://docs.evcc.io/${lang}`;
const locale = getLocale();
const path = locale === "de" ? "" : `/en`;
return `https://docs.evcc.io${path}`;
}

View file

@ -2,7 +2,6 @@ import { mount, config } from "@vue/test-utils";
import { describe, expect, test, vi } from "vite-plus/test";
import formatter, { POWER_UNIT } from "./formatter";
import * as units from "../units";
import settings from "../settings";
import { defineComponent } from "vue";
import { CURRENCY } from "@/types/evcc";
@ -276,7 +275,7 @@ describe("12h/24h time format", () => {
test("12h format", () => {
is12hSpy.mockReturnValue(true);
expect(fmt.fmtHourMinute(testDate)).toBe("3:30 PM");
expect(fmt.fmtFullDateTime(testDate)).toBe("So., 15. Jan. 2023, 3:30 PM");
expect(fmt.fmtFullDateTime(testDate, false)).toBe("So., 15. Jan., 3:30 PM");
expect(fmt.fmtWeekdayTime(testDate)).toBe("So. 3:30 PM");
expect(fmt.fmtAbsoluteDate(testDate)).toBe("So 3:30 PM");
});
@ -284,29 +283,12 @@ describe("12h/24h time format", () => {
test("24h format", () => {
is12hSpy.mockReturnValue(false);
expect(fmt.fmtHourMinute(testDate)).toBe("15:30");
expect(fmt.fmtFullDateTime(testDate)).toBe("So., 15. Jan. 2023, 15:30");
expect(fmt.fmtFullDateTime(testDate, false)).toBe("So., 15. Jan., 15:30");
expect(fmt.fmtWeekdayTime(testDate)).toBe("So., 15:30");
expect(fmt.fmtAbsoluteDate(testDate)).toBe("So 15:30");
});
});
describe("date format", () => {
test("should order day and month by preference", () => {
is12hSpy.mockReturnValue(false);
settings.dateFormat = "dmy";
expect(fmt.fmtFullDateTime(testDate)).toBe("So. 15 Jan. 2023 15:30");
settings.dateFormat = "mdy";
expect(fmt.fmtFullDateTime(testDate)).toBe("So. Jan. 15, 2023 15:30");
settings.dateFormat = "ymd";
expect(fmt.fmtFullDateTime(testDate)).toBe("So. 2023-01-15 15:30");
settings.dateFormat = "";
});
test("should format day and time", () => {
is12hSpy.mockReturnValue(false);
expect(fmt.fmtWeekdayDayTime(testDate)).toBe("So. 15, 15:30");
});
});
describe("fmtTimeRange", () => {
test("should format time ranges in 24h format", () => {
is12hSpy.mockReturnValue(false);

View file

@ -1,55 +1,6 @@
import { defineComponent } from "vue";
import { is12hFormat } from "@/units";
import { CURRENCY } from "../types/evcc";
import settings from "@/settings";
import type { DateFormat } from "@/settings";
// Extract a single part in date context, where names can differ from their
// standalone forms (German "So.", "Jan." vs "So", "Jan").
function datePart(
date: Date,
locale: string | undefined,
type: Intl.DateTimeFormatPartTypes
): string {
return (
new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "short" })
.formatToParts(date)
.find((part) => part.type === type)?.value ?? ""
);
}
// Day+month(+year) in the user's date order, e.g. "17 Mai" or "Mai 17, 2025".
// Ordering and punctuation come from a reference locale (en-GB day-first,
// en-US month-first), month names stay translated. ymd is the full ISO date.
function formatDayMonth(
date: Date,
locale: string | undefined,
fmt: DateFormat,
year = false
): string {
if (fmt === "ymd") return isoDate(date);
const orderLocale = fmt === "mdy" ? "en-US" : "en-GB";
const month = datePart(date, locale, "month");
// some locales (e.g. Czech) use numeric months in date context
const name = /\p{L}/u.test(month)
? month
: new Intl.DateTimeFormat(locale, { month: "short" }).format(date);
return new Intl.DateTimeFormat(orderLocale, {
month: "short",
day: "numeric",
year: year ? "numeric" : undefined,
})
.formatToParts(date)
.map((part) => (part.type === "month" ? name : part.value))
.join("");
}
// local-time ISO date, Intl cannot produce this reliably across locales
function isoDate(date: Date): string {
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${mm}-${dd}`;
}
const CURRENCY_SYMBOLS: Record<CURRENCY, string> = {
AUD: "$",
@ -75,10 +26,6 @@ const CURRENCY_SYMBOLS: Record<CURRENCY, string> = {
TRY: "₺",
MYR: "RM",
THB: "฿",
BYN: "Br",
UAH: "₴",
RUB: "₽",
KZT: "₸",
};
// list of currencies where energy price should be displayed in subunits (factor 100)
@ -98,7 +45,6 @@ const ENERGY_PRICE_IN_SUBUNIT: Partial<Record<CURRENCY, string>> = {
SEK: "öre", // Swedish öre
ZAR: "c", // South African cent
TRY: "krş", // Türkiye kuruş
BYN: "к.", // Belarusian kapeyka
};
export enum POWER_UNIT {
@ -116,11 +62,6 @@ export default defineComponent({
fmtDigits: 1,
};
},
computed: {
dateFormat(): DateFormat {
return settings.dateFormat || "";
},
},
methods: {
energyPriceSubunit(currency: CURRENCY): string | undefined {
if (currency === CURRENCY.CHF) {
@ -324,33 +265,15 @@ export default defineComponent({
hour12: is12hFormat(),
}).format(date);
},
fmtFullDateTime(date: Date) {
const locale = this.$i18n?.locale;
const fmt = this.dateFormat;
if (!fmt) {
// auto: single Intl call preserves locale-native separators (e.g. German "So., 15. Jan.,")
return new Intl.DateTimeFormat(locale, {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
hour12: is12hFormat(),
}).format(date);
}
const time = new Intl.DateTimeFormat(locale, {
fmtFullDateTime(date: Date, short: boolean) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: short ? undefined : "short",
month: short ? "numeric" : "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: is12hFormat(),
}).format(date);
const weekday = datePart(date, locale, "weekday");
return `${weekday} ${formatDayMonth(date, locale, fmt, true)} ${time}`.trim();
},
// weekday + day of month + time, for lists within a known month
fmtWeekdayDayTime(date: Date) {
const weekday = datePart(date, this.$i18n?.locale, "weekday");
return `${weekday} ${date.getDate()}, ${this.fmtHourMinute(date)}`;
},
fmtWeekdayTime(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
@ -372,17 +295,11 @@ export default defineComponent({
}).format(date);
},
fmtDayMonth(date: Date) {
const locale = this.$i18n?.locale;
const fmt = this.dateFormat;
if (!fmt) {
return new Intl.DateTimeFormat(locale, {
weekday: "short",
day: "numeric",
month: "short",
}).format(date);
}
const weekday = datePart(date, locale, "weekday");
return `${weekday} ${formatDayMonth(date, locale, fmt)}`.trim();
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: "short",
day: "numeric",
month: "short",
}).format(date);
},
fmtDayMonthYear(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {

View file

@ -23,7 +23,6 @@ const BATTERY_UNIT = "battery_unit";
const SETTINGS_PRICE_ZOOM = "settings_price_zoom";
const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin";
const LAST_BATTERY_SMART_COST_LIMIT = "last_battery_smart_cost_limit";
const SETTINGS_DATE_FORMAT = "settings_date_format";
const LAST_TARGET_TIME = "last_target_time";
const LAST_SOC_GOAL = "last_soc_goal";
const LAST_ENERGY_GOAL = "last_energy_goal";
@ -100,8 +99,6 @@ function saveJSON(key: string) {
};
}
export type DateFormat = "" | "dmy" | "mdy" | "ymd";
export interface LoadpointSettings {
order?: number;
visible?: boolean;
@ -115,7 +112,6 @@ export interface Settings {
theme: THEME | null;
unit: string;
is12hFormat: boolean;
dateFormat: DateFormat;
energyflowDetails: boolean;
energyflowCo2: boolean;
energyflowPv: boolean;
@ -145,7 +141,6 @@ const settings: Settings = reactive({
theme: read(SETTINGS_THEME),
unit: read(SETTINGS_UNIT),
is12hFormat: readBool(SETTINGS_12H_FORMAT),
dateFormat: read(SETTINGS_DATE_FORMAT) || "",
energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS),
energyflowCo2: readBool(SETTINGS_ENERGYFLOW_CO2),
energyflowPv: readBool(SETTINGS_ENERGYFLOW_PV),
@ -174,7 +169,6 @@ watch(() => settings.locale, save(SETTINGS_LOCALE));
watch(() => settings.theme, save(SETTINGS_THEME));
watch(() => settings.unit, save(SETTINGS_UNIT));
watch(() => settings.is12hFormat, saveBool(SETTINGS_12H_FORMAT));
watch(() => settings.dateFormat, save(SETTINGS_DATE_FORMAT));
watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS));
watch(() => settings.energyflowCo2, saveBool(SETTINGS_ENERGYFLOW_CO2));
watch(() => settings.energyflowPv, saveBool(SETTINGS_ENERGYFLOW_PV));

View file

@ -9,7 +9,7 @@ export function getThemePreference(): THEME | null {
if (theme && Object.values(THEME).includes(theme)) {
return theme;
}
return window.evcc?.customTheme || THEME.AUTO;
return THEME.AUTO;
}
export function setThemePreference(theme: THEME) {

View file

@ -8,14 +8,12 @@ declare global {
app: any;
evcc?: {
version: string;
commit: string;
customCss: boolean;
customLogo: boolean;
customBrand: string;
customWebsite: string;
customEmail: string;
customPhone: string;
customTheme: THEME;
};
}
interface Window {
@ -461,10 +459,6 @@ export enum ConfigType {
export type ConfigVehicle = Entity;
export type ConfigMessenger = Entity;
export interface ConfigCurtailer extends Entity {
deviceTitle?: string;
}
export interface ConfigHems extends Entity {
deviceProduct?: string;
}
@ -535,7 +529,6 @@ export enum SMART_COST_TYPE {
CO2 = "co2",
PRICE_DYNAMIC = "pricedynamic",
PRICE_FORECAST = "priceforecast",
PRICE_STATIC = "pricestatic",
}
export enum LENGTH_UNIT {
@ -816,10 +809,6 @@ export enum CURRENCY {
TRY = "TRY",
MYR = "MYR",
THB = "THB",
BYN = "BYN",
UAH = "UAH",
RUB = "RUB",
KZT = "KZT",
}
export enum ICON_SIZE {
@ -1135,10 +1124,8 @@ export interface Battery {
capacity: number;
/** Charge level in %. Weighted by capacity across all batteries. */
soc: number;
/** Total discharged energy in kWh. */
/** Total battery energy in kWh. */
energy?: number;
/** Total charged energy in kWh. */
returnEnergy?: number;
/** Measurement data per battery meter. */
devices?: BatteryMeter[];
/** Projected charge levels based on the solar and price forecast. */
@ -1380,8 +1367,7 @@ export type DeviceType =
| "loadpoint"
| "messenger"
| "tariff"
| "hems"
| "curtailer";
| "hems";
export type MeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext" | "consumer";
export type MeterTemplateUsage = "grid" | "pv" | "battery" | "charge" | "aux";
export type TariffType = "grid" | "feedIn" | "co2" | "planner" | "solar" | "temperature";
@ -1398,7 +1384,6 @@ export interface SiteConfig {
aux: string[] | null;
ext: string[] | null;
consumer: string[] | null;
curtail: string[] | null;
}
export type ValueOf<T> = T[keyof T];

View file

@ -1,5 +1,4 @@
import settings from "./settings";
import type { DateFormat } from "./settings";
import { LENGTH_UNIT } from "./types/evcc";
const MILES_FACTOR = 0.6213711922;
@ -35,11 +34,3 @@ export function is12hFormat() {
export function set12hFormat(value: boolean) {
settings.is12hFormat = value;
}
export function getDateFormat(): DateFormat {
return settings.dateFormat || "";
}
export function setDateFormat(value: DateFormat) {
settings.dateFormat = value;
}

View file

@ -2,13 +2,14 @@ import { describe, expect, test } from "vite-plus/test";
import {
isDevelopment,
isNightly,
commitFromVersion,
getReleaseName,
getShortVersion,
isNewVersionAvailable,
} from "./version";
const DEV = "0.0.0";
const NIGHTLY = "0.304.0-dev.1712345678";
const NIGHTLY = "0.304.0-dev+abc1234";
const STABLE = "0.303.1";
describe("isDevelopment", () => {
@ -20,13 +21,20 @@ describe("isDevelopment", () => {
});
describe("isNightly", () => {
test("only pre-release versions", () => {
test("only versions with build metadata", () => {
expect(isNightly(DEV)).toBe(false);
expect(isNightly(NIGHTLY)).toBe(true);
expect(isNightly(STABLE)).toBe(false);
});
});
describe("commitFromVersion", () => {
test("extracts build metadata", () => {
expect(commitFromVersion(NIGHTLY)).toBe("abc1234");
expect(commitFromVersion(STABLE)).toBe("");
});
});
describe("getReleaseName", () => {
test("maps version to release name", () => {
expect(getReleaseName(DEV)).toBe("development");
@ -38,15 +46,14 @@ describe("getReleaseName", () => {
describe("getShortVersion", () => {
test("formats version", () => {
expect(getShortVersion(DEV)).toBe("dev build");
expect(getShortVersion(NIGHTLY)).toBe("v0.304.0-dev.1712345678");
expect(getShortVersion(NIGHTLY)).toBe("v0.304.0-dev+abc1234");
expect(getShortVersion(STABLE)).toBe("v0.303.1");
});
});
describe("isNewVersionAvailable", () => {
test("never for dev or nightly builds", () => {
test("never for dev builds", () => {
expect(isNewVersionAvailable(DEV, "0.303.1")).toBe(false);
expect(isNewVersionAvailable(NIGHTLY, "0.304.0")).toBe(false);
});
test("only when available differs", () => {
expect(isNewVersionAvailable(STABLE, "0.303.1")).toBe(false);

View file

@ -2,9 +2,13 @@ export function isDevelopment(version: string): boolean {
return version === "0.0.0";
}
// untagged builds are pre-releases carrying the build timestamp: 0.304.0-dev.1712345678
// untagged builds carry the commit as build metadata: 0.304.0-dev+abc1234
export function isNightly(version: string): boolean {
return version.includes("-dev.");
return version.includes("+");
}
export function commitFromVersion(version: string): string {
return version.split("+")[1] ?? "";
}
export function getReleaseName(version: string): string {
@ -19,8 +23,7 @@ export function getShortVersion(version: string): string {
}
export function isNewVersionAvailable(installed?: string, available?: string): boolean {
const v = installed || "";
return !!available && !isDevelopment(v) && !isNightly(v) && available !== installed;
return !!available && !isDevelopment(installed || "") && available !== installed;
}
export function isNewVersionUnacknowledged(

View file

@ -105,7 +105,6 @@ export default defineComponent({
aboutModalProps() {
return {
installed: window.evcc?.version,
commit: window.evcc?.commit,
customLogo: this.custom.logo,
customBrand: this.custom.brand,
customWebsite: this.custom.website,

View file

@ -150,31 +150,6 @@
data-testid="add-grid"
@click="openModal('meter', { type: 'grid' })"
/>
<DeviceCard
v-for="curtailer in curtailerDevices"
:id="`curtailer_${curtailer.name}`"
:key="curtailer.name"
:name="curtailer.name"
:title="curtailerTitle(curtailer)"
:editable="!!curtailer.id"
:error="hasDeviceError('curtailer', curtailer.name)"
:banner="curtailmentBanner('curtailer', curtailer.name)"
data-testid="curtailer"
@edit="openModal('curtailer', { id: curtailer.id })"
>
<template #icon>
<CurtailIcon />
</template>
<template #tags>
<DeviceTags :tags="deviceTags('curtailer', curtailer.name)" />
</template>
</DeviceCard>
<NewDeviceButton
v-if="pvMeters.length > 0"
:title="$t('config.main.addCurtailer')"
data-testid="add-curtailer"
@click="openModal('curtailer')"
/>
</div>
</ConfigSection>
@ -187,7 +162,7 @@
meter-type="pv"
:has-error="hasDeviceError('meter', meter.name)"
:tags="deviceTags('meter', meter.name)"
:banner="curtailmentBanner('meter', meter.name)"
:banner="meterBanner(meter.name)"
@edit="(type, id) => openModal('meter', { type, id })"
@enable="handleDisable('meter', meter.id, false)"
/>
@ -419,7 +394,8 @@
</template>
</DeviceCard>
<DeviceCard
:title="$t('config.remote.title')"
v-if="experimental"
:title="`${$t('config.remote.title')} 🧪`"
editable
:unconfigured="isUnconfigured(remoteTags)"
data-testid="remote-access"
@ -549,7 +525,6 @@
<MessagingLegacyModal @changed="loadDirty" />
<MessagingModal :messengers="messengers" @changed="loadDirty" />
<MessengerModal @changed="messengerChanged" />
<CurtailerModal @changed="curtailerChanged" />
<TariffsLegacyModal @changed="loadDirty" />
<TariffModal
:currency="currency"
@ -604,8 +579,6 @@ import CircuitsModal from "../components/Config/CircuitsModal.vue";
import CircuitTags from "../components/Config/CircuitTags.vue";
import collector from "../mixins/collector";
import ControlModal from "../components/Config/ControlModal.vue";
import CurtailerModal from "../components/Config/CurtailerModal.vue";
import CurtailIcon from "../components/MaterialIcon/Curtail.vue";
import DeviceCard from "../components/Config/DeviceCard.vue";
import DeviceTags from "../components/Config/DeviceTags.vue";
import EebusIcon from "../components/MaterialIcon/Eebus.vue";
@ -660,7 +633,6 @@ import type {
ConfigCharger,
ConfigVehicle,
ConfigCircuit,
ConfigCurtailer,
ConfigMessenger,
ConfigHems,
ConfigLoadpoint,
@ -719,8 +691,6 @@ export default defineComponent({
CircuitsModal,
CircuitTags,
ControlModal,
CurtailerModal,
CurtailIcon,
DeviceCard,
DeviceTags,
EebusIcon,
@ -779,7 +749,6 @@ export default defineComponent({
data() {
return {
messengers: [] as ConfigMessenger[],
curtailers: [] as ConfigCurtailer[],
vehicles: [] as ConfigVehicle[],
meters: [] as ConfigMeter[],
loadpoints: [] as ConfigLoadpoint[],
@ -803,7 +772,6 @@ export default defineComponent({
aux: null as string[] | null,
ext: null as string[] | null,
consumer: null as string[] | null,
curtail: null as string[] | null,
} as SiteConfig,
deviceValueTimeout: null as Timeout,
deviceValues: {
@ -813,7 +781,6 @@ export default defineComponent({
loadpoint: {},
messenger: {},
tariff: {},
curtailer: {},
} as DeviceValuesMap,
isComponentMounted: true,
isPageVisible: true,
@ -901,10 +868,8 @@ export default defineComponent({
{
slug: "grid",
icon: "shopicon-regular-powersupply",
count: (this.gridMeter ? 1 : 0) + this.curtailerDevices.length,
error:
(!!this.gridMeter && this.hasDeviceError("meter", this.gridMeter.name)) ||
this.curtailerDevices.some((c) => this.hasDeviceError("curtailer", c.name)),
count: this.gridMeter ? 1 : 0,
error: !!this.gridMeter && this.hasDeviceError("meter", this.gridMeter.name),
},
{
slug: "pv-battery",
@ -912,7 +877,7 @@ export default defineComponent({
count: pvAndBattery.length,
error: meterError(pvAndBattery),
warning:
this.pvMeters.some((m) => this.curtailmentBanner("meter", m.name)) ||
this.pvMeters.some((m) => this.meterBanner(m.name)) ||
meterDisabled(pvAndBattery),
},
{
@ -991,12 +956,6 @@ export default defineComponent({
const names = this.site?.ext;
return this.getMetersByNames(names);
},
curtailerDevices(): ConfigCurtailer[] {
const names = this.site?.curtail || [];
return names
.map((name) => this.curtailers.find((c) => c.name === name))
.filter((c): c is ConfigCurtailer => c !== undefined);
},
consumerMeters() {
return this.getMetersByNames(this.site?.consumer);
},
@ -1091,9 +1050,7 @@ export default defineComponent({
return result;
},
vehicleOptions(): VehicleOption[] {
return this.vehicles
.filter((v) => !v.deviceDisable)
.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
return this.vehicles.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
},
hems() {
return store.state?.hems;
@ -1334,7 +1291,6 @@ export default defineComponent({
await this.loadLoadpoints();
await this.loadCircuits();
await this.loadMessengers();
await this.loadCurtailers();
await this.loadTariffs();
await this.loadTariffRefs();
await this.loadHems();
@ -1355,9 +1311,6 @@ export default defineComponent({
async loadMessengers() {
this.messengers = (await this.loadConfig("devices/messenger")) || [];
},
async loadCurtailers() {
this.curtailers = (await this.loadConfig("devices/curtailer")) || [];
},
async loadVehicles() {
this.vehicles = (await this.loadConfig("devices/vehicle")) || [];
},
@ -1511,25 +1464,6 @@ export default defineComponent({
this.loadMessengers();
this.loadDirty();
},
curtailerTitle(curtailer: ConfigCurtailer): string {
return (
curtailer.deviceTitle ||
curtailer.config?.template ||
this.$t("config.curtailer.title")
);
},
async curtailerChanged(result: ModalResult) {
if (result.action === "added" && result.name) {
this.site.curtail = [...(this.site.curtail || []), result.name];
await this.saveSite("curtail");
}
if (result.action === "removed") {
await this.loadSite();
}
await this.loadCurtailers();
await this.loadDirty();
this.updateValues();
},
siteChanged() {
this.loadDirty();
},
@ -1570,7 +1504,6 @@ export default defineComponent({
vehicle: this.vehicles,
charger: this.chargers,
tariff: this.tariffs,
curtailer: this.curtailers,
} as Record<DeviceType, any[]>;
for (const type in devices) {
for (const device of devices[type as DeviceType]) {
@ -1590,10 +1523,9 @@ export default defineComponent({
deviceTags(type: DeviceType, id: string) {
return this.deviceValues[type][id] || {};
},
curtailmentBanner(type: DeviceType, name: string): string | undefined {
// devices report the allowed feed-in percent, 100 = uncurtailed
const value = this.deviceTags(type, name)["curtailed"]?.value;
return typeof value === "number" && value < 100
meterBanner(name: string): string | undefined {
// the tag is only present while curtailing, a zero percent limit is still one
return this.deviceTags("meter", name)["curtailed"]?.value !== undefined
? this.$t("config.deviceValue.productionLimited")
: undefined;
},

View file

@ -262,7 +262,7 @@
<input
v-model.number="logCount"
type="number"
class="form-control text-end"
class="form-control text-end log-count-input"
min="0"
step="25"
/>
@ -687,6 +687,11 @@ export default defineComponent({
<style scoped>
@import "../../css/breakpoints.css";
.log-count-input::-webkit-outer-spin-button,
.log-count-input::-webkit-inner-spin-button {
margin-left: 0.5rem;
}
@media (--md-and-up) {
.log-lines-input {
width: 170px;

View file

@ -22,7 +22,6 @@ import (
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/evcc-io/evcc/api"
@ -368,24 +367,21 @@ func (wb *AlpitronicHYC) TotalEnergy() (float64, error) {
var _ api.Identifier = (*AlpitronicHYC)(nil)
// Identify implements the api.Identifier interface
func (wb *AlpitronicHYC) Identify() ([]string, error) {
func (wb *AlpitronicHYC) Identify() (string, error) {
b, err := wb.inputG()
if err != nil {
return nil, err
}
var res []string
// the idTag register holds the rfid uid as null-padded ascii string
if idTag := hycInput(b, hycRegIdTag, 20); !allZero(idTag) {
res = append(res, strings.ToLower(strings.TrimRight(string(idTag), "\x00")))
return "", err
}
if vid := hycInput(b, hycRegVID, 8); !allZero(vid) {
res = append(res, hex.EncodeToString(vid))
return hex.EncodeToString(vid), nil
}
return res, nil
if idTag := hycInput(b, hycRegIdTag, 20); !allZero(idTag) {
return hex.EncodeToString(idTag), nil
}
return "", nil
}
var _ api.Battery = (*AlpitronicHYC)(nil)

View file

@ -3,6 +3,7 @@ package charger
import (
"context"
"net"
"strings"
"sync"
"testing"
"time"
@ -10,6 +11,7 @@ import (
"github.com/andig/mbserver"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util/modbus"
"github.com/evcc-io/evcc/util/sponsor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -216,7 +218,7 @@ func TestAlpitronicIdentify(t *testing.T) {
require.NoError(t, err)
assert.Empty(t, id)
// tag before vehicle id
// vehicle id takes precedence over the tag
regs := hycRegs(1, hycStateCharging)
regs[hycReg(1, hycRegVID)+1] = 0xAABB
regs[hycReg(1, hycRegVID)+2] = 0xCCDD
@ -227,9 +229,9 @@ func TestAlpitronicIdentify(t *testing.T) {
id, err = wb.Identify()
require.NoError(t, err)
assert.Equal(t, []string{"ab", "0000aabbccddeeff"}, id)
assert.Equal(t, "0000aabbccddeeff", id)
// tag only
// tag as fallback
regs = hycRegs(1, hycStateCharging)
regs[hycReg(1, hycRegIdTag)] = 0x4142
@ -237,17 +239,7 @@ func TestAlpitronicIdentify(t *testing.T) {
id, err = wb.Identify()
require.NoError(t, err)
assert.Equal(t, []string{"ab"}, id)
// vehicle id only
regs = hycRegs(1, hycStateCharging)
regs[hycReg(1, hycRegVID)+3] = 0xEEFF
wb, _ = hycTestCharger(t, 1, regs)
id, err = wb.Identify()
require.NoError(t, err)
assert.Equal(t, []string{"000000000000eeff"}, id)
assert.Equal(t, "4142"+strings.Repeat("0", 36), id)
}
func TestAlpitronicEnable(t *testing.T) {
@ -361,3 +353,14 @@ func TestAlpitronicReadFailure(t *testing.T) {
_, err = wb.CurrentPower()
assert.Error(t, err)
}
func TestAlpitronicSponsorGate(t *testing.T) {
// go-e tests set the global sponsor.Subject and never reset it
old := sponsor.Subject
sponsor.Subject = ""
t.Cleanup(func() { sponsor.Subject = old })
// tests run without sponsorship: the public constructor must refuse
_, err := NewAlpitronicHYC(t.Context(), modbus.TcpSettings{URI: "localhost:0", ID: 1}, 1)
assert.ErrorIs(t, err, api.ErrSponsorRequired)
}

View file

@ -299,13 +299,13 @@ func (wb *Amperfied) Voltages() (float64, float64, float64, error) {
var _ api.Identifier = (*Amperfied)(nil)
// Identify implements the api.Identifier interface
func (wb *Amperfied) Identify() ([]string, error) {
func (wb *Amperfied) Identify() (string, error) {
b, err := wb.conn.ReadInputRegisters(ampRegRfidUID, 6)
if err != nil {
return nil, err
return "", err
}
return []string{hex.EncodeToString(b)}, nil
return hex.EncodeToString(b), nil
}
var _ api.Diagnosis = (*Amperfied)(nil)

View file

@ -504,33 +504,24 @@ func (wb *BenderCC) getPhases() (int, error) {
}
// identify implements the api.Identifier interface
func (wb *BenderCC) identify() ([]string, error) {
var ids []string
func (wb *BenderCC) identify() (string, error) {
if !wb.legacy && !wb.mennekes4 {
b, err := wb.conn.ReadHoldingRegisters(bendRegSmartVehicleDetected, 1)
if err == nil && binary.BigEndian.Uint16(b) != 0 {
b, err = wb.conn.ReadHoldingRegisters(bendRegEVCCID, 6)
}
if err != nil {
return nil, err
}
if id := bytesAsString(b); id != "" {
ids = append(ids, id)
if id := bytesAsString(b); id != "" || err != nil {
return id, err
}
}
b, err := wb.conn.ReadHoldingRegisters(bendRegUserID, 10)
if err != nil {
return nil, err
return "", err
}
if id := bytesAsString(b); id != "" {
ids = append(ids, id)
}
return ids, nil
return bytesAsString(b), nil
}
// soc implements the api.Battery interface

View file

@ -242,11 +242,11 @@ func (wb *CfosPowerBrain) WakeUp() error {
var _ api.Identifier = (*CfosPowerBrain)(nil)
// Identify implements the api.Identifier interface
func (wb *CfosPowerBrain) Identify() ([]string, error) {
func (wb *CfosPowerBrain) Identify() (string, error) {
b, err := wb.conn.ReadHoldingRegisters(cfosRegLastRfid, 15)
if err != nil {
return nil, err
return "", err
}
return []string{bytesAsString(b)}, nil
return bytesAsString(b), nil
}

View file

@ -118,12 +118,7 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.C
if err != nil {
return nil, fmt.Errorf("identify: %w", err)
}
if identify != nil {
implement.Has(c, implement.Identifier(func() ([]string, error) {
id, err := identify()
return []string{id}, err
}))
}
implement.May(c, implement.Identifier(identify))
// decorate wakeup
if cc.Wakeup != nil {

View file

@ -289,13 +289,13 @@ func (wb *Compleo) ChargeDuration() (time.Duration, error) {
var _ api.Identifier = (*Compleo)(nil)
// Identify implements the api.Identifier interface
func (wb *Compleo) Identify() ([]string, error) {
func (wb *Compleo) Identify() (string, error) {
b, err := wb.conn.ReadInputRegisters(compleoRegIdTag+wb.offset, 10)
if err != nil {
return nil, err
return "", err
}
return []string{bytesAsString(b)}, nil
return bytesAsString(b), nil
}
var _ loadpoint.Controller = (*Compleo)(nil)

View file

@ -291,13 +291,13 @@ func (wb *Dadapower) Phases1p3p(phases int) error {
var _ api.Identifier = (*Dadapower)(nil)
// Identify implements the api.Identifier interface
func (wb *Dadapower) Identify() ([]string, error) {
func (wb *Dadapower) Identify() (string, error) {
u, err := wb.conn.ReadInputRegisters(dadapowerRegIdentification+wb.regOffset, 20)
if err != nil {
return nil, err
return "", err
}
return []string{bytesAsString(u)}, nil
return bytesAsString(u), nil
}
var _ api.Diagnosis = (*Dadapower)(nil)

View file

@ -318,14 +318,12 @@ func (wb *DaheimLaden) Voltages() (float64, float64, float64, error) {
var _ api.Identifier = (*DaheimLaden)(nil)
// Identify implements the api.Identifier interface. Only usable with PRO
func (wb *DaheimLaden) Identify() ([]string, error) {
func (wb *DaheimLaden) Identify() (string, error) {
b, err := wb.conn.ReadHoldingRegisters(dlRegCardId, 16)
if err != nil {
return nil, err
return "", err
}
id, err := utf16BEBytesAsString(b)
return []string{id}, err
return utf16BEBytesAsString(b)
}
// phases1p3p implements the api.PhaseSwitcher interface
@ -363,37 +361,27 @@ func (wb *DaheimLaden) getPhases() (int, error) {
}
func (wb *DaheimLaden) checkStation() error {
// map may still be zero right after the wake-up call- poll briefly until populated
for range 5 {
b, err := wb.conn.ReadHoldingRegisters(dlRegEvseMaxCurrent, 22)
if err != nil {
return err
}
s, err := utf16BEBytesAsString(b[2*(dlRegStationId-dlRegEvseMaxCurrent):])
if err != nil {
return err
}
if len(s) == 0 {
time.Sleep(200 * time.Millisecond)
continue
}
if strings.Contains(strings.ToLower(s), "heidelbridge") {
return api.ErrSponsorRequired
}
for _, r := range s {
if r < 0x20 || r > 0x7e {
return api.ErrSponsorRequired
}
}
return nil
b, err := wb.conn.ReadHoldingRegisters(dlRegEvseMaxCurrent, 22)
if err != nil {
return api.ErrSponsorRequired
}
// station id starts (dlRegStationId-dlRegEvseMaxCurrent) registers into the block
s, err := utf16BEBytesAsString(b[2*(dlRegStationId-dlRegEvseMaxCurrent):])
if err != nil || s == "" {
return api.ErrSponsorRequired
}
return api.ErrSponsorRequired
for _, r := range s {
if r < 0x20 || r > 0x7e {
return api.ErrSponsorRequired
}
}
if strings.Contains(strings.ToLower(s), "heidelbridge") {
return api.ErrSponsorRequired
}
return nil
}
var _ api.Diagnosis = (*DaheimLaden)(nil)

View file

@ -354,13 +354,13 @@ func (wb *Delta) CurrentPower() (float64, error) {
var _ api.Identifier = (*Delta)(nil)
// Identify implements the api.Identifier interface
func (wb *Delta) Identify() ([]string, error) {
func (wb *Delta) Identify() (string, error) {
b, err := wb.conn.ReadInputRegisters(wb.base+deltaRegEvseRfidUID, 20)
if err != nil {
return nil, err
return "", err
}
return []string{bytesAsString(b)}, nil
return bytesAsString(b), nil
}
var _ api.Diagnosis = (*Delta)(nil)

View file

@ -748,17 +748,16 @@ var _ api.Identifier = (*E3dc)(nil)
// Identify implements the api.Identifier interface
// Returns the RFID tag ID from WB_SESSION_AUTH_DATA if a session is active
func (wb *E3dc) Identify() ([]string, error) {
func (wb *E3dc) Identify() (string, error) {
wb.mu.Lock()
defer wb.mu.Unlock()
msg, found, err := wb.sessionMessage(rscp.WB_SESSION_AUTH_DATA)
if err != nil || !found {
return nil, err
return "", err
}
id, err := rscpString(msg)
return []string{id}, err
return rscpString(msg)
}
var _ api.PhaseSwitcher = (*E3dc)(nil)

View file

@ -850,11 +850,11 @@ func (c *Easee) StatusReason() (api.Reason, error) {
var _ api.Identifier = (*Easee)(nil)
// Currents implements the api.PhaseCurrents interface
func (c *Easee) Identify() ([]string, error) {
func (c *Easee) Identify() (string, error) {
c.mux.Lock()
defer c.mux.Unlock()
return []string{c.rfid}, nil
return c.rfid, nil
}
// Set smart charging status to update the chargers led (smart=blue, fast=white)

View file

@ -520,26 +520,19 @@ func (c *EEBus) currents() (float64, float64, float64, error) {
var _ api.Identifier = (*EEBus)(nil)
// Identify implements the api.Identifier interface
func (c *EEBus) Identify() ([]string, error) {
func (c *EEBus) Identify() (string, error) {
evEntity, ok := c.isEvConnected()
if !ok {
return nil, nil
return "", nil
}
identification, err := c.cem.EvCC.Identifications(evEntity)
if err != nil {
return nil, nil
if identification, err := c.cem.EvCC.Identifications(evEntity); err == nil && len(identification) > 0 {
// return the first identification for now
// later this could be multiple, e.g. MAC Address and PCID
return identification[0].Value, nil
}
// may be multiple, e.g. MAC address and PCID
var res []string
for _, i := range identification {
if i.Value != "" {
res = append(res, i.Value)
}
}
return res, nil
return "", nil
}
var _ api.Battery = (*EEBus)(nil)

View file

@ -15,7 +15,6 @@ import (
spineapi "github.com/enbility/spine-go/api"
"github.com/enbility/spine-go/model"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/implement"
"github.com/evcc-io/evcc/server/eebus"
"github.com/evcc-io/evcc/util"
)
@ -28,7 +27,6 @@ import (
// enabling the charger schedules or resumes the optional consumption, disabling
// it pauses or aborts the running process.
type EEBusOHPCF struct {
implement.Caps
*embed
cem *eebus.CustomerEnergyManagement
ma *eebus.MonitoringAppliance
@ -60,11 +58,10 @@ func init() {
// NewEEBusOHPCFFromConfig creates an EEBus OHPCF charger from generic config
func NewEEBusOHPCFFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
cc := struct {
embed `mapstructure:",squash"`
Ski string
Ip string
Reboost time.Duration
DisablePowerLimiter bool
embed `mapstructure:",squash"`
Ski string
Ip string
Reboost time.Duration
}{
embed: embed{
Icon_: "heatpump",
@ -77,19 +74,18 @@ func NewEEBusOHPCFFromConfig(ctx context.Context, other map[string]any) (api.Cha
return nil, err
}
return NewEEBusOHPCF(ctx, &cc.embed, cc.Ski, cc.Ip, cc.Reboost, cc.DisablePowerLimiter)
return NewEEBusOHPCF(ctx, &cc.embed, cc.Ski, cc.Ip, cc.Reboost)
}
// NewEEBusOHPCF creates an EEBus OHPCF charger, registers it with the EEBus
// instance and waits for the connection.
func NewEEBusOHPCF(ctx context.Context, embed *embed, ski, ip string, reboost time.Duration, disablePowerLimiter bool) (api.Charger, error) {
func NewEEBusOHPCF(ctx context.Context, embed *embed, ski, ip string, reboost time.Duration) (api.Charger, error) {
inst, err := eebus.Instance()
if err != nil {
return nil, err
}
c := &EEBusOHPCF{
Caps: implement.New(),
embed: embed,
log: util.NewLogger("eebus-ohpcf"),
cem: inst.CustomerEnergyManagement(),
@ -100,10 +96,6 @@ func NewEEBusOHPCF(ctx context.Context, embed *embed, ski, ip string, reboost ti
reboost: reboost,
}
if !disablePowerLimiter {
implement.Has(c, implement.PowerLimiter(c.getMinMaxPower))
}
if err := inst.RegisterDevice(ski, ip, c); err != nil {
return nil, err
}
@ -468,8 +460,11 @@ func (c *EEBusOHPCF) apply(enable bool) error {
return nil
}
// getMinMaxPower is the callback used to provide the optional power limits.
func (c *EEBusOHPCF) getMinMaxPower() (float64, float64, error) {
var _ api.PowerLimiter = (*EEBusOHPCF)(nil)
// GetMinMaxPower implements the api.PowerLimiter interface, reporting the
// optional consumption as expected min/max or ErrNotAvailable if none.
func (c *EEBusOHPCF) GetMinMaxPower() (float64, float64, error) {
entity, ok := c.connectedCompressor()
if !ok {
return 0, 0, errNotConnected

View file

@ -41,6 +41,7 @@ type Enovates struct {
}
const (
enovatesRegMaxAmp = 51 // uint16 RO, max amp per phase
enovatesRegCurrents = 200 // 3x uint16 RO, mA
enovatesRegVoltages = 203 // 3x int16 RO, V
enovatesRegPower = 206 // int16 RO, W (active power total)
@ -48,7 +49,7 @@ const (
enovatesRegMode3StateStr = 301 // 2x register RO, IEC 61851 state, e.g. "C2"
enovatesRegEMSLimit = 400 // int16 RW, mA (-1 = no limit, 0 = disabled)
enovatesRegToken = 401 // 16x register RO, ASCII
enovatesRegCurrentOffered = 417 // uint16 RO, mA, current actually offered to the vehicle
enovatesRegCurrentOffered = 417 // uint16 RO, mA
enovatesRegSerial = 5032 // 16x register RO, ASCII
enovatesRegModel = 5048 // 16x register RO, ASCII
enovatesRegFirmware = 5064 // 16x register RO, ASCII
@ -91,9 +92,9 @@ func NewEnovates(ctx context.Context, settings modbus.TcpSettings) (api.Charger,
current: 6000,
}
// the transaction token is only readable when EMS over Modbus TCP is enabled
if _, err := wb.conn.ReadHoldingRegisters(enovatesRegToken, 16); err != nil {
return nil, fmt.Errorf("ems over modbus tcp not enabled: %w", err)
// verify connection and EMS register availability
if _, err := wb.conn.ReadHoldingRegisters(enovatesRegMode3StateStr, 2); err != nil {
return nil, err
}
return wb, nil
@ -159,13 +160,22 @@ var _ api.CurrentGetter = (*Enovates)(nil)
// GetMaxCurrent implements the api.CurrentGetter interface
func (wb *Enovates) GetMaxCurrent() (float64, error) {
// the EMS limit register may read -1 (no limit), hence use the actually offered current
b, err := wb.conn.ReadHoldingRegisters(enovatesRegCurrentOffered, 1)
b, err := wb.conn.ReadHoldingRegisters(enovatesRegEMSLimit, 1)
if err != nil {
return 0, err
}
return float64(binary.BigEndian.Uint16(b)) / 1e3, nil
// -1 signals no EMS limit, fall back to the hardware maximum
if limit := int16(binary.BigEndian.Uint16(b)); limit >= 0 {
return float64(limit) / 1e3, nil
}
b, err = wb.conn.ReadHoldingRegisters(enovatesRegMaxAmp, 1)
if err != nil {
return 0, err
}
return rs485.RTUUint16ToFloat64(b), nil
}
var _ api.Meter = (*Enovates)(nil)
@ -223,13 +233,13 @@ func (wb *Enovates) Voltages() (float64, float64, float64, error) {
var _ api.Identifier = (*Enovates)(nil)
// Identify implements the api.Identifier interface
func (wb *Enovates) Identify() ([]string, error) {
func (wb *Enovates) Identify() (string, error) {
b, err := wb.conn.ReadHoldingRegisters(enovatesRegToken, 16)
if err != nil {
return nil, err
return "", err
}
return []string{trimModbusString(b)}, nil
return trimModbusString(b), nil
}
var _ api.Diagnosis = (*Enovates)(nil)

View file

@ -378,11 +378,11 @@ func (wb *EVECUBE) phases1p3p(phases int) error {
}
// identify implements the api.Identifier interface
func (wb *EVECUBE) identify() ([]string, error) {
func (wb *EVECUBE) identify() (string, error) {
status, err := wb.getAutomationStatus()
if err != nil {
return nil, err
return "", err
}
return []string{status.AuthTag.Tag}, nil
return status.AuthTag.Tag, nil
}

Some files were not shown because too many files have changed in this diff Show more