Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbb7d704f6 | ||
|
|
d284a2f9af | ||
|
|
96fb302147 | ||
|
|
eb836e9ec8 | ||
|
|
52c0cfaa5d | ||
|
|
d1b1ded0e6 |
539 changed files with 3745 additions and 12916 deletions
|
|
@ -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
|
|
||||||
2
.github/actions/build-toolchain/action.yml
vendored
2
.github/actions/build-toolchain/action.yml
vendored
|
|
@ -13,7 +13,7 @@ inputs:
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/setup-go@v7
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: false # managed explicitly below to keep the scoped namespaces
|
cache: false # managed explicitly below to keep the scoped namespaces
|
||||||
|
|
|
||||||
6
.github/actions/docker-cache/action.yml
vendored
6
.github/actions/docker-cache/action.yml
vendored
|
|
@ -32,12 +32,6 @@ runs:
|
||||||
${{ runner.os }}-docker-mounts-${{ inputs.cache-scope }}-
|
${{ runner.os }}-docker-mounts-${{ inputs.cache-scope }}-
|
||||||
${{ runner.os }}-docker-mounts-main-
|
${{ 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
|
- name: Inject cache mounts
|
||||||
uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0
|
uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
40
.github/scripts/release-tag.sh
vendored
40
.github/scripts/release-tag.sh
vendored
|
|
@ -3,9 +3,7 @@
|
||||||
#
|
#
|
||||||
# Feature releases (patch level 0) must be tagged on master. Bugfix releases may
|
# 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
|
# 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
|
# shipping everything that landed on master since.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# Prints `latest=<true|false>` for consumption as a GitHub step output. Only the
|
# Prints `latest=<true|false>` for consumption as a GitHub step output. Only the
|
||||||
# newest release may move the `latest` pointers (docker tag, homebrew formula,
|
# newest release may move the `latest` pointers (docker tag, homebrew formula,
|
||||||
|
|
@ -29,43 +27,13 @@ validate() {
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local line="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
|
||||||
|
|
||||||
if [[ ${BASH_REMATCH[3]} == 0 ]] && ! git merge-base --is-ancestor "$tag" "$MASTER_REF"; then
|
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
|
echo "::error::feature release '$tag' must be tagged on master" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
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
|
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
|
if [[ $newest == "$tag" ]]; then
|
||||||
echo "latest=true"
|
echo "latest=true"
|
||||||
|
|
@ -87,12 +55,10 @@ self_test() {
|
||||||
git checkout --quiet -b fix
|
git checkout --quiet -b fix
|
||||||
commit two
|
commit two
|
||||||
git tag 0.1.1 # bugfix release off master
|
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 tag 0.2.0 # feature release off master
|
||||||
git checkout --quiet master
|
git checkout --quiet master
|
||||||
commit three
|
commit three
|
||||||
git tag 0.3.0
|
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
|
git tag 9.9.9-fork # tags from forks must not count as a release
|
||||||
|
|
||||||
MASTER_REF=master
|
MASTER_REF=master
|
||||||
|
|
@ -110,8 +76,6 @@ self_test() {
|
||||||
expect 0.1.0 latest=false # feature release on master, superseded
|
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.1.1 latest=false # bugfix release off master, older line
|
||||||
expect 0.3.0 latest=true # newest release
|
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.2.0 FAIL # feature release not on master
|
||||||
expect 0.1 FAIL # not MAJOR.MINOR.PATCH
|
expect 0.1 FAIL # not MAJOR.MINOR.PATCH
|
||||||
expect v0.1.0 FAIL # no v prefix allowed
|
expect v0.1.0 FAIL # no v prefix allowed
|
||||||
|
|
|
||||||
32
.github/workflows/command-backport.yml
vendored
32
.github/workflows/command-backport.yml
vendored
|
|
@ -57,33 +57,11 @@ jobs:
|
||||||
core.setFailed(message);
|
core.setFailed(message);
|
||||||
};
|
};
|
||||||
|
|
||||||
// the command is usually posted right before hitting merge, so give the
|
const { data: pr } = await github.rest.pulls.get({
|
||||||
// pull request time to land instead of failing on the race
|
owner,
|
||||||
const deadline = Date.now() + 5 * 60 * 1000;
|
repo,
|
||||||
let pr;
|
pull_number: context.payload.issue.number,
|
||||||
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));
|
|
||||||
}
|
|
||||||
if (!pr.merged) {
|
if (!pr.merged) {
|
||||||
fail(`pull request #${pr.number} is not merged`);
|
fail(`pull request #${pr.number} is not merged`);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
1
.github/workflows/command-nightly.yml
vendored
1
.github/workflows/command-nightly.yml
vendored
|
|
@ -60,7 +60,6 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
id-token: write # depot OIDC
|
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
report:
|
report:
|
||||||
|
|
|
||||||
16
.github/workflows/nightly-docker.yml
vendored
16
.github/workflows/nightly-docker.yml
vendored
|
|
@ -13,7 +13,6 @@ jobs:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write # depot OIDC
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
@ -28,10 +27,14 @@ jobs:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_PASS }}
|
password: ${{ secrets.DOCKER_PASS }}
|
||||||
|
|
||||||
# depot builders keep the Dockerfile's cache mounts on their own volumes,
|
- name: Setup Buildx
|
||||||
# so they need neither buildx nor the cache dance
|
id: buildx
|
||||||
- name: Setup Depot
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
|
||||||
|
- name: Restore build cache
|
||||||
|
uses: ./.github/actions/docker-cache
|
||||||
|
with:
|
||||||
|
builder: ${{ steps.buildx.outputs.name }}
|
||||||
|
|
||||||
- name: Define tags
|
- name: Define tags
|
||||||
id: meta
|
id: meta
|
||||||
|
|
@ -43,9 +46,8 @@ jobs:
|
||||||
type=raw,value=nightly.{{date 'YYYYMMDD'}}-{{sha}}
|
type=raw,value=nightly.{{date 'YYYYMMDD'}}-{{sha}}
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
||||||
with:
|
with:
|
||||||
project: ${{ secrets.DEPOT_PROJECT_ID }}
|
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v6
|
platforms: linux/amd64,linux/arm64,linux/arm/v6
|
||||||
push: true
|
push: true
|
||||||
|
|
|
||||||
1
.github/workflows/nightly.yml
vendored
1
.github/workflows/nightly.yml
vendored
|
|
@ -48,7 +48,6 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
id-token: write # depot OIDC
|
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
hassio:
|
hassio:
|
||||||
|
|
|
||||||
22
.github/workflows/release.yml
vendored
22
.github/workflows/release.yml
vendored
|
|
@ -46,7 +46,6 @@ jobs:
|
||||||
runs-on: depot-ubuntu-24.04-arm
|
runs-on: depot-ubuntu-24.04-arm
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write # depot OIDC
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
@ -60,10 +59,14 @@ jobs:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_PASS }}
|
password: ${{ secrets.DOCKER_PASS }}
|
||||||
|
|
||||||
# depot builders keep the Dockerfile's cache mounts on their own volumes,
|
- name: Setup Buildx
|
||||||
# so they need neither buildx nor the cache dance
|
id: buildx
|
||||||
- name: Setup Depot
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
|
||||||
|
- name: Restore build cache
|
||||||
|
uses: ./.github/actions/docker-cache
|
||||||
|
with:
|
||||||
|
builder: ${{ steps.buildx.outputs.name }}
|
||||||
|
|
||||||
- name: Meta
|
- name: Meta
|
||||||
id: meta
|
id: meta
|
||||||
|
|
@ -76,9 +79,8 @@ jobs:
|
||||||
latest=${{ needs.guard.outputs.latest }}
|
latest=${{ needs.guard.outputs.latest }}
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
||||||
with:
|
with:
|
||||||
project: ${{ secrets.DEPOT_PROJECT_ID }}
|
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v6
|
platforms: linux/amd64,linux/arm64,linux/arm/v6
|
||||||
push: true
|
push: true
|
||||||
|
|
@ -144,12 +146,6 @@ jobs:
|
||||||
# a bugfix release of an older line must not move the homebrew formula
|
# a bugfix release of an older line must not move the homebrew formula
|
||||||
# or the Github latest release marker
|
# or the Github latest release marker
|
||||||
MAKE_LATEST: ${{ needs.guard.outputs.latest }}
|
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
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ linters:
|
||||||
settings:
|
settings:
|
||||||
modernize:
|
modernize:
|
||||||
disable:
|
disable:
|
||||||
- embedlit
|
|
||||||
- stringsbuilder
|
- stringsbuilder
|
||||||
staticcheck:
|
staticcheck:
|
||||||
checks:
|
checks:
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ builds:
|
||||||
- -trimpath
|
- -trimpath
|
||||||
- -tags=release
|
- -tags=release
|
||||||
ldflags:
|
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:
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
|
|
@ -56,9 +57,10 @@ checksum:
|
||||||
name_template: "checksums.txt"
|
name_template: "checksums.txt"
|
||||||
|
|
||||||
snapshot:
|
snapshot:
|
||||||
# semver pre-release of the upcoming version, the timestamp keeps the package
|
# semver pre-release of the upcoming version. The timestamp makes the package
|
||||||
# version increasing monotonically for apt
|
# version increase monotonically: apt compares it numerically, while the commit
|
||||||
version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}"
|
# alone would sort randomly.
|
||||||
|
version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}+{{ .ShortCommit }}"
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ builds:
|
||||||
- -trimpath
|
- -trimpath
|
||||||
- -tags=release
|
- -tags=release
|
||||||
ldflags:
|
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:
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
|
|
@ -59,26 +60,14 @@ archives:
|
||||||
universal_binaries:
|
universal_binaries:
|
||||||
- replace: true
|
- 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:
|
checksum:
|
||||||
name_template: "checksums.txt"
|
name_template: "checksums.txt"
|
||||||
|
|
||||||
snapshot:
|
snapshot:
|
||||||
# semver pre-release of the upcoming version, the timestamp keeps the package
|
# semver pre-release of the upcoming version. The timestamp makes the package
|
||||||
# version increasing monotonically for apt
|
# version increase monotonically: apt compares it numerically, while the commit
|
||||||
version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}"
|
# alone would sort randomly.
|
||||||
|
version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}+{{ .ShortCommit }}"
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
|
|
@ -135,7 +124,7 @@ nfpms:
|
||||||
preremove: ./packaging/scripts/preremove.sh
|
preremove: ./packaging/scripts/preremove.sh
|
||||||
postremove: ./packaging/scripts/postremove.sh
|
postremove: ./packaging/scripts/postremove.sh
|
||||||
|
|
||||||
homebrew_casks:
|
brews:
|
||||||
- skip_upload: '{{ if eq (envOrDefault "MAKE_LATEST" "true") "true" }}false{{ else }}true{{ end }}'
|
- skip_upload: '{{ if eq (envOrDefault "MAKE_LATEST" "true") "true" }}false{{ else }}true{{ end }}'
|
||||||
repository:
|
repository:
|
||||||
owner: evcc-io
|
owner: evcc-io
|
||||||
|
|
@ -143,7 +132,15 @@ homebrew_casks:
|
||||||
commit_author:
|
commit_author:
|
||||||
name: andig
|
name: andig
|
||||||
email: info@evcc.io
|
email: info@evcc.io
|
||||||
|
directory: Formula
|
||||||
homepage: "https://evcc.io"
|
homepage: "https://evcc.io"
|
||||||
description: "Sonne tanken ☀️🚘"
|
description: "Sonne tanken ☀️🚘"
|
||||||
binaries:
|
license: "MIT"
|
||||||
- evcc
|
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"
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
import { StorybookConfig } from "@storybook/vue3-vite";
|
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 {
|
export default {
|
||||||
stories: ["../assets/js/**/*.stories.@(js|ts)"],
|
stories: ["../assets/js/**/*.stories.@(js|ts)"],
|
||||||
|
|
@ -17,21 +10,4 @@ export default {
|
||||||
core: {
|
core: {
|
||||||
disableTelemetry: true,
|
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;
|
} satisfies StorybookConfig;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { type Preview, setup } from "@storybook/vue3";
|
||||||
import "bootstrap/dist/css/bootstrap.min.css";
|
import "bootstrap/dist/css/bootstrap.min.css";
|
||||||
import smoothscroll from "smoothscroll-polyfill";
|
import smoothscroll from "smoothscroll-polyfill";
|
||||||
import setupI18n from "../assets/js/i18n";
|
import setupI18n from "../assets/js/i18n";
|
||||||
import tooltip from "../assets/js/directives/tooltip";
|
|
||||||
import "../assets/css/app.css";
|
import "../assets/css/app.css";
|
||||||
import { watchThemeChanges } from "../assets/js/theme";
|
import { watchThemeChanges } from "../assets/js/theme";
|
||||||
|
|
||||||
|
|
@ -36,7 +35,6 @@ export default {
|
||||||
|
|
||||||
setup((app) => {
|
setup((app) => {
|
||||||
app.use(setupI18n());
|
app.use(setupI18n());
|
||||||
app.directive("tooltip", tooltip);
|
|
||||||
|
|
||||||
// Mock router-link for Storybook
|
// Mock router-link for Storybook
|
||||||
app.component("router-link", {
|
app.component("router-link", {
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,6 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w
|
||||||
### Styling & Internationalization
|
### Styling & Internationalization
|
||||||
|
|
||||||
- Use CSS Custom Properties for theming (semantic names: `--evcc-green`, `--evcc-battery`)
|
- 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 existing custom media queries for responsive breakpoints
|
||||||
- Use `$t()` function for all user-facing strings
|
- Use `$t()` function for all user-facing strings
|
||||||
- Update both `i18n/en.json` and `i18n/de.json` for new strings
|
- Update both `i18n/en.json` and `i18n/de.json` for new strings
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
# 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:${PATH}"
|
||||||
ENV PATH="/root/.vite-plus/bin:/root/.local/share/vite-plus/bin:${PATH}"
|
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
|
|
@ -25,7 +24,7 @@ RUN make ui
|
||||||
|
|
||||||
|
|
||||||
# STEP 2 build executable binary
|
# 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.
|
# Install git + SSL ca certificates.
|
||||||
# Git is required for fetching the dependencies.
|
# Git is required for fetching the dependencies.
|
||||||
|
|
|
||||||
54
FORK.md
54
FORK.md
|
|
@ -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.
|
|
||||||
19
Makefile
19
Makefile
|
|
@ -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
|
# 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)
|
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 }')
|
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
|
# untagged builds are semver pre-releases of the upcoming version
|
||||||
# timestamp keeps them increasing monotonically
|
TAG_NAME ?= $(if $(filter-out 0,$(GIT_DIST)),$(NEXT_TAG)-dev+$(SHA),$(GIT_TAG))
|
||||||
BUILD_TIMESTAMP := $(shell date -u '+%s')
|
|
||||||
TAG_NAME ?= $(if $(filter-out 0,$(GIT_DIST)),$(NEXT_TAG)-dev.$(BUILD_TIMESTAMP),$(GIT_TAG))
|
|
||||||
COMMIT := $(SHA)
|
COMMIT := $(SHA)
|
||||||
# hide commit for releases
|
# hide commit for releases
|
||||||
ifeq ($(RELEASE),1)
|
ifeq ($(RELEASE),1)
|
||||||
COMMIT :=
|
COMMIT :=
|
||||||
endif
|
endif
|
||||||
VERSION := $(if $(TAG_NAME),$(TAG_NAME),$(SHA))
|
VERSION := $(if $(TAG_NAME),$(TAG_NAME),$(SHA))
|
||||||
|
BUILD_DATE := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
|
||||||
BUILD_TAGS := -tags=release
|
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
|
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)'
|
BUILD_ARGS := -trimpath -ldflags='$(LD_FLAGS)'
|
||||||
|
|
@ -34,7 +33,7 @@ IMAGE_FILE := evcc_$(TAG_NAME).img
|
||||||
PACKAGES = ./release
|
PACKAGES = ./release
|
||||||
|
|
||||||
# asn1-patch
|
# asn1-patch
|
||||||
GOROOT = $(shell go env GOROOT)
|
GOROOT := $(shell go env GOROOT)
|
||||||
CURRDIR := $(shell pwd)
|
CURRDIR := $(shell pwd)
|
||||||
|
|
||||||
default:: ui build
|
default:: ui build
|
||||||
|
|
@ -100,7 +99,7 @@ porcelain::
|
||||||
test -z "$$(git status --porcelain)" || (git status; git diff; false)
|
test -z "$$(git status --porcelain)" || (git status; git diff; false)
|
||||||
|
|
||||||
build::
|
build::
|
||||||
@echo Version: $(VERSION) $(SHA)
|
@echo Version: $(VERSION) $(SHA) $(BUILD_DATE)
|
||||||
CGO_ENABLED=0 go build -v $(BUILD_TAGS) $(BUILD_ARGS)
|
CGO_ENABLED=0 go build -v $(BUILD_TAGS) $(BUILD_ARGS)
|
||||||
|
|
||||||
snapshot::
|
snapshot::
|
||||||
|
|
@ -110,15 +109,15 @@ release::
|
||||||
goreleaser --clean
|
goreleaser --clean
|
||||||
|
|
||||||
docker::
|
docker::
|
||||||
@echo Version: $(VERSION) $(SHA)
|
@echo Version: $(VERSION) $(SHA) $(BUILD_DATE)
|
||||||
docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):$(DOCKER_TAG) --push .
|
docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):$(DOCKER_TAG) --push .
|
||||||
|
|
||||||
publish-nightly::
|
publish-nightly::
|
||||||
@echo Version: $(VERSION) $(SHA)
|
@echo Version: $(VERSION) $(SHA) $(BUILD_DATE)
|
||||||
docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):nightly --push .
|
docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):nightly --push .
|
||||||
|
|
||||||
publish-release::
|
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 .
|
docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):latest --tag $(DOCKER_IMAGE):$(VERSION) --build-arg RELEASE=1 --push .
|
||||||
|
|
||||||
apt-nightly::
|
apt-nightly::
|
||||||
|
|
@ -154,7 +153,7 @@ gok-update::
|
||||||
${GOK} update yes
|
${GOK} update yes
|
||||||
|
|
||||||
soc::
|
soc::
|
||||||
@echo Version: $(VERSION) $(SHA)
|
@echo Version: $(VERSION) $(SHA) $(BUILD_DATE)
|
||||||
go build $(BUILD_TAGS) $(BUILD_ARGS) github.com/evcc-io/evcc/cmd/soc
|
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
|
# patch asn1.go to allow Elli buggy certificates to be accepted with EEBUS
|
||||||
|
|
|
||||||
|
|
@ -130,10 +130,9 @@ type ChargeRater interface {
|
||||||
ChargedEnergy() (float64, error)
|
ChargedEnergy() (float64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identifier identifies a vehicle and is implemented by the charger.
|
// 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.
|
|
||||||
type Identifier interface {
|
type Identifier interface {
|
||||||
Identify() ([]string, error)
|
Identify() (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authorizer authorizes a charging session by supplying RFID credentials
|
// Authorizer authorizes a charging session by supplying RFID credentials
|
||||||
|
|
|
||||||
19
api/error.go
19
api/error.go
|
|
@ -7,28 +7,23 @@ import (
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// permanentError is a sentinel error that signals permanence to backoff while
|
// permanentError is a sentinel error that keeps matching errors.Is after
|
||||||
// remaining distinguishable from the other permanent sentinels.
|
// backoff has stripped the backoff.Permanent wrapper.
|
||||||
type permanentError struct {
|
type permanentError struct {
|
||||||
msg string
|
msg string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *permanentError) Error() string { return e.msg }
|
func (e *permanentError) Error() string { return e.msg }
|
||||||
|
|
||||||
// As signals permanence to backoff. Wrapping the sentinel in backoff.Permanent
|
// Is matches the wrapped sentinel, too
|
||||||
// instead would make errors.Is match any other permanent error, since
|
func (e *permanentError) Is(target error) bool {
|
||||||
// backoff.PermanentError.Is matches by type rather than identity.
|
var t *permanentError
|
||||||
func (e *permanentError) As(target any) bool {
|
return errors.As(target, &t) && t == e
|
||||||
if p, ok := target.(**backoff.PermanentError); ok {
|
|
||||||
*p = &backoff.PermanentError{Err: e}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// permanent creates a permanent sentinel error
|
// permanent creates a permanent sentinel error
|
||||||
func permanent(msg string) error {
|
func permanent(msg string) error {
|
||||||
return &permanentError{msg}
|
return backoff.Permanent(&permanentError{msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrNotAvailable indicates that a feature is not available
|
// ErrNotAvailable indicates that a feature is not available
|
||||||
|
|
|
||||||
|
|
@ -8,47 +8,20 @@ import (
|
||||||
"github.com/stretchr/testify/assert"
|
"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.
|
// Backoff returns permanent errors unwrapped. These must still match the sentinel.
|
||||||
func TestPermanentSentinels(t *testing.T) {
|
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) {
|
_, unwrapped := backoff.RetryWithData(func() (int, error) {
|
||||||
return 0, err
|
return 0, tc.err
|
||||||
}, &backoff.StopBackOff{})
|
}, &backoff.StopBackOff{})
|
||||||
|
|
||||||
assert.ErrorIs(t, unwrapped, err)
|
assert.ErrorIs(t, unwrapped, tc.err)
|
||||||
assert.ErrorIs(t, fmt.Errorf("wrapped: %w", unwrapped), err)
|
assert.ErrorIs(t, fmt.Errorf("wrapped: %w", unwrapped), tc.err)
|
||||||
assert.ErrorIs(t, fmt.Errorf("wrapped: %w", err), 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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,6 @@ type All struct {
|
||||||
Site map[string]any
|
Site map[string]any
|
||||||
Loadpoints []config.Named
|
Loadpoints []config.Named
|
||||||
Circuits []config.Named
|
Circuits []config.Named
|
||||||
Curtailers []config.Named
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Javascript struct {
|
type Javascript struct {
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,7 @@ func (i *iDimmer) Dimmed() (bool, error) {
|
||||||
return i.dimmer1()
|
return i.dimmer1()
|
||||||
}
|
}
|
||||||
|
|
||||||
func Identifier(identifier0 func() ([]string, error)) api.Identifier {
|
func Identifier(identifier0 func() (string, error)) api.Identifier {
|
||||||
if identifier0 == nil {
|
if identifier0 == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -236,10 +236,10 @@ func Identifier(identifier0 func() ([]string, error)) api.Identifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
type iIdentifier struct {
|
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()
|
return i.identifier0()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -378,21 +378,6 @@ func (i *iPhaseVoltages) Voltages() (float64, float64, float64, error) {
|
||||||
return i.phaseVoltages0()
|
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 {
|
func Resurrector(resurrector0 func() error) api.Resurrector {
|
||||||
if resurrector0 == nil {
|
if resurrector0 == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -396,10 +396,10 @@ func (m *MockIdentifier) EXPECT() *MockIdentifierMockRecorder {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identify mocks base method.
|
// Identify mocks base method.
|
||||||
func (m *MockIdentifier) Identify() ([]string, error) {
|
func (m *MockIdentifier) Identify() (string, error) {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
ret := m.ctrl.Call(m, "Identify")
|
ret := m.ctrl.Call(m, "Identify")
|
||||||
ret0, _ := ret[0].([]string)
|
ret0, _ := ret[0].(string)
|
||||||
ret1, _ := ret[1].(error)
|
ret1, _ := ret[1].(error)
|
||||||
return ret0, ret1
|
return ret0, ret1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,6 @@
|
||||||
--evcc-gray: var(--bs-gray-medium);
|
--evcc-gray: var(--bs-gray-medium);
|
||||||
--evcc-gray-50: #93949e80;
|
--evcc-gray-50: #93949e80;
|
||||||
--evcc-gray-25: #93949e40;
|
--evcc-gray-25: #93949e40;
|
||||||
--evcc-gray-15: #93949e26;
|
|
||||||
--evcc-gray-10: #93949e20;
|
--evcc-gray-10: #93949e20;
|
||||||
--evcc-accent1: var(--evcc-dark-yellow);
|
--evcc-accent1: var(--evcc-dark-yellow);
|
||||||
--evcc-accent2: var(--evcc-darker-green);
|
--evcc-accent2: var(--evcc-darker-green);
|
||||||
|
|
@ -952,9 +951,3 @@ input::-webkit-date-and-time-value {
|
||||||
.sticky-bottom {
|
.sticky-bottom {
|
||||||
bottom: calc(var(--tab-bar-height) + 0.25rem);
|
bottom: calc(var(--tab-bar-height) + 0.25rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
[_echarts_instance_] {
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-webkit-touch-callout: none;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { VueHeadMixin, createHead } from "@unhead/vue/client";
|
||||||
import App from "./views/App.vue";
|
import App from "./views/App.vue";
|
||||||
import setupRouter from "./router.ts";
|
import setupRouter from "./router.ts";
|
||||||
import setupI18n from "./i18n.ts";
|
import setupI18n from "./i18n.ts";
|
||||||
import tooltip from "./directives/tooltip.ts";
|
|
||||||
import { watchThemeChanges } from "./theme.ts";
|
import { watchThemeChanges } from "./theme.ts";
|
||||||
import { applyUrlSettings } from "./urlSettings.ts";
|
import { applyUrlSettings } from "./urlSettings.ts";
|
||||||
import { appDetection, sendToApp } from "./utils/native";
|
import { appDetection, sendToApp } from "./utils/native";
|
||||||
|
|
@ -100,7 +99,6 @@ app.use(i18n);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.use(head);
|
app.use(head);
|
||||||
app.mixin(VueHeadMixin);
|
app.mixin(VueHeadMixin);
|
||||||
app.directive("tooltip", tooltip);
|
|
||||||
window.app = app.mount("#app");
|
window.app = app.mount("#app");
|
||||||
|
|
||||||
watchThemeChanges();
|
watchThemeChanges();
|
||||||
|
|
|
||||||
|
|
@ -52,11 +52,6 @@ export default {
|
||||||
hasUpdater: { control: "boolean" },
|
hasUpdater: { control: "boolean" },
|
||||||
uploadMessage: { control: "text" },
|
uploadMessage: { control: "text" },
|
||||||
uploadProgress: { control: "number" },
|
uploadProgress: { control: "number" },
|
||||||
customLogo: { control: "boolean" },
|
|
||||||
customBrand: { control: "text" },
|
|
||||||
customWebsite: { control: "text" },
|
|
||||||
customEmail: { control: "text" },
|
|
||||||
customPhone: { control: "text" },
|
|
||||||
},
|
},
|
||||||
} as Meta<typeof AboutModal>;
|
} as Meta<typeof AboutModal>;
|
||||||
|
|
||||||
|
|
@ -99,22 +94,10 @@ StableUpdateWithUpdater.args = {
|
||||||
|
|
||||||
export const Nightly = Template.bind({});
|
export const Nightly = Template.bind({});
|
||||||
Nightly.args = {
|
Nightly.args = {
|
||||||
installed: "0.304.0-dev.1712345678",
|
installed: "0.304.0-dev+5ce7be4",
|
||||||
commit: "5ce7be4",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DevBuild = Template.bind({});
|
export const DevBuild = Template.bind({});
|
||||||
DevBuild.args = {
|
DevBuild.args = {
|
||||||
installed: "0.0.0",
|
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",
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<GenericModal id="aboutModal" @opened="acknowledge">
|
<GenericModal id="aboutModal" :size="modalSize" @opened="acknowledge">
|
||||||
<template #title>
|
<template #title>
|
||||||
<a :href="websiteUrl" target="_blank" rel="noopener noreferrer">
|
<a :href="websiteUrl" target="_blank" rel="noopener noreferrer">
|
||||||
<template v-if="customLogo">
|
<template v-if="customLogo">
|
||||||
|
|
@ -44,12 +44,14 @@
|
||||||
>
|
>
|
||||||
v{{ installed }}
|
v{{ installed }}
|
||||||
</a>
|
</a>
|
||||||
<Badge v-if="newVersionAvailable">
|
<span
|
||||||
{{ $t("footer.version.availableLong") }}
|
v-if="!nightly && !newVersionAvailable"
|
||||||
</Badge>
|
class="text-muted text-nowrap"
|
||||||
<Badge v-else-if="!nightly" variant="muted">
|
>{{ $t("footer.version.latestVersion") }}</span
|
||||||
{{ $t("footer.version.latestVersion") }}
|
>
|
||||||
</Badge>
|
<span v-if="newVersionAvailable" class="text-nowrap">{{
|
||||||
|
$t("footer.version.availableLong")
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -113,22 +115,8 @@
|
||||||
|
|
||||||
<!-- open source -->
|
<!-- open source -->
|
||||||
<hr />
|
<hr />
|
||||||
<p
|
<p class="mb-0 small d-flex flex-wrap column-gap-1">
|
||||||
class="mb-0 d-flex justify-content-between align-items-center flex-wrap column-gap-2 row-gap-1"
|
<i18n-t keypath="footer.version.madeByCommunity" tag="span">
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<a
|
<a
|
||||||
:href="githubRepoUrl"
|
:href="githubRepoUrl"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
@ -137,13 +125,15 @@
|
||||||
>{{ $t("footer.version.community") }}</a
|
>{{ $t("footer.version.community") }}</a
|
||||||
>
|
>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
<a
|
<i18n-t keypath="footer.version.poweredByOpenSource" tag="span" class="d-inline">
|
||||||
class="evcc-gray"
|
<a
|
||||||
:href="githubDependenciesUrl"
|
class="text-muted"
|
||||||
target="_blank"
|
:href="githubDependenciesUrl"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
>{{ $t("footer.version.openSource") }}</a
|
rel="noopener noreferrer"
|
||||||
>
|
>{{ $t("footer.version.openSource") }}</a
|
||||||
|
>
|
||||||
|
</i18n-t>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</GenericModal>
|
</GenericModal>
|
||||||
|
|
@ -154,11 +144,16 @@ import GenericModal from "./Helper/GenericModal.vue";
|
||||||
import "@h2d2/shopicons/es/regular/gift";
|
import "@h2d2/shopicons/es/regular/gift";
|
||||||
import "@h2d2/shopicons/es/filled/heart";
|
import "@h2d2/shopicons/es/filled/heart";
|
||||||
import Logo from "./Footer/Logo.vue";
|
import Logo from "./Footer/Logo.vue";
|
||||||
import Badge from "./Helper/Badge.vue";
|
|
||||||
import api from "@/api";
|
import api from "@/api";
|
||||||
import settings from "@/settings";
|
import settings from "@/settings";
|
||||||
import { extractDomain } from "@/utils/extractDomain";
|
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";
|
import { defineComponent } from "vue";
|
||||||
|
|
||||||
const GITHUB_REPO = "https://github.com/evcc-io/evcc";
|
const GITHUB_REPO = "https://github.com/evcc-io/evcc";
|
||||||
|
|
@ -166,10 +161,9 @@ const EVCC_WEBSITE = "https://evcc.io/";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "AboutModal",
|
name: "AboutModal",
|
||||||
components: { GenericModal, Logo, Badge },
|
components: { GenericModal, Logo },
|
||||||
props: {
|
props: {
|
||||||
installed: { type: String, default: "" },
|
installed: { type: String, default: "" },
|
||||||
commit: { type: String, default: "" },
|
|
||||||
availableVersion: String,
|
availableVersion: String,
|
||||||
releaseNotes: String,
|
releaseNotes: String,
|
||||||
hasUpdater: Boolean,
|
hasUpdater: Boolean,
|
||||||
|
|
@ -210,14 +204,14 @@ export default defineComponent({
|
||||||
githubRepoUrl() {
|
githubRepoUrl() {
|
||||||
return GITHUB_REPO;
|
return GITHUB_REPO;
|
||||||
},
|
},
|
||||||
evccWebsiteUrl() {
|
|
||||||
return EVCC_WEBSITE;
|
|
||||||
},
|
|
||||||
githubDependenciesUrl() {
|
githubDependenciesUrl() {
|
||||||
return `${GITHUB_REPO}/network/dependencies`;
|
return `${GITHUB_REPO}/network/dependencies`;
|
||||||
},
|
},
|
||||||
githubCommitUrl() {
|
githubCommitUrl() {
|
||||||
return `${GITHUB_REPO}/commit/${this.commit}`;
|
return `${GITHUB_REPO}/commit/${commitFromVersion(this.installed)}`;
|
||||||
|
},
|
||||||
|
modalSize() {
|
||||||
|
return this.newVersionAvailable ? undefined : "sm";
|
||||||
},
|
},
|
||||||
cleanedReleaseNotes() {
|
cleanedReleaseNotes() {
|
||||||
if (!this.releaseNotes) return "";
|
if (!this.releaseNotes) return "";
|
||||||
|
|
@ -252,9 +246,6 @@ export default defineComponent({
|
||||||
.about-logo {
|
.about-logo {
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
}
|
}
|
||||||
.footer-logo {
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
.custom-logo {
|
.custom-logo {
|
||||||
height: 4rem;
|
height: 4rem;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
resetUrl() {
|
resetUrl() {
|
||||||
return `${docsPrefix()}/faq#password-reset`;
|
return `${docsPrefix()}/docs/faq#password-reset`;
|
||||||
},
|
},
|
||||||
modalSize() {
|
modalSize() {
|
||||||
return this.demoMode ? "md" : "sm";
|
return this.demoMode ? "md" : "sm";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<div data-testid="plan-strategy" class="mb-5 mb-lg-4">
|
<div data-testid="plan-strategy" class="mb-5 mb-lg-4">
|
||||||
<div :class="{ 'mb-2': open }">
|
<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
|
<button
|
||||||
|
v-else
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-link btn-sm text-gray p-0 border-0 d-flex align-items-center text-start"
|
class="btn btn-link btn-sm text-gray p-0 border-0 d-flex align-items-center text-start"
|
||||||
:class="{ 'text-primary': open }"
|
:class="{ 'text-primary': open }"
|
||||||
|
|
@ -16,7 +22,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="collapsible-wrapper" :class="{ open }">
|
<div class="collapsible-wrapper" :class="{ open }">
|
||||||
<div class="collapsible-content ring-space">
|
<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="col-12 col-lg-6 mb-3">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label
|
<label
|
||||||
|
|
@ -27,15 +39,6 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="col-7 col-lg-12">
|
<div class="col-7 col-lg-12">
|
||||||
<select
|
<select
|
||||||
v-if="disabled"
|
|
||||||
:id="formId('continuous')"
|
|
||||||
class="form-select"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<option>{{ $t("general.none") }}</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
v-else
|
|
||||||
:id="formId('continuous')"
|
:id="formId('continuous')"
|
||||||
v-model="localContinuous"
|
v-model="localContinuous"
|
||||||
class="form-select"
|
class="form-select"
|
||||||
|
|
@ -64,17 +67,6 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="col-7 col-lg-12">
|
<div class="col-7 col-lg-12">
|
||||||
<select
|
<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')"
|
:id="formId('precondition')"
|
||||||
v-model="localPrecondition"
|
v-model="localPrecondition"
|
||||||
class="form-select"
|
class="form-select"
|
||||||
|
|
@ -94,11 +86,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-muted mt-1">
|
<div class="small text-muted mt-1">
|
||||||
{{
|
{{ $t("main.chargingPlan.precondition.description") }}
|
||||||
$t(
|
|
||||||
`main.chargingPlan.precondition.${disabled ? "disabledDescription" : "description"}`
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -113,13 +101,6 @@ import formatter from "@/mixins/formatter";
|
||||||
import { SMART_COST_TYPE, type PlanStrategy } from "@/types/evcc";
|
import { SMART_COST_TYPE, type PlanStrategy } from "@/types/evcc";
|
||||||
import DropdownIcon from "../MaterialIcon/Dropdown.vue";
|
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({
|
export default defineComponent({
|
||||||
name: "ChargingPlanStrategy",
|
name: "ChargingPlanStrategy",
|
||||||
components: { DropdownIcon },
|
components: { DropdownIcon },
|
||||||
|
|
@ -128,6 +109,7 @@ export default defineComponent({
|
||||||
id: [String, Number],
|
id: [String, Number],
|
||||||
precondition: { type: Number, default: 0 },
|
precondition: { type: Number, default: 0 },
|
||||||
continuous: { type: Boolean, default: false },
|
continuous: { type: Boolean, default: false },
|
||||||
|
disabled: Boolean,
|
||||||
smartCostType: String as PropType<SMART_COST_TYPE>,
|
smartCostType: String as PropType<SMART_COST_TYPE>,
|
||||||
},
|
},
|
||||||
emits: ["update"],
|
emits: ["update"],
|
||||||
|
|
@ -139,10 +121,6 @@ export default defineComponent({
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
disabled(): boolean {
|
|
||||||
// options only make sense with a dynamic planner tariff
|
|
||||||
return !this.smartCostType || this.smartCostType === SMART_COST_TYPE.PRICE_STATIC;
|
|
||||||
},
|
|
||||||
isCo2(): boolean {
|
isCo2(): boolean {
|
||||||
return this.smartCostType === SMART_COST_TYPE.CO2;
|
return this.smartCostType === SMART_COST_TYPE.CO2;
|
||||||
},
|
},
|
||||||
|
|
@ -151,16 +129,14 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
summary(): string {
|
summary(): string {
|
||||||
if (this.disabled) {
|
if (this.disabled) {
|
||||||
return this.$t("main.chargingPlan.precondition.summaryAll");
|
return this.$t("main.chargingPlan.strategyDisabledDescription");
|
||||||
}
|
}
|
||||||
const parts = [
|
const parts = [
|
||||||
this.$t(
|
this.$t(
|
||||||
`main.chargingPlan.optimization.${this.continuous ? "continuous" : this.cheapestKey}`
|
`main.chargingPlan.optimization.${this.continuous ? "continuous" : this.cheapestKey}`
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
if (this.precondition >= EVERYTHING) {
|
if (this.precondition) {
|
||||||
parts.push(this.$t("main.chargingPlan.precondition.summaryAll"));
|
|
||||||
} else if (this.precondition) {
|
|
||||||
parts.push(
|
parts.push(
|
||||||
this.$t("main.chargingPlan.precondition.summary", {
|
this.$t("main.chargingPlan.precondition.summary", {
|
||||||
precondition: this.fmtDurationLong(this.precondition),
|
precondition: this.fmtDurationLong(this.precondition),
|
||||||
|
|
@ -170,9 +146,6 @@ export default defineComponent({
|
||||||
return parts.join(", ");
|
return parts.join(", ");
|
||||||
},
|
},
|
||||||
optimizationDescription(): string {
|
optimizationDescription(): string {
|
||||||
if (this.disabled) {
|
|
||||||
return this.$t("main.chargingPlan.optimization.disabledDescription");
|
|
||||||
}
|
|
||||||
const variant = this.localContinuous
|
const variant = this.localContinuous
|
||||||
? this.isCo2
|
? this.isCo2
|
||||||
? "continuousCo2"
|
? "continuousCo2"
|
||||||
|
|
@ -181,6 +154,13 @@ export default defineComponent({
|
||||||
return this.$t(`main.chargingPlan.optimization.${variant}Description`);
|
return this.$t(`main.chargingPlan.optimization.${variant}Description`);
|
||||||
},
|
},
|
||||||
preconditionOptions() {
|
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];
|
const options = [QUARTER_HOUR, HALF_HOUR, ONE_HOUR, TWO_HOURS, EVERYTHING];
|
||||||
|
|
||||||
// support custom values (via API)
|
// support custom values (via API)
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
:id="id"
|
:id="id"
|
||||||
:precondition="effectivePlanStrategy?.precondition"
|
:precondition="effectivePlanStrategy?.precondition"
|
||||||
:continuous="effectivePlanStrategy?.continuous"
|
:continuous="effectivePlanStrategy?.continuous"
|
||||||
|
:disabled="strategyDisabled"
|
||||||
:smart-cost-type="smartCostType"
|
:smart-cost-type="smartCostType"
|
||||||
@update="updatePlanStrategy"
|
@update="updatePlanStrategy"
|
||||||
/>
|
/>
|
||||||
|
|
@ -161,6 +162,13 @@ export default defineComponent({
|
||||||
nextPlanTitle(): string {
|
nextPlanTitle(): string {
|
||||||
return `${this.$t("main.targetCharge.nextPlan")}: #${this.nextPlanId}`;
|
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: {
|
watch: {
|
||||||
effectivePlanTime(newValue: string) {
|
effectivePlanTime(newValue: string) {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
<template>
|
<template>
|
||||||
<p class="mb-0 root" data-testid="plan-warnings">
|
<p class="mb-3 root" data-testid="plan-warnings">
|
||||||
<span v-if="targetIsAboveLimit" class="d-block evcc-gray mt-1">
|
<span v-if="targetIsAboveLimit" class="d-block evcc-gray mb-1">
|
||||||
{{ $t("main.targetCharge.targetIsAboveLimit", { limit: limitFmt }) }}
|
{{ $t("main.targetCharge.targetIsAboveLimit", { limit: limitFmt }) }}
|
||||||
</span>
|
</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") }}
|
{{ $t("main.targetCharge.onlyInPvMode") }}
|
||||||
</span>
|
</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") }}
|
{{ $t("main.targetCharge.targetIsTooFarInTheFuture") }}
|
||||||
</span>
|
</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 }) }}
|
{{ $t("main.targetCharge.notReachableInTime", { overrun: overrunFmt }) }}
|
||||||
</span>
|
</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") }}
|
{{ $t("main.targetCharge.targetIsAboveVehicleLimit") }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -97,31 +97,11 @@
|
||||||
</small>
|
</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-outline-danger mt-3"
|
class="btn btn-outline-danger mt-3"
|
||||||
:disabled="
|
:disabled="!selectedReset.sessions && !selectedReset.settings"
|
||||||
!selectedReset.sessions &&
|
|
||||||
!selectedReset.settings &&
|
|
||||||
!selectedReset.remote
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
{{ $t("config.system.backupRestore.reset.action") }}
|
{{ $t("config.system.backupRestore.reset.action") }}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -212,7 +192,6 @@ export default defineComponent({
|
||||||
selectedReset: {
|
selectedReset: {
|
||||||
sessions: false,
|
sessions: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
remote: false,
|
|
||||||
},
|
},
|
||||||
file: null as File | null,
|
file: null as File | null,
|
||||||
confirmType: "" as "backup" | "restore" | "reset" | "",
|
confirmType: "" as "backup" | "restore" | "reset" | "",
|
||||||
|
|
@ -249,7 +228,6 @@ export default defineComponent({
|
||||||
this.selectedReset = {
|
this.selectedReset = {
|
||||||
sessions: false,
|
sessions: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
remote: false,
|
|
||||||
};
|
};
|
||||||
this.file = null;
|
this.file = null;
|
||||||
this.navigateHomeAfterRestart = false;
|
this.navigateHomeAfterRestart = false;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
name="circuits"
|
name="circuits"
|
||||||
:title="$t('config.circuits.title')"
|
:title="$t('config.circuits.title')"
|
||||||
:description="$t('config.circuits.description')"
|
:description="$t('config.circuits.description')"
|
||||||
docs="/features/loadmanagement"
|
docs="/docs/features/loadmanagement"
|
||||||
:defaultYaml="defaultYaml"
|
:defaultYaml="defaultYaml"
|
||||||
removeKey="circuits"
|
removeKey="circuits"
|
||||||
endpoint="/config/circuits"
|
endpoint="/config/circuits"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
:label="$t('config.control.labelInterval')"
|
:label="$t('config.control.labelInterval')"
|
||||||
:help="$t('config.control.descriptionInterval')"
|
:help="$t('config.control.descriptionInterval')"
|
||||||
example="30 s"
|
example="30 s"
|
||||||
docsLink="/reference/configuration/interval"
|
docsLink="/docs/reference/configuration/interval"
|
||||||
>
|
>
|
||||||
<div class="input-group input-width">
|
<div class="input-group input-width">
|
||||||
<input
|
<input
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
:label="$t('config.control.labelResidualPower')"
|
:label="$t('config.control.labelResidualPower')"
|
||||||
:help="$t('config.control.descriptionResidualPower')"
|
:help="$t('config.control.descriptionResidualPower')"
|
||||||
example="100 W"
|
example="100 W"
|
||||||
docsLink="/reference/configuration/site#residualpower"
|
docsLink="/docs/reference/configuration/site#residualpower"
|
||||||
>
|
>
|
||||||
<div class="input-group input-width">
|
<div class="input-group input-width">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -34,7 +34,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
docsLink() {
|
docsLink() {
|
||||||
return `${docsPrefix()}/user-defined-devices#${this.type}`;
|
return `${docsPrefix()}/docs/devices/plugins#${this.type}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
name="eebus"
|
name="eebus"
|
||||||
:title="$t('config.eebus.title')"
|
:title="$t('config.eebus.title')"
|
||||||
:description="$t('config.eebus.description')"
|
:description="$t('config.eebus.description')"
|
||||||
docs="/reference/configuration/eebus"
|
docs="/docs/reference/configuration/eebus"
|
||||||
endpoint="/config/eebus"
|
endpoint="/config/eebus"
|
||||||
state-key="eebus.config"
|
state-key="eebus.config"
|
||||||
:no-buttons="fromYaml"
|
:no-buttons="fromYaml"
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,7 @@ export default defineComponent({
|
||||||
return (this as any).fmtTimeAgo(ms - Date.now());
|
return (this as any).fmtTimeAgo(ms - Date.now());
|
||||||
},
|
},
|
||||||
docsLink(): string {
|
docsLink(): string {
|
||||||
return `${docsPrefix()}/external-limit`;
|
return `${docsPrefix()}/docs/external-limit`;
|
||||||
},
|
},
|
||||||
experimental(): boolean {
|
experimental(): boolean {
|
||||||
return store.state?.experimental === true;
|
return store.state?.experimental === true;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
name="influx"
|
name="influx"
|
||||||
:title="$t('config.influx.title')"
|
:title="$t('config.influx.title')"
|
||||||
:description="$t('config.influx.description')"
|
:description="$t('config.influx.description')"
|
||||||
docs="/reference/configuration/influx"
|
docs="/docs/reference/configuration/influx"
|
||||||
endpoint="/config/influx"
|
endpoint="/config/influx"
|
||||||
state-key="influx"
|
state-key="influx"
|
||||||
@changed="$emit('changed')"
|
@changed="$emit('changed')"
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export default defineComponent({
|
||||||
return `claude mcp add --transport http evcc ${this.mcpUrl}`;
|
return `claude mcp add --transport http evcc ${this.mcpUrl}`;
|
||||||
},
|
},
|
||||||
docsLink(): string {
|
docsLink(): string {
|
||||||
return `${docsPrefix()}/integrations/mcp`;
|
return `${docsPrefix()}/docs/integrations/mcp`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
/>
|
/>
|
||||||
<div class="text-end small text-gray mt-1">
|
<div class="text-end small text-gray mt-1">
|
||||||
<a
|
<a
|
||||||
:href="`${docsPrefix()}/notifications#placeholders`"
|
:href="`${docsPrefix()}/docs/notifications#placeholders`"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="text-gray"
|
class="text-gray"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
name="messaginglegacy"
|
name="messaginglegacy"
|
||||||
:title="`${$t('config.messaging.title')} (${$t('config.general.legacy')})`"
|
:title="`${$t('config.messaging.title')} (${$t('config.general.legacy')})`"
|
||||||
:description="$t('config.messaging.description')"
|
:description="$t('config.messaging.description')"
|
||||||
docs="/reference/configuration/messaging"
|
docs="/docs/reference/configuration/messaging"
|
||||||
:defaultYaml="defaultYaml"
|
:defaultYaml="defaultYaml"
|
||||||
endpoint="/config/messaging"
|
endpoint="/config/messaging"
|
||||||
removeKey="messaging"
|
removeKey="messaging"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
name="messaging"
|
name="messaging"
|
||||||
:title="$t('config.messaging.title')"
|
:title="$t('config.messaging.title')"
|
||||||
:description="$t('config.messaging.description')"
|
:description="$t('config.messaging.description')"
|
||||||
docs="/notifications"
|
docs="/docs/notifications"
|
||||||
endpoint="/config/messagingEvents"
|
endpoint="/config/messagingEvents"
|
||||||
state-key="messagingEvents"
|
state-key="messagingEvents"
|
||||||
data-testid="messaging-modal"
|
data-testid="messaging-modal"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
name="modbusproxy"
|
name="modbusproxy"
|
||||||
:title="$t('config.modbusproxy.title')"
|
:title="$t('config.modbusproxy.title')"
|
||||||
:description="$t('config.modbusproxy.description')"
|
:description="$t('config.modbusproxy.description')"
|
||||||
docs="/reference/configuration/modbusproxy"
|
docs="/docs/reference/configuration/modbusproxy"
|
||||||
endpoint="/config/modbusproxy"
|
endpoint="/config/modbusproxy"
|
||||||
state-key="modbusproxy"
|
state-key="modbusproxy"
|
||||||
store-values-in-array
|
store-values-in-array
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
name="mqtt"
|
name="mqtt"
|
||||||
:title="$t('config.mqtt.title')"
|
:title="$t('config.mqtt.title')"
|
||||||
:description="$t('config.mqtt.description')"
|
:description="$t('config.mqtt.description')"
|
||||||
docs="/reference/configuration/mqtt"
|
docs="/docs/reference/configuration/mqtt"
|
||||||
endpoint="/config/mqtt"
|
endpoint="/config/mqtt"
|
||||||
state-key="mqtt"
|
state-key="mqtt"
|
||||||
@changed="$emit('changed')"
|
@changed="$emit('changed')"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@click="$emit('click')"
|
@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>
|
<span class="text-start">{{ title }}</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export default defineComponent({
|
||||||
return !!store.state?.evopt;
|
return !!store.state?.evopt;
|
||||||
},
|
},
|
||||||
docsLink(): string {
|
docsLink(): string {
|
||||||
return `${docsPrefix()}/features/optimizer`;
|
return `${docsPrefix()}/docs/features/optimizer`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,15 @@
|
||||||
data-testid="remote-modal"
|
data-testid="remote-modal"
|
||||||
@open="onOpen"
|
@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" />
|
<SponsorTokenRequired v-if="!isSponsor" feature class="mt-0" />
|
||||||
<ErrorMessage :error="error" />
|
<ErrorMessage :error="error" />
|
||||||
|
|
||||||
<template v-if="view === 'list'">
|
<template v-if="view === 'list'">
|
||||||
<p>
|
<p>{{ $t("config.remote.description") }}</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>
|
|
||||||
<div class="form-check form-switch my-3">
|
<div class="form-check form-switch my-3">
|
||||||
<input
|
<input
|
||||||
id="remoteEnabled"
|
id="remoteEnabled"
|
||||||
|
|
@ -141,10 +138,6 @@ export default defineComponent({
|
||||||
status(): RemoteStatus {
|
status(): RemoteStatus {
|
||||||
return this.remote?.status ?? { connected: false, loginBlocked: false };
|
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 {
|
modalTitle(): string {
|
||||||
switch (this.view) {
|
switch (this.view) {
|
||||||
case "create":
|
case "create":
|
||||||
|
|
@ -152,7 +145,7 @@ export default defineComponent({
|
||||||
case "reveal":
|
case "reveal":
|
||||||
return this.$t("config.remote.clientCreated");
|
return this.$t("config.remote.clientCreated");
|
||||||
default:
|
default:
|
||||||
return this.$t("config.remote.title");
|
return `${this.$t("config.remote.title")} 🧪`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -127,9 +127,9 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
curlExample(): string {
|
curlExample(): string {
|
||||||
const key = this.revealedKey ?? "";
|
const key = this.revealedKey ?? "";
|
||||||
const url = `${window.location.origin}/api/db/backup`;
|
const url = `${window.location.origin}/api/system/backup`;
|
||||||
return [
|
return [
|
||||||
`curl -X GET ${url} \\`,
|
`curl -X POST ${url} \\`,
|
||||||
` -H "Authorization: Bearer ${key}" \\`,
|
` -H "Authorization: Bearer ${key}" \\`,
|
||||||
` -o evcc-backup.db`,
|
` -o evcc-backup.db`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
name="shm"
|
name="shm"
|
||||||
:title="$t('config.shm.title')"
|
:title="$t('config.shm.title')"
|
||||||
:description="$t('config.shm.description')"
|
:description="$t('config.shm.description')"
|
||||||
docs="/reference/configuration/hems"
|
docs="/docs/reference/configuration/hems"
|
||||||
endpoint="/config/shm"
|
endpoint="/config/shm"
|
||||||
state-key="shm"
|
state-key="shm"
|
||||||
disable-remove
|
disable-remove
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ export default {
|
||||||
return this.token && !this.fromYaml;
|
return this.token && !this.fromYaml;
|
||||||
},
|
},
|
||||||
trialTokenLink() {
|
trialTokenLink() {
|
||||||
return `${docsPrefix()}/sponsorship#trial`;
|
return `${docsPrefix()}/docs/sponsorship#trial`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
v-tooltip="tooltip"
|
ref="root"
|
||||||
class="d-flex align-items-center gap-2 text-nowrap evcc-gray"
|
class="d-flex align-items-center gap-2 text-nowrap evcc-gray"
|
||||||
:role="tooltip ? 'img' : undefined"
|
:role="tooltip ? 'img' : undefined"
|
||||||
:aria-label="tooltip || 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>
|
<span class="d-inline-block rounded-circle status-dot" :class="dotClass"></span>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
@ -12,6 +14,7 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, type PropType } from "vue";
|
import { defineComponent, type PropType } from "vue";
|
||||||
|
import Tooltip from "bootstrap/js/dist/tooltip";
|
||||||
|
|
||||||
type Variant = "success" | "warning" | "danger" | "muted";
|
type Variant = "success" | "warning" | "danger" | "muted";
|
||||||
|
|
||||||
|
|
@ -21,6 +24,9 @@ export default defineComponent({
|
||||||
variant: { type: String as PropType<Variant>, default: "muted" },
|
variant: { type: String as PropType<Variant>, default: "muted" },
|
||||||
tooltip: { type: String, default: "" },
|
tooltip: { type: String, default: "" },
|
||||||
},
|
},
|
||||||
|
data() {
|
||||||
|
return { tooltipInstance: null as Tooltip | null };
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
dotClass(): string {
|
dotClass(): string {
|
||||||
switch (this.variant) {
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
name="tariffsLegacy"
|
name="tariffsLegacy"
|
||||||
:title="`${$t('config.tariff.title')} (${$t('config.general.legacy')})`"
|
:title="`${$t('config.tariff.title')} (${$t('config.general.legacy')})`"
|
||||||
:description="$t('config.tariff.description')"
|
:description="$t('config.tariff.description')"
|
||||||
docs="/tariffs"
|
docs="/docs/tariffs"
|
||||||
:defaultYaml="defaultYaml"
|
:defaultYaml="defaultYaml"
|
||||||
removeKey="tariffs"
|
removeKey="tariffs"
|
||||||
endpoint="/config/tariffs"
|
endpoint="/config/tariffs"
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -16,9 +16,6 @@ power: # current power
|
||||||
#maxpower: # maximum AC power in W (for hybrid pv)
|
#maxpower: # maximum AC power in W (for hybrid pv)
|
||||||
# source: const
|
# source: const
|
||||||
# value: 5000
|
# value: 5000
|
||||||
#curtailed: # current feed-in limit in % (0..100, 100 = uncurtailed)
|
|
||||||
# source: const
|
|
||||||
# value: 100
|
|
||||||
#soc: # state of charge in % (for battery)
|
#soc: # state of charge in % (for battery)
|
||||||
# source: const
|
# source: const
|
||||||
# value: 75
|
# value: 75
|
||||||
|
|
@ -53,6 +50,3 @@ power: # current power
|
||||||
#batterymode: # set charging mode directly (1: normal, 2: hold, 3: charge) (for battery)
|
#batterymode: # set charging mode directly (1: normal, 2: hold, 3: charge) (for battery)
|
||||||
# source: js
|
# source: js
|
||||||
# script: console.log(batterymode)
|
# script: console.log(batterymode)
|
||||||
#curtail: # set feed-in limit in % (0..100, 100 = uncurtailed)
|
|
||||||
# source: js
|
|
||||||
# script: console.log(curtail)
|
|
||||||
|
|
|
||||||
|
|
@ -656,7 +656,7 @@ export default defineComponent({
|
||||||
height: 0;
|
height: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
transition-property: height, opacity, transform;
|
transition-property: height, opacity, transform;
|
||||||
transition-duration: 0;
|
transition-duration: 0;
|
||||||
transition-timing-function: cubic-bezier(0.5, 0.5, 0.5, 1.15);
|
transition-timing-function: cubic-bezier(0.5, 0.5, 0.5, 1.15);
|
||||||
|
|
|
||||||
|
|
@ -236,8 +236,6 @@ export default defineComponent({
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-right: 1.2rem;
|
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 {
|
.label-scale-name {
|
||||||
color: var(--evcc-gray);
|
color: var(--evcc-gray);
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,7 @@
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,19 +59,6 @@
|
||||||
equal-width
|
equal-width
|
||||||
/>
|
/>
|
||||||
</FormRow>
|
</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')">
|
<FormRow v-if="loadpoints.length" :label="$t('settings.loadpoints.label')">
|
||||||
<LoadpointOrderSettings :loadpoints="loadpoints" />
|
<LoadpointOrderSettings :loadpoints="loadpoints" />
|
||||||
</FormRow>
|
</FormRow>
|
||||||
|
|
@ -104,24 +91,14 @@ import {
|
||||||
removeLocalePreference,
|
removeLocalePreference,
|
||||||
} from "@/i18n.ts";
|
} from "@/i18n.ts";
|
||||||
import { getThemePreference, setThemePreference } from "@/theme.ts";
|
import { getThemePreference, setThemePreference } from "@/theme.ts";
|
||||||
import {
|
import { getUnits, setUnits, is12hFormat, set12hFormat } from "@/units";
|
||||||
getUnits,
|
|
||||||
setUnits,
|
|
||||||
is12hFormat,
|
|
||||||
set12hFormat,
|
|
||||||
getDateFormat,
|
|
||||||
setDateFormat,
|
|
||||||
} from "@/units";
|
|
||||||
import { isApp } from "@/utils/native";
|
import { isApp } from "@/utils/native";
|
||||||
import { defineComponent, type PropType } from "vue";
|
import { defineComponent, type PropType } from "vue";
|
||||||
import { LENGTH_UNIT, THEME, TIME_FORMAT, type UiLoadpoint } from "@/types/evcc";
|
import { LENGTH_UNIT, THEME, TIME_FORMAT, type UiLoadpoint } from "@/types/evcc";
|
||||||
import formatter from "@/mixins/formatter";
|
|
||||||
import type { DateFormat } from "@/settings";
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "UserInterfaceSettings",
|
name: "UserInterfaceSettings",
|
||||||
components: { FormRow, SelectGroup, LoadpointOrderSettings },
|
components: { FormRow, SelectGroup, LoadpointOrderSettings },
|
||||||
mixins: [formatter],
|
|
||||||
props: {
|
props: {
|
||||||
loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
|
loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
|
||||||
},
|
},
|
||||||
|
|
@ -138,17 +115,6 @@ export default defineComponent({
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
dateFormat: {
|
|
||||||
get(): DateFormat {
|
|
||||||
return getDateFormat();
|
|
||||||
},
|
|
||||||
set(value: DateFormat) {
|
|
||||||
setDateFormat(value);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
dateFormatExample(): string {
|
|
||||||
return this.fmtFullDateTime(new Date(2015, 9, 21, 16, 29));
|
|
||||||
},
|
|
||||||
languageOptions: () => {
|
languageOptions: () => {
|
||||||
const locales = Object.entries(LOCALES).map(([key, value]) => {
|
const locales = Object.entries(LOCALES).map(([key, value]) => {
|
||||||
return { value: key, name: value[1] };
|
return { value: key, name: value[1] };
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import Loadpoints from "./Loadpoints.vue";
|
import Loadpoints from "./Loadpoints.vue";
|
||||||
import type { Meta, StoryFn } from "@storybook/vue3";
|
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
|
// Create LoadpointCompact structure for the Loadpoints component
|
||||||
const createLoadpoint = (opts: any = {}) => {
|
const createLoadpoint = (opts: any = {}) => {
|
||||||
|
|
@ -117,18 +117,11 @@ TwoLoadpoints.args = {
|
||||||
index: 0,
|
index: 0,
|
||||||
vehicleName: "vehicle_3",
|
vehicleName: "vehicle_3",
|
||||||
charging: true,
|
charging: true,
|
||||||
chargePower: 1380,
|
chargePower: 8050,
|
||||||
power: 1380,
|
power: 8050,
|
||||||
connected: true,
|
connected: true,
|
||||||
soc: 66,
|
soc: 66,
|
||||||
vehicleSoc: 66,
|
vehicleSoc: 66,
|
||||||
mode: CHARGE_MODE.PV,
|
|
||||||
phasesConfigured: 3,
|
|
||||||
phasesActive: 1,
|
|
||||||
offeredCurrent: 9,
|
|
||||||
chargeCurrents: [6, 0, 0],
|
|
||||||
minCurrent: 6,
|
|
||||||
maxCurrent: 16,
|
|
||||||
}),
|
}),
|
||||||
createLoadpoint({
|
createLoadpoint({
|
||||||
title: "Charging point with a very very very long title that tests layout compatibility!!!",
|
title: "Charging point with a very very very long title that tests layout compatibility!!!",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import Phases from "./Phases.vue";
|
import Phases from "./Phases.vue";
|
||||||
import type { Meta, StoryFn } from "@storybook/vue3";
|
import type { Meta, StoryFn } from "@storybook/vue3";
|
||||||
import { ref, onMounted, onUnmounted } from "vue";
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "Loadpoints/Phases",
|
title: "Loadpoints/Phases",
|
||||||
|
|
@ -10,7 +9,6 @@ export default {
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
phasesActive: { control: { type: "number" } },
|
phasesActive: { control: { type: "number" } },
|
||||||
phasesConfigured: { control: { type: "number" } },
|
|
||||||
minCurrent: { control: { type: "number" } },
|
minCurrent: { control: { type: "number" } },
|
||||||
maxCurrent: { control: { type: "number" } },
|
maxCurrent: { control: { type: "number" } },
|
||||||
offeredCurrent: { control: { type: "number" } },
|
offeredCurrent: { control: { type: "number" } },
|
||||||
|
|
@ -26,117 +24,72 @@ const Template: StoryFn<typeof Phases> = (args) => ({
|
||||||
template: '<Phases v-bind="args" />',
|
template: '<Phases v-bind="args" />',
|
||||||
});
|
});
|
||||||
|
|
||||||
const base = {
|
export const OnePhase = Template.bind({});
|
||||||
phasesConfigured: 3,
|
OnePhase.args = {
|
||||||
|
phasesActive: 1,
|
||||||
minCurrent: 6,
|
minCurrent: 6,
|
||||||
maxCurrent: 16,
|
maxCurrent: 16,
|
||||||
offeredCurrent: 8,
|
offeredCurrent: 8,
|
||||||
chargeCurrents: undefined,
|
chargeCurrents: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- expected phases only (no per-phase measurement) ---
|
export const TwoPhase = Template.bind({});
|
||||||
|
TwoPhase.args = {
|
||||||
export const NoData = Template.bind({});
|
...OnePhase.args,
|
||||||
NoData.args = { ...base, phasesActive: undefined, offeredCurrent: 0 };
|
phasesActive: 2,
|
||||||
|
|
||||||
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],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1p device but 3 phases measured: configuration error, warning color + tooltip
|
export const ThreePhase = Template.bind({});
|
||||||
export const Mismatch1p = Template.bind({});
|
ThreePhase.args = {
|
||||||
Mismatch1p.args = {
|
...OnePhase.args,
|
||||||
...base,
|
|
||||||
phasesConfigured: 1,
|
|
||||||
phasesActive: 3,
|
phasesActive: 3,
|
||||||
offeredCurrent: 10,
|
|
||||||
chargeCurrents: [10, 10, 10],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// cycles 1p → 3p → 1p every few seconds to inspect the transition
|
export const RealCurrents = Template.bind({});
|
||||||
export const Switching: StoryFn<typeof Phases> = (args) => ({
|
RealCurrents.args = {
|
||||||
components: { Phases },
|
...OnePhase.args,
|
||||||
setup() {
|
phasesActive: 3,
|
||||||
const phasesActive = ref(1);
|
offeredCurrent: 13,
|
||||||
let timer: ReturnType<typeof setInterval>;
|
chargeCurrents: [11, 9, 12],
|
||||||
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 };
|
|
||||||
|
|
||||||
// --- 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 = [
|
export const TwoPhasesActive = Template.bind({});
|
||||||
{ name: "NoData", args: NoData.args },
|
TwoPhasesActive.args = {
|
||||||
{ name: "Expected1p", args: Expected1p.args },
|
...OnePhase.args,
|
||||||
{ name: "Expected2p", args: Expected2p.args },
|
phasesActive: 2,
|
||||||
{ name: "Expected3p", args: Expected3p.args },
|
offeredCurrent: 16,
|
||||||
{ name: "Measured1p", args: Measured1p.args },
|
chargeCurrents: [16, 16, 0.3],
|
||||||
{ 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 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 1p3p auto (0) behaves like 3p
|
export const AsymetricPhases = Template.bind({});
|
||||||
const configs = [
|
AsymetricPhases.args = {
|
||||||
{ name: "1p device", phasesConfigured: 1 },
|
...OnePhase.args,
|
||||||
{ name: "3p/auto", phasesConfigured: 3 },
|
phasesActive: 2,
|
||||||
];
|
offeredCurrent: 16,
|
||||||
|
chargeCurrents: [8, 0.9, 14],
|
||||||
|
};
|
||||||
|
|
||||||
export const Overview: StoryFn<typeof Phases> = () => ({
|
export const OnlySecondPhase = Template.bind({});
|
||||||
components: { Phases },
|
OnlySecondPhase.args = {
|
||||||
setup() {
|
...OnePhase.args,
|
||||||
return { scenarios, configs };
|
phasesActive: 1,
|
||||||
},
|
offeredCurrent: 13,
|
||||||
template: `
|
chargeCurrents: [0, 13, 0],
|
||||||
<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>
|
export const MainlyThirdPhase = Template.bind({});
|
||||||
<template v-for="s in scenarios" :key="s.name">
|
MainlyThirdPhase.args = {
|
||||||
<div style="opacity: 0.7;">{{ s.name }}</div>
|
...OnePhase.args,
|
||||||
<Phases v-for="c in configs" :key="c.name" v-bind="{ ...s.args, phasesConfigured: c.phasesConfigured }" />
|
phasesActive: 1,
|
||||||
</template>
|
offeredCurrent: 10,
|
||||||
</div>
|
chargeCurrents: [0.007, 0.009, 5.945],
|
||||||
`,
|
minCurrent: 6,
|
||||||
});
|
maxCurrent: 20,
|
||||||
Overview.parameters = { controls: { disable: true } };
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div :class="`phases d-flex justify-content-between`">
|
||||||
v-tooltip="phasesMismatch ? $t('main.loadpoint.phasesMismatch') : undefined"
|
|
||||||
class="phases d-flex"
|
|
||||||
:class="{ 'phases-warning': phasesMismatch }"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-for="num in [1, 2, 3]"
|
v-for="num in [1, 2, 3]"
|
||||||
:key="num"
|
:key="num"
|
||||||
class="phase me-1"
|
class="phase me-1"
|
||||||
:class="{
|
:class="{ 'phase-inactive': !isPhaseActive(num) }"
|
||||||
'phase-inactive': !isPhaseActive(num),
|
|
||||||
'phase-hidden': !isPhaseActive(num) && (maxPhases === 1 || num > maxPhases),
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<div
|
<div class="target" :style="{ width: `${targetWidth()}%` }"></div>
|
||||||
v-show="targetWidth() > 0"
|
|
||||||
class="target"
|
|
||||||
:style="{ width: `${targetWidth()}%` }"
|
|
||||||
></div>
|
|
||||||
<div class="real" :style="{ width: `${realWidth(num)}%` }"></div>
|
<div class="real" :style="{ width: `${realWidth(num)}%` }"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -34,7 +23,6 @@ export default defineComponent({
|
||||||
offeredCurrent: { type: Number, default: 0 },
|
offeredCurrent: { type: Number, default: 0 },
|
||||||
chargeCurrents: { type: Array as PropType<number[]> },
|
chargeCurrents: { type: Array as PropType<number[]> },
|
||||||
phasesActive: { type: Number as PropType<PHASES> },
|
phasesActive: { type: Number as PropType<PHASES> },
|
||||||
phasesConfigured: { type: Number, default: 3 },
|
|
||||||
minCurrent: { type: Number, default: 6 },
|
minCurrent: { type: Number, default: 6 },
|
||||||
maxCurrent: { type: Number, default: 16 },
|
maxCurrent: { type: Number, default: 16 },
|
||||||
},
|
},
|
||||||
|
|
@ -43,17 +31,9 @@ export default defineComponent({
|
||||||
if (!this.chargeCurrents) return false;
|
if (!this.chargeCurrents) return false;
|
||||||
return this.chargeCurrents.filter((c) => c >= MIN_ACTIVE_CURRENT).length > 0;
|
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: {
|
methods: {
|
||||||
targetWidth() {
|
targetWidth() {
|
||||||
if (this.offeredCurrent <= 0) return 0;
|
|
||||||
const current = Math.min(
|
const current = Math.min(
|
||||||
Math.max(this.minCurrent, this.offeredCurrent),
|
Math.max(this.minCurrent, this.offeredCurrent),
|
||||||
this.maxCurrent
|
this.maxCurrent
|
||||||
|
|
@ -72,8 +52,7 @@ export default defineComponent({
|
||||||
const current = this.chargeCurrents[num - 1];
|
const current = this.chargeCurrents[num - 1];
|
||||||
return current !== undefined && current >= MIN_ACTIVE_CURRENT;
|
return current !== undefined && current >= MIN_ACTIVE_CURRENT;
|
||||||
}
|
}
|
||||||
// unknown active phases: assume all
|
return num <= (this.phasesActive || 0);
|
||||||
return num <= (this.phasesActive || this.maxPhases);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -84,14 +63,15 @@ export default defineComponent({
|
||||||
width: 73px;
|
width: 73px;
|
||||||
}
|
}
|
||||||
.phase {
|
.phase {
|
||||||
background-color: var(--evcc-gray-15);
|
background-color: var(--bs-gray-bright);
|
||||||
height: 4px;
|
height: 4px;
|
||||||
flex: 1 1 0;
|
flex-grow: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 2px;
|
border-radius: 1px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
flex-basis: 100%;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition-property: flex-basis, flex-grow, margin, opacity;
|
transition-property: flex-basis, margin, opacity;
|
||||||
transition-duration: var(--evcc-transition-slow);
|
transition-duration: var(--evcc-transition-slow);
|
||||||
transition-timing-function: ease-in;
|
transition-timing-function: ease-in;
|
||||||
}
|
}
|
||||||
|
|
@ -99,13 +79,6 @@ html.dark .phase {
|
||||||
background-color: var(--bs-gray-bright);
|
background-color: var(--bs-gray-bright);
|
||||||
}
|
}
|
||||||
.phase-inactive {
|
.phase-inactive {
|
||||||
flex: 0 0 4px;
|
|
||||||
}
|
|
||||||
.phase-inactive .target,
|
|
||||||
.phase-inactive .real {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
.phase-hidden {
|
|
||||||
flex-basis: 0;
|
flex-basis: 0;
|
||||||
margin-right: 0 !important;
|
margin-right: 0 !important;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
@ -123,15 +96,9 @@ html.dark .phase {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.target {
|
.target {
|
||||||
background-color: color-mix(in srgb, var(--evcc-dark-green) 40%, white);
|
background-color: var(--evcc-green);
|
||||||
}
|
}
|
||||||
.real {
|
.real {
|
||||||
background-color: var(--evcc-dark-green);
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -146,7 +146,7 @@ export default defineComponent({
|
||||||
...tooltipStyle(colors.text || ""),
|
...tooltipStyle(colors.text || ""),
|
||||||
formatter: this.tooltipFormatter,
|
formatter: this.tooltipFormatter,
|
||||||
},
|
},
|
||||||
xAxis: slotXAxis(this.times, this.weekdayShort),
|
xAxis: slotXAxis(this.times),
|
||||||
yAxis: forecastYAxis({
|
yAxis: forecastYAxis({
|
||||||
min: undefined,
|
min: undefined,
|
||||||
position: "right",
|
position: "right",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { xAxisLabelStyle } from "../Forecast/echarts";
|
import { xAxisLabelStyle } from "../Forecast/echarts";
|
||||||
import colors from "@/colors";
|
|
||||||
import type { BatteryDetail } from "@/types/evcc";
|
import type { BatteryDetail } from "@/types/evcc";
|
||||||
|
|
||||||
// loadpoint part of a vehicle entry title: "Carport (blue e-Golf)" → "Carport"
|
// 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;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMidnight(time?: number): boolean {
|
// category x axis over slot times, labels at full hours every 4h (6h on mobile)
|
||||||
if (time === undefined) return false;
|
export function slotXAxis(times: number[]) {
|
||||||
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) {
|
|
||||||
const step = window.innerWidth < 576 ? 6 : 4;
|
const step = window.innerWidth < 576 ? 6 : 4;
|
||||||
return {
|
return {
|
||||||
type: "category",
|
type: "category",
|
||||||
data: times.map(String),
|
data: times.map(String),
|
||||||
axisLine: { show: false },
|
axisLine: { show: false },
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
splitLine: {
|
splitLine: { show: false },
|
||||||
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" },
|
|
||||||
},
|
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
...xAxisLabelStyle(),
|
...xAxisLabelStyle(),
|
||||||
interval: 0,
|
interval: 0,
|
||||||
formatter: (value: string) => {
|
formatter: (value: string) => {
|
||||||
const d = new Date(Number(value));
|
const d = new Date(Number(value));
|
||||||
if (d.getMinutes() !== 0 || d.getHours() % step !== 0) return "";
|
if (d.getMinutes() !== 0 || d.getHours() % step !== 0) return "";
|
||||||
const label = String(d.getHours());
|
return String(d.getHours());
|
||||||
return d.getHours() === 0 ? `${label}\n${weekdayShort(d)}` : label;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -60,11 +60,11 @@
|
||||||
</th>
|
</th>
|
||||||
<td>
|
<td>
|
||||||
<template v-if="session.created">
|
<template v-if="session.created">
|
||||||
{{ fmtFullDateTime(new Date(session.created)) }}
|
{{ fmtFullDateTime(new Date(session.created), false) }}
|
||||||
</template>
|
</template>
|
||||||
<br />
|
<br />
|
||||||
<template v-if="session.finished">
|
<template v-if="session.finished">
|
||||||
{{ fmtFullDateTime(new Date(session.finished)) }}
|
{{ fmtFullDateTime(new Date(session.finished), false) }}
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@
|
||||||
@click="showDetails(session.id)"
|
@click="showDetails(session.id)"
|
||||||
>
|
>
|
||||||
<td class="ps-0 tabular">
|
<td class="ps-0 tabular">
|
||||||
{{ fmtWeekdayDayTime(new Date(session.created)) }}
|
{{ fmtFullDateTime(new Date(session.created), true) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="d-none d-md-table-cell">
|
<td class="d-none d-md-table-cell">
|
||||||
{{ session.loadpoint }}
|
{{ session.loadpoint }}
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
docsLink() {
|
docsLink() {
|
||||||
return `${docsPrefix()}/faq#telemetry`;
|
return `${docsPrefix()}/docs/faq#telemetry`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -16,91 +16,85 @@
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
v-for="vehicle in vehicles"
|
v-for="(vehicle, index) in vehicles"
|
||||||
:key="vehicle.name"
|
:key="vehicle.name"
|
||||||
role="group"
|
role="group"
|
||||||
:aria-label="vehicle.title"
|
: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="d-flex flex-wrap align-items-center column-gap-2 row-gap-1 mb-3">
|
||||||
<div class="flex-grow-1 overflow-hidden ring-space">
|
<VehicleIcon :name="vehicle.icon" class="flex-shrink-0" />
|
||||||
<div class="d-flex flex-wrap align-items-center column-gap-2 row-gap-1 mb-3">
|
<strong class="text-truncate">{{ vehicle.title }}</strong>
|
||||||
<strong class="text-truncate">{{ vehicle.title }}</strong>
|
<Badge v-if="connectedLoadpoint(vehicle)" class="flex-shrink-0">
|
||||||
<Badge v-if="connectedLoadpoint(vehicle)" class="flex-shrink-0">
|
{{
|
||||||
{{
|
$t("main.vehicleSettings.connectedTo", [
|
||||||
$t("main.vehicleSettings.connectedTo", [
|
connectedLoadpoint(vehicle)?.title ||
|
||||||
connectedLoadpoint(vehicle)?.title ||
|
$t("main.loadpoint.fallbackName"),
|
||||||
$t("main.loadpoint.fallbackName"),
|
])
|
||||||
])
|
}}
|
||||||
}}
|
</Badge>
|
||||||
</Badge>
|
<Badge v-else variant="muted" class="flex-shrink-0">
|
||||||
<Badge v-else variant="muted" class="flex-shrink-0">
|
{{ $t("main.vehicleSettings.notConnected") }}
|
||||||
{{ $t("main.vehicleSettings.notConnected") }}
|
</Badge>
|
||||||
</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>
|
</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>
|
</div>
|
||||||
<p class="mb-0 border-top pt-4">
|
<p class="mb-0 border-top pt-4">
|
||||||
<i18n-t keypath="main.vehicleSettings.editHint" tag="span" scope="global">
|
<i18n-t keypath="main.vehicleSettings.editHint" tag="span" scope="global">
|
||||||
|
|
@ -116,7 +110,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Badge from "../Helper/Badge.vue";
|
import Badge from "../Helper/Badge.vue";
|
||||||
import GenericModal from "../Helper/GenericModal.vue";
|
import GenericModal from "../Helper/GenericModal.vue";
|
||||||
import CustomSelect from "../Helper/CustomSelect.vue";
|
import SettingsFormRow from "../Helper/SettingsFormRow.vue";
|
||||||
import VehicleIcon from "../VehicleIcon";
|
import VehicleIcon from "../VehicleIcon";
|
||||||
import api from "@/api";
|
import api from "@/api";
|
||||||
import formatter from "@/mixins/formatter";
|
import formatter from "@/mixins/formatter";
|
||||||
|
|
@ -130,7 +124,7 @@ const { OFF, PV, MINPV, NOW } = CHARGE_MODE;
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "VehicleSettingsModal",
|
name: "VehicleSettingsModal",
|
||||||
components: { Badge, CustomSelect, GenericModal, VehicleIcon },
|
components: { Badge, GenericModal, SettingsFormRow, VehicleIcon },
|
||||||
mixins: [formatter],
|
mixins: [formatter],
|
||||||
props: {
|
props: {
|
||||||
vehicles: { type: Array as PropType<Vehicle[]>, default: () => [] },
|
vehicles: { type: Array as PropType<Vehicle[]>, default: () => [] },
|
||||||
|
|
@ -143,7 +137,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
docsUrl(): string {
|
docsUrl(): string {
|
||||||
return `${docsPrefix()}/features/limits`;
|
return `${docsPrefix()}/docs/features/limits`;
|
||||||
},
|
},
|
||||||
modeOptions(): SelectOption<string>[] {
|
modeOptions(): SelectOption<string>[] {
|
||||||
return [
|
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 {
|
selectValue(event: Event): string {
|
||||||
return (event.target as HTMLSelectElement).value;
|
return (event.target as HTMLSelectElement).value;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -110,6 +110,7 @@ export async function ensureCurrentLocaleMessages(i18n: VueI18nInstance) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function docsPrefix() {
|
export function docsPrefix() {
|
||||||
const lang = getLocale() === "de" ? "de" : "en";
|
const locale = getLocale();
|
||||||
return `https://docs.evcc.io/${lang}`;
|
const path = locale === "de" ? "" : `/en`;
|
||||||
|
return `https://docs.evcc.io${path}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { mount, config } from "@vue/test-utils";
|
||||||
import { describe, expect, test, vi } from "vite-plus/test";
|
import { describe, expect, test, vi } from "vite-plus/test";
|
||||||
import formatter, { POWER_UNIT } from "./formatter";
|
import formatter, { POWER_UNIT } from "./formatter";
|
||||||
import * as units from "../units";
|
import * as units from "../units";
|
||||||
import settings from "../settings";
|
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
import { CURRENCY } from "@/types/evcc";
|
import { CURRENCY } from "@/types/evcc";
|
||||||
|
|
||||||
|
|
@ -276,7 +275,7 @@ describe("12h/24h time format", () => {
|
||||||
test("12h format", () => {
|
test("12h format", () => {
|
||||||
is12hSpy.mockReturnValue(true);
|
is12hSpy.mockReturnValue(true);
|
||||||
expect(fmt.fmtHourMinute(testDate)).toBe("3:30 PM");
|
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.fmtWeekdayTime(testDate)).toBe("So. 3:30 PM");
|
||||||
expect(fmt.fmtAbsoluteDate(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", () => {
|
test("24h format", () => {
|
||||||
is12hSpy.mockReturnValue(false);
|
is12hSpy.mockReturnValue(false);
|
||||||
expect(fmt.fmtHourMinute(testDate)).toBe("15:30");
|
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.fmtWeekdayTime(testDate)).toBe("So., 15:30");
|
||||||
expect(fmt.fmtAbsoluteDate(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", () => {
|
describe("fmtTimeRange", () => {
|
||||||
test("should format time ranges in 24h format", () => {
|
test("should format time ranges in 24h format", () => {
|
||||||
is12hSpy.mockReturnValue(false);
|
is12hSpy.mockReturnValue(false);
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,6 @@
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
import { is12hFormat } from "@/units";
|
import { is12hFormat } from "@/units";
|
||||||
import { CURRENCY } from "../types/evcc";
|
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> = {
|
const CURRENCY_SYMBOLS: Record<CURRENCY, string> = {
|
||||||
AUD: "$",
|
AUD: "$",
|
||||||
|
|
@ -75,10 +26,6 @@ const CURRENCY_SYMBOLS: Record<CURRENCY, string> = {
|
||||||
TRY: "₺",
|
TRY: "₺",
|
||||||
MYR: "RM",
|
MYR: "RM",
|
||||||
THB: "฿",
|
THB: "฿",
|
||||||
BYN: "Br",
|
|
||||||
UAH: "₴",
|
|
||||||
RUB: "₽",
|
|
||||||
KZT: "₸",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// list of currencies where energy price should be displayed in subunits (factor 100)
|
// 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
|
SEK: "öre", // Swedish öre
|
||||||
ZAR: "c", // South African cent
|
ZAR: "c", // South African cent
|
||||||
TRY: "krş", // Türkiye kuruş
|
TRY: "krş", // Türkiye kuruş
|
||||||
BYN: "к.", // Belarusian kapeyka
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum POWER_UNIT {
|
export enum POWER_UNIT {
|
||||||
|
|
@ -116,11 +62,6 @@ export default defineComponent({
|
||||||
fmtDigits: 1,
|
fmtDigits: 1,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
|
||||||
dateFormat(): DateFormat {
|
|
||||||
return settings.dateFormat || "";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
energyPriceSubunit(currency: CURRENCY): string | undefined {
|
energyPriceSubunit(currency: CURRENCY): string | undefined {
|
||||||
if (currency === CURRENCY.CHF) {
|
if (currency === CURRENCY.CHF) {
|
||||||
|
|
@ -324,33 +265,15 @@ export default defineComponent({
|
||||||
hour12: is12hFormat(),
|
hour12: is12hFormat(),
|
||||||
}).format(date);
|
}).format(date);
|
||||||
},
|
},
|
||||||
fmtFullDateTime(date: Date) {
|
fmtFullDateTime(date: Date, short: boolean) {
|
||||||
const locale = this.$i18n?.locale;
|
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||||
const fmt = this.dateFormat;
|
weekday: short ? undefined : "short",
|
||||||
if (!fmt) {
|
month: short ? "numeric" : "short",
|
||||||
// auto: single Intl call preserves locale-native separators (e.g. German "So., 15. Jan.,")
|
day: "numeric",
|
||||||
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, {
|
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "numeric",
|
minute: "numeric",
|
||||||
hour12: is12hFormat(),
|
hour12: is12hFormat(),
|
||||||
}).format(date);
|
}).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) {
|
fmtWeekdayTime(date: Date) {
|
||||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||||
|
|
@ -372,17 +295,11 @@ export default defineComponent({
|
||||||
}).format(date);
|
}).format(date);
|
||||||
},
|
},
|
||||||
fmtDayMonth(date: Date) {
|
fmtDayMonth(date: Date) {
|
||||||
const locale = this.$i18n?.locale;
|
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||||
const fmt = this.dateFormat;
|
weekday: "short",
|
||||||
if (!fmt) {
|
day: "numeric",
|
||||||
return new Intl.DateTimeFormat(locale, {
|
month: "short",
|
||||||
weekday: "short",
|
}).format(date);
|
||||||
day: "numeric",
|
|
||||||
month: "short",
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
const weekday = datePart(date, locale, "weekday");
|
|
||||||
return `${weekday} ${formatDayMonth(date, locale, fmt)}`.trim();
|
|
||||||
},
|
},
|
||||||
fmtDayMonthYear(date: Date) {
|
fmtDayMonthYear(date: Date) {
|
||||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ const BATTERY_UNIT = "battery_unit";
|
||||||
const SETTINGS_PRICE_ZOOM = "settings_price_zoom";
|
const SETTINGS_PRICE_ZOOM = "settings_price_zoom";
|
||||||
const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin";
|
const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin";
|
||||||
const LAST_BATTERY_SMART_COST_LIMIT = "last_battery_smart_cost_limit";
|
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_TARGET_TIME = "last_target_time";
|
||||||
const LAST_SOC_GOAL = "last_soc_goal";
|
const LAST_SOC_GOAL = "last_soc_goal";
|
||||||
const LAST_ENERGY_GOAL = "last_energy_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 {
|
export interface LoadpointSettings {
|
||||||
order?: number;
|
order?: number;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
|
|
@ -115,7 +112,6 @@ export interface Settings {
|
||||||
theme: THEME | null;
|
theme: THEME | null;
|
||||||
unit: string;
|
unit: string;
|
||||||
is12hFormat: boolean;
|
is12hFormat: boolean;
|
||||||
dateFormat: DateFormat;
|
|
||||||
energyflowDetails: boolean;
|
energyflowDetails: boolean;
|
||||||
energyflowCo2: boolean;
|
energyflowCo2: boolean;
|
||||||
energyflowPv: boolean;
|
energyflowPv: boolean;
|
||||||
|
|
@ -145,7 +141,6 @@ const settings: Settings = reactive({
|
||||||
theme: read(SETTINGS_THEME),
|
theme: read(SETTINGS_THEME),
|
||||||
unit: read(SETTINGS_UNIT),
|
unit: read(SETTINGS_UNIT),
|
||||||
is12hFormat: readBool(SETTINGS_12H_FORMAT),
|
is12hFormat: readBool(SETTINGS_12H_FORMAT),
|
||||||
dateFormat: read(SETTINGS_DATE_FORMAT) || "",
|
|
||||||
energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS),
|
energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS),
|
||||||
energyflowCo2: readBool(SETTINGS_ENERGYFLOW_CO2),
|
energyflowCo2: readBool(SETTINGS_ENERGYFLOW_CO2),
|
||||||
energyflowPv: readBool(SETTINGS_ENERGYFLOW_PV),
|
energyflowPv: readBool(SETTINGS_ENERGYFLOW_PV),
|
||||||
|
|
@ -174,7 +169,6 @@ watch(() => settings.locale, save(SETTINGS_LOCALE));
|
||||||
watch(() => settings.theme, save(SETTINGS_THEME));
|
watch(() => settings.theme, save(SETTINGS_THEME));
|
||||||
watch(() => settings.unit, save(SETTINGS_UNIT));
|
watch(() => settings.unit, save(SETTINGS_UNIT));
|
||||||
watch(() => settings.is12hFormat, saveBool(SETTINGS_12H_FORMAT));
|
watch(() => settings.is12hFormat, saveBool(SETTINGS_12H_FORMAT));
|
||||||
watch(() => settings.dateFormat, save(SETTINGS_DATE_FORMAT));
|
|
||||||
watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS));
|
watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS));
|
||||||
watch(() => settings.energyflowCo2, saveBool(SETTINGS_ENERGYFLOW_CO2));
|
watch(() => settings.energyflowCo2, saveBool(SETTINGS_ENERGYFLOW_CO2));
|
||||||
watch(() => settings.energyflowPv, saveBool(SETTINGS_ENERGYFLOW_PV));
|
watch(() => settings.energyflowPv, saveBool(SETTINGS_ENERGYFLOW_PV));
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export function getThemePreference(): THEME | null {
|
||||||
if (theme && Object.values(THEME).includes(theme)) {
|
if (theme && Object.values(THEME).includes(theme)) {
|
||||||
return theme;
|
return theme;
|
||||||
}
|
}
|
||||||
return window.evcc?.customTheme || THEME.AUTO;
|
return THEME.AUTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setThemePreference(theme: THEME) {
|
export function setThemePreference(theme: THEME) {
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,12 @@ declare global {
|
||||||
app: any;
|
app: any;
|
||||||
evcc?: {
|
evcc?: {
|
||||||
version: string;
|
version: string;
|
||||||
commit: string;
|
|
||||||
customCss: boolean;
|
customCss: boolean;
|
||||||
customLogo: boolean;
|
customLogo: boolean;
|
||||||
customBrand: string;
|
customBrand: string;
|
||||||
customWebsite: string;
|
customWebsite: string;
|
||||||
customEmail: string;
|
customEmail: string;
|
||||||
customPhone: string;
|
customPhone: string;
|
||||||
customTheme: THEME;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
@ -461,10 +459,6 @@ export enum ConfigType {
|
||||||
export type ConfigVehicle = Entity;
|
export type ConfigVehicle = Entity;
|
||||||
export type ConfigMessenger = Entity;
|
export type ConfigMessenger = Entity;
|
||||||
|
|
||||||
export interface ConfigCurtailer extends Entity {
|
|
||||||
deviceTitle?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConfigHems extends Entity {
|
export interface ConfigHems extends Entity {
|
||||||
deviceProduct?: string;
|
deviceProduct?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -535,7 +529,6 @@ export enum SMART_COST_TYPE {
|
||||||
CO2 = "co2",
|
CO2 = "co2",
|
||||||
PRICE_DYNAMIC = "pricedynamic",
|
PRICE_DYNAMIC = "pricedynamic",
|
||||||
PRICE_FORECAST = "priceforecast",
|
PRICE_FORECAST = "priceforecast",
|
||||||
PRICE_STATIC = "pricestatic",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum LENGTH_UNIT {
|
export enum LENGTH_UNIT {
|
||||||
|
|
@ -816,10 +809,6 @@ export enum CURRENCY {
|
||||||
TRY = "TRY",
|
TRY = "TRY",
|
||||||
MYR = "MYR",
|
MYR = "MYR",
|
||||||
THB = "THB",
|
THB = "THB",
|
||||||
BYN = "BYN",
|
|
||||||
UAH = "UAH",
|
|
||||||
RUB = "RUB",
|
|
||||||
KZT = "KZT",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ICON_SIZE {
|
export enum ICON_SIZE {
|
||||||
|
|
@ -1135,10 +1124,8 @@ export interface Battery {
|
||||||
capacity: number;
|
capacity: number;
|
||||||
/** Charge level in %. Weighted by capacity across all batteries. */
|
/** Charge level in %. Weighted by capacity across all batteries. */
|
||||||
soc: number;
|
soc: number;
|
||||||
/** Total discharged energy in kWh. */
|
/** Total battery energy in kWh. */
|
||||||
energy?: number;
|
energy?: number;
|
||||||
/** Total charged energy in kWh. */
|
|
||||||
returnEnergy?: number;
|
|
||||||
/** Measurement data per battery meter. */
|
/** Measurement data per battery meter. */
|
||||||
devices?: BatteryMeter[];
|
devices?: BatteryMeter[];
|
||||||
/** Projected charge levels based on the solar and price forecast. */
|
/** Projected charge levels based on the solar and price forecast. */
|
||||||
|
|
@ -1380,8 +1367,7 @@ export type DeviceType =
|
||||||
| "loadpoint"
|
| "loadpoint"
|
||||||
| "messenger"
|
| "messenger"
|
||||||
| "tariff"
|
| "tariff"
|
||||||
| "hems"
|
| "hems";
|
||||||
| "curtailer";
|
|
||||||
export type MeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext" | "consumer";
|
export type MeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext" | "consumer";
|
||||||
export type MeterTemplateUsage = "grid" | "pv" | "battery" | "charge" | "aux";
|
export type MeterTemplateUsage = "grid" | "pv" | "battery" | "charge" | "aux";
|
||||||
export type TariffType = "grid" | "feedIn" | "co2" | "planner" | "solar" | "temperature";
|
export type TariffType = "grid" | "feedIn" | "co2" | "planner" | "solar" | "temperature";
|
||||||
|
|
@ -1398,7 +1384,6 @@ export interface SiteConfig {
|
||||||
aux: string[] | null;
|
aux: string[] | null;
|
||||||
ext: string[] | null;
|
ext: string[] | null;
|
||||||
consumer: string[] | null;
|
consumer: string[] | null;
|
||||||
curtail: string[] | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ValueOf<T> = T[keyof T];
|
export type ValueOf<T> = T[keyof T];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import settings from "./settings";
|
import settings from "./settings";
|
||||||
import type { DateFormat } from "./settings";
|
|
||||||
import { LENGTH_UNIT } from "./types/evcc";
|
import { LENGTH_UNIT } from "./types/evcc";
|
||||||
|
|
||||||
const MILES_FACTOR = 0.6213711922;
|
const MILES_FACTOR = 0.6213711922;
|
||||||
|
|
@ -35,11 +34,3 @@ export function is12hFormat() {
|
||||||
export function set12hFormat(value: boolean) {
|
export function set12hFormat(value: boolean) {
|
||||||
settings.is12hFormat = value;
|
settings.is12hFormat = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDateFormat(): DateFormat {
|
|
||||||
return settings.dateFormat || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setDateFormat(value: DateFormat) {
|
|
||||||
settings.dateFormat = value;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@ import { describe, expect, test } from "vite-plus/test";
|
||||||
import {
|
import {
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
isNightly,
|
isNightly,
|
||||||
|
commitFromVersion,
|
||||||
getReleaseName,
|
getReleaseName,
|
||||||
getShortVersion,
|
getShortVersion,
|
||||||
isNewVersionAvailable,
|
isNewVersionAvailable,
|
||||||
} from "./version";
|
} from "./version";
|
||||||
|
|
||||||
const DEV = "0.0.0";
|
const DEV = "0.0.0";
|
||||||
const NIGHTLY = "0.304.0-dev.1712345678";
|
const NIGHTLY = "0.304.0-dev+abc1234";
|
||||||
const STABLE = "0.303.1";
|
const STABLE = "0.303.1";
|
||||||
|
|
||||||
describe("isDevelopment", () => {
|
describe("isDevelopment", () => {
|
||||||
|
|
@ -20,13 +21,20 @@ describe("isDevelopment", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isNightly", () => {
|
describe("isNightly", () => {
|
||||||
test("only pre-release versions", () => {
|
test("only versions with build metadata", () => {
|
||||||
expect(isNightly(DEV)).toBe(false);
|
expect(isNightly(DEV)).toBe(false);
|
||||||
expect(isNightly(NIGHTLY)).toBe(true);
|
expect(isNightly(NIGHTLY)).toBe(true);
|
||||||
expect(isNightly(STABLE)).toBe(false);
|
expect(isNightly(STABLE)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("commitFromVersion", () => {
|
||||||
|
test("extracts build metadata", () => {
|
||||||
|
expect(commitFromVersion(NIGHTLY)).toBe("abc1234");
|
||||||
|
expect(commitFromVersion(STABLE)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getReleaseName", () => {
|
describe("getReleaseName", () => {
|
||||||
test("maps version to release name", () => {
|
test("maps version to release name", () => {
|
||||||
expect(getReleaseName(DEV)).toBe("development");
|
expect(getReleaseName(DEV)).toBe("development");
|
||||||
|
|
@ -38,15 +46,14 @@ describe("getReleaseName", () => {
|
||||||
describe("getShortVersion", () => {
|
describe("getShortVersion", () => {
|
||||||
test("formats version", () => {
|
test("formats version", () => {
|
||||||
expect(getShortVersion(DEV)).toBe("dev build");
|
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");
|
expect(getShortVersion(STABLE)).toBe("v0.303.1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isNewVersionAvailable", () => {
|
describe("isNewVersionAvailable", () => {
|
||||||
test("never for dev or nightly builds", () => {
|
test("never for dev builds", () => {
|
||||||
expect(isNewVersionAvailable(DEV, "0.303.1")).toBe(false);
|
expect(isNewVersionAvailable(DEV, "0.303.1")).toBe(false);
|
||||||
expect(isNewVersionAvailable(NIGHTLY, "0.304.0")).toBe(false);
|
|
||||||
});
|
});
|
||||||
test("only when available differs", () => {
|
test("only when available differs", () => {
|
||||||
expect(isNewVersionAvailable(STABLE, "0.303.1")).toBe(false);
|
expect(isNewVersionAvailable(STABLE, "0.303.1")).toBe(false);
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,13 @@ export function isDevelopment(version: string): boolean {
|
||||||
return version === "0.0.0";
|
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 {
|
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 {
|
export function getReleaseName(version: string): string {
|
||||||
|
|
@ -19,8 +23,7 @@ export function getShortVersion(version: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isNewVersionAvailable(installed?: string, available?: string): boolean {
|
export function isNewVersionAvailable(installed?: string, available?: string): boolean {
|
||||||
const v = installed || "";
|
return !!available && !isDevelopment(installed || "") && available !== installed;
|
||||||
return !!available && !isDevelopment(v) && !isNightly(v) && available !== installed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isNewVersionUnacknowledged(
|
export function isNewVersionUnacknowledged(
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,6 @@ export default defineComponent({
|
||||||
aboutModalProps() {
|
aboutModalProps() {
|
||||||
return {
|
return {
|
||||||
installed: window.evcc?.version,
|
installed: window.evcc?.version,
|
||||||
commit: window.evcc?.commit,
|
|
||||||
customLogo: this.custom.logo,
|
customLogo: this.custom.logo,
|
||||||
customBrand: this.custom.brand,
|
customBrand: this.custom.brand,
|
||||||
customWebsite: this.custom.website,
|
customWebsite: this.custom.website,
|
||||||
|
|
|
||||||
|
|
@ -150,31 +150,6 @@
|
||||||
data-testid="add-grid"
|
data-testid="add-grid"
|
||||||
@click="openModal('meter', { type: '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>
|
</div>
|
||||||
</ConfigSection>
|
</ConfigSection>
|
||||||
|
|
||||||
|
|
@ -187,7 +162,7 @@
|
||||||
meter-type="pv"
|
meter-type="pv"
|
||||||
:has-error="hasDeviceError('meter', meter.name)"
|
:has-error="hasDeviceError('meter', meter.name)"
|
||||||
:tags="deviceTags('meter', meter.name)"
|
:tags="deviceTags('meter', meter.name)"
|
||||||
:banner="curtailmentBanner('meter', meter.name)"
|
:banner="meterBanner(meter.name)"
|
||||||
@edit="(type, id) => openModal('meter', { type, id })"
|
@edit="(type, id) => openModal('meter', { type, id })"
|
||||||
@enable="handleDisable('meter', meter.id, false)"
|
@enable="handleDisable('meter', meter.id, false)"
|
||||||
/>
|
/>
|
||||||
|
|
@ -419,7 +394,8 @@
|
||||||
</template>
|
</template>
|
||||||
</DeviceCard>
|
</DeviceCard>
|
||||||
<DeviceCard
|
<DeviceCard
|
||||||
:title="$t('config.remote.title')"
|
v-if="experimental"
|
||||||
|
:title="`${$t('config.remote.title')} 🧪`"
|
||||||
editable
|
editable
|
||||||
:unconfigured="isUnconfigured(remoteTags)"
|
:unconfigured="isUnconfigured(remoteTags)"
|
||||||
data-testid="remote-access"
|
data-testid="remote-access"
|
||||||
|
|
@ -549,7 +525,6 @@
|
||||||
<MessagingLegacyModal @changed="loadDirty" />
|
<MessagingLegacyModal @changed="loadDirty" />
|
||||||
<MessagingModal :messengers="messengers" @changed="loadDirty" />
|
<MessagingModal :messengers="messengers" @changed="loadDirty" />
|
||||||
<MessengerModal @changed="messengerChanged" />
|
<MessengerModal @changed="messengerChanged" />
|
||||||
<CurtailerModal @changed="curtailerChanged" />
|
|
||||||
<TariffsLegacyModal @changed="loadDirty" />
|
<TariffsLegacyModal @changed="loadDirty" />
|
||||||
<TariffModal
|
<TariffModal
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
|
|
@ -604,8 +579,6 @@ import CircuitsModal from "../components/Config/CircuitsModal.vue";
|
||||||
import CircuitTags from "../components/Config/CircuitTags.vue";
|
import CircuitTags from "../components/Config/CircuitTags.vue";
|
||||||
import collector from "../mixins/collector";
|
import collector from "../mixins/collector";
|
||||||
import ControlModal from "../components/Config/ControlModal.vue";
|
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 DeviceCard from "../components/Config/DeviceCard.vue";
|
||||||
import DeviceTags from "../components/Config/DeviceTags.vue";
|
import DeviceTags from "../components/Config/DeviceTags.vue";
|
||||||
import EebusIcon from "../components/MaterialIcon/Eebus.vue";
|
import EebusIcon from "../components/MaterialIcon/Eebus.vue";
|
||||||
|
|
@ -660,7 +633,6 @@ import type {
|
||||||
ConfigCharger,
|
ConfigCharger,
|
||||||
ConfigVehicle,
|
ConfigVehicle,
|
||||||
ConfigCircuit,
|
ConfigCircuit,
|
||||||
ConfigCurtailer,
|
|
||||||
ConfigMessenger,
|
ConfigMessenger,
|
||||||
ConfigHems,
|
ConfigHems,
|
||||||
ConfigLoadpoint,
|
ConfigLoadpoint,
|
||||||
|
|
@ -719,8 +691,6 @@ export default defineComponent({
|
||||||
CircuitsModal,
|
CircuitsModal,
|
||||||
CircuitTags,
|
CircuitTags,
|
||||||
ControlModal,
|
ControlModal,
|
||||||
CurtailerModal,
|
|
||||||
CurtailIcon,
|
|
||||||
DeviceCard,
|
DeviceCard,
|
||||||
DeviceTags,
|
DeviceTags,
|
||||||
EebusIcon,
|
EebusIcon,
|
||||||
|
|
@ -779,7 +749,6 @@ export default defineComponent({
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
messengers: [] as ConfigMessenger[],
|
messengers: [] as ConfigMessenger[],
|
||||||
curtailers: [] as ConfigCurtailer[],
|
|
||||||
vehicles: [] as ConfigVehicle[],
|
vehicles: [] as ConfigVehicle[],
|
||||||
meters: [] as ConfigMeter[],
|
meters: [] as ConfigMeter[],
|
||||||
loadpoints: [] as ConfigLoadpoint[],
|
loadpoints: [] as ConfigLoadpoint[],
|
||||||
|
|
@ -803,7 +772,6 @@ export default defineComponent({
|
||||||
aux: null as string[] | null,
|
aux: null as string[] | null,
|
||||||
ext: null as string[] | null,
|
ext: null as string[] | null,
|
||||||
consumer: null as string[] | null,
|
consumer: null as string[] | null,
|
||||||
curtail: null as string[] | null,
|
|
||||||
} as SiteConfig,
|
} as SiteConfig,
|
||||||
deviceValueTimeout: null as Timeout,
|
deviceValueTimeout: null as Timeout,
|
||||||
deviceValues: {
|
deviceValues: {
|
||||||
|
|
@ -813,7 +781,6 @@ export default defineComponent({
|
||||||
loadpoint: {},
|
loadpoint: {},
|
||||||
messenger: {},
|
messenger: {},
|
||||||
tariff: {},
|
tariff: {},
|
||||||
curtailer: {},
|
|
||||||
} as DeviceValuesMap,
|
} as DeviceValuesMap,
|
||||||
isComponentMounted: true,
|
isComponentMounted: true,
|
||||||
isPageVisible: true,
|
isPageVisible: true,
|
||||||
|
|
@ -901,10 +868,8 @@ export default defineComponent({
|
||||||
{
|
{
|
||||||
slug: "grid",
|
slug: "grid",
|
||||||
icon: "shopicon-regular-powersupply",
|
icon: "shopicon-regular-powersupply",
|
||||||
count: (this.gridMeter ? 1 : 0) + this.curtailerDevices.length,
|
count: this.gridMeter ? 1 : 0,
|
||||||
error:
|
error: !!this.gridMeter && this.hasDeviceError("meter", this.gridMeter.name),
|
||||||
(!!this.gridMeter && this.hasDeviceError("meter", this.gridMeter.name)) ||
|
|
||||||
this.curtailerDevices.some((c) => this.hasDeviceError("curtailer", c.name)),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "pv-battery",
|
slug: "pv-battery",
|
||||||
|
|
@ -912,7 +877,7 @@ export default defineComponent({
|
||||||
count: pvAndBattery.length,
|
count: pvAndBattery.length,
|
||||||
error: meterError(pvAndBattery),
|
error: meterError(pvAndBattery),
|
||||||
warning:
|
warning:
|
||||||
this.pvMeters.some((m) => this.curtailmentBanner("meter", m.name)) ||
|
this.pvMeters.some((m) => this.meterBanner(m.name)) ||
|
||||||
meterDisabled(pvAndBattery),
|
meterDisabled(pvAndBattery),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -991,12 +956,6 @@ export default defineComponent({
|
||||||
const names = this.site?.ext;
|
const names = this.site?.ext;
|
||||||
return this.getMetersByNames(names);
|
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() {
|
consumerMeters() {
|
||||||
return this.getMetersByNames(this.site?.consumer);
|
return this.getMetersByNames(this.site?.consumer);
|
||||||
},
|
},
|
||||||
|
|
@ -1091,9 +1050,7 @@ export default defineComponent({
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
vehicleOptions(): VehicleOption[] {
|
vehicleOptions(): VehicleOption[] {
|
||||||
return this.vehicles
|
return this.vehicles.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
|
||||||
.filter((v) => !v.deviceDisable)
|
|
||||||
.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
|
|
||||||
},
|
},
|
||||||
hems() {
|
hems() {
|
||||||
return store.state?.hems;
|
return store.state?.hems;
|
||||||
|
|
@ -1334,7 +1291,6 @@ export default defineComponent({
|
||||||
await this.loadLoadpoints();
|
await this.loadLoadpoints();
|
||||||
await this.loadCircuits();
|
await this.loadCircuits();
|
||||||
await this.loadMessengers();
|
await this.loadMessengers();
|
||||||
await this.loadCurtailers();
|
|
||||||
await this.loadTariffs();
|
await this.loadTariffs();
|
||||||
await this.loadTariffRefs();
|
await this.loadTariffRefs();
|
||||||
await this.loadHems();
|
await this.loadHems();
|
||||||
|
|
@ -1355,9 +1311,6 @@ export default defineComponent({
|
||||||
async loadMessengers() {
|
async loadMessengers() {
|
||||||
this.messengers = (await this.loadConfig("devices/messenger")) || [];
|
this.messengers = (await this.loadConfig("devices/messenger")) || [];
|
||||||
},
|
},
|
||||||
async loadCurtailers() {
|
|
||||||
this.curtailers = (await this.loadConfig("devices/curtailer")) || [];
|
|
||||||
},
|
|
||||||
async loadVehicles() {
|
async loadVehicles() {
|
||||||
this.vehicles = (await this.loadConfig("devices/vehicle")) || [];
|
this.vehicles = (await this.loadConfig("devices/vehicle")) || [];
|
||||||
},
|
},
|
||||||
|
|
@ -1511,25 +1464,6 @@ export default defineComponent({
|
||||||
this.loadMessengers();
|
this.loadMessengers();
|
||||||
this.loadDirty();
|
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() {
|
siteChanged() {
|
||||||
this.loadDirty();
|
this.loadDirty();
|
||||||
},
|
},
|
||||||
|
|
@ -1570,7 +1504,6 @@ export default defineComponent({
|
||||||
vehicle: this.vehicles,
|
vehicle: this.vehicles,
|
||||||
charger: this.chargers,
|
charger: this.chargers,
|
||||||
tariff: this.tariffs,
|
tariff: this.tariffs,
|
||||||
curtailer: this.curtailers,
|
|
||||||
} as Record<DeviceType, any[]>;
|
} as Record<DeviceType, any[]>;
|
||||||
for (const type in devices) {
|
for (const type in devices) {
|
||||||
for (const device of devices[type as DeviceType]) {
|
for (const device of devices[type as DeviceType]) {
|
||||||
|
|
@ -1590,10 +1523,9 @@ export default defineComponent({
|
||||||
deviceTags(type: DeviceType, id: string) {
|
deviceTags(type: DeviceType, id: string) {
|
||||||
return this.deviceValues[type][id] || {};
|
return this.deviceValues[type][id] || {};
|
||||||
},
|
},
|
||||||
curtailmentBanner(type: DeviceType, name: string): string | undefined {
|
meterBanner(name: string): string | undefined {
|
||||||
// devices report the allowed feed-in percent, 100 = uncurtailed
|
// the tag is only present while curtailing, a zero percent limit is still one
|
||||||
const value = this.deviceTags(type, name)["curtailed"]?.value;
|
return this.deviceTags("meter", name)["curtailed"]?.value !== undefined
|
||||||
return typeof value === "number" && value < 100
|
|
||||||
? this.$t("config.deviceValue.productionLimited")
|
? this.$t("config.deviceValue.productionLimited")
|
||||||
: undefined;
|
: undefined;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -262,7 +262,7 @@
|
||||||
<input
|
<input
|
||||||
v-model.number="logCount"
|
v-model.number="logCount"
|
||||||
type="number"
|
type="number"
|
||||||
class="form-control text-end"
|
class="form-control text-end log-count-input"
|
||||||
min="0"
|
min="0"
|
||||||
step="25"
|
step="25"
|
||||||
/>
|
/>
|
||||||
|
|
@ -687,6 +687,11 @@ export default defineComponent({
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@import "../../css/breakpoints.css";
|
@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) {
|
@media (--md-and-up) {
|
||||||
.log-lines-input {
|
.log-lines-input {
|
||||||
width: 170px;
|
width: 170px;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
|
|
@ -368,24 +367,21 @@ func (wb *AlpitronicHYC) TotalEnergy() (float64, error) {
|
||||||
var _ api.Identifier = (*AlpitronicHYC)(nil)
|
var _ api.Identifier = (*AlpitronicHYC)(nil)
|
||||||
|
|
||||||
// Identify implements the api.Identifier interface
|
// Identify implements the api.Identifier interface
|
||||||
func (wb *AlpitronicHYC) Identify() ([]string, error) {
|
func (wb *AlpitronicHYC) Identify() (string, error) {
|
||||||
b, err := wb.inputG()
|
b, err := wb.inputG()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", 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")))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if vid := hycInput(b, hycRegVID, 8); !allZero(vid) {
|
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)
|
var _ api.Battery = (*AlpitronicHYC)(nil)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package charger
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -10,6 +11,7 @@ import (
|
||||||
"github.com/andig/mbserver"
|
"github.com/andig/mbserver"
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
"github.com/evcc-io/evcc/util/modbus"
|
"github.com/evcc-io/evcc/util/modbus"
|
||||||
|
"github.com/evcc-io/evcc/util/sponsor"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
@ -216,7 +218,7 @@ func TestAlpitronicIdentify(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, id)
|
assert.Empty(t, id)
|
||||||
|
|
||||||
// tag before vehicle id
|
// vehicle id takes precedence over the tag
|
||||||
regs := hycRegs(1, hycStateCharging)
|
regs := hycRegs(1, hycStateCharging)
|
||||||
regs[hycReg(1, hycRegVID)+1] = 0xAABB
|
regs[hycReg(1, hycRegVID)+1] = 0xAABB
|
||||||
regs[hycReg(1, hycRegVID)+2] = 0xCCDD
|
regs[hycReg(1, hycRegVID)+2] = 0xCCDD
|
||||||
|
|
@ -227,9 +229,9 @@ func TestAlpitronicIdentify(t *testing.T) {
|
||||||
|
|
||||||
id, err = wb.Identify()
|
id, err = wb.Identify()
|
||||||
require.NoError(t, err)
|
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 = hycRegs(1, hycStateCharging)
|
||||||
regs[hycReg(1, hycRegIdTag)] = 0x4142
|
regs[hycReg(1, hycRegIdTag)] = 0x4142
|
||||||
|
|
||||||
|
|
@ -237,17 +239,7 @@ func TestAlpitronicIdentify(t *testing.T) {
|
||||||
|
|
||||||
id, err = wb.Identify()
|
id, err = wb.Identify()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []string{"ab"}, id)
|
assert.Equal(t, "4142"+strings.Repeat("0", 36), 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlpitronicEnable(t *testing.T) {
|
func TestAlpitronicEnable(t *testing.T) {
|
||||||
|
|
@ -361,3 +353,14 @@ func TestAlpitronicReadFailure(t *testing.T) {
|
||||||
_, err = wb.CurrentPower()
|
_, err = wb.CurrentPower()
|
||||||
assert.Error(t, err)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -299,13 +299,13 @@ func (wb *Amperfied) Voltages() (float64, float64, float64, error) {
|
||||||
var _ api.Identifier = (*Amperfied)(nil)
|
var _ api.Identifier = (*Amperfied)(nil)
|
||||||
|
|
||||||
// Identify implements the api.Identifier interface
|
// 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)
|
b, err := wb.conn.ReadInputRegisters(ampRegRfidUID, 6)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return []string{hex.EncodeToString(b)}, nil
|
return hex.EncodeToString(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ api.Diagnosis = (*Amperfied)(nil)
|
var _ api.Diagnosis = (*Amperfied)(nil)
|
||||||
|
|
|
||||||
|
|
@ -504,33 +504,24 @@ func (wb *BenderCC) getPhases() (int, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// identify implements the api.Identifier interface
|
// identify implements the api.Identifier interface
|
||||||
func (wb *BenderCC) identify() ([]string, error) {
|
func (wb *BenderCC) identify() (string, error) {
|
||||||
var ids []string
|
|
||||||
|
|
||||||
if !wb.legacy && !wb.mennekes4 {
|
if !wb.legacy && !wb.mennekes4 {
|
||||||
b, err := wb.conn.ReadHoldingRegisters(bendRegSmartVehicleDetected, 1)
|
b, err := wb.conn.ReadHoldingRegisters(bendRegSmartVehicleDetected, 1)
|
||||||
if err == nil && binary.BigEndian.Uint16(b) != 0 {
|
if err == nil && binary.BigEndian.Uint16(b) != 0 {
|
||||||
b, err = wb.conn.ReadHoldingRegisters(bendRegEVCCID, 6)
|
b, err = wb.conn.ReadHoldingRegisters(bendRegEVCCID, 6)
|
||||||
}
|
}
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if id := bytesAsString(b); id != "" {
|
if id := bytesAsString(b); id != "" || err != nil {
|
||||||
ids = append(ids, id)
|
return id, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err := wb.conn.ReadHoldingRegisters(bendRegUserID, 10)
|
b, err := wb.conn.ReadHoldingRegisters(bendRegUserID, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if id := bytesAsString(b); id != "" {
|
return bytesAsString(b), nil
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ids, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// soc implements the api.Battery interface
|
// soc implements the api.Battery interface
|
||||||
|
|
|
||||||
|
|
@ -242,11 +242,11 @@ func (wb *CfosPowerBrain) WakeUp() error {
|
||||||
var _ api.Identifier = (*CfosPowerBrain)(nil)
|
var _ api.Identifier = (*CfosPowerBrain)(nil)
|
||||||
|
|
||||||
// Identify implements the api.Identifier interface
|
// 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)
|
b, err := wb.conn.ReadHoldingRegisters(cfosRegLastRfid, 15)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return []string{bytesAsString(b)}, nil
|
return bytesAsString(b), nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,12 +118,7 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.C
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("identify: %w", err)
|
return nil, fmt.Errorf("identify: %w", err)
|
||||||
}
|
}
|
||||||
if identify != nil {
|
implement.May(c, implement.Identifier(identify))
|
||||||
implement.Has(c, implement.Identifier(func() ([]string, error) {
|
|
||||||
id, err := identify()
|
|
||||||
return []string{id}, err
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// decorate wakeup
|
// decorate wakeup
|
||||||
if cc.Wakeup != nil {
|
if cc.Wakeup != nil {
|
||||||
|
|
|
||||||
|
|
@ -289,13 +289,13 @@ func (wb *Compleo) ChargeDuration() (time.Duration, error) {
|
||||||
var _ api.Identifier = (*Compleo)(nil)
|
var _ api.Identifier = (*Compleo)(nil)
|
||||||
|
|
||||||
// Identify implements the api.Identifier interface
|
// 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)
|
b, err := wb.conn.ReadInputRegisters(compleoRegIdTag+wb.offset, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return []string{bytesAsString(b)}, nil
|
return bytesAsString(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ loadpoint.Controller = (*Compleo)(nil)
|
var _ loadpoint.Controller = (*Compleo)(nil)
|
||||||
|
|
|
||||||
|
|
@ -291,13 +291,13 @@ func (wb *Dadapower) Phases1p3p(phases int) error {
|
||||||
var _ api.Identifier = (*Dadapower)(nil)
|
var _ api.Identifier = (*Dadapower)(nil)
|
||||||
|
|
||||||
// Identify implements the api.Identifier interface
|
// 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)
|
u, err := wb.conn.ReadInputRegisters(dadapowerRegIdentification+wb.regOffset, 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return []string{bytesAsString(u)}, nil
|
return bytesAsString(u), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ api.Diagnosis = (*Dadapower)(nil)
|
var _ api.Diagnosis = (*Dadapower)(nil)
|
||||||
|
|
|
||||||
|
|
@ -318,14 +318,12 @@ func (wb *DaheimLaden) Voltages() (float64, float64, float64, error) {
|
||||||
var _ api.Identifier = (*DaheimLaden)(nil)
|
var _ api.Identifier = (*DaheimLaden)(nil)
|
||||||
|
|
||||||
// Identify implements the api.Identifier interface. Only usable with PRO
|
// 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)
|
b, err := wb.conn.ReadHoldingRegisters(dlRegCardId, 16)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
|
return utf16BEBytesAsString(b)
|
||||||
id, err := utf16BEBytesAsString(b)
|
|
||||||
return []string{id}, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// phases1p3p implements the api.PhaseSwitcher interface
|
// phases1p3p implements the api.PhaseSwitcher interface
|
||||||
|
|
@ -363,37 +361,27 @@ func (wb *DaheimLaden) getPhases() (int, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (wb *DaheimLaden) checkStation() error {
|
func (wb *DaheimLaden) checkStation() error {
|
||||||
// map may still be zero right after the wake-up call- poll briefly until populated
|
b, err := wb.conn.ReadHoldingRegisters(dlRegEvseMaxCurrent, 22)
|
||||||
for range 5 {
|
if err != nil {
|
||||||
b, err := wb.conn.ReadHoldingRegisters(dlRegEvseMaxCurrent, 22)
|
return api.ErrSponsorRequired
|
||||||
if err != nil {
|
}
|
||||||
return err
|
// station id starts (dlRegStationId-dlRegEvseMaxCurrent) registers into the block
|
||||||
}
|
s, err := utf16BEBytesAsString(b[2*(dlRegStationId-dlRegEvseMaxCurrent):])
|
||||||
|
if err != nil || s == "" {
|
||||||
s, err := utf16BEBytesAsString(b[2*(dlRegStationId-dlRegEvseMaxCurrent):])
|
return api.ErrSponsorRequired
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
var _ api.Diagnosis = (*DaheimLaden)(nil)
|
||||||
|
|
|
||||||
|
|
@ -354,13 +354,13 @@ func (wb *Delta) CurrentPower() (float64, error) {
|
||||||
var _ api.Identifier = (*Delta)(nil)
|
var _ api.Identifier = (*Delta)(nil)
|
||||||
|
|
||||||
// Identify implements the api.Identifier interface
|
// 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)
|
b, err := wb.conn.ReadInputRegisters(wb.base+deltaRegEvseRfidUID, 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return []string{bytesAsString(b)}, nil
|
return bytesAsString(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ api.Diagnosis = (*Delta)(nil)
|
var _ api.Diagnosis = (*Delta)(nil)
|
||||||
|
|
|
||||||
|
|
@ -748,17 +748,16 @@ var _ api.Identifier = (*E3dc)(nil)
|
||||||
|
|
||||||
// Identify implements the api.Identifier interface
|
// Identify implements the api.Identifier interface
|
||||||
// Returns the RFID tag ID from WB_SESSION_AUTH_DATA if a session is active
|
// 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()
|
wb.mu.Lock()
|
||||||
defer wb.mu.Unlock()
|
defer wb.mu.Unlock()
|
||||||
|
|
||||||
msg, found, err := wb.sessionMessage(rscp.WB_SESSION_AUTH_DATA)
|
msg, found, err := wb.sessionMessage(rscp.WB_SESSION_AUTH_DATA)
|
||||||
if err != nil || !found {
|
if err != nil || !found {
|
||||||
return nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := rscpString(msg)
|
return rscpString(msg)
|
||||||
return []string{id}, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ api.PhaseSwitcher = (*E3dc)(nil)
|
var _ api.PhaseSwitcher = (*E3dc)(nil)
|
||||||
|
|
|
||||||
|
|
@ -850,11 +850,11 @@ func (c *Easee) StatusReason() (api.Reason, error) {
|
||||||
var _ api.Identifier = (*Easee)(nil)
|
var _ api.Identifier = (*Easee)(nil)
|
||||||
|
|
||||||
// Currents implements the api.PhaseCurrents interface
|
// Currents implements the api.PhaseCurrents interface
|
||||||
func (c *Easee) Identify() ([]string, error) {
|
func (c *Easee) Identify() (string, error) {
|
||||||
c.mux.Lock()
|
c.mux.Lock()
|
||||||
defer c.mux.Unlock()
|
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)
|
// Set smart charging status to update the chargers led (smart=blue, fast=white)
|
||||||
|
|
|
||||||
|
|
@ -520,26 +520,19 @@ func (c *EEBus) currents() (float64, float64, float64, error) {
|
||||||
var _ api.Identifier = (*EEBus)(nil)
|
var _ api.Identifier = (*EEBus)(nil)
|
||||||
|
|
||||||
// Identify implements the api.Identifier interface
|
// Identify implements the api.Identifier interface
|
||||||
func (c *EEBus) Identify() ([]string, error) {
|
func (c *EEBus) Identify() (string, error) {
|
||||||
evEntity, ok := c.isEvConnected()
|
evEntity, ok := c.isEvConnected()
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
identification, err := c.cem.EvCC.Identifications(evEntity)
|
if identification, err := c.cem.EvCC.Identifications(evEntity); err == nil && len(identification) > 0 {
|
||||||
if err != nil {
|
// return the first identification for now
|
||||||
return nil, nil
|
// 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
|
return "", nil
|
||||||
var res []string
|
|
||||||
for _, i := range identification {
|
|
||||||
if i.Value != "" {
|
|
||||||
res = append(res, i.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ api.Battery = (*EEBus)(nil)
|
var _ api.Battery = (*EEBus)(nil)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import (
|
||||||
spineapi "github.com/enbility/spine-go/api"
|
spineapi "github.com/enbility/spine-go/api"
|
||||||
"github.com/enbility/spine-go/model"
|
"github.com/enbility/spine-go/model"
|
||||||
"github.com/evcc-io/evcc/api"
|
"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/server/eebus"
|
||||||
"github.com/evcc-io/evcc/util"
|
"github.com/evcc-io/evcc/util"
|
||||||
)
|
)
|
||||||
|
|
@ -28,7 +27,6 @@ import (
|
||||||
// enabling the charger schedules or resumes the optional consumption, disabling
|
// enabling the charger schedules or resumes the optional consumption, disabling
|
||||||
// it pauses or aborts the running process.
|
// it pauses or aborts the running process.
|
||||||
type EEBusOHPCF struct {
|
type EEBusOHPCF struct {
|
||||||
implement.Caps
|
|
||||||
*embed
|
*embed
|
||||||
cem *eebus.CustomerEnergyManagement
|
cem *eebus.CustomerEnergyManagement
|
||||||
ma *eebus.MonitoringAppliance
|
ma *eebus.MonitoringAppliance
|
||||||
|
|
@ -60,11 +58,10 @@ func init() {
|
||||||
// NewEEBusOHPCFFromConfig creates an EEBus OHPCF charger from generic config
|
// NewEEBusOHPCFFromConfig creates an EEBus OHPCF charger from generic config
|
||||||
func NewEEBusOHPCFFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
|
func NewEEBusOHPCFFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
|
||||||
cc := struct {
|
cc := struct {
|
||||||
embed `mapstructure:",squash"`
|
embed `mapstructure:",squash"`
|
||||||
Ski string
|
Ski string
|
||||||
Ip string
|
Ip string
|
||||||
Reboost time.Duration
|
Reboost time.Duration
|
||||||
DisablePowerLimiter bool
|
|
||||||
}{
|
}{
|
||||||
embed: embed{
|
embed: embed{
|
||||||
Icon_: "heatpump",
|
Icon_: "heatpump",
|
||||||
|
|
@ -77,19 +74,18 @@ func NewEEBusOHPCFFromConfig(ctx context.Context, other map[string]any) (api.Cha
|
||||||
return nil, err
|
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
|
// NewEEBusOHPCF creates an EEBus OHPCF charger, registers it with the EEBus
|
||||||
// instance and waits for the connection.
|
// 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()
|
inst, err := eebus.Instance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
c := &EEBusOHPCF{
|
c := &EEBusOHPCF{
|
||||||
Caps: implement.New(),
|
|
||||||
embed: embed,
|
embed: embed,
|
||||||
log: util.NewLogger("eebus-ohpcf"),
|
log: util.NewLogger("eebus-ohpcf"),
|
||||||
cem: inst.CustomerEnergyManagement(),
|
cem: inst.CustomerEnergyManagement(),
|
||||||
|
|
@ -100,10 +96,6 @@ func NewEEBusOHPCF(ctx context.Context, embed *embed, ski, ip string, reboost ti
|
||||||
reboost: reboost,
|
reboost: reboost,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !disablePowerLimiter {
|
|
||||||
implement.Has(c, implement.PowerLimiter(c.getMinMaxPower))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := inst.RegisterDevice(ski, ip, c); err != nil {
|
if err := inst.RegisterDevice(ski, ip, c); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -468,8 +460,11 @@ func (c *EEBusOHPCF) apply(enable bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getMinMaxPower is the callback used to provide the optional power limits.
|
var _ api.PowerLimiter = (*EEBusOHPCF)(nil)
|
||||||
func (c *EEBusOHPCF) getMinMaxPower() (float64, float64, error) {
|
|
||||||
|
// 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()
|
entity, ok := c.connectedCompressor()
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, 0, errNotConnected
|
return 0, 0, errNotConnected
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ type Enovates struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
enovatesRegMaxAmp = 51 // uint16 RO, max amp per phase
|
||||||
enovatesRegCurrents = 200 // 3x uint16 RO, mA
|
enovatesRegCurrents = 200 // 3x uint16 RO, mA
|
||||||
enovatesRegVoltages = 203 // 3x int16 RO, V
|
enovatesRegVoltages = 203 // 3x int16 RO, V
|
||||||
enovatesRegPower = 206 // int16 RO, W (active power total)
|
enovatesRegPower = 206 // int16 RO, W (active power total)
|
||||||
|
|
@ -48,7 +49,7 @@ const (
|
||||||
enovatesRegMode3StateStr = 301 // 2x register RO, IEC 61851 state, e.g. "C2"
|
enovatesRegMode3StateStr = 301 // 2x register RO, IEC 61851 state, e.g. "C2"
|
||||||
enovatesRegEMSLimit = 400 // int16 RW, mA (-1 = no limit, 0 = disabled)
|
enovatesRegEMSLimit = 400 // int16 RW, mA (-1 = no limit, 0 = disabled)
|
||||||
enovatesRegToken = 401 // 16x register RO, ASCII
|
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
|
enovatesRegSerial = 5032 // 16x register RO, ASCII
|
||||||
enovatesRegModel = 5048 // 16x register RO, ASCII
|
enovatesRegModel = 5048 // 16x register RO, ASCII
|
||||||
enovatesRegFirmware = 5064 // 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,
|
current: 6000,
|
||||||
}
|
}
|
||||||
|
|
||||||
// the transaction token is only readable when EMS over Modbus TCP is enabled
|
// verify connection and EMS register availability
|
||||||
if _, err := wb.conn.ReadHoldingRegisters(enovatesRegToken, 16); err != nil {
|
if _, err := wb.conn.ReadHoldingRegisters(enovatesRegMode3StateStr, 2); err != nil {
|
||||||
return nil, fmt.Errorf("ems over modbus tcp not enabled: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return wb, nil
|
return wb, nil
|
||||||
|
|
@ -159,13 +160,22 @@ var _ api.CurrentGetter = (*Enovates)(nil)
|
||||||
|
|
||||||
// GetMaxCurrent implements the api.CurrentGetter interface
|
// GetMaxCurrent implements the api.CurrentGetter interface
|
||||||
func (wb *Enovates) GetMaxCurrent() (float64, error) {
|
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(enovatesRegEMSLimit, 1)
|
||||||
b, err := wb.conn.ReadHoldingRegisters(enovatesRegCurrentOffered, 1)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
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)
|
var _ api.Meter = (*Enovates)(nil)
|
||||||
|
|
@ -223,13 +233,13 @@ func (wb *Enovates) Voltages() (float64, float64, float64, error) {
|
||||||
var _ api.Identifier = (*Enovates)(nil)
|
var _ api.Identifier = (*Enovates)(nil)
|
||||||
|
|
||||||
// Identify implements the api.Identifier interface
|
// 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)
|
b, err := wb.conn.ReadHoldingRegisters(enovatesRegToken, 16)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return []string{trimModbusString(b)}, nil
|
return trimModbusString(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ api.Diagnosis = (*Enovates)(nil)
|
var _ api.Diagnosis = (*Enovates)(nil)
|
||||||
|
|
|
||||||
|
|
@ -378,11 +378,11 @@ func (wb *EVECUBE) phases1p3p(phases int) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// identify implements the api.Identifier interface
|
// identify implements the api.Identifier interface
|
||||||
func (wb *EVECUBE) identify() ([]string, error) {
|
func (wb *EVECUBE) identify() (string, error) {
|
||||||
status, err := wb.getAutomationStatus()
|
status, err := wb.getAutomationStatus()
|
||||||
if err != nil {
|
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
Loading…
Add table
Add a link
Reference in a new issue