Compare commits
134 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19348d3a37 | |||
| 13215c74c2 | |||
|
|
d379b7802e | ||
|
|
dbe100b24b | ||
|
|
490f209e57 | ||
|
|
f193eb199a | ||
|
|
8abae0fd68 | ||
|
|
7dd4007cfa | ||
|
|
e8b5477373 | ||
|
|
19048976ab | ||
|
|
ea8e2a72c0 | ||
|
|
8ccd72e141 | ||
|
|
fb120252a0 | ||
|
|
f40d67ea72 | ||
|
|
8905c2777b | ||
|
|
27bbdce298 | ||
|
|
7549beaf44 | ||
|
|
0d0815963c | ||
|
|
f693454502 | ||
|
|
8d425d3920 | ||
|
|
10b0c99f17 | ||
|
|
01ffc7d12f | ||
|
|
a80ad57c9e | ||
|
|
fd095120d8 | ||
|
|
c314e8e838 | ||
|
|
f8c1dec7b6 | ||
|
|
36614fea46 | ||
|
|
cba812d884 | ||
|
|
063088c070 | ||
|
|
887ec9ecd2 | ||
|
|
88a4004d4d | ||
|
|
1defa5f7fc | ||
|
|
9beca9b22f | ||
|
|
022171ffde | ||
|
|
e4e5c2db26 | ||
|
|
b3db9e93fe | ||
|
|
2b87fe2b1d | ||
|
|
a66fa2fa04 | ||
|
|
442113e9ea | ||
|
|
10c646e668 | ||
|
|
4adbf141d6 | ||
|
|
226425e22e | ||
|
|
2d207d5109 | ||
|
|
5960e9fcfc | ||
|
|
1416134c1d | ||
|
|
b15f81a85a | ||
|
|
7f33bb9317 | ||
|
|
f216a440e1 | ||
|
|
56eccb08ea | ||
|
|
0812904b48 | ||
|
|
6b94ac6b01 | ||
|
|
531a0bc653 | ||
|
|
c1067111e4 | ||
|
|
cd08f3d36c | ||
|
|
f7b3f394f3 | ||
|
|
3159126248 | ||
|
|
c1960b378a | ||
|
|
48c125e93b | ||
|
|
62cca5e885 | ||
|
|
defe9403de | ||
|
|
f43499ea97 | ||
|
|
af4d14f65c | ||
|
|
83d322b5e9 | ||
|
|
bf57d398c2 | ||
|
|
fbbe12a0ec | ||
|
|
ebe064ad97 | ||
|
|
469ccb83ab | ||
|
|
8edf92a103 | ||
|
|
8661c66fc7 | ||
|
|
7c0ddf1828 | ||
|
|
7df861f66e | ||
|
|
c28f2ff623 | ||
|
|
37d8aecbb2 | ||
|
|
28bb5ba859 | ||
|
|
4e45dc79bb | ||
|
|
9600fc5b9f | ||
|
|
a8c4dfd372 | ||
|
|
eeb2f593bf | ||
|
|
176509d094 | ||
|
|
13db309c94 | ||
|
|
769a6a1aa5 | ||
|
|
20058f6e0d | ||
|
|
a578893edd | ||
|
|
e7b694e550 | ||
|
|
e2c59fc6e1 | ||
|
|
fb35c0905f | ||
|
|
1b909edd9b | ||
|
|
9f70a18501 | ||
|
|
de0bac2bc8 | ||
|
|
1a0a05c9ad | ||
|
|
ab8d778fc5 | ||
|
|
7d9fe8c997 | ||
|
|
f6e6db9eae | ||
|
|
9a87bd105e | ||
|
|
6715711d49 | ||
|
|
e63bae0c8b | ||
|
|
748d36d130 | ||
|
|
41319dfa39 | ||
|
|
80a6231a11 | ||
|
|
45f49aac52 | ||
|
|
859c905640 | ||
|
|
829f11f23e | ||
|
|
72b7e27057 | ||
|
|
c745672437 | ||
|
|
0be2e0d803 | ||
|
|
0ae85295dc | ||
|
|
e7ab19d7ca | ||
|
|
2524b46204 | ||
|
|
6d276526a6 | ||
|
|
88b641301a | ||
|
|
f4b289f745 | ||
|
|
cc92f3938a | ||
|
|
50ff9ab777 | ||
|
|
adfbd057b0 | ||
|
|
8cf5a44a32 | ||
|
|
35ed5e5b91 | ||
|
|
de3f8995cd | ||
|
|
9034f45355 | ||
|
|
4cc75b1a45 | ||
|
|
64d48e5800 | ||
|
|
47fd73be33 | ||
|
|
bc38df1ed6 | ||
|
|
ba8596be96 | ||
|
|
d22b67fd18 | ||
|
|
fa6f757618 | ||
|
|
dfad373ab3 | ||
|
|
bb6854b1ed | ||
|
|
24498ad1f6 | ||
|
|
eec0a8f46c | ||
|
|
3c25327f7e | ||
|
|
52ab97c939 | ||
|
|
f0e2dd39d3 | ||
|
|
e772d8413a | ||
|
|
b5907192f5 |
539 changed files with 12915 additions and 3744 deletions
73
.forgejo/workflows/build.yml
Normal file
73
.forgejo/workflows/build.yml
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
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@v6
|
- uses: actions/setup-go@v7
|
||||||
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,6 +32,12 @@ 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,7 +3,9 @@
|
||||||
#
|
#
|
||||||
# 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.
|
# shipping everything that landed on master since. Either way the tag must build
|
||||||
|
# on the previous release of its own line, so a tag pushed from the wrong branch
|
||||||
|
# cannot ship an older commit under a newer version.
|
||||||
#
|
#
|
||||||
# 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,
|
||||||
|
|
@ -27,13 +29,43 @@ 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=$(git tag --list | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort --version-sort | tail -1) || true
|
newest=$(printf '%s\n' "$releases" | tail -1)
|
||||||
|
|
||||||
if [[ $newest == "$tag" ]]; then
|
if [[ $newest == "$tag" ]]; then
|
||||||
echo "latest=true"
|
echo "latest=true"
|
||||||
|
|
@ -55,10 +87,12 @@ 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
|
||||||
|
|
@ -76,6 +110,8 @@ 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,11 +57,33 @@ jobs:
|
||||||
core.setFailed(message);
|
core.setFailed(message);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: pr } = await github.rest.pulls.get({
|
// the command is usually posted right before hitting merge, so give the
|
||||||
owner,
|
// pull request time to land instead of failing on the race
|
||||||
repo,
|
const deadline = Date.now() + 5 * 60 * 1000;
|
||||||
pull_number: context.payload.issue.number,
|
let pr;
|
||||||
});
|
for (;;) {
|
||||||
|
try {
|
||||||
|
({ data: pr } = await github.rest.pulls.get({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: context.payload.issue.number,
|
||||||
|
}));
|
||||||
|
// a closed pull request will never merge, no point in waiting it out
|
||||||
|
if (pr.merged || pr.state === 'closed') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// a blip while polling must not fail the command, but a request that
|
||||||
|
// is still failing when the time is up is a real error
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 15000));
|
||||||
|
}
|
||||||
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,6 +60,7 @@ 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,6 +13,7 @@ jobs:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
id-token: write # depot OIDC
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
@ -27,14 +28,10 @@ jobs:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_PASS }}
|
password: ${{ secrets.DOCKER_PASS }}
|
||||||
|
|
||||||
- name: Setup Buildx
|
# depot builders keep the Dockerfile's cache mounts on their own volumes,
|
||||||
id: buildx
|
# so they need neither buildx nor the cache dance
|
||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
- name: Setup Depot
|
||||||
|
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
|
||||||
|
|
@ -46,8 +43,9 @@ jobs:
|
||||||
type=raw,value=nightly.{{date 'YYYYMMDD'}}-{{sha}}
|
type=raw,value=nightly.{{date 'YYYYMMDD'}}-{{sha}}
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
|
||||||
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,6 +48,7 @@ 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,6 +46,7 @@ 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
|
||||||
|
|
@ -59,14 +60,10 @@ jobs:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_PASS }}
|
password: ${{ secrets.DOCKER_PASS }}
|
||||||
|
|
||||||
- name: Setup Buildx
|
# depot builders keep the Dockerfile's cache mounts on their own volumes,
|
||||||
id: buildx
|
# so they need neither buildx nor the cache dance
|
||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
- name: Setup Depot
|
||||||
|
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
|
||||||
|
|
@ -79,8 +76,9 @@ jobs:
|
||||||
latest=${{ needs.guard.outputs.latest }}
|
latest=${{ needs.guard.outputs.latest }}
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
|
||||||
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
|
||||||
|
|
@ -146,6 +144,12 @@ 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,6 +23,7 @@ linters:
|
||||||
settings:
|
settings:
|
||||||
modernize:
|
modernize:
|
||||||
disable:
|
disable:
|
||||||
|
- embedlit
|
||||||
- stringsbuilder
|
- stringsbuilder
|
||||||
staticcheck:
|
staticcheck:
|
||||||
checks:
|
checks:
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@ builds:
|
||||||
- -trimpath
|
- -trimpath
|
||||||
- -tags=release
|
- -tags=release
|
||||||
ldflags:
|
ldflags:
|
||||||
# the package version carries a timestamp for ordering, the reported version does not
|
- -X github.com/evcc-io/evcc/util.Version={{ .Version }} -X github.com/evcc-io/evcc/util.Commit={{ .ShortCommit }} -s -w
|
||||||
- -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:
|
||||||
|
|
@ -57,10 +56,9 @@ checksum:
|
||||||
name_template: "checksums.txt"
|
name_template: "checksums.txt"
|
||||||
|
|
||||||
snapshot:
|
snapshot:
|
||||||
# semver pre-release of the upcoming version. The timestamp makes the package
|
# semver pre-release of the upcoming version, the timestamp keeps the package
|
||||||
# version increase monotonically: apt compares it numerically, while the commit
|
# version increasing monotonically for apt
|
||||||
# alone would sort randomly.
|
version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}"
|
||||||
version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}+{{ .ShortCommit }}"
|
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,7 @@ builds:
|
||||||
- -trimpath
|
- -trimpath
|
||||||
- -tags=release
|
- -tags=release
|
||||||
ldflags:
|
ldflags:
|
||||||
# the package version carries a timestamp for ordering, the reported version does not
|
- -X github.com/evcc-io/evcc/util.Version={{ .Version }} -s -w
|
||||||
- -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:
|
||||||
|
|
@ -60,14 +59,26 @@ 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 makes the package
|
# semver pre-release of the upcoming version, the timestamp keeps the package
|
||||||
# version increase monotonically: apt compares it numerically, while the commit
|
# version increasing monotonically for apt
|
||||||
# alone would sort randomly.
|
version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}"
|
||||||
version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}+{{ .ShortCommit }}"
|
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
|
|
@ -124,7 +135,7 @@ nfpms:
|
||||||
preremove: ./packaging/scripts/preremove.sh
|
preremove: ./packaging/scripts/preremove.sh
|
||||||
postremove: ./packaging/scripts/postremove.sh
|
postremove: ./packaging/scripts/postremove.sh
|
||||||
|
|
||||||
brews:
|
homebrew_casks:
|
||||||
- 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
|
||||||
|
|
@ -132,15 +143,7 @@ brews:
|
||||||
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 ☀️🚘"
|
||||||
license: "MIT"
|
binaries:
|
||||||
test: |
|
- evcc
|
||||||
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,4 +1,11 @@
|
||||||
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)"],
|
||||||
|
|
@ -10,4 +17,21 @@ 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,6 +2,7 @@ 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";
|
||||||
|
|
||||||
|
|
@ -35,6 +36,7 @@ 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,6 +200,7 @@ 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,8 +3,9 @@ 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.
|
||||||
ENV PATH="/root/.vite-plus/bin:${PATH}"
|
# newer vite+ releases dropped the split directory layout, so accept both bin locations
|
||||||
|
ENV PATH="/root/.vite-plus/bin:/root/.local/share/vite-plus/bin:${PATH}"
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
|
|
@ -24,7 +25,7 @@ RUN make ui
|
||||||
|
|
||||||
|
|
||||||
# STEP 2 build executable binary
|
# STEP 2 build executable binary
|
||||||
FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS builder
|
FROM --platform=$BUILDPLATFORM golang:1.27.0-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
Normal file
54
FORK.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# 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,15 +6,16 @@ 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
|
# untagged builds are semver pre-releases of the upcoming version, the build
|
||||||
TAG_NAME ?= $(if $(filter-out 0,$(GIT_DIST)),$(NEXT_TAG)-dev+$(SHA),$(GIT_TAG))
|
# timestamp keeps them increasing monotonically
|
||||||
|
BUILD_TIMESTAMP := $(shell date -u '+%s')
|
||||||
|
TAG_NAME ?= $(if $(filter-out 0,$(GIT_DIST)),$(NEXT_TAG)-dev.$(BUILD_TIMESTAMP),$(GIT_TAG))
|
||||||
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)'
|
||||||
|
|
@ -33,7 +34,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
|
||||||
|
|
@ -99,7 +100,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) $(BUILD_DATE)
|
@echo Version: $(VERSION) $(SHA)
|
||||||
CGO_ENABLED=0 go build -v $(BUILD_TAGS) $(BUILD_ARGS)
|
CGO_ENABLED=0 go build -v $(BUILD_TAGS) $(BUILD_ARGS)
|
||||||
|
|
||||||
snapshot::
|
snapshot::
|
||||||
|
|
@ -109,15 +110,15 @@ release::
|
||||||
goreleaser --clean
|
goreleaser --clean
|
||||||
|
|
||||||
docker::
|
docker::
|
||||||
@echo Version: $(VERSION) $(SHA) $(BUILD_DATE)
|
@echo Version: $(VERSION) $(SHA)
|
||||||
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) $(BUILD_DATE)
|
@echo Version: $(VERSION) $(SHA)
|
||||||
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) $(BUILD_DATE)
|
@echo Version: $(VERSION) $(SHA)
|
||||||
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::
|
||||||
|
|
@ -153,7 +154,7 @@ gok-update::
|
||||||
${GOK} update yes
|
${GOK} update yes
|
||||||
|
|
||||||
soc::
|
soc::
|
||||||
@echo Version: $(VERSION) $(SHA) $(BUILD_DATE)
|
@echo Version: $(VERSION) $(SHA)
|
||||||
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,9 +130,10 @@ 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,23 +7,28 @@ import (
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// permanentError is a sentinel error that keeps matching errors.Is after
|
// permanentError is a sentinel error that signals permanence to backoff while
|
||||||
// backoff has stripped the backoff.Permanent wrapper.
|
// remaining distinguishable from the other permanent sentinels.
|
||||||
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 }
|
||||||
|
|
||||||
// Is matches the wrapped sentinel, too
|
// As signals permanence to backoff. Wrapping the sentinel in backoff.Permanent
|
||||||
func (e *permanentError) Is(target error) bool {
|
// instead would make errors.Is match any other permanent error, since
|
||||||
var t *permanentError
|
// backoff.PermanentError.Is matches by type rather than identity.
|
||||||
return errors.As(target, &t) && t == e
|
func (e *permanentError) As(target any) bool {
|
||||||
|
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 backoff.Permanent(&permanentError{msg})
|
return &permanentError{msg}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrNotAvailable indicates that a feature is not available
|
// ErrNotAvailable indicates that a feature is not available
|
||||||
|
|
|
||||||
|
|
@ -8,20 +8,47 @@ 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 _, tc := range []struct{ err, other error }{
|
for _, err := range permanentSentinels {
|
||||||
{ErrNotAvailable, ErrUnsupportedPlatform},
|
|
||||||
{ErrUnsupportedPlatform, ErrNotAvailable},
|
|
||||||
{ErrMissingCredentials, ErrMissingToken},
|
|
||||||
{ErrMissingToken, ErrMissingCredentials},
|
|
||||||
} {
|
|
||||||
_, unwrapped := backoff.RetryWithData(func() (int, error) {
|
_, unwrapped := backoff.RetryWithData(func() (int, error) {
|
||||||
return 0, tc.err
|
return 0, err
|
||||||
}, &backoff.StopBackOff{})
|
}, &backoff.StopBackOff{})
|
||||||
|
|
||||||
assert.ErrorIs(t, unwrapped, tc.err)
|
assert.ErrorIs(t, unwrapped, err)
|
||||||
assert.ErrorIs(t, fmt.Errorf("wrapped: %w", unwrapped), tc.err)
|
assert.ErrorIs(t, fmt.Errorf("wrapped: %w", unwrapped), err)
|
||||||
assert.NotErrorIs(t, unwrapped, tc.other)
|
assert.ErrorIs(t, fmt.Errorf("wrapped: %w", err), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,6 +64,7 @@ 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,6 +378,21 @@ 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,6 +50,7 @@
|
||||||
--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);
|
||||||
|
|
@ -951,3 +952,9 @@ 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,6 +6,7 @@ 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";
|
||||||
|
|
@ -99,6 +100,7 @@ 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,6 +52,11 @@ 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>;
|
||||||
|
|
||||||
|
|
@ -94,10 +99,22 @@ StableUpdateWithUpdater.args = {
|
||||||
|
|
||||||
export const Nightly = Template.bind({});
|
export const Nightly = Template.bind({});
|
||||||
Nightly.args = {
|
Nightly.args = {
|
||||||
installed: "0.304.0-dev+5ce7be4",
|
installed: "0.304.0-dev.1712345678",
|
||||||
|
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" :size="modalSize" @opened="acknowledge">
|
<GenericModal id="aboutModal" @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,14 +44,12 @@
|
||||||
>
|
>
|
||||||
v{{ installed }}
|
v{{ installed }}
|
||||||
</a>
|
</a>
|
||||||
<span
|
<Badge v-if="newVersionAvailable">
|
||||||
v-if="!nightly && !newVersionAvailable"
|
{{ $t("footer.version.availableLong") }}
|
||||||
class="text-muted text-nowrap"
|
</Badge>
|
||||||
>{{ $t("footer.version.latestVersion") }}</span
|
<Badge v-else-if="!nightly" variant="muted">
|
||||||
>
|
{{ $t("footer.version.latestVersion") }}
|
||||||
<span v-if="newVersionAvailable" class="text-nowrap">{{
|
</Badge>
|
||||||
$t("footer.version.availableLong")
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -115,8 +113,22 @@
|
||||||
|
|
||||||
<!-- open source -->
|
<!-- open source -->
|
||||||
<hr />
|
<hr />
|
||||||
<p class="mb-0 small d-flex flex-wrap column-gap-1">
|
<p
|
||||||
<i18n-t keypath="footer.version.madeByCommunity" tag="span">
|
class="mb-0 d-flex justify-content-between align-items-center flex-wrap column-gap-2 row-gap-1"
|
||||||
|
>
|
||||||
|
<span v-if="customLogo" class="d-flex align-items-center gap-2">
|
||||||
|
<span>{{ $t("footer.version.poweredBy") }}</span>
|
||||||
|
<a
|
||||||
|
:href="evccWebsiteUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="d-flex"
|
||||||
|
aria-label="evcc"
|
||||||
|
>
|
||||||
|
<Logo class="footer-logo" />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<i18n-t v-else keypath="footer.version.madeByCommunity" tag="span">
|
||||||
<a
|
<a
|
||||||
:href="githubRepoUrl"
|
:href="githubRepoUrl"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
@ -125,15 +137,13 @@
|
||||||
>{{ $t("footer.version.community") }}</a
|
>{{ $t("footer.version.community") }}</a
|
||||||
>
|
>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
<i18n-t keypath="footer.version.poweredByOpenSource" tag="span" class="d-inline">
|
<a
|
||||||
<a
|
class="evcc-gray"
|
||||||
class="text-muted"
|
:href="githubDependenciesUrl"
|
||||||
:href="githubDependenciesUrl"
|
target="_blank"
|
||||||
target="_blank"
|
rel="noopener noreferrer"
|
||||||
rel="noopener noreferrer"
|
>{{ $t("footer.version.openSource") }}</a
|
||||||
>{{ $t("footer.version.openSource") }}</a
|
>
|
||||||
>
|
|
||||||
</i18n-t>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</GenericModal>
|
</GenericModal>
|
||||||
|
|
@ -144,16 +154,11 @@ 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 {
|
import { isDevelopment, isNightly, getReleaseName, isNewVersionAvailable } from "@/utils/version";
|
||||||
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";
|
||||||
|
|
@ -161,9 +166,10 @@ const EVCC_WEBSITE = "https://evcc.io/";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "AboutModal",
|
name: "AboutModal",
|
||||||
components: { GenericModal, Logo },
|
components: { GenericModal, Logo, Badge },
|
||||||
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,
|
||||||
|
|
@ -204,14 +210,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/${commitFromVersion(this.installed)}`;
|
return `${GITHUB_REPO}/commit/${this.commit}`;
|
||||||
},
|
|
||||||
modalSize() {
|
|
||||||
return this.newVersionAvailable ? undefined : "sm";
|
|
||||||
},
|
},
|
||||||
cleanedReleaseNotes() {
|
cleanedReleaseNotes() {
|
||||||
if (!this.releaseNotes) return "";
|
if (!this.releaseNotes) return "";
|
||||||
|
|
@ -246,6 +252,9 @@ 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()}/docs/faq#password-reset`;
|
return `${docsPrefix()}/faq#password-reset`;
|
||||||
},
|
},
|
||||||
modalSize() {
|
modalSize() {
|
||||||
return this.demoMode ? "md" : "sm";
|
return this.demoMode ? "md" : "sm";
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
<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 }"
|
||||||
|
|
@ -22,13 +16,7 @@
|
||||||
</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 v-if="disabled" class="row mb-4">
|
<div class="row">
|
||||||
<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
|
||||||
|
|
@ -39,6 +27,15 @@
|
||||||
</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"
|
||||||
|
|
@ -67,6 +64,17 @@
|
||||||
</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"
|
||||||
|
|
@ -86,7 +94,11 @@
|
||||||
</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>
|
||||||
|
|
@ -101,6 +113,13 @@ 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 },
|
||||||
|
|
@ -109,7 +128,6 @@ 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"],
|
||||||
|
|
@ -121,6 +139,10 @@ 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;
|
||||||
},
|
},
|
||||||
|
|
@ -129,14 +151,16 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
summary(): string {
|
summary(): string {
|
||||||
if (this.disabled) {
|
if (this.disabled) {
|
||||||
return this.$t("main.chargingPlan.strategyDisabledDescription");
|
return this.$t("main.chargingPlan.precondition.summaryAll");
|
||||||
}
|
}
|
||||||
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) {
|
if (this.precondition >= EVERYTHING) {
|
||||||
|
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),
|
||||||
|
|
@ -146,6 +170,9 @@ 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"
|
||||||
|
|
@ -154,13 +181,6 @@ 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,7 +36,6 @@
|
||||||
: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"
|
||||||
/>
|
/>
|
||||||
|
|
@ -162,13 +161,6 @@ 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-3 root" data-testid="plan-warnings">
|
<p class="mb-0 root" data-testid="plan-warnings">
|
||||||
<span v-if="targetIsAboveLimit" class="d-block evcc-gray mb-1">
|
<span v-if="targetIsAboveLimit" class="d-block evcc-gray mt-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 mb-1">
|
<span v-if="mode && ['off', 'now'].includes(mode)" class="d-block text-warning mt-1">
|
||||||
{{ $t("main.targetCharge.onlyInPvMode") }}
|
{{ $t("main.targetCharge.onlyInPvMode") }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="timeTooFarInTheFuture" class="d-block evcc-gray mb-1">
|
<span v-if="timeTooFarInTheFuture" class="d-block evcc-gray mt-1">
|
||||||
{{ $t("main.targetCharge.targetIsTooFarInTheFuture") }}
|
{{ $t("main.targetCharge.targetIsTooFarInTheFuture") }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="notReachableInTime" class="d-block text-warning mb-1">
|
<span v-if="notReachableInTime" class="d-block text-warning mt-1">
|
||||||
{{ $t("main.targetCharge.notReachableInTime", { overrun: overrunFmt }) }}
|
{{ $t("main.targetCharge.notReachableInTime", { overrun: overrunFmt }) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="targetIsAboveVehicleLimit" class="d-block text-warning mb-1">
|
<span v-if="targetIsAboveVehicleLimit" class="d-block text-warning mt-1">
|
||||||
{{ $t("main.targetCharge.targetIsAboveVehicleLimit") }}
|
{{ $t("main.targetCharge.targetIsAboveVehicleLimit") }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -97,11 +97,31 @@
|
||||||
</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="!selectedReset.sessions && !selectedReset.settings"
|
:disabled="
|
||||||
|
!selectedReset.sessions &&
|
||||||
|
!selectedReset.settings &&
|
||||||
|
!selectedReset.remote
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ $t("config.system.backupRestore.reset.action") }}
|
{{ $t("config.system.backupRestore.reset.action") }}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -192,6 +212,7 @@ 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" | "",
|
||||||
|
|
@ -228,6 +249,7 @@ 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="/docs/features/loadmanagement"
|
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="/docs/reference/configuration/interval"
|
docsLink="/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="/docs/reference/configuration/site#residualpower"
|
docsLink="/reference/configuration/site#residualpower"
|
||||||
>
|
>
|
||||||
<div class="input-group input-width">
|
<div class="input-group input-width">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
97
assets/js/components/Config/CurtailerModal.vue
Normal file
97
assets/js/components/Config/CurtailerModal.vue
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<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()}/docs/devices/plugins#${this.type}`;
|
return `${docsPrefix()}/user-defined-devices#${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="/docs/reference/configuration/eebus"
|
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()}/docs/external-limit`;
|
return `${docsPrefix()}/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="/docs/reference/configuration/influx"
|
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()}/docs/integrations/mcp`;
|
return `${docsPrefix()}/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()}/docs/notifications#placeholders`"
|
:href="`${docsPrefix()}/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="/docs/reference/configuration/messaging"
|
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="/docs/notifications"
|
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="/docs/reference/configuration/modbusproxy"
|
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="/docs/reference/configuration/mqtt"
|
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"></shopicon-regular-plus>
|
<shopicon-regular-plus class="me-1 flex-shrink-0"></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()}/docs/features/optimizer`;
|
return `${docsPrefix()}/features/optimizer`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,18 @@
|
||||||
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>{{ $t("config.remote.description") }}</p>
|
<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"
|
||||||
|
|
@ -138,6 +141,10 @@ 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":
|
||||||
|
|
@ -145,7 +152,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/system/backup`;
|
const url = `${window.location.origin}/api/db/backup`;
|
||||||
return [
|
return [
|
||||||
`curl -X POST ${url} \\`,
|
`curl -X GET ${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="/docs/reference/configuration/hems"
|
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()}/docs/sponsorship#trial`;
|
return `${docsPrefix()}/sponsorship#trial`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
ref="root"
|
v-tooltip="tooltip"
|
||||||
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 />
|
||||||
|
|
@ -14,7 +12,6 @@
|
||||||
|
|
||||||
<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";
|
||||||
|
|
||||||
|
|
@ -24,9 +21,6 @@ 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) {
|
||||||
|
|
@ -41,30 +35,6 @@ 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="/docs/tariffs"
|
docs="/tariffs"
|
||||||
:defaultYaml="defaultYaml"
|
:defaultYaml="defaultYaml"
|
||||||
removeKey="tariffs"
|
removeKey="tariffs"
|
||||||
endpoint="/config/tariffs"
|
endpoint="/config/tariffs"
|
||||||
|
|
|
||||||
28
assets/js/components/Config/defaultYaml/curtailer.yaml
Normal file
28
assets/js/components/Config/defaultYaml/curtailer.yaml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
## 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,6 +16,9 @@ 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
|
||||||
|
|
@ -50,3 +53,6 @@ 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: visible;
|
overflow: hidden;
|
||||||
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,6 +236,8 @@ 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,7 +2,4 @@
|
||||||
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,6 +59,19 @@
|
||||||
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>
|
||||||
|
|
@ -91,14 +104,24 @@ import {
|
||||||
removeLocalePreference,
|
removeLocalePreference,
|
||||||
} from "@/i18n.ts";
|
} from "@/i18n.ts";
|
||||||
import { getThemePreference, setThemePreference } from "@/theme.ts";
|
import { getThemePreference, setThemePreference } from "@/theme.ts";
|
||||||
import { getUnits, setUnits, is12hFormat, set12hFormat } from "@/units";
|
import {
|
||||||
|
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: () => [] },
|
||||||
},
|
},
|
||||||
|
|
@ -115,6 +138,17 @@ 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 { CURRENCY, SMART_COST_TYPE } from "@/types/evcc";
|
import { CHARGE_MODE, 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,11 +117,18 @@ TwoLoadpoints.args = {
|
||||||
index: 0,
|
index: 0,
|
||||||
vehicleName: "vehicle_3",
|
vehicleName: "vehicle_3",
|
||||||
charging: true,
|
charging: true,
|
||||||
chargePower: 8050,
|
chargePower: 1380,
|
||||||
power: 8050,
|
power: 1380,
|
||||||
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,5 +1,6 @@
|
||||||
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",
|
||||||
|
|
@ -9,6 +10,7 @@ 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" } },
|
||||||
|
|
@ -24,72 +26,117 @@ const Template: StoryFn<typeof Phases> = (args) => ({
|
||||||
template: '<Phases v-bind="args" />',
|
template: '<Phases v-bind="args" />',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const OnePhase = Template.bind({});
|
const base = {
|
||||||
OnePhase.args = {
|
phasesConfigured: 3,
|
||||||
phasesActive: 1,
|
|
||||||
minCurrent: 6,
|
minCurrent: 6,
|
||||||
maxCurrent: 16,
|
maxCurrent: 16,
|
||||||
offeredCurrent: 8,
|
offeredCurrent: 8,
|
||||||
chargeCurrents: undefined,
|
chargeCurrents: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TwoPhase = Template.bind({});
|
// --- expected phases only (no per-phase measurement) ---
|
||||||
TwoPhase.args = {
|
|
||||||
...OnePhase.args,
|
|
||||||
phasesActive: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ThreePhase = Template.bind({});
|
export const NoData = Template.bind({});
|
||||||
ThreePhase.args = {
|
NoData.args = { ...base, phasesActive: undefined, offeredCurrent: 0 };
|
||||||
...OnePhase.args,
|
|
||||||
phasesActive: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RealCurrents = Template.bind({});
|
export const Expected1p = Template.bind({});
|
||||||
RealCurrents.args = {
|
Expected1p.args = { ...base, phasesActive: 1 };
|
||||||
...OnePhase.args,
|
|
||||||
phasesActive: 3,
|
|
||||||
offeredCurrent: 13,
|
|
||||||
chargeCurrents: [11, 9, 12],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OnePhaseMoreAvailable = Template.bind({});
|
export const Expected2p = Template.bind({});
|
||||||
OnePhaseMoreAvailable.args = {
|
Expected2p.args = { ...base, phasesActive: 2 };
|
||||||
...OnePhase.args,
|
|
||||||
offeredCurrent: 12,
|
|
||||||
chargeCurrents: [6, 0.2, 0],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TwoPhasesActive = Template.bind({});
|
export const Expected3p = Template.bind({});
|
||||||
TwoPhasesActive.args = {
|
Expected3p.args = { ...base, phasesActive: 3 };
|
||||||
...OnePhase.args,
|
|
||||||
phasesActive: 2,
|
|
||||||
offeredCurrent: 16,
|
|
||||||
chargeCurrents: [16, 16, 0.3],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AsymetricPhases = Template.bind({});
|
// --- measured per-phase currents ---
|
||||||
AsymetricPhases.args = {
|
|
||||||
...OnePhase.args,
|
|
||||||
phasesActive: 2,
|
|
||||||
offeredCurrent: 16,
|
|
||||||
chargeCurrents: [8, 0.9, 14],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OnlySecondPhase = Template.bind({});
|
export const Measured1p = Template.bind({});
|
||||||
OnlySecondPhase.args = {
|
Measured1p.args = { ...base, phasesActive: 1, offeredCurrent: 12, chargeCurrents: [6, 0.2, 0] };
|
||||||
...OnePhase.args,
|
|
||||||
|
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,
|
phasesActive: 1,
|
||||||
offeredCurrent: 13,
|
offeredCurrent: 20,
|
||||||
chargeCurrents: [0, 13, 0],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MainlyThirdPhase = Template.bind({});
|
|
||||||
MainlyThirdPhase.args = {
|
|
||||||
...OnePhase.args,
|
|
||||||
phasesActive: 1,
|
|
||||||
offeredCurrent: 10,
|
|
||||||
chargeCurrents: [0.007, 0.009, 5.945],
|
|
||||||
minCurrent: 6,
|
|
||||||
maxCurrent: 20,
|
maxCurrent: 20,
|
||||||
|
chargeCurrents: [0.007, 0.009, 5.945],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 1p device but 3 phases measured: configuration error, warning color + tooltip
|
||||||
|
export const Mismatch1p = Template.bind({});
|
||||||
|
Mismatch1p.args = {
|
||||||
|
...base,
|
||||||
|
phasesConfigured: 1,
|
||||||
|
phasesActive: 3,
|
||||||
|
offeredCurrent: 10,
|
||||||
|
chargeCurrents: [10, 10, 10],
|
||||||
|
};
|
||||||
|
|
||||||
|
// cycles 1p → 3p → 1p every few seconds to inspect the transition
|
||||||
|
export const Switching: StoryFn<typeof Phases> = (args) => ({
|
||||||
|
components: { Phases },
|
||||||
|
setup() {
|
||||||
|
const phasesActive = ref(1);
|
||||||
|
let timer: ReturnType<typeof setInterval>;
|
||||||
|
onMounted(() => {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
phasesActive.value = phasesActive.value === 1 ? 3 : 1;
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
onUnmounted(() => clearInterval(timer));
|
||||||
|
return { args, phasesActive };
|
||||||
|
},
|
||||||
|
template: '<Phases v-bind="args" :phasesActive="phasesActive" />',
|
||||||
|
});
|
||||||
|
Switching.args = { ...base, phasesConfigured: 0, offeredCurrent: 12 };
|
||||||
|
|
||||||
|
// --- overview: rows = scenario, columns = device phase configuration ---
|
||||||
|
|
||||||
|
const scenarios = [
|
||||||
|
{ name: "NoData", args: NoData.args },
|
||||||
|
{ name: "Expected1p", args: Expected1p.args },
|
||||||
|
{ name: "Expected2p", args: Expected2p.args },
|
||||||
|
{ name: "Expected3p", args: Expected3p.args },
|
||||||
|
{ name: "Measured1p", args: Measured1p.args },
|
||||||
|
{ name: "Measured2p", args: Measured2p.args },
|
||||||
|
{ name: "Measured3p", args: Measured3p.args },
|
||||||
|
{ name: "Asymmetric", args: Asymmetric.args },
|
||||||
|
{ name: "OnlyL2", args: OnlyL2.args },
|
||||||
|
{ name: "MainlyL3", args: MainlyL3.args },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 1p3p auto (0) behaves like 3p
|
||||||
|
const configs = [
|
||||||
|
{ name: "1p device", phasesConfigured: 1 },
|
||||||
|
{ name: "3p/auto", phasesConfigured: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Overview: StoryFn<typeof Phases> = () => ({
|
||||||
|
components: { Phases },
|
||||||
|
setup() {
|
||||||
|
return { scenarios, configs };
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div style="display: grid; grid-template-columns: 140px repeat(2, 120px); gap: 0.75rem 1.5rem; align-items: center; padding: 1rem; background: var(--evcc-box); border-radius: 0.5rem; color: var(--evcc-default-text); font-size: 0.85rem;">
|
||||||
|
<div></div>
|
||||||
|
<div v-for="c in configs" :key="c.name" style="opacity: 0.7;">{{ c.name }}</div>
|
||||||
|
<template v-for="s in scenarios" :key="s.name">
|
||||||
|
<div style="opacity: 0.7;">{{ s.name }}</div>
|
||||||
|
<Phases v-for="c in configs" :key="c.name" v-bind="{ ...s.args, phasesConfigured: c.phasesConfigured }" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
Overview.parameters = { controls: { disable: true } };
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,23 @@
|
||||||
<template>
|
<template>
|
||||||
<div :class="`phases d-flex justify-content-between`">
|
<div
|
||||||
|
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="{ 'phase-inactive': !isPhaseActive(num) }"
|
:class="{
|
||||||
|
'phase-inactive': !isPhaseActive(num),
|
||||||
|
'phase-hidden': !isPhaseActive(num) && (maxPhases === 1 || num > maxPhases),
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<div class="target" :style="{ width: `${targetWidth()}%` }"></div>
|
<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>
|
||||||
|
|
@ -23,6 +34,7 @@ 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 },
|
||||||
},
|
},
|
||||||
|
|
@ -31,9 +43,17 @@ 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
|
||||||
|
|
@ -52,7 +72,8 @@ 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;
|
||||||
}
|
}
|
||||||
return num <= (this.phasesActive || 0);
|
// unknown active phases: assume all
|
||||||
|
return num <= (this.phasesActive || this.maxPhases);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -63,15 +84,14 @@ export default defineComponent({
|
||||||
width: 73px;
|
width: 73px;
|
||||||
}
|
}
|
||||||
.phase {
|
.phase {
|
||||||
background-color: var(--bs-gray-bright);
|
background-color: var(--evcc-gray-15);
|
||||||
height: 4px;
|
height: 4px;
|
||||||
flex-grow: 1;
|
flex: 1 1 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 1px;
|
border-radius: 2px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-basis: 100%;
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition-property: flex-basis, margin, opacity;
|
transition-property: flex-basis, flex-grow, margin, opacity;
|
||||||
transition-duration: var(--evcc-transition-slow);
|
transition-duration: var(--evcc-transition-slow);
|
||||||
transition-timing-function: ease-in;
|
transition-timing-function: ease-in;
|
||||||
}
|
}
|
||||||
|
|
@ -79,6 +99,13 @@ 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;
|
||||||
|
|
@ -96,9 +123,15 @@ html.dark .phase {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.target {
|
.target {
|
||||||
background-color: var(--evcc-green);
|
background-color: color-mix(in srgb, var(--evcc-dark-green) 40%, white);
|
||||||
}
|
}
|
||||||
.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>
|
||||||
|
|
|
||||||
18
assets/js/components/MaterialIcon/Curtail.vue
Normal file
18
assets/js/components/MaterialIcon/Curtail.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<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),
|
xAxis: slotXAxis(this.times, this.weekdayShort),
|
||||||
yAxis: forecastYAxis({
|
yAxis: forecastYAxis({
|
||||||
min: undefined,
|
min: undefined,
|
||||||
position: "right",
|
position: "right",
|
||||||
|
|
|
||||||
35
assets/js/components/Optimize/chart.test.ts
Normal file
35
assets/js/components/Optimize/chart.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
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,4 +1,5 @@
|
||||||
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"
|
||||||
|
|
@ -19,22 +20,35 @@ export function slotTimes(timestamp: string, dt: number[]): number[] {
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
// category x axis over slot times, labels at full hours every 4h (6h on mobile)
|
export function isMidnight(time?: number): boolean {
|
||||||
export function slotXAxis(times: number[]) {
|
if (time === undefined) return false;
|
||||||
|
const d = new Date(time);
|
||||||
|
return d.getHours() === 0 && d.getMinutes() === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// category x axis over slot times, labels at full hours every 4h (6h on mobile),
|
||||||
|
// day boundaries as split line with the weekday shown at 00:00
|
||||||
|
export function slotXAxis(times: number[], weekdayShort: (d: Date) => string) {
|
||||||
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: { show: false },
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
// split lines sit at the left edge of their slot
|
||||||
|
interval: (index: number) => index > 0 && isMidnight(times[index]),
|
||||||
|
lineStyle: { color: colors.muted || "", type: "solid" },
|
||||||
|
},
|
||||||
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 "";
|
||||||
return String(d.getHours());
|
const label = 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), false) }}
|
{{ fmtFullDateTime(new Date(session.created)) }}
|
||||||
</template>
|
</template>
|
||||||
<br />
|
<br />
|
||||||
<template v-if="session.finished">
|
<template v-if="session.finished">
|
||||||
{{ fmtFullDateTime(new Date(session.finished), false) }}
|
{{ fmtFullDateTime(new Date(session.finished)) }}
|
||||||
</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">
|
||||||
{{ fmtFullDateTime(new Date(session.created), true) }}
|
{{ fmtWeekdayDayTime(new Date(session.created)) }}
|
||||||
</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()}/docs/faq#telemetry`;
|
return `${docsPrefix()}/faq#telemetry`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -16,85 +16,91 @@
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
v-for="(vehicle, index) in vehicles"
|
v-for="vehicle in vehicles"
|
||||||
:key="vehicle.name"
|
:key="vehicle.name"
|
||||||
role="group"
|
role="group"
|
||||||
:aria-label="vehicle.title"
|
:aria-label="vehicle.title"
|
||||||
:class="{ 'border-top pt-4': index > 0 }"
|
class="d-flex gap-2 mb-3"
|
||||||
class="mb-4"
|
|
||||||
>
|
>
|
||||||
<div class="d-flex flex-wrap align-items-center column-gap-2 row-gap-1 mb-3">
|
<VehicleIcon :name="vehicle.icon" class="flex-shrink-0" />
|
||||||
<VehicleIcon :name="vehicle.icon" class="flex-shrink-0" />
|
<div class="flex-grow-1 overflow-hidden ring-space">
|
||||||
<strong class="text-truncate">{{ vehicle.title }}</strong>
|
<div class="d-flex flex-wrap align-items-center column-gap-2 row-gap-1 mb-3">
|
||||||
<Badge v-if="connectedLoadpoint(vehicle)" class="flex-shrink-0">
|
<strong class="text-truncate">{{ vehicle.title }}</strong>
|
||||||
{{
|
<Badge v-if="connectedLoadpoint(vehicle)" class="flex-shrink-0">
|
||||||
$t("main.vehicleSettings.connectedTo", [
|
{{
|
||||||
connectedLoadpoint(vehicle)?.title ||
|
$t("main.vehicleSettings.connectedTo", [
|
||||||
$t("main.loadpoint.fallbackName"),
|
connectedLoadpoint(vehicle)?.title ||
|
||||||
])
|
$t("main.loadpoint.fallbackName"),
|
||||||
}}
|
])
|
||||||
</Badge>
|
}}
|
||||||
<Badge v-else variant="muted" class="flex-shrink-0">
|
</Badge>
|
||||||
{{ $t("main.vehicleSettings.notConnected") }}
|
<Badge v-else variant="muted" class="flex-shrink-0">
|
||||||
</Badge>
|
{{ $t("main.vehicleSettings.notConnected") }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6 col-lg-3 mb-3">
|
||||||
|
<div class="small text-uppercase evcc-gray text-truncate">
|
||||||
|
{{ $t("main.vehicleSettings.mode") }}
|
||||||
|
</div>
|
||||||
|
<CustomSelect
|
||||||
|
:id="fieldId(vehicle, 'mode')"
|
||||||
|
inline
|
||||||
|
:options="modeOptions"
|
||||||
|
:selected="vehicle.mode ?? ''"
|
||||||
|
:ariaLabel="$t('main.vehicleSettings.mode')"
|
||||||
|
@change="changeMode(vehicle, $event)"
|
||||||
|
>
|
||||||
|
<span class="text-decoration-underline text-primary">
|
||||||
|
{{ optionName(modeOptions, vehicle.mode ?? "") }}
|
||||||
|
</span>
|
||||||
|
</CustomSelect>
|
||||||
|
</div>
|
||||||
|
<template v-if="socSupported(vehicle)">
|
||||||
|
<div class="col-6 col-lg-3 mb-3">
|
||||||
|
<div class="small text-uppercase evcc-gray text-truncate">
|
||||||
|
{{ $t("main.vehicleSettings.minSoc") }}
|
||||||
|
</div>
|
||||||
|
<CustomSelect
|
||||||
|
:id="fieldId(vehicle, 'minSoc')"
|
||||||
|
inline
|
||||||
|
:options="socOptions(vehicle, true)"
|
||||||
|
:selected="vehicle.minSoc ?? 0"
|
||||||
|
:ariaLabel="$t('main.vehicleSettings.minSoc')"
|
||||||
|
@change="changeMinSoc(vehicle, $event)"
|
||||||
|
>
|
||||||
|
<span class="text-decoration-underline text-primary">
|
||||||
|
{{
|
||||||
|
optionName(
|
||||||
|
socOptions(vehicle, true),
|
||||||
|
vehicle.minSoc ?? 0
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</CustomSelect>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-3 mb-3">
|
||||||
|
<div class="small text-uppercase evcc-gray text-truncate">
|
||||||
|
{{ $t("main.vehicleSettings.limitSoc") }}
|
||||||
|
</div>
|
||||||
|
<CustomSelect
|
||||||
|
:id="fieldId(vehicle, 'limitSoc')"
|
||||||
|
inline
|
||||||
|
:options="socOptions(vehicle)"
|
||||||
|
:selected="vehicle.limitSoc || 100"
|
||||||
|
:ariaLabel="$t('main.vehicleSettings.limitSoc')"
|
||||||
|
@change="changeLimitSoc(vehicle, $event)"
|
||||||
|
>
|
||||||
|
<span class="text-decoration-underline text-primary">
|
||||||
|
{{
|
||||||
|
optionName(socOptions(vehicle), vehicle.limitSoc || 100)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</CustomSelect>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</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">
|
||||||
|
|
@ -110,7 +116,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 SettingsFormRow from "../Helper/SettingsFormRow.vue";
|
import CustomSelect from "../Helper/CustomSelect.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";
|
||||||
|
|
@ -124,7 +130,7 @@ const { OFF, PV, MINPV, NOW } = CHARGE_MODE;
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "VehicleSettingsModal",
|
name: "VehicleSettingsModal",
|
||||||
components: { Badge, GenericModal, SettingsFormRow, VehicleIcon },
|
components: { Badge, CustomSelect, GenericModal, VehicleIcon },
|
||||||
mixins: [formatter],
|
mixins: [formatter],
|
||||||
props: {
|
props: {
|
||||||
vehicles: { type: Array as PropType<Vehicle[]>, default: () => [] },
|
vehicles: { type: Array as PropType<Vehicle[]>, default: () => [] },
|
||||||
|
|
@ -137,7 +143,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
docsUrl(): string {
|
docsUrl(): string {
|
||||||
return `${docsPrefix()}/docs/features/limits`;
|
return `${docsPrefix()}/features/limits`;
|
||||||
},
|
},
|
||||||
modeOptions(): SelectOption<string>[] {
|
modeOptions(): SelectOption<string>[] {
|
||||||
return [
|
return [
|
||||||
|
|
@ -188,6 +194,9 @@ 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;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
25
assets/js/directives/tooltip.ts
Normal file
25
assets/js/directives/tooltip.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
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,7 +110,6 @@ export async function ensureCurrentLocaleMessages(i18n: VueI18nInstance) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function docsPrefix() {
|
export function docsPrefix() {
|
||||||
const locale = getLocale();
|
const lang = getLocale() === "de" ? "de" : "en";
|
||||||
const path = locale === "de" ? "" : `/en`;
|
return `https://docs.evcc.io/${lang}`;
|
||||||
return `https://docs.evcc.io${path}`;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ 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";
|
||||||
|
|
||||||
|
|
@ -275,7 +276,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, false)).toBe("So., 15. Jan., 3:30 PM");
|
expect(fmt.fmtFullDateTime(testDate)).toBe("So., 15. Jan. 2023, 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");
|
||||||
});
|
});
|
||||||
|
|
@ -283,12 +284,29 @@ 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, false)).toBe("So., 15. Jan., 15:30");
|
expect(fmt.fmtFullDateTime(testDate)).toBe("So., 15. Jan. 2023, 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,6 +1,55 @@
|
||||||
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: "$",
|
||||||
|
|
@ -26,6 +75,10 @@ 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)
|
||||||
|
|
@ -45,6 +98,7 @@ 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 {
|
||||||
|
|
@ -62,6 +116,11 @@ 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) {
|
||||||
|
|
@ -265,15 +324,33 @@ export default defineComponent({
|
||||||
hour12: is12hFormat(),
|
hour12: is12hFormat(),
|
||||||
}).format(date);
|
}).format(date);
|
||||||
},
|
},
|
||||||
fmtFullDateTime(date: Date, short: boolean) {
|
fmtFullDateTime(date: Date) {
|
||||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
const locale = this.$i18n?.locale;
|
||||||
weekday: short ? undefined : "short",
|
const fmt = this.dateFormat;
|
||||||
month: short ? "numeric" : "short",
|
if (!fmt) {
|
||||||
day: "numeric",
|
// auto: single Intl call preserves locale-native separators (e.g. German "So., 15. Jan.,")
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
hour12: is12hFormat(),
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
const time = new Intl.DateTimeFormat(locale, {
|
||||||
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, {
|
||||||
|
|
@ -295,11 +372,17 @@ export default defineComponent({
|
||||||
}).format(date);
|
}).format(date);
|
||||||
},
|
},
|
||||||
fmtDayMonth(date: Date) {
|
fmtDayMonth(date: Date) {
|
||||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
const locale = this.$i18n?.locale;
|
||||||
weekday: "short",
|
const fmt = this.dateFormat;
|
||||||
day: "numeric",
|
if (!fmt) {
|
||||||
month: "short",
|
return new Intl.DateTimeFormat(locale, {
|
||||||
}).format(date);
|
weekday: "short",
|
||||||
|
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,6 +23,7 @@ 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";
|
||||||
|
|
@ -99,6 +100,8 @@ function saveJSON(key: string) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DateFormat = "" | "dmy" | "mdy" | "ymd";
|
||||||
|
|
||||||
export interface LoadpointSettings {
|
export interface LoadpointSettings {
|
||||||
order?: number;
|
order?: number;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
|
|
@ -112,6 +115,7 @@ 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;
|
||||||
|
|
@ -141,6 +145,7 @@ 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),
|
||||||
|
|
@ -169,6 +174,7 @@ 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 THEME.AUTO;
|
return window.evcc?.customTheme || THEME.AUTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setThemePreference(theme: THEME) {
|
export function setThemePreference(theme: THEME) {
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@ 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 {
|
||||||
|
|
@ -459,6 +461,10 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -529,6 +535,7 @@ 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 {
|
||||||
|
|
@ -809,6 +816,10 @@ 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 {
|
||||||
|
|
@ -1124,8 +1135,10 @@ 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 battery energy in kWh. */
|
/** Total discharged 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. */
|
||||||
|
|
@ -1367,7 +1380,8 @@ 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";
|
||||||
|
|
@ -1384,6 +1398,7 @@ 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,4 +1,5 @@
|
||||||
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;
|
||||||
|
|
@ -34,3 +35,11 @@ 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,14 +2,13 @@ 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+abc1234";
|
const NIGHTLY = "0.304.0-dev.1712345678";
|
||||||
const STABLE = "0.303.1";
|
const STABLE = "0.303.1";
|
||||||
|
|
||||||
describe("isDevelopment", () => {
|
describe("isDevelopment", () => {
|
||||||
|
|
@ -21,20 +20,13 @@ describe("isDevelopment", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isNightly", () => {
|
describe("isNightly", () => {
|
||||||
test("only versions with build metadata", () => {
|
test("only pre-release versions", () => {
|
||||||
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");
|
||||||
|
|
@ -46,14 +38,15 @@ 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+abc1234");
|
expect(getShortVersion(NIGHTLY)).toBe("v0.304.0-dev.1712345678");
|
||||||
expect(getShortVersion(STABLE)).toBe("v0.303.1");
|
expect(getShortVersion(STABLE)).toBe("v0.303.1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isNewVersionAvailable", () => {
|
describe("isNewVersionAvailable", () => {
|
||||||
test("never for dev builds", () => {
|
test("never for dev or nightly 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,13 +2,9 @@ export function isDevelopment(version: string): boolean {
|
||||||
return version === "0.0.0";
|
return version === "0.0.0";
|
||||||
}
|
}
|
||||||
|
|
||||||
// untagged builds carry the commit as build metadata: 0.304.0-dev+abc1234
|
// untagged builds are pre-releases carrying the build timestamp: 0.304.0-dev.1712345678
|
||||||
export function isNightly(version: string): boolean {
|
export function isNightly(version: string): boolean {
|
||||||
return version.includes("+");
|
return version.includes("-dev.");
|
||||||
}
|
|
||||||
|
|
||||||
export function commitFromVersion(version: string): string {
|
|
||||||
return version.split("+")[1] ?? "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReleaseName(version: string): string {
|
export function getReleaseName(version: string): string {
|
||||||
|
|
@ -23,7 +19,8 @@ export function getShortVersion(version: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isNewVersionAvailable(installed?: string, available?: string): boolean {
|
export function isNewVersionAvailable(installed?: string, available?: string): boolean {
|
||||||
return !!available && !isDevelopment(installed || "") && available !== installed;
|
const v = installed || "";
|
||||||
|
return !!available && !isDevelopment(v) && !isNightly(v) && available !== installed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isNewVersionUnacknowledged(
|
export function isNewVersionUnacknowledged(
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ 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,6 +150,31 @@
|
||||||
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>
|
||||||
|
|
||||||
|
|
@ -162,7 +187,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="meterBanner(meter.name)"
|
:banner="curtailmentBanner('meter', 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)"
|
||||||
/>
|
/>
|
||||||
|
|
@ -394,8 +419,7 @@
|
||||||
</template>
|
</template>
|
||||||
</DeviceCard>
|
</DeviceCard>
|
||||||
<DeviceCard
|
<DeviceCard
|
||||||
v-if="experimental"
|
:title="$t('config.remote.title')"
|
||||||
:title="`${$t('config.remote.title')} 🧪`"
|
|
||||||
editable
|
editable
|
||||||
:unconfigured="isUnconfigured(remoteTags)"
|
:unconfigured="isUnconfigured(remoteTags)"
|
||||||
data-testid="remote-access"
|
data-testid="remote-access"
|
||||||
|
|
@ -525,6 +549,7 @@
|
||||||
<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"
|
||||||
|
|
@ -579,6 +604,8 @@ 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";
|
||||||
|
|
@ -633,6 +660,7 @@ import type {
|
||||||
ConfigCharger,
|
ConfigCharger,
|
||||||
ConfigVehicle,
|
ConfigVehicle,
|
||||||
ConfigCircuit,
|
ConfigCircuit,
|
||||||
|
ConfigCurtailer,
|
||||||
ConfigMessenger,
|
ConfigMessenger,
|
||||||
ConfigHems,
|
ConfigHems,
|
||||||
ConfigLoadpoint,
|
ConfigLoadpoint,
|
||||||
|
|
@ -691,6 +719,8 @@ export default defineComponent({
|
||||||
CircuitsModal,
|
CircuitsModal,
|
||||||
CircuitTags,
|
CircuitTags,
|
||||||
ControlModal,
|
ControlModal,
|
||||||
|
CurtailerModal,
|
||||||
|
CurtailIcon,
|
||||||
DeviceCard,
|
DeviceCard,
|
||||||
DeviceTags,
|
DeviceTags,
|
||||||
EebusIcon,
|
EebusIcon,
|
||||||
|
|
@ -749,6 +779,7 @@ 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[],
|
||||||
|
|
@ -772,6 +803,7 @@ 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: {
|
||||||
|
|
@ -781,6 +813,7 @@ export default defineComponent({
|
||||||
loadpoint: {},
|
loadpoint: {},
|
||||||
messenger: {},
|
messenger: {},
|
||||||
tariff: {},
|
tariff: {},
|
||||||
|
curtailer: {},
|
||||||
} as DeviceValuesMap,
|
} as DeviceValuesMap,
|
||||||
isComponentMounted: true,
|
isComponentMounted: true,
|
||||||
isPageVisible: true,
|
isPageVisible: true,
|
||||||
|
|
@ -868,8 +901,10 @@ export default defineComponent({
|
||||||
{
|
{
|
||||||
slug: "grid",
|
slug: "grid",
|
||||||
icon: "shopicon-regular-powersupply",
|
icon: "shopicon-regular-powersupply",
|
||||||
count: this.gridMeter ? 1 : 0,
|
count: (this.gridMeter ? 1 : 0) + this.curtailerDevices.length,
|
||||||
error: !!this.gridMeter && this.hasDeviceError("meter", this.gridMeter.name),
|
error:
|
||||||
|
(!!this.gridMeter && this.hasDeviceError("meter", this.gridMeter.name)) ||
|
||||||
|
this.curtailerDevices.some((c) => this.hasDeviceError("curtailer", c.name)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "pv-battery",
|
slug: "pv-battery",
|
||||||
|
|
@ -877,7 +912,7 @@ export default defineComponent({
|
||||||
count: pvAndBattery.length,
|
count: pvAndBattery.length,
|
||||||
error: meterError(pvAndBattery),
|
error: meterError(pvAndBattery),
|
||||||
warning:
|
warning:
|
||||||
this.pvMeters.some((m) => this.meterBanner(m.name)) ||
|
this.pvMeters.some((m) => this.curtailmentBanner("meter", m.name)) ||
|
||||||
meterDisabled(pvAndBattery),
|
meterDisabled(pvAndBattery),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -956,6 +991,12 @@ 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);
|
||||||
},
|
},
|
||||||
|
|
@ -1050,7 +1091,9 @@ export default defineComponent({
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
vehicleOptions(): VehicleOption[] {
|
vehicleOptions(): VehicleOption[] {
|
||||||
return this.vehicles.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
|
return this.vehicles
|
||||||
|
.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;
|
||||||
|
|
@ -1291,6 +1334,7 @@ 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();
|
||||||
|
|
@ -1311,6 +1355,9 @@ 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")) || [];
|
||||||
},
|
},
|
||||||
|
|
@ -1464,6 +1511,25 @@ 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();
|
||||||
},
|
},
|
||||||
|
|
@ -1504,6 +1570,7 @@ 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]) {
|
||||||
|
|
@ -1523,9 +1590,10 @@ export default defineComponent({
|
||||||
deviceTags(type: DeviceType, id: string) {
|
deviceTags(type: DeviceType, id: string) {
|
||||||
return this.deviceValues[type][id] || {};
|
return this.deviceValues[type][id] || {};
|
||||||
},
|
},
|
||||||
meterBanner(name: string): string | undefined {
|
curtailmentBanner(type: DeviceType, name: string): string | undefined {
|
||||||
// the tag is only present while curtailing, a zero percent limit is still one
|
// devices report the allowed feed-in percent, 100 = uncurtailed
|
||||||
return this.deviceTags("meter", name)["curtailed"]?.value !== undefined
|
const value = this.deviceTags(type, name)["curtailed"]?.value;
|
||||||
|
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 log-count-input"
|
class="form-control text-end"
|
||||||
min="0"
|
min="0"
|
||||||
step="25"
|
step="25"
|
||||||
/>
|
/>
|
||||||
|
|
@ -687,11 +687,6 @@ 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,6 +22,7 @@ import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
|
|
@ -367,21 +368,24 @@ 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 "", err
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var res []string
|
||||||
|
|
||||||
|
// the idTag register holds the rfid uid as null-padded ascii string
|
||||||
|
if idTag := hycInput(b, hycRegIdTag, 20); !allZero(idTag) {
|
||||||
|
res = append(res, strings.ToLower(strings.TrimRight(string(idTag), "\x00")))
|
||||||
}
|
}
|
||||||
|
|
||||||
if vid := hycInput(b, hycRegVID, 8); !allZero(vid) {
|
if vid := hycInput(b, hycRegVID, 8); !allZero(vid) {
|
||||||
return hex.EncodeToString(vid), nil
|
res = append(res, hex.EncodeToString(vid))
|
||||||
}
|
}
|
||||||
|
|
||||||
if idTag := hycInput(b, hycRegIdTag, 20); !allZero(idTag) {
|
return res, nil
|
||||||
return hex.EncodeToString(idTag), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ api.Battery = (*AlpitronicHYC)(nil)
|
var _ api.Battery = (*AlpitronicHYC)(nil)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package charger
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -11,7 +10,6 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
@ -218,7 +216,7 @@ func TestAlpitronicIdentify(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, id)
|
assert.Empty(t, id)
|
||||||
|
|
||||||
// vehicle id takes precedence over the tag
|
// tag before vehicle id
|
||||||
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
|
||||||
|
|
@ -229,9 +227,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, "0000aabbccddeeff", id)
|
assert.Equal(t, []string{"ab", "0000aabbccddeeff"}, id)
|
||||||
|
|
||||||
// tag as fallback
|
// tag only
|
||||||
regs = hycRegs(1, hycStateCharging)
|
regs = hycRegs(1, hycStateCharging)
|
||||||
regs[hycReg(1, hycRegIdTag)] = 0x4142
|
regs[hycReg(1, hycRegIdTag)] = 0x4142
|
||||||
|
|
||||||
|
|
@ -239,7 +237,17 @@ func TestAlpitronicIdentify(t *testing.T) {
|
||||||
|
|
||||||
id, err = wb.Identify()
|
id, err = wb.Identify()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "4142"+strings.Repeat("0", 36), id)
|
assert.Equal(t, []string{"ab"}, id)
|
||||||
|
|
||||||
|
// vehicle id only
|
||||||
|
regs = hycRegs(1, hycStateCharging)
|
||||||
|
regs[hycReg(1, hycRegVID)+3] = 0xEEFF
|
||||||
|
|
||||||
|
wb, _ = hycTestCharger(t, 1, regs)
|
||||||
|
|
||||||
|
id, err = wb.Identify()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"000000000000eeff"}, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlpitronicEnable(t *testing.T) {
|
func TestAlpitronicEnable(t *testing.T) {
|
||||||
|
|
@ -353,14 +361,3 @@ 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 "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return hex.EncodeToString(b), nil
|
return []string{hex.EncodeToString(b)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ api.Diagnosis = (*Amperfied)(nil)
|
var _ api.Diagnosis = (*Amperfied)(nil)
|
||||||
|
|
|
||||||
|
|
@ -504,24 +504,33 @@ 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 != "" || err != nil {
|
if id := bytesAsString(b); id != "" {
|
||||||
return id, err
|
ids = append(ids, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err := wb.conn.ReadHoldingRegisters(bendRegUserID, 10)
|
b, err := wb.conn.ReadHoldingRegisters(bendRegUserID, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return bytesAsString(b), nil
|
if id := bytesAsString(b); id != "" {
|
||||||
|
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 "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return bytesAsString(b), nil
|
return []string{bytesAsString(b)}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,12 @@ 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)
|
||||||
}
|
}
|
||||||
implement.May(c, implement.Identifier(identify))
|
if identify != nil {
|
||||||
|
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 "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return bytesAsString(b), nil
|
return []string{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 "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return bytesAsString(u), nil
|
return []string{bytesAsString(u)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ api.Diagnosis = (*Dadapower)(nil)
|
var _ api.Diagnosis = (*Dadapower)(nil)
|
||||||
|
|
|
||||||
|
|
@ -318,12 +318,14 @@ 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 "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
return utf16BEBytesAsString(b)
|
|
||||||
|
id, err := utf16BEBytesAsString(b)
|
||||||
|
return []string{id}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// phases1p3p implements the api.PhaseSwitcher interface
|
// phases1p3p implements the api.PhaseSwitcher interface
|
||||||
|
|
@ -361,27 +363,37 @@ func (wb *DaheimLaden) getPhases() (int, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (wb *DaheimLaden) checkStation() error {
|
func (wb *DaheimLaden) checkStation() error {
|
||||||
b, err := wb.conn.ReadHoldingRegisters(dlRegEvseMaxCurrent, 22)
|
// map may still be zero right after the wake-up call- poll briefly until populated
|
||||||
if err != nil {
|
for range 5 {
|
||||||
return api.ErrSponsorRequired
|
b, err := wb.conn.ReadHoldingRegisters(dlRegEvseMaxCurrent, 22)
|
||||||
}
|
if err != nil {
|
||||||
// station id starts (dlRegStationId-dlRegEvseMaxCurrent) registers into the block
|
return err
|
||||||
s, err := utf16BEBytesAsString(b[2*(dlRegStationId-dlRegEvseMaxCurrent):])
|
}
|
||||||
if err != nil || s == "" {
|
|
||||||
return api.ErrSponsorRequired
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, r := range s {
|
s, err := utf16BEBytesAsString(b[2*(dlRegStationId-dlRegEvseMaxCurrent):])
|
||||||
if r < 0x20 || r > 0x7e {
|
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
|
return api.ErrSponsorRequired
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, r := range s {
|
||||||
|
if r < 0x20 || r > 0x7e {
|
||||||
|
return api.ErrSponsorRequired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(strings.ToLower(s), "heidelbridge") {
|
return api.ErrSponsorRequired
|
||||||
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 "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return bytesAsString(b), nil
|
return []string{bytesAsString(b)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ api.Diagnosis = (*Delta)(nil)
|
var _ api.Diagnosis = (*Delta)(nil)
|
||||||
|
|
|
||||||
|
|
@ -748,16 +748,17 @@ 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 "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return rscpString(msg)
|
id, err := 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 c.rfid, nil
|
return []string{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,19 +520,26 @@ 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
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if identification, err := c.cem.EvCC.Identifications(evEntity); err == nil && len(identification) > 0 {
|
identification, err := c.cem.EvCC.Identifications(evEntity)
|
||||||
// return the first identification for now
|
if err != nil {
|
||||||
// later this could be multiple, e.g. MAC Address and PCID
|
return nil, nil
|
||||||
return identification[0].Value, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", nil
|
// may be multiple, e.g. MAC address and PCID
|
||||||
|
var res []string
|
||||||
|
for _, i := range identification {
|
||||||
|
if i.Value != "" {
|
||||||
|
res = append(res, i.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ api.Battery = (*EEBus)(nil)
|
var _ api.Battery = (*EEBus)(nil)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
@ -27,6 +28,7 @@ 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
|
||||||
|
|
@ -58,10 +60,11 @@ 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",
|
||||||
|
|
@ -74,18 +77,19 @@ 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)
|
return NewEEBusOHPCF(ctx, &cc.embed, cc.Ski, cc.Ip, cc.Reboost, cc.DisablePowerLimiter)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) (api.Charger, error) {
|
func NewEEBusOHPCF(ctx context.Context, embed *embed, ski, ip string, reboost time.Duration, disablePowerLimiter bool) (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(),
|
||||||
|
|
@ -96,6 +100,10 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -460,11 +468,8 @@ func (c *EEBusOHPCF) apply(enable bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ api.PowerLimiter = (*EEBusOHPCF)(nil)
|
// getMinMaxPower is the callback used to provide the optional power limits.
|
||||||
|
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,7 +41,6 @@ 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)
|
||||||
|
|
@ -49,7 +48,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
|
enovatesRegCurrentOffered = 417 // uint16 RO, mA, current actually offered to the vehicle
|
||||||
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
|
||||||
|
|
@ -92,9 +91,9 @@ func NewEnovates(ctx context.Context, settings modbus.TcpSettings) (api.Charger,
|
||||||
current: 6000,
|
current: 6000,
|
||||||
}
|
}
|
||||||
|
|
||||||
// verify connection and EMS register availability
|
// the transaction token is only readable when EMS over Modbus TCP is enabled
|
||||||
if _, err := wb.conn.ReadHoldingRegisters(enovatesRegMode3StateStr, 2); err != nil {
|
if _, err := wb.conn.ReadHoldingRegisters(enovatesRegToken, 16); err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("ems over modbus tcp not enabled: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return wb, nil
|
return wb, nil
|
||||||
|
|
@ -160,22 +159,13 @@ 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) {
|
||||||
b, err := wb.conn.ReadHoldingRegisters(enovatesRegEMSLimit, 1)
|
// the EMS limit register may read -1 (no limit), hence use the actually offered current
|
||||||
|
b, err := wb.conn.ReadHoldingRegisters(enovatesRegCurrentOffered, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// -1 signals no EMS limit, fall back to the hardware maximum
|
return float64(binary.BigEndian.Uint16(b)) / 1e3, nil
|
||||||
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)
|
||||||
|
|
@ -233,13 +223,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 "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return trimModbusString(b), nil
|
return []string{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 "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return status.AuthTag.Tag, nil
|
return []string{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