Compare commits
11 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e395dcf371 | ||
|
|
db206de2f9 | ||
|
|
5f5b33289c | ||
|
|
c4fbffa55e | ||
|
|
6e121c778f | ||
|
|
bbb7d704f6 | ||
|
|
d284a2f9af | ||
|
|
96fb302147 | ||
|
|
eb836e9ec8 | ||
|
|
52c0cfaa5d | ||
|
|
d1b1ded0e6 |
531 changed files with 3716 additions and 12678 deletions
|
|
@ -1,73 +0,0 @@
|
|||
name: Build
|
||||
|
||||
# Forgejo bevorzugt .forgejo/workflows gegenüber .github/workflows. Die 23
|
||||
# Upstream-Workflows laufen hier also nicht mit — die verlangen ohnehin
|
||||
# GitHub-spezifische Runner-Label (depot-ubuntu-24.04-arm) und Secrets.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Go build & test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# main.go bindet dist/ per //go:embed ein. Das Verzeichnis wird vom
|
||||
# Frontend-Build erzeugt und ist gitignored — für den reinen Compile-Check
|
||||
# reicht ein Platzhalter, go:embed braucht nur mindestens eine Datei.
|
||||
- name: Stub embedded assets
|
||||
run: mkdir -p dist && echo stub > dist/index.html
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./util/sponsor/... ./charger/... ./core/... ./server/...
|
||||
|
||||
- name: Test
|
||||
run: CGO_ENABLED=0 go test ./...
|
||||
|
||||
binary:
|
||||
name: evcc binary
|
||||
runs-on: ubuntu-latest
|
||||
needs: verify
|
||||
|
||||
steps:
|
||||
# fetch-depth 0: das Makefile leitet VERSION aus git describe --tags ab
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 26
|
||||
cache: npm
|
||||
|
||||
- name: Install UI deps
|
||||
run: npm ci
|
||||
|
||||
- name: Build UI
|
||||
run: make ui
|
||||
|
||||
- name: Build binary
|
||||
run: make build
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: evcc
|
||||
path: evcc
|
||||
2
.github/actions/build-toolchain/action.yml
vendored
2
.github/actions/build-toolchain/action.yml
vendored
|
|
@ -13,7 +13,7 @@ inputs:
|
|||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false # managed explicitly below to keep the scoped namespaces
|
||||
|
|
|
|||
6
.github/actions/docker-cache/action.yml
vendored
6
.github/actions/docker-cache/action.yml
vendored
|
|
@ -32,12 +32,6 @@ runs:
|
|||
${{ runner.os }}-docker-mounts-${{ inputs.cache-scope }}-
|
||||
${{ runner.os }}-docker-mounts-main-
|
||||
|
||||
# the go module cache restores read-only, which fails the dance's cleanup
|
||||
# of the source directory after injection
|
||||
- name: Make cache mounts writable
|
||||
shell: bash
|
||||
run: test -d .docker-cache && chmod -R u+w .docker-cache || true
|
||||
|
||||
- name: Inject cache mounts
|
||||
uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0
|
||||
with:
|
||||
|
|
|
|||
40
.github/scripts/release-tag.sh
vendored
40
.github/scripts/release-tag.sh
vendored
|
|
@ -3,9 +3,7 @@
|
|||
#
|
||||
# Feature releases (patch level 0) must be tagged on master. Bugfix releases may
|
||||
# be tagged on any branch so an older release line can be serviced without
|
||||
# shipping everything that landed on master since. Either way the tag must build
|
||||
# on the previous release of its own line, so a tag pushed from the wrong branch
|
||||
# cannot ship an older commit under a newer version.
|
||||
# shipping everything that landed on master since.
|
||||
#
|
||||
# Prints `latest=<true|false>` for consumption as a GitHub step output. Only the
|
||||
# newest release may move the `latest` pointers (docker tag, homebrew formula,
|
||||
|
|
@ -29,43 +27,13 @@ validate() {
|
|||
return 1
|
||||
fi
|
||||
|
||||
local line="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
|
||||
if [[ ${BASH_REMATCH[3]} == 0 ]] && ! git merge-base --is-ancestor "$tag" "$MASTER_REF"; then
|
||||
echo "::error::feature release '$tag' must be tagged on master" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local releases
|
||||
releases=$(git tag --list | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort --version-sort) || true
|
||||
|
||||
# a release must build on its own line. a tag pushed from the wrong branch
|
||||
# would otherwise ship an older commit under a newer version.
|
||||
local earlier
|
||||
# awk must not exit early here, that would SIGPIPE the grep it reads from
|
||||
earlier=$(printf '%s\n' "$releases" | grep -E "^${line//./\\.}\." | awk -v t="$tag" '$0 == t { seen = 1 } !seen')
|
||||
|
||||
if [[ -n $earlier ]]; then
|
||||
# the newest predecessor the tag really builds on. a tag from the wrong
|
||||
# branch has none, a repeated one has it at the very same commit.
|
||||
local prev
|
||||
prev=$(while read -r rel; do
|
||||
if git merge-base --is-ancestor "$rel" "$tag"; then echo "$rel"; fi
|
||||
done <<<"$earlier" | tail -1)
|
||||
|
||||
if [[ -z $prev ]]; then
|
||||
echo "::error::release '$tag' does not build on $(tail -1 <<<"$earlier"), it was tagged on the wrong branch" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $(git rev-parse "$tag^{commit}") == $(git rev-parse "$prev^{commit}") ]]; then
|
||||
echo "::error::release '$tag' points at the same commit as '$prev'" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
local newest
|
||||
newest=$(printf '%s\n' "$releases" | tail -1)
|
||||
newest=$(git tag --list | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort --version-sort | tail -1) || true
|
||||
|
||||
if [[ $newest == "$tag" ]]; then
|
||||
echo "latest=true"
|
||||
|
|
@ -87,12 +55,10 @@ self_test() {
|
|||
git checkout --quiet -b fix
|
||||
commit two
|
||||
git tag 0.1.1 # bugfix release off master
|
||||
git tag 0.1.2 # same commit as its predecessor
|
||||
git tag 0.2.0 # feature release off master
|
||||
git checkout --quiet master
|
||||
commit three
|
||||
git tag 0.3.0
|
||||
git tag 0.2.1 # tagged on the wrong branch, does not contain 0.2.0
|
||||
git tag 9.9.9-fork # tags from forks must not count as a release
|
||||
|
||||
MASTER_REF=master
|
||||
|
|
@ -110,8 +76,6 @@ self_test() {
|
|||
expect 0.1.0 latest=false # feature release on master, superseded
|
||||
expect 0.1.1 latest=false # bugfix release off master, older line
|
||||
expect 0.3.0 latest=true # newest release
|
||||
expect 0.1.2 FAIL # same commit as its predecessor
|
||||
expect 0.2.1 FAIL # tagged on the wrong branch
|
||||
expect 0.2.0 FAIL # feature release not on master
|
||||
expect 0.1 FAIL # not MAJOR.MINOR.PATCH
|
||||
expect v0.1.0 FAIL # no v prefix allowed
|
||||
|
|
|
|||
32
.github/workflows/command-backport.yml
vendored
32
.github/workflows/command-backport.yml
vendored
|
|
@ -57,33 +57,11 @@ jobs:
|
|||
core.setFailed(message);
|
||||
};
|
||||
|
||||
// the command is usually posted right before hitting merge, so give the
|
||||
// pull request time to land instead of failing on the race
|
||||
const deadline = Date.now() + 5 * 60 * 1000;
|
||||
let pr;
|
||||
for (;;) {
|
||||
try {
|
||||
({ data: pr } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: context.payload.issue.number,
|
||||
}));
|
||||
// a closed pull request will never merge, no point in waiting it out
|
||||
if (pr.merged || pr.state === 'closed') {
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
// a blip while polling must not fail the command, but a request that
|
||||
// is still failing when the time is up is a real error
|
||||
if (Date.now() >= deadline) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 15000));
|
||||
}
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: context.payload.issue.number,
|
||||
});
|
||||
if (!pr.merged) {
|
||||
fail(`pull request #${pr.number} is not merged`);
|
||||
return;
|
||||
|
|
|
|||
1
.github/workflows/command-nightly.yml
vendored
1
.github/workflows/command-nightly.yml
vendored
|
|
@ -60,7 +60,6 @@ jobs:
|
|||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
id-token: write # depot OIDC
|
||||
secrets: inherit
|
||||
|
||||
report:
|
||||
|
|
|
|||
16
.github/workflows/nightly-docker.yml
vendored
16
.github/workflows/nightly-docker.yml
vendored
|
|
@ -13,7 +13,6 @@ jobs:
|
|||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # depot OIDC
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
|
@ -28,10 +27,14 @@ jobs:
|
|||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASS }}
|
||||
|
||||
# depot builders keep the Dockerfile's cache mounts on their own volumes,
|
||||
# so they need neither buildx nor the cache dance
|
||||
- name: Setup Depot
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
||||
- name: Setup Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
|
||||
- name: Restore build cache
|
||||
uses: ./.github/actions/docker-cache
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
|
||||
- name: Define tags
|
||||
id: meta
|
||||
|
|
@ -43,9 +46,8 @@ jobs:
|
|||
type=raw,value=nightly.{{date 'YYYYMMDD'}}-{{sha}}
|
||||
|
||||
- name: Publish
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
||||
with:
|
||||
project: ${{ secrets.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v6
|
||||
push: true
|
||||
|
|
|
|||
1
.github/workflows/nightly.yml
vendored
1
.github/workflows/nightly.yml
vendored
|
|
@ -48,7 +48,6 @@ jobs:
|
|||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
id-token: write # depot OIDC
|
||||
secrets: inherit
|
||||
|
||||
hassio:
|
||||
|
|
|
|||
22
.github/workflows/release.yml
vendored
22
.github/workflows/release.yml
vendored
|
|
@ -46,7 +46,6 @@ jobs:
|
|||
runs-on: depot-ubuntu-24.04-arm
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # depot OIDC
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
|
@ -60,10 +59,14 @@ jobs:
|
|||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASS }}
|
||||
|
||||
# depot builders keep the Dockerfile's cache mounts on their own volumes,
|
||||
# so they need neither buildx nor the cache dance
|
||||
- name: Setup Depot
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
||||
- name: Setup Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
|
||||
- name: Restore build cache
|
||||
uses: ./.github/actions/docker-cache
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
|
||||
- name: Meta
|
||||
id: meta
|
||||
|
|
@ -76,9 +79,8 @@ jobs:
|
|||
latest=${{ needs.guard.outputs.latest }}
|
||||
|
||||
- name: Publish
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
||||
with:
|
||||
project: ${{ secrets.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v6
|
||||
push: true
|
||||
|
|
@ -144,12 +146,6 @@ jobs:
|
|||
# a bugfix release of an older line must not move the homebrew formula
|
||||
# or the Github latest release marker
|
||||
MAKE_LATEST: ${{ needs.guard.outputs.latest }}
|
||||
# macOS code signing and notarization
|
||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ linters:
|
|||
settings:
|
||||
modernize:
|
||||
disable:
|
||||
- embedlit
|
||||
- stringsbuilder
|
||||
staticcheck:
|
||||
checks:
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ builds:
|
|||
- -trimpath
|
||||
- -tags=release
|
||||
ldflags:
|
||||
- -X github.com/evcc-io/evcc/util.Version={{ .Version }} -X github.com/evcc-io/evcc/util.Commit={{ .ShortCommit }} -s -w
|
||||
# the package version carries a timestamp for ordering, the reported version does not
|
||||
- -X github.com/evcc-io/evcc/util.Version={{ if .IsSnapshot }}{{ incminor .Tag }}-dev+{{ .ShortCommit }}{{ else }}{{ .Version }}{{ end }} -X github.com/evcc-io/evcc/util.Commit={{ .ShortCommit }} -s -w
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
|
|
@ -56,9 +57,10 @@ checksum:
|
|||
name_template: "checksums.txt"
|
||||
|
||||
snapshot:
|
||||
# semver pre-release of the upcoming version, the timestamp keeps the package
|
||||
# version increasing monotonically for apt
|
||||
version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}"
|
||||
# semver pre-release of the upcoming version. The timestamp makes the package
|
||||
# version increase monotonically: apt compares it numerically, while the commit
|
||||
# alone would sort randomly.
|
||||
version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}+{{ .ShortCommit }}"
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ builds:
|
|||
- -trimpath
|
||||
- -tags=release
|
||||
ldflags:
|
||||
- -X github.com/evcc-io/evcc/util.Version={{ .Version }} -s -w
|
||||
# the package version carries a timestamp for ordering, the reported version does not
|
||||
- -X github.com/evcc-io/evcc/util.Version={{ if .IsSnapshot }}{{ incminor .Tag }}-dev+{{ .ShortCommit }}{{ else }}{{ .Version }}{{ end }} -s -w
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
|
|
@ -59,26 +60,14 @@ archives:
|
|||
universal_binaries:
|
||||
- replace: true
|
||||
|
||||
notarize:
|
||||
macos:
|
||||
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
|
||||
sign:
|
||||
certificate: "{{ .Env.MACOS_SIGN_P12 }}"
|
||||
password: "{{ .Env.MACOS_SIGN_PASSWORD }}"
|
||||
notarize:
|
||||
issuer_id: "{{ .Env.MACOS_NOTARY_ISSUER_ID }}"
|
||||
key_id: "{{ .Env.MACOS_NOTARY_KEY_ID }}"
|
||||
key: "{{ .Env.MACOS_NOTARY_KEY }}"
|
||||
wait: true
|
||||
timeout: 20m
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
|
||||
snapshot:
|
||||
# semver pre-release of the upcoming version, the timestamp keeps the package
|
||||
# version increasing monotonically for apt
|
||||
version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}"
|
||||
# semver pre-release of the upcoming version. The timestamp makes the package
|
||||
# version increase monotonically: apt compares it numerically, while the commit
|
||||
# alone would sort randomly.
|
||||
version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}+{{ .ShortCommit }}"
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
|
|
@ -135,7 +124,7 @@ nfpms:
|
|||
preremove: ./packaging/scripts/preremove.sh
|
||||
postremove: ./packaging/scripts/postremove.sh
|
||||
|
||||
homebrew_casks:
|
||||
brews:
|
||||
- skip_upload: '{{ if eq (envOrDefault "MAKE_LATEST" "true") "true" }}false{{ else }}true{{ end }}'
|
||||
repository:
|
||||
owner: evcc-io
|
||||
|
|
@ -143,7 +132,15 @@ homebrew_casks:
|
|||
commit_author:
|
||||
name: andig
|
||||
email: info@evcc.io
|
||||
directory: Formula
|
||||
homepage: "https://evcc.io"
|
||||
description: "Sonne tanken ☀️🚘"
|
||||
binaries:
|
||||
- evcc
|
||||
license: "MIT"
|
||||
test: |
|
||||
system "#{bin}/evcc --version"
|
||||
service: |
|
||||
run [opt_bin/"evcc"]
|
||||
working_dir HOMEBREW_PREFIX
|
||||
keep_alive true
|
||||
log_path var/"log/evcc.log"
|
||||
error_log_path var/"log/evcc.log"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,4 @@
|
|||
import { StorybookConfig } from "@storybook/vue3-vite";
|
||||
import { createReadStream } from "node:fs";
|
||||
|
||||
// serve test logos so the custom branding stories can render them
|
||||
const customLogos = {
|
||||
"/custom-logo-light": "tests/custom-logo-light.svg",
|
||||
"/custom-logo-dark": "tests/custom-logo-dark.svg",
|
||||
};
|
||||
|
||||
export default {
|
||||
stories: ["../assets/js/**/*.stories.@(js|ts)"],
|
||||
|
|
@ -17,21 +10,4 @@ export default {
|
|||
core: {
|
||||
disableTelemetry: true,
|
||||
},
|
||||
viteFinal: (config) => ({
|
||||
...config,
|
||||
plugins: [
|
||||
...(config.plugins ?? []),
|
||||
{
|
||||
name: "evcc-custom-logos",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const file = customLogos[req.url?.split("?")[0] as keyof typeof customLogos];
|
||||
if (!file) return next();
|
||||
res.setHeader("Content-Type", "image/svg+xml");
|
||||
createReadStream(file).pipe(res);
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
} satisfies StorybookConfig;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { type Preview, setup } from "@storybook/vue3";
|
|||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import smoothscroll from "smoothscroll-polyfill";
|
||||
import setupI18n from "../assets/js/i18n";
|
||||
import tooltip from "../assets/js/directives/tooltip";
|
||||
import "../assets/css/app.css";
|
||||
import { watchThemeChanges } from "../assets/js/theme";
|
||||
|
||||
|
|
@ -36,7 +35,6 @@ export default {
|
|||
|
||||
setup((app) => {
|
||||
app.use(setupI18n());
|
||||
app.directive("tooltip", tooltip);
|
||||
|
||||
// Mock router-link for Storybook
|
||||
app.component("router-link", {
|
||||
|
|
|
|||
|
|
@ -200,7 +200,6 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w
|
|||
### Styling & Internationalization
|
||||
|
||||
- Use CSS Custom Properties for theming (semantic names: `--evcc-green`, `--evcc-battery`)
|
||||
- No inline `style` attributes; use Bootstrap utility classes or scoped CSS (inline only for truly dynamic values)
|
||||
- Use existing custom media queries for responsive breakpoints
|
||||
- Use `$t()` function for all user-facing strings
|
||||
- Update both `i18n/en.json` and `i18n/de.json` for new strings
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ FROM --platform=$BUILDPLATFORM node:26-alpine AS node
|
|||
|
||||
RUN apk update && apk add --no-cache make curl bash && curl -fsSL https://vite.plus | bash
|
||||
|
||||
# the installer only wires vp into interactive shell rc files, which RUN steps don't source.
|
||||
# newer vite+ releases dropped the split directory layout, so accept both bin locations
|
||||
ENV PATH="/root/.vite-plus/bin:/root/.local/share/vite-plus/bin:${PATH}"
|
||||
# the installer only wires vp into interactive shell rc files, which RUN steps don't source
|
||||
ENV PATH="/root/.vite-plus/bin:${PATH}"
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
|
|
@ -25,7 +24,7 @@ RUN make ui
|
|||
|
||||
|
||||
# STEP 2 build executable binary
|
||||
FROM --platform=$BUILDPLATFORM golang:1.27.0-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS builder
|
||||
|
||||
# Install git + SSL ca certificates.
|
||||
# Git is required for fetching the dependencies.
|
||||
|
|
|
|||
54
FORK.md
54
FORK.md
|
|
@ -1,54 +0,0 @@
|
|||
# Fork-Hinweise
|
||||
|
||||
Fork von [evcc-io/evcc](https://github.com/evcc-io/evcc).
|
||||
|
||||
## Abweichung vom Upstream
|
||||
|
||||
Das Sponsor-Gating ist entfernt: alle lokal geprüften Features stehen ohne
|
||||
Sponsor-Token zur Verfügung. Der Eingriff sitzt an einer einzigen Stelle in
|
||||
`util/sponsor/auth.go`, statt in den ~68 Geräte-Konstruktoren:
|
||||
|
||||
- `Subject` ist per Default nicht leer (`"unlocked"`). Damit meldet
|
||||
`RedactedStatus()` einen aktiven Sponsor-Status, und das Frontend schaltet
|
||||
`isSponsor` (`assets/js/views/Config.vue`) frei — Geräte-Dialoge zeigen kein
|
||||
„Sponsor-Token erforderlich" mehr.
|
||||
- `IsAuthorized()` liefert immer `true`. Das öffnet alle Aufrufer, u. a. die
|
||||
Charger-/Vehicle-Konstruktoren, `server/modbus/proxy.go` und den
|
||||
Optimizer-Gate in `core/site_optimizer.go`.
|
||||
- `ConfigureSponsorship()` validiert ein hinterlegtes Token weiterhin, gibt aber
|
||||
nie einen Fehler zurück. Ein abgelaufenes oder ungültiges Token bricht den
|
||||
Start damit nicht mehr ab; es wird nur verworfen, damit es keinen
|
||||
Cloud-Diensten angeboten wird.
|
||||
|
||||
Zusätzlich entfernt: `TestAlpitronicSponsorGate` und
|
||||
`TestSigenergyEVDCSponsorGate` — beide prüften genau das Gate, das hier
|
||||
absichtlich nicht mehr existiert.
|
||||
|
||||
## Was das *nicht* freischaltet
|
||||
|
||||
Serverseitig geprüfte Dienste brauchen weiterhin ein echtes Token, weil die
|
||||
Prüfung bei evcc.io stattfindet und nicht im Client:
|
||||
|
||||
- Optimizer-API (`core/site_optimizer.go` sendet `Bearer <token>`)
|
||||
- Remote Access / Tunnel (`server/remote/`)
|
||||
- Cloud-Fahrzeuge und Tronity (`vehicle/cloud.go`, `vehicle/tronity.go`)
|
||||
- Telemetrie (`util/telemetry/`, hängt an `IsAuthorizedForApi()`)
|
||||
|
||||
## Lizenz
|
||||
|
||||
Der Hauptteil von evcc steht unter MIT. `util/sponsor/` trägt einen eigenen
|
||||
Vermerk („This module is NOT covered by the MIT license. All rights reserved.").
|
||||
Diese Änderung betrifft genau dieses Modul.
|
||||
|
||||
evcc finanziert sich über Sponsoring: <https://sponsor.evcc.io>
|
||||
|
||||
## Upstream-Sync
|
||||
|
||||
```
|
||||
git remote add upstream https://github.com/evcc-io/evcc.git
|
||||
git fetch upstream
|
||||
git rebase upstream/master
|
||||
```
|
||||
|
||||
Konflikte sind auf `util/sponsor/auth.go` beschränkt; `FORK.md` liegt bewusst
|
||||
außerhalb der Upstream-Dateien.
|
||||
19
Makefile
19
Makefile
|
|
@ -6,16 +6,15 @@ GIT_TAG := $(shell test -d .git && git describe --abbrev=0 --tags)
|
|||
# commits since the latest tag, empty or 0 means the commit is tagged
|
||||
GIT_DIST := $(shell test -n "$(GIT_TAG)" && git rev-list --count $(GIT_TAG)..HEAD)
|
||||
NEXT_TAG = $(shell echo $(GIT_TAG) | awk -F. -v b='$(BUMP)' '{ if (b == "patch") { m = $$2; p = $$3 + 1 } else { m = $$2 + 1; p = 0 }; print $$1 "." m "." p }')
|
||||
# untagged builds are semver pre-releases of the upcoming version, the build
|
||||
# timestamp keeps them increasing monotonically
|
||||
BUILD_TIMESTAMP := $(shell date -u '+%s')
|
||||
TAG_NAME ?= $(if $(filter-out 0,$(GIT_DIST)),$(NEXT_TAG)-dev.$(BUILD_TIMESTAMP),$(GIT_TAG))
|
||||
# untagged builds are semver pre-releases of the upcoming version
|
||||
TAG_NAME ?= $(if $(filter-out 0,$(GIT_DIST)),$(NEXT_TAG)-dev+$(SHA),$(GIT_TAG))
|
||||
COMMIT := $(SHA)
|
||||
# hide commit for releases
|
||||
ifeq ($(RELEASE),1)
|
||||
COMMIT :=
|
||||
endif
|
||||
VERSION := $(if $(TAG_NAME),$(TAG_NAME),$(SHA))
|
||||
BUILD_DATE := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
|
||||
BUILD_TAGS := -tags=release
|
||||
LD_FLAGS := -X github.com/evcc-io/evcc/util.Version=$(VERSION) -X github.com/evcc-io/evcc/util.Commit=$(COMMIT) -s -w
|
||||
BUILD_ARGS := -trimpath -ldflags='$(LD_FLAGS)'
|
||||
|
|
@ -34,7 +33,7 @@ IMAGE_FILE := evcc_$(TAG_NAME).img
|
|||
PACKAGES = ./release
|
||||
|
||||
# asn1-patch
|
||||
GOROOT = $(shell go env GOROOT)
|
||||
GOROOT := $(shell go env GOROOT)
|
||||
CURRDIR := $(shell pwd)
|
||||
|
||||
default:: ui build
|
||||
|
|
@ -100,7 +99,7 @@ porcelain::
|
|||
test -z "$$(git status --porcelain)" || (git status; git diff; false)
|
||||
|
||||
build::
|
||||
@echo Version: $(VERSION) $(SHA)
|
||||
@echo Version: $(VERSION) $(SHA) $(BUILD_DATE)
|
||||
CGO_ENABLED=0 go build -v $(BUILD_TAGS) $(BUILD_ARGS)
|
||||
|
||||
snapshot::
|
||||
|
|
@ -110,15 +109,15 @@ release::
|
|||
goreleaser --clean
|
||||
|
||||
docker::
|
||||
@echo Version: $(VERSION) $(SHA)
|
||||
@echo Version: $(VERSION) $(SHA) $(BUILD_DATE)
|
||||
docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):$(DOCKER_TAG) --push .
|
||||
|
||||
publish-nightly::
|
||||
@echo Version: $(VERSION) $(SHA)
|
||||
@echo Version: $(VERSION) $(SHA) $(BUILD_DATE)
|
||||
docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):nightly --push .
|
||||
|
||||
publish-release::
|
||||
@echo Version: $(VERSION) $(SHA)
|
||||
@echo Version: $(VERSION) $(SHA) $(BUILD_DATE)
|
||||
docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):latest --tag $(DOCKER_IMAGE):$(VERSION) --build-arg RELEASE=1 --push .
|
||||
|
||||
apt-nightly::
|
||||
|
|
@ -154,7 +153,7 @@ gok-update::
|
|||
${GOK} update yes
|
||||
|
||||
soc::
|
||||
@echo Version: $(VERSION) $(SHA)
|
||||
@echo Version: $(VERSION) $(SHA) $(BUILD_DATE)
|
||||
go build $(BUILD_TAGS) $(BUILD_ARGS) github.com/evcc-io/evcc/cmd/soc
|
||||
|
||||
# patch asn1.go to allow Elli buggy certificates to be accepted with EEBUS
|
||||
|
|
|
|||
|
|
@ -130,10 +130,9 @@ type ChargeRater interface {
|
|||
ChargedEnergy() (float64, error)
|
||||
}
|
||||
|
||||
// Identifier identifies a vehicle and is implemented by the charger.
|
||||
// A charger may know more than one identity, e.g. an RFID tag and a vehicle id.
|
||||
// Identifier identifies a vehicle and is implemented by the charger
|
||||
type Identifier interface {
|
||||
Identify() ([]string, error)
|
||||
Identify() (string, error)
|
||||
}
|
||||
|
||||
// Authorizer authorizes a charging session by supplying RFID credentials
|
||||
|
|
|
|||
19
api/error.go
19
api/error.go
|
|
@ -7,28 +7,23 @@ import (
|
|||
"github.com/cenkalti/backoff/v4"
|
||||
)
|
||||
|
||||
// permanentError is a sentinel error that signals permanence to backoff while
|
||||
// remaining distinguishable from the other permanent sentinels.
|
||||
// permanentError is a sentinel error that keeps matching errors.Is after
|
||||
// backoff has stripped the backoff.Permanent wrapper.
|
||||
type permanentError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *permanentError) Error() string { return e.msg }
|
||||
|
||||
// As signals permanence to backoff. Wrapping the sentinel in backoff.Permanent
|
||||
// instead would make errors.Is match any other permanent error, since
|
||||
// backoff.PermanentError.Is matches by type rather than identity.
|
||||
func (e *permanentError) As(target any) bool {
|
||||
if p, ok := target.(**backoff.PermanentError); ok {
|
||||
*p = &backoff.PermanentError{Err: e}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
// Is matches the wrapped sentinel, too
|
||||
func (e *permanentError) Is(target error) bool {
|
||||
var t *permanentError
|
||||
return errors.As(target, &t) && t == e
|
||||
}
|
||||
|
||||
// permanent creates a permanent sentinel error
|
||||
func permanent(msg string) error {
|
||||
return &permanentError{msg}
|
||||
return backoff.Permanent(&permanentError{msg})
|
||||
}
|
||||
|
||||
// ErrNotAvailable indicates that a feature is not available
|
||||
|
|
|
|||
|
|
@ -8,47 +8,20 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// permanentSentinels are all errors created by permanent()
|
||||
var permanentSentinels = []error{
|
||||
ErrNotAvailable,
|
||||
ErrUnsupportedPlatform,
|
||||
ErrSponsorRequired,
|
||||
ErrMissingCredentials,
|
||||
ErrMissingToken,
|
||||
}
|
||||
|
||||
// Backoff returns permanent errors unwrapped. These must still match the sentinel.
|
||||
func TestPermanentSentinels(t *testing.T) {
|
||||
for _, err := range permanentSentinels {
|
||||
for _, tc := range []struct{ err, other error }{
|
||||
{ErrNotAvailable, ErrUnsupportedPlatform},
|
||||
{ErrUnsupportedPlatform, ErrNotAvailable},
|
||||
{ErrMissingCredentials, ErrMissingToken},
|
||||
{ErrMissingToken, ErrMissingCredentials},
|
||||
} {
|
||||
_, unwrapped := backoff.RetryWithData(func() (int, error) {
|
||||
return 0, err
|
||||
return 0, tc.err
|
||||
}, &backoff.StopBackOff{})
|
||||
|
||||
assert.ErrorIs(t, unwrapped, err)
|
||||
assert.ErrorIs(t, fmt.Errorf("wrapped: %w", unwrapped), err)
|
||||
assert.ErrorIs(t, fmt.Errorf("wrapped: %w", err), err)
|
||||
assert.ErrorIs(t, unwrapped, tc.err)
|
||||
assert.ErrorIs(t, fmt.Errorf("wrapped: %w", unwrapped), tc.err)
|
||||
assert.NotErrorIs(t, unwrapped, tc.other)
|
||||
}
|
||||
}
|
||||
|
||||
// Permanent sentinels must not match each other, whether returned directly or
|
||||
// unwrapped by backoff.
|
||||
func TestPermanentSentinelIdentity(t *testing.T) {
|
||||
for _, err := range permanentSentinels {
|
||||
_, unwrapped := backoff.RetryWithData(func() (int, error) {
|
||||
return 0, err
|
||||
}, &backoff.StopBackOff{})
|
||||
|
||||
for _, other := range permanentSentinels {
|
||||
if other == err {
|
||||
continue
|
||||
}
|
||||
|
||||
assert.NotErrorIs(t, err, other)
|
||||
assert.NotErrorIs(t, unwrapped, other)
|
||||
assert.NotErrorIs(t, fmt.Errorf("wrapped: %w", err), other)
|
||||
}
|
||||
}
|
||||
|
||||
// login required is permanent, too
|
||||
assert.NotErrorIs(t, LoginRequiredError("foo"), ErrNotAvailable)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ type All struct {
|
|||
Site map[string]any
|
||||
Loadpoints []config.Named
|
||||
Circuits []config.Named
|
||||
Curtailers []config.Named
|
||||
}
|
||||
|
||||
type Javascript struct {
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ func (i *iDimmer) Dimmed() (bool, error) {
|
|||
return i.dimmer1()
|
||||
}
|
||||
|
||||
func Identifier(identifier0 func() ([]string, error)) api.Identifier {
|
||||
func Identifier(identifier0 func() (string, error)) api.Identifier {
|
||||
if identifier0 == nil {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -236,10 +236,10 @@ func Identifier(identifier0 func() ([]string, error)) api.Identifier {
|
|||
}
|
||||
|
||||
type iIdentifier struct {
|
||||
identifier0 func() ([]string, error)
|
||||
identifier0 func() (string, error)
|
||||
}
|
||||
|
||||
func (i *iIdentifier) Identify() ([]string, error) {
|
||||
func (i *iIdentifier) Identify() (string, error) {
|
||||
return i.identifier0()
|
||||
}
|
||||
|
||||
|
|
@ -378,21 +378,6 @@ func (i *iPhaseVoltages) Voltages() (float64, float64, float64, error) {
|
|||
return i.phaseVoltages0()
|
||||
}
|
||||
|
||||
func PowerLimiter(powerLimiter0 func() (float64, float64, error)) api.PowerLimiter {
|
||||
if powerLimiter0 == nil {
|
||||
return nil
|
||||
}
|
||||
return &iPowerLimiter{powerLimiter0}
|
||||
}
|
||||
|
||||
type iPowerLimiter struct {
|
||||
powerLimiter0 func() (float64, float64, error)
|
||||
}
|
||||
|
||||
func (i *iPowerLimiter) GetMinMaxPower() (float64, float64, error) {
|
||||
return i.powerLimiter0()
|
||||
}
|
||||
|
||||
func Resurrector(resurrector0 func() error) api.Resurrector {
|
||||
if resurrector0 == nil {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -396,10 +396,10 @@ func (m *MockIdentifier) EXPECT() *MockIdentifierMockRecorder {
|
|||
}
|
||||
|
||||
// Identify mocks base method.
|
||||
func (m *MockIdentifier) Identify() ([]string, error) {
|
||||
func (m *MockIdentifier) Identify() (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Identify")
|
||||
ret0, _ := ret[0].([]string)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@
|
|||
--evcc-gray: var(--bs-gray-medium);
|
||||
--evcc-gray-50: #93949e80;
|
||||
--evcc-gray-25: #93949e40;
|
||||
--evcc-gray-15: #93949e26;
|
||||
--evcc-gray-10: #93949e20;
|
||||
--evcc-accent1: var(--evcc-dark-yellow);
|
||||
--evcc-accent2: var(--evcc-darker-green);
|
||||
|
|
@ -952,9 +951,3 @@ input::-webkit-date-and-time-value {
|
|||
.sticky-bottom {
|
||||
bottom: calc(var(--tab-bar-height) + 0.25rem);
|
||||
}
|
||||
|
||||
[_echarts_instance_] {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { VueHeadMixin, createHead } from "@unhead/vue/client";
|
|||
import App from "./views/App.vue";
|
||||
import setupRouter from "./router.ts";
|
||||
import setupI18n from "./i18n.ts";
|
||||
import tooltip from "./directives/tooltip.ts";
|
||||
import { watchThemeChanges } from "./theme.ts";
|
||||
import { applyUrlSettings } from "./urlSettings.ts";
|
||||
import { appDetection, sendToApp } from "./utils/native";
|
||||
|
|
@ -100,7 +99,6 @@ app.use(i18n);
|
|||
app.use(router);
|
||||
app.use(head);
|
||||
app.mixin(VueHeadMixin);
|
||||
app.directive("tooltip", tooltip);
|
||||
window.app = app.mount("#app");
|
||||
|
||||
watchThemeChanges();
|
||||
|
|
|
|||
|
|
@ -52,11 +52,6 @@ export default {
|
|||
hasUpdater: { control: "boolean" },
|
||||
uploadMessage: { control: "text" },
|
||||
uploadProgress: { control: "number" },
|
||||
customLogo: { control: "boolean" },
|
||||
customBrand: { control: "text" },
|
||||
customWebsite: { control: "text" },
|
||||
customEmail: { control: "text" },
|
||||
customPhone: { control: "text" },
|
||||
},
|
||||
} as Meta<typeof AboutModal>;
|
||||
|
||||
|
|
@ -99,22 +94,10 @@ StableUpdateWithUpdater.args = {
|
|||
|
||||
export const Nightly = Template.bind({});
|
||||
Nightly.args = {
|
||||
installed: "0.304.0-dev.1712345678",
|
||||
commit: "5ce7be4",
|
||||
installed: "0.304.0-dev+5ce7be4",
|
||||
};
|
||||
|
||||
export const DevBuild = Template.bind({});
|
||||
DevBuild.args = {
|
||||
installed: "0.0.0",
|
||||
};
|
||||
|
||||
export const CustomBranding = Template.bind({});
|
||||
CustomBranding.args = {
|
||||
installed: "0.303.1",
|
||||
availableVersion: "0.303.1",
|
||||
customLogo: true,
|
||||
customBrand: "G1GA HEMS",
|
||||
customWebsite: "https://example.com/hems",
|
||||
customEmail: "support@example.com",
|
||||
customPhone: "+49 123 456789",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<GenericModal id="aboutModal" @opened="acknowledge">
|
||||
<GenericModal id="aboutModal" :size="modalSize" @opened="acknowledge">
|
||||
<template #title>
|
||||
<a :href="websiteUrl" target="_blank" rel="noopener noreferrer">
|
||||
<template v-if="customLogo">
|
||||
|
|
@ -44,12 +44,14 @@
|
|||
>
|
||||
v{{ installed }}
|
||||
</a>
|
||||
<Badge v-if="newVersionAvailable">
|
||||
{{ $t("footer.version.availableLong") }}
|
||||
</Badge>
|
||||
<Badge v-else-if="!nightly" variant="muted">
|
||||
{{ $t("footer.version.latestVersion") }}
|
||||
</Badge>
|
||||
<span
|
||||
v-if="!nightly && !newVersionAvailable"
|
||||
class="text-muted text-nowrap"
|
||||
>{{ $t("footer.version.latestVersion") }}</span
|
||||
>
|
||||
<span v-if="newVersionAvailable" class="text-nowrap">{{
|
||||
$t("footer.version.availableLong")
|
||||
}}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -113,22 +115,8 @@
|
|||
|
||||
<!-- open source -->
|
||||
<hr />
|
||||
<p
|
||||
class="mb-0 d-flex justify-content-between align-items-center flex-wrap column-gap-2 row-gap-1"
|
||||
>
|
||||
<span v-if="customLogo" class="d-flex align-items-center gap-2">
|
||||
<span>{{ $t("footer.version.poweredBy") }}</span>
|
||||
<a
|
||||
:href="evccWebsiteUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="d-flex"
|
||||
aria-label="evcc"
|
||||
>
|
||||
<Logo class="footer-logo" />
|
||||
</a>
|
||||
</span>
|
||||
<i18n-t v-else keypath="footer.version.madeByCommunity" tag="span">
|
||||
<p class="mb-0 small d-flex flex-wrap column-gap-1">
|
||||
<i18n-t keypath="footer.version.madeByCommunity" tag="span">
|
||||
<a
|
||||
:href="githubRepoUrl"
|
||||
target="_blank"
|
||||
|
|
@ -137,13 +125,15 @@
|
|||
>{{ $t("footer.version.community") }}</a
|
||||
>
|
||||
</i18n-t>
|
||||
<a
|
||||
class="evcc-gray"
|
||||
:href="githubDependenciesUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ $t("footer.version.openSource") }}</a
|
||||
>
|
||||
<i18n-t keypath="footer.version.poweredByOpenSource" tag="span" class="d-inline">
|
||||
<a
|
||||
class="text-muted"
|
||||
:href="githubDependenciesUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ $t("footer.version.openSource") }}</a
|
||||
>
|
||||
</i18n-t>
|
||||
</p>
|
||||
</div>
|
||||
</GenericModal>
|
||||
|
|
@ -154,11 +144,16 @@ import GenericModal from "./Helper/GenericModal.vue";
|
|||
import "@h2d2/shopicons/es/regular/gift";
|
||||
import "@h2d2/shopicons/es/filled/heart";
|
||||
import Logo from "./Footer/Logo.vue";
|
||||
import Badge from "./Helper/Badge.vue";
|
||||
import api from "@/api";
|
||||
import settings from "@/settings";
|
||||
import { extractDomain } from "@/utils/extractDomain";
|
||||
import { isDevelopment, isNightly, getReleaseName, isNewVersionAvailable } from "@/utils/version";
|
||||
import {
|
||||
isDevelopment,
|
||||
isNightly,
|
||||
getReleaseName,
|
||||
commitFromVersion,
|
||||
isNewVersionAvailable,
|
||||
} from "@/utils/version";
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
const GITHUB_REPO = "https://github.com/evcc-io/evcc";
|
||||
|
|
@ -166,10 +161,9 @@ const EVCC_WEBSITE = "https://evcc.io/";
|
|||
|
||||
export default defineComponent({
|
||||
name: "AboutModal",
|
||||
components: { GenericModal, Logo, Badge },
|
||||
components: { GenericModal, Logo },
|
||||
props: {
|
||||
installed: { type: String, default: "" },
|
||||
commit: { type: String, default: "" },
|
||||
availableVersion: String,
|
||||
releaseNotes: String,
|
||||
hasUpdater: Boolean,
|
||||
|
|
@ -210,14 +204,14 @@ export default defineComponent({
|
|||
githubRepoUrl() {
|
||||
return GITHUB_REPO;
|
||||
},
|
||||
evccWebsiteUrl() {
|
||||
return EVCC_WEBSITE;
|
||||
},
|
||||
githubDependenciesUrl() {
|
||||
return `${GITHUB_REPO}/network/dependencies`;
|
||||
},
|
||||
githubCommitUrl() {
|
||||
return `${GITHUB_REPO}/commit/${this.commit}`;
|
||||
return `${GITHUB_REPO}/commit/${commitFromVersion(this.installed)}`;
|
||||
},
|
||||
modalSize() {
|
||||
return this.newVersionAvailable ? undefined : "sm";
|
||||
},
|
||||
cleanedReleaseNotes() {
|
||||
if (!this.releaseNotes) return "";
|
||||
|
|
@ -252,9 +246,6 @@ export default defineComponent({
|
|||
.about-logo {
|
||||
height: 2.5rem;
|
||||
}
|
||||
.footer-logo {
|
||||
height: 1rem;
|
||||
}
|
||||
.custom-logo {
|
||||
height: 4rem;
|
||||
width: auto;
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export default defineComponent({
|
|||
},
|
||||
computed: {
|
||||
resetUrl() {
|
||||
return `${docsPrefix()}/faq#password-reset`;
|
||||
return `${docsPrefix()}/docs/faq#password-reset`;
|
||||
},
|
||||
modalSize() {
|
||||
return this.demoMode ? "md" : "sm";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
<template>
|
||||
<div data-testid="plan-strategy" class="mb-5 mb-lg-4">
|
||||
<div :class="{ 'mb-2': open }">
|
||||
<div v-if="disabled">
|
||||
<span class="fw-bold evcc-gray">{{ $t("main.chargingPlan.strategy.label") }}:</span>
|
||||
{{ " " }}
|
||||
<span class="small">{{ summary }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-link btn-sm text-gray p-0 border-0 d-flex align-items-center text-start"
|
||||
:class="{ 'text-primary': open }"
|
||||
|
|
@ -16,7 +22,13 @@
|
|||
</div>
|
||||
<div class="collapsible-wrapper" :class="{ open }">
|
||||
<div class="collapsible-content ring-space">
|
||||
<div class="row">
|
||||
<div v-if="disabled" class="row mb-4">
|
||||
<div class="small text-muted">
|
||||
<strong class="text-primary">{{ $t("general.note") }}</strong>
|
||||
{{ $t("main.chargingPlan.strategyDisabledDescription") }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="row">
|
||||
<div class="col-12 col-lg-6 mb-3">
|
||||
<div class="row">
|
||||
<label
|
||||
|
|
@ -27,15 +39,6 @@
|
|||
</label>
|
||||
<div class="col-7 col-lg-12">
|
||||
<select
|
||||
v-if="disabled"
|
||||
:id="formId('continuous')"
|
||||
class="form-select"
|
||||
disabled
|
||||
>
|
||||
<option>{{ $t("general.none") }}</option>
|
||||
</select>
|
||||
<select
|
||||
v-else
|
||||
:id="formId('continuous')"
|
||||
v-model="localContinuous"
|
||||
class="form-select"
|
||||
|
|
@ -64,17 +67,6 @@
|
|||
</label>
|
||||
<div class="col-7 col-lg-12">
|
||||
<select
|
||||
v-if="disabled"
|
||||
:id="formId('precondition')"
|
||||
class="form-select"
|
||||
disabled
|
||||
>
|
||||
<option>
|
||||
{{ $t("main.chargingPlan.precondition.optionAll") }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-else
|
||||
:id="formId('precondition')"
|
||||
v-model="localPrecondition"
|
||||
class="form-select"
|
||||
|
|
@ -94,11 +86,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="small text-muted mt-1">
|
||||
{{
|
||||
$t(
|
||||
`main.chargingPlan.precondition.${disabled ? "disabledDescription" : "description"}`
|
||||
)
|
||||
}}
|
||||
{{ $t("main.chargingPlan.precondition.description") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -113,13 +101,6 @@ import formatter from "@/mixins/formatter";
|
|||
import { SMART_COST_TYPE, type PlanStrategy } from "@/types/evcc";
|
||||
import DropdownIcon from "../MaterialIcon/Dropdown.vue";
|
||||
|
||||
const HOUR = 60 * 60;
|
||||
const QUARTER_HOUR = 0.25 * HOUR;
|
||||
const HALF_HOUR = 0.5 * HOUR;
|
||||
const ONE_HOUR = 1 * HOUR;
|
||||
const TWO_HOURS = 2 * HOUR;
|
||||
const EVERYTHING = 7 * 24 * HOUR;
|
||||
|
||||
export default defineComponent({
|
||||
name: "ChargingPlanStrategy",
|
||||
components: { DropdownIcon },
|
||||
|
|
@ -128,6 +109,7 @@ export default defineComponent({
|
|||
id: [String, Number],
|
||||
precondition: { type: Number, default: 0 },
|
||||
continuous: { type: Boolean, default: false },
|
||||
disabled: Boolean,
|
||||
smartCostType: String as PropType<SMART_COST_TYPE>,
|
||||
},
|
||||
emits: ["update"],
|
||||
|
|
@ -139,10 +121,6 @@ export default defineComponent({
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
disabled(): boolean {
|
||||
// options only make sense with a dynamic planner tariff
|
||||
return !this.smartCostType || this.smartCostType === SMART_COST_TYPE.PRICE_STATIC;
|
||||
},
|
||||
isCo2(): boolean {
|
||||
return this.smartCostType === SMART_COST_TYPE.CO2;
|
||||
},
|
||||
|
|
@ -151,16 +129,14 @@ export default defineComponent({
|
|||
},
|
||||
summary(): string {
|
||||
if (this.disabled) {
|
||||
return this.$t("main.chargingPlan.precondition.summaryAll");
|
||||
return this.$t("main.chargingPlan.strategyDisabledDescription");
|
||||
}
|
||||
const parts = [
|
||||
this.$t(
|
||||
`main.chargingPlan.optimization.${this.continuous ? "continuous" : this.cheapestKey}`
|
||||
),
|
||||
];
|
||||
if (this.precondition >= EVERYTHING) {
|
||||
parts.push(this.$t("main.chargingPlan.precondition.summaryAll"));
|
||||
} else if (this.precondition) {
|
||||
if (this.precondition) {
|
||||
parts.push(
|
||||
this.$t("main.chargingPlan.precondition.summary", {
|
||||
precondition: this.fmtDurationLong(this.precondition),
|
||||
|
|
@ -170,9 +146,6 @@ export default defineComponent({
|
|||
return parts.join(", ");
|
||||
},
|
||||
optimizationDescription(): string {
|
||||
if (this.disabled) {
|
||||
return this.$t("main.chargingPlan.optimization.disabledDescription");
|
||||
}
|
||||
const variant = this.localContinuous
|
||||
? this.isCo2
|
||||
? "continuousCo2"
|
||||
|
|
@ -181,6 +154,13 @@ export default defineComponent({
|
|||
return this.$t(`main.chargingPlan.optimization.${variant}Description`);
|
||||
},
|
||||
preconditionOptions() {
|
||||
const HOUR = 60 * 60;
|
||||
const QUARTER_HOUR = 0.25 * HOUR;
|
||||
const HALF_HOUR = 0.5 * HOUR;
|
||||
const ONE_HOUR = 1 * HOUR;
|
||||
const TWO_HOURS = 2 * HOUR;
|
||||
const EVERYTHING = 7 * 24 * HOUR;
|
||||
|
||||
const options = [QUARTER_HOUR, HALF_HOUR, ONE_HOUR, TWO_HOURS, EVERYTHING];
|
||||
|
||||
// support custom values (via API)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
:id="id"
|
||||
:precondition="effectivePlanStrategy?.precondition"
|
||||
:continuous="effectivePlanStrategy?.continuous"
|
||||
:disabled="strategyDisabled"
|
||||
:smart-cost-type="smartCostType"
|
||||
@update="updatePlanStrategy"
|
||||
/>
|
||||
|
|
@ -161,6 +162,13 @@ export default defineComponent({
|
|||
nextPlanTitle(): string {
|
||||
return `${this.$t("main.targetCharge.nextPlan")}: #${this.nextPlanId}`;
|
||||
},
|
||||
strategyDisabled(): boolean {
|
||||
// options only make sense if there are variable prices
|
||||
// TODO: make this logic more robust (api fails, missing data)
|
||||
const slots = this.forecast?.planner || [];
|
||||
const values = new Set(slots.map(({ value }) => value));
|
||||
return values.size <= 1;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
effectivePlanTime(newValue: string) {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
<template>
|
||||
<p class="mb-0 root" data-testid="plan-warnings">
|
||||
<span v-if="targetIsAboveLimit" class="d-block evcc-gray mt-1">
|
||||
<p class="mb-3 root" data-testid="plan-warnings">
|
||||
<span v-if="targetIsAboveLimit" class="d-block evcc-gray mb-1">
|
||||
{{ $t("main.targetCharge.targetIsAboveLimit", { limit: limitFmt }) }}
|
||||
</span>
|
||||
<span v-if="mode && ['off', 'now'].includes(mode)" class="d-block text-warning mt-1">
|
||||
<span v-if="mode && ['off', 'now'].includes(mode)" class="d-block text-warning mb-1">
|
||||
{{ $t("main.targetCharge.onlyInPvMode") }}
|
||||
</span>
|
||||
<span v-if="timeTooFarInTheFuture" class="d-block evcc-gray mt-1">
|
||||
<span v-if="timeTooFarInTheFuture" class="d-block evcc-gray mb-1">
|
||||
{{ $t("main.targetCharge.targetIsTooFarInTheFuture") }}
|
||||
</span>
|
||||
<span v-if="notReachableInTime" class="d-block text-warning mt-1">
|
||||
<span v-if="notReachableInTime" class="d-block text-warning mb-1">
|
||||
{{ $t("main.targetCharge.notReachableInTime", { overrun: overrunFmt }) }}
|
||||
</span>
|
||||
<span v-if="targetIsAboveVehicleLimit" class="d-block text-warning mt-1">
|
||||
<span v-if="targetIsAboveVehicleLimit" class="d-block text-warning mb-1">
|
||||
{{ $t("main.targetCharge.targetIsAboveVehicleLimit") }}
|
||||
</span>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -97,31 +97,11 @@
|
|||
</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-flex mb-1">
|
||||
<input
|
||||
id="resetRemote"
|
||||
v-model="selectedReset.remote"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label class="form-check-label ms-2" for="resetRemote">
|
||||
<span>{{ $t("config.system.backupRestore.reset.remote") }}</span>
|
||||
<br />
|
||||
<small>
|
||||
{{ $t("config.system.backupRestore.reset.remoteDescription") }}
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-outline-danger mt-3"
|
||||
:disabled="
|
||||
!selectedReset.sessions &&
|
||||
!selectedReset.settings &&
|
||||
!selectedReset.remote
|
||||
"
|
||||
:disabled="!selectedReset.sessions && !selectedReset.settings"
|
||||
>
|
||||
{{ $t("config.system.backupRestore.reset.action") }}
|
||||
</button>
|
||||
|
|
@ -212,7 +192,6 @@ export default defineComponent({
|
|||
selectedReset: {
|
||||
sessions: false,
|
||||
settings: false,
|
||||
remote: false,
|
||||
},
|
||||
file: null as File | null,
|
||||
confirmType: "" as "backup" | "restore" | "reset" | "",
|
||||
|
|
@ -249,7 +228,6 @@ export default defineComponent({
|
|||
this.selectedReset = {
|
||||
sessions: false,
|
||||
settings: false,
|
||||
remote: false,
|
||||
};
|
||||
this.file = null;
|
||||
this.navigateHomeAfterRestart = false;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
name="circuits"
|
||||
:title="$t('config.circuits.title')"
|
||||
:description="$t('config.circuits.description')"
|
||||
docs="/features/loadmanagement"
|
||||
docs="/docs/features/loadmanagement"
|
||||
:defaultYaml="defaultYaml"
|
||||
removeKey="circuits"
|
||||
endpoint="/config/circuits"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
:label="$t('config.control.labelInterval')"
|
||||
:help="$t('config.control.descriptionInterval')"
|
||||
example="30 s"
|
||||
docsLink="/reference/configuration/interval"
|
||||
docsLink="/docs/reference/configuration/interval"
|
||||
>
|
||||
<div class="input-group input-width">
|
||||
<input
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
:label="$t('config.control.labelResidualPower')"
|
||||
:help="$t('config.control.descriptionResidualPower')"
|
||||
example="100 W"
|
||||
docsLink="/reference/configuration/site#residualpower"
|
||||
docsLink="/docs/reference/configuration/site#residualpower"
|
||||
>
|
||||
<div class="input-group input-width">
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
<template>
|
||||
<DeviceModalBase
|
||||
:id="id"
|
||||
name="curtailer"
|
||||
device-type="curtailer"
|
||||
:modal-title="$t(`config.curtailer.${isNew ? 'titleAdd' : 'titleEdit'}`)"
|
||||
:provide-template-options="provideTemplateOptions"
|
||||
:initial-values="initialValues"
|
||||
:on-template-change="handleTemplateChange"
|
||||
:preserve-on-template-change="preserveFields"
|
||||
@added="(name) => $emit('changed', { action: 'added', name })"
|
||||
@updated="$emit('changed', { action: 'updated' })"
|
||||
@removed="$emit('changed', { action: 'removed' })"
|
||||
>
|
||||
<template #description>
|
||||
<p class="mt-0 mb-4">{{ $t("config.curtailer.description") }}</p>
|
||||
</template>
|
||||
|
||||
<template #before-template="{ values }">
|
||||
<FormRow id="curtailerParamDeviceTitle" :label="$t('config.general.title')">
|
||||
<PropertyField
|
||||
id="curtailerParamDeviceTitle"
|
||||
v-model.trim="values.deviceTitle"
|
||||
type="String"
|
||||
size="w-100"
|
||||
class="me-2"
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
</template>
|
||||
</DeviceModalBase>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import DeviceModalBase from "./DeviceModal/DeviceModalBase.vue";
|
||||
import FormRow from "./FormRow.vue";
|
||||
import PropertyField from "./PropertyField.vue";
|
||||
import type { DeviceValues, Product } from "./DeviceModal";
|
||||
import { type TemplateGroup, customTemplateOption } from "./DeviceModal/TemplateSelector.vue";
|
||||
import { ConfigType } from "@/types/evcc";
|
||||
import defaultCurtailerYaml from "./defaultYaml/curtailer.yaml?raw";
|
||||
import { getModal } from "@/configModal";
|
||||
|
||||
const initialValues = {
|
||||
type: ConfigType.Template,
|
||||
deviceTitle: "",
|
||||
template: null,
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "CurtailerModal",
|
||||
components: {
|
||||
DeviceModalBase,
|
||||
FormRow,
|
||||
PropertyField,
|
||||
},
|
||||
emits: ["changed"],
|
||||
data() {
|
||||
return {
|
||||
initialValues,
|
||||
preserveFields: ["deviceTitle"],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
id(): number | undefined {
|
||||
return getModal("curtailer")?.id;
|
||||
},
|
||||
isNew(): boolean {
|
||||
return this.id === undefined;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
provideTemplateOptions(products: Product[]): TemplateGroup[] {
|
||||
return [
|
||||
{
|
||||
label: "generic",
|
||||
options: [
|
||||
...products.filter((p: Product) => p.group === "generic"),
|
||||
customTemplateOption(this.$t("config.general.customOption")),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "specific",
|
||||
options: products.filter((p: Product) => p.group !== "generic"),
|
||||
},
|
||||
];
|
||||
},
|
||||
handleTemplateChange(value: string, values: DeviceValues) {
|
||||
if (value === ConfigType.Custom) {
|
||||
values.type = ConfigType.Custom;
|
||||
values.yaml = defaultCurtailerYaml;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
@ -34,7 +34,7 @@ export default defineComponent({
|
|||
},
|
||||
computed: {
|
||||
docsLink() {
|
||||
return `${docsPrefix()}/user-defined-devices#${this.type}`;
|
||||
return `${docsPrefix()}/docs/devices/plugins#${this.type}`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
name="eebus"
|
||||
:title="$t('config.eebus.title')"
|
||||
:description="$t('config.eebus.description')"
|
||||
docs="/reference/configuration/eebus"
|
||||
docs="/docs/reference/configuration/eebus"
|
||||
endpoint="/config/eebus"
|
||||
state-key="eebus.config"
|
||||
:no-buttons="fromYaml"
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ export default defineComponent({
|
|||
return (this as any).fmtTimeAgo(ms - Date.now());
|
||||
},
|
||||
docsLink(): string {
|
||||
return `${docsPrefix()}/external-limit`;
|
||||
return `${docsPrefix()}/docs/external-limit`;
|
||||
},
|
||||
experimental(): boolean {
|
||||
return store.state?.experimental === true;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
name="influx"
|
||||
:title="$t('config.influx.title')"
|
||||
:description="$t('config.influx.description')"
|
||||
docs="/reference/configuration/influx"
|
||||
docs="/docs/reference/configuration/influx"
|
||||
endpoint="/config/influx"
|
||||
state-key="influx"
|
||||
@changed="$emit('changed')"
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export default defineComponent({
|
|||
return `claude mcp add --transport http evcc ${this.mcpUrl}`;
|
||||
},
|
||||
docsLink(): string {
|
||||
return `${docsPrefix()}/integrations/mcp`;
|
||||
return `${docsPrefix()}/docs/integrations/mcp`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
/>
|
||||
<div class="text-end small text-gray mt-1">
|
||||
<a
|
||||
:href="`${docsPrefix()}/notifications#placeholders`"
|
||||
:href="`${docsPrefix()}/docs/notifications#placeholders`"
|
||||
target="_blank"
|
||||
class="text-gray"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
name="messaginglegacy"
|
||||
:title="`${$t('config.messaging.title')} (${$t('config.general.legacy')})`"
|
||||
:description="$t('config.messaging.description')"
|
||||
docs="/reference/configuration/messaging"
|
||||
docs="/docs/reference/configuration/messaging"
|
||||
:defaultYaml="defaultYaml"
|
||||
endpoint="/config/messaging"
|
||||
removeKey="messaging"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
name="messaging"
|
||||
:title="$t('config.messaging.title')"
|
||||
:description="$t('config.messaging.description')"
|
||||
docs="/notifications"
|
||||
docs="/docs/notifications"
|
||||
endpoint="/config/messagingEvents"
|
||||
state-key="messagingEvents"
|
||||
data-testid="messaging-modal"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
name="modbusproxy"
|
||||
:title="$t('config.modbusproxy.title')"
|
||||
:description="$t('config.modbusproxy.description')"
|
||||
docs="/reference/configuration/modbusproxy"
|
||||
docs="/docs/reference/configuration/modbusproxy"
|
||||
endpoint="/config/modbusproxy"
|
||||
state-key="modbusproxy"
|
||||
store-values-in-array
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
name="mqtt"
|
||||
:title="$t('config.mqtt.title')"
|
||||
:description="$t('config.mqtt.description')"
|
||||
docs="/reference/configuration/mqtt"
|
||||
docs="/docs/reference/configuration/mqtt"
|
||||
endpoint="/config/mqtt"
|
||||
state-key="mqtt"
|
||||
@changed="$emit('changed')"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
tabindex="0"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<shopicon-regular-plus class="me-1 flex-shrink-0"></shopicon-regular-plus>
|
||||
<shopicon-regular-plus class="me-1"></shopicon-regular-plus>
|
||||
<span class="text-start">{{ title }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export default defineComponent({
|
|||
return !!store.state?.evopt;
|
||||
},
|
||||
docsLink(): string {
|
||||
return `${docsPrefix()}/features/optimizer`;
|
||||
return `${docsPrefix()}/docs/features/optimizer`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -6,18 +6,15 @@
|
|||
data-testid="remote-modal"
|
||||
@open="onOpen"
|
||||
>
|
||||
<div class="alert alert-warning">
|
||||
Development preview. Not ready for general use. Use with caution and monitor your system
|
||||
closely. Feedback welcome!
|
||||
</div>
|
||||
<SponsorTokenRequired v-if="!isSponsor" feature class="mt-0" />
|
||||
<ErrorMessage :error="error" />
|
||||
|
||||
<template v-if="view === 'list'">
|
||||
<p>
|
||||
{{ $t("config.remote.description") }}
|
||||
<i18n-t tag="span" keypath="config.remote.privacy" scope="global">
|
||||
<a :href="privacyPolicyLink" target="_blank">
|
||||
{{ $t("config.remote.privacyLink") }}
|
||||
</a>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<p>{{ $t("config.remote.description") }}</p>
|
||||
<div class="form-check form-switch my-3">
|
||||
<input
|
||||
id="remoteEnabled"
|
||||
|
|
@ -141,10 +138,6 @@ export default defineComponent({
|
|||
status(): RemoteStatus {
|
||||
return this.remote?.status ?? { connected: false, loginBlocked: false };
|
||||
},
|
||||
privacyPolicyLink(): string {
|
||||
const path = this.$i18n.locale === "de" ? "" : "/en";
|
||||
return `https://evcc.io${path}/datenschutz/`;
|
||||
},
|
||||
modalTitle(): string {
|
||||
switch (this.view) {
|
||||
case "create":
|
||||
|
|
@ -152,7 +145,7 @@ export default defineComponent({
|
|||
case "reveal":
|
||||
return this.$t("config.remote.clientCreated");
|
||||
default:
|
||||
return this.$t("config.remote.title");
|
||||
return `${this.$t("config.remote.title")} 🧪`;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -127,9 +127,9 @@ export default defineComponent({
|
|||
},
|
||||
curlExample(): string {
|
||||
const key = this.revealedKey ?? "";
|
||||
const url = `${window.location.origin}/api/db/backup`;
|
||||
const url = `${window.location.origin}/api/system/backup`;
|
||||
return [
|
||||
`curl -X GET ${url} \\`,
|
||||
`curl -X POST ${url} \\`,
|
||||
` -H "Authorization: Bearer ${key}" \\`,
|
||||
` -o evcc-backup.db`,
|
||||
].join("\n");
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
name="shm"
|
||||
:title="$t('config.shm.title')"
|
||||
:description="$t('config.shm.description')"
|
||||
docs="/reference/configuration/hems"
|
||||
docs="/docs/reference/configuration/hems"
|
||||
endpoint="/config/shm"
|
||||
state-key="shm"
|
||||
disable-remove
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ export default {
|
|||
return this.token && !this.fromYaml;
|
||||
},
|
||||
trialTokenLink() {
|
||||
return `${docsPrefix()}/sponsorship#trial`;
|
||||
return `${docsPrefix()}/docs/sponsorship#trial`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
<template>
|
||||
<span
|
||||
v-tooltip="tooltip"
|
||||
ref="root"
|
||||
class="d-flex align-items-center gap-2 text-nowrap evcc-gray"
|
||||
:role="tooltip ? 'img' : undefined"
|
||||
:aria-label="tooltip || undefined"
|
||||
:title="tooltip || undefined"
|
||||
:data-bs-toggle="tooltip ? 'tooltip' : undefined"
|
||||
>
|
||||
<span class="d-inline-block rounded-circle status-dot" :class="dotClass"></span>
|
||||
<slot />
|
||||
|
|
@ -12,6 +14,7 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import Tooltip from "bootstrap/js/dist/tooltip";
|
||||
|
||||
type Variant = "success" | "warning" | "danger" | "muted";
|
||||
|
||||
|
|
@ -21,6 +24,9 @@ export default defineComponent({
|
|||
variant: { type: String as PropType<Variant>, default: "muted" },
|
||||
tooltip: { type: String, default: "" },
|
||||
},
|
||||
data() {
|
||||
return { tooltipInstance: null as Tooltip | null };
|
||||
},
|
||||
computed: {
|
||||
dotClass(): string {
|
||||
switch (this.variant) {
|
||||
|
|
@ -35,6 +41,30 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
tooltip() {
|
||||
this.initTooltip();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initTooltip();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.tooltipInstance?.dispose();
|
||||
},
|
||||
methods: {
|
||||
initTooltip() {
|
||||
this.$nextTick(() => {
|
||||
this.tooltipInstance?.dispose();
|
||||
this.tooltipInstance = null;
|
||||
const el = this.$refs["root"] as Element | undefined;
|
||||
if (el && this.tooltip) {
|
||||
// explicit title: bootstrap clears the attr, so :title alone goes stale
|
||||
this.tooltipInstance = new Tooltip(el, { title: this.tooltip });
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
name="tariffsLegacy"
|
||||
:title="`${$t('config.tariff.title')} (${$t('config.general.legacy')})`"
|
||||
:description="$t('config.tariff.description')"
|
||||
docs="/tariffs"
|
||||
docs="/docs/tariffs"
|
||||
:defaultYaml="defaultYaml"
|
||||
removeKey="tariffs"
|
||||
endpoint="/config/tariffs"
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
## required attributes
|
||||
# feed-in limit in percent of nominal production power (0..100, 100 = uncurtailed)
|
||||
curtail: # set feed-in limit
|
||||
source: js
|
||||
script: console.log(curtail)
|
||||
curtailed: # current feed-in limit
|
||||
source: const
|
||||
value: 100
|
||||
|
||||
## alternative examples
|
||||
|
||||
#curtail:
|
||||
# source: http
|
||||
# uri: http://inverter.local/api/limit?percent={{ .curtail }}
|
||||
# method: POST
|
||||
#curtailed:
|
||||
# source: http
|
||||
# uri: http://inverter.local/api/limit
|
||||
# jq: .percent
|
||||
|
||||
#curtail:
|
||||
# source: modbus
|
||||
# uri: 192.168.179.200:502
|
||||
# id: 1
|
||||
# register: { type: writesingle, address: 40016, encoding: uint16 }
|
||||
#curtailed:
|
||||
# source: error
|
||||
# error: ErrNotAvailable
|
||||
|
|
@ -16,9 +16,6 @@ power: # current power
|
|||
#maxpower: # maximum AC power in W (for hybrid pv)
|
||||
# source: const
|
||||
# value: 5000
|
||||
#curtailed: # current feed-in limit in % (0..100, 100 = uncurtailed)
|
||||
# source: const
|
||||
# value: 100
|
||||
#soc: # state of charge in % (for battery)
|
||||
# source: const
|
||||
# value: 75
|
||||
|
|
@ -53,6 +50,3 @@ power: # current power
|
|||
#batterymode: # set charging mode directly (1: normal, 2: hold, 3: charge) (for battery)
|
||||
# source: js
|
||||
# script: console.log(batterymode)
|
||||
#curtail: # set feed-in limit in % (0..100, 100 = uncurtailed)
|
||||
# source: js
|
||||
# script: console.log(curtail)
|
||||
|
|
|
|||
|
|
@ -656,7 +656,7 @@ export default defineComponent({
|
|||
height: 0;
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
transition-property: height, opacity, transform;
|
||||
transition-duration: 0;
|
||||
transition-timing-function: cubic-bezier(0.5, 0.5, 0.5, 1.15);
|
||||
|
|
|
|||
|
|
@ -236,8 +236,6 @@ export default defineComponent({
|
|||
display: flex;
|
||||
overflow: hidden;
|
||||
margin-right: 1.2rem;
|
||||
/* Safari composites the width-animated bars without subpixel antialiasing, blurring labels on 1x displays */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.label-scale-name {
|
||||
color: var(--evcc-gray);
|
||||
|
|
|
|||
|
|
@ -2,4 +2,7 @@
|
|||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,19 +59,6 @@
|
|||
equal-width
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow id="settingsDateFormat" :label="$t('settings.dateFormat.label')">
|
||||
<select
|
||||
id="settingsDateFormat"
|
||||
v-model="dateFormat"
|
||||
class="form-select form-select-sm w-75"
|
||||
>
|
||||
<option value="">{{ $t("settings.dateFormat.auto") }}</option>
|
||||
<option value="dmy">{{ $t("settings.dateFormat.dmy") }}</option>
|
||||
<option value="mdy">{{ $t("settings.dateFormat.mdy") }}</option>
|
||||
<option value="ymd">{{ $t("settings.dateFormat.ymd") }}</option>
|
||||
</select>
|
||||
<div class="form-text evcc-gray">{{ dateFormatExample }}</div>
|
||||
</FormRow>
|
||||
<FormRow v-if="loadpoints.length" :label="$t('settings.loadpoints.label')">
|
||||
<LoadpointOrderSettings :loadpoints="loadpoints" />
|
||||
</FormRow>
|
||||
|
|
@ -104,24 +91,14 @@ import {
|
|||
removeLocalePreference,
|
||||
} from "@/i18n.ts";
|
||||
import { getThemePreference, setThemePreference } from "@/theme.ts";
|
||||
import {
|
||||
getUnits,
|
||||
setUnits,
|
||||
is12hFormat,
|
||||
set12hFormat,
|
||||
getDateFormat,
|
||||
setDateFormat,
|
||||
} from "@/units";
|
||||
import { getUnits, setUnits, is12hFormat, set12hFormat } from "@/units";
|
||||
import { isApp } from "@/utils/native";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import { LENGTH_UNIT, THEME, TIME_FORMAT, type UiLoadpoint } from "@/types/evcc";
|
||||
import formatter from "@/mixins/formatter";
|
||||
import type { DateFormat } from "@/settings";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UserInterfaceSettings",
|
||||
components: { FormRow, SelectGroup, LoadpointOrderSettings },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
|
||||
},
|
||||
|
|
@ -138,17 +115,6 @@ export default defineComponent({
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
dateFormat: {
|
||||
get(): DateFormat {
|
||||
return getDateFormat();
|
||||
},
|
||||
set(value: DateFormat) {
|
||||
setDateFormat(value);
|
||||
},
|
||||
},
|
||||
dateFormatExample(): string {
|
||||
return this.fmtFullDateTime(new Date(2015, 9, 21, 16, 29));
|
||||
},
|
||||
languageOptions: () => {
|
||||
const locales = Object.entries(LOCALES).map(([key, value]) => {
|
||||
return { value: key, name: value[1] };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Loadpoints from "./Loadpoints.vue";
|
||||
import type { Meta, StoryFn } from "@storybook/vue3";
|
||||
import { CHARGE_MODE, CURRENCY, SMART_COST_TYPE } from "@/types/evcc";
|
||||
import { CURRENCY, SMART_COST_TYPE } from "@/types/evcc";
|
||||
|
||||
// Create LoadpointCompact structure for the Loadpoints component
|
||||
const createLoadpoint = (opts: any = {}) => {
|
||||
|
|
@ -117,18 +117,11 @@ TwoLoadpoints.args = {
|
|||
index: 0,
|
||||
vehicleName: "vehicle_3",
|
||||
charging: true,
|
||||
chargePower: 1380,
|
||||
power: 1380,
|
||||
chargePower: 8050,
|
||||
power: 8050,
|
||||
connected: true,
|
||||
soc: 66,
|
||||
vehicleSoc: 66,
|
||||
mode: CHARGE_MODE.PV,
|
||||
phasesConfigured: 3,
|
||||
phasesActive: 1,
|
||||
offeredCurrent: 9,
|
||||
chargeCurrents: [6, 0, 0],
|
||||
minCurrent: 6,
|
||||
maxCurrent: 16,
|
||||
}),
|
||||
createLoadpoint({
|
||||
title: "Charging point with a very very very long title that tests layout compatibility!!!",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import Phases from "./Phases.vue";
|
||||
import type { Meta, StoryFn } from "@storybook/vue3";
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
|
||||
export default {
|
||||
title: "Loadpoints/Phases",
|
||||
|
|
@ -10,7 +9,6 @@ export default {
|
|||
},
|
||||
argTypes: {
|
||||
phasesActive: { control: { type: "number" } },
|
||||
phasesConfigured: { control: { type: "number" } },
|
||||
minCurrent: { control: { type: "number" } },
|
||||
maxCurrent: { control: { type: "number" } },
|
||||
offeredCurrent: { control: { type: "number" } },
|
||||
|
|
@ -26,117 +24,72 @@ const Template: StoryFn<typeof Phases> = (args) => ({
|
|||
template: '<Phases v-bind="args" />',
|
||||
});
|
||||
|
||||
const base = {
|
||||
phasesConfigured: 3,
|
||||
export const OnePhase = Template.bind({});
|
||||
OnePhase.args = {
|
||||
phasesActive: 1,
|
||||
minCurrent: 6,
|
||||
maxCurrent: 16,
|
||||
offeredCurrent: 8,
|
||||
chargeCurrents: undefined,
|
||||
};
|
||||
|
||||
// --- expected phases only (no per-phase measurement) ---
|
||||
|
||||
export const NoData = Template.bind({});
|
||||
NoData.args = { ...base, phasesActive: undefined, offeredCurrent: 0 };
|
||||
|
||||
export const Expected1p = Template.bind({});
|
||||
Expected1p.args = { ...base, phasesActive: 1 };
|
||||
|
||||
export const Expected2p = Template.bind({});
|
||||
Expected2p.args = { ...base, phasesActive: 2 };
|
||||
|
||||
export const Expected3p = Template.bind({});
|
||||
Expected3p.args = { ...base, phasesActive: 3 };
|
||||
|
||||
// --- measured per-phase currents ---
|
||||
|
||||
export const Measured1p = Template.bind({});
|
||||
Measured1p.args = { ...base, phasesActive: 1, offeredCurrent: 12, chargeCurrents: [6, 0.2, 0] };
|
||||
|
||||
export const Measured2p = Template.bind({});
|
||||
Measured2p.args = { ...base, phasesActive: 2, offeredCurrent: 16, chargeCurrents: [16, 16, 0.3] };
|
||||
|
||||
export const Measured3p = Template.bind({});
|
||||
Measured3p.args = { ...base, phasesActive: 3, offeredCurrent: 13, chargeCurrents: [11, 9, 12] };
|
||||
|
||||
export const Asymmetric = Template.bind({});
|
||||
Asymmetric.args = { ...base, phasesActive: 2, offeredCurrent: 16, chargeCurrents: [8, 0.9, 14] };
|
||||
|
||||
export const OnlyL2 = Template.bind({});
|
||||
OnlyL2.args = { ...base, phasesActive: 1, offeredCurrent: 13, chargeCurrents: [0, 13, 0] };
|
||||
|
||||
export const MainlyL3 = Template.bind({});
|
||||
MainlyL3.args = {
|
||||
...base,
|
||||
phasesActive: 1,
|
||||
offeredCurrent: 20,
|
||||
maxCurrent: 20,
|
||||
chargeCurrents: [0.007, 0.009, 5.945],
|
||||
export const TwoPhase = Template.bind({});
|
||||
TwoPhase.args = {
|
||||
...OnePhase.args,
|
||||
phasesActive: 2,
|
||||
};
|
||||
|
||||
// 1p device but 3 phases measured: configuration error, warning color + tooltip
|
||||
export const Mismatch1p = Template.bind({});
|
||||
Mismatch1p.args = {
|
||||
...base,
|
||||
phasesConfigured: 1,
|
||||
export const ThreePhase = Template.bind({});
|
||||
ThreePhase.args = {
|
||||
...OnePhase.args,
|
||||
phasesActive: 3,
|
||||
offeredCurrent: 10,
|
||||
chargeCurrents: [10, 10, 10],
|
||||
};
|
||||
|
||||
// cycles 1p → 3p → 1p every few seconds to inspect the transition
|
||||
export const Switching: StoryFn<typeof Phases> = (args) => ({
|
||||
components: { Phases },
|
||||
setup() {
|
||||
const phasesActive = ref(1);
|
||||
let timer: ReturnType<typeof setInterval>;
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => {
|
||||
phasesActive.value = phasesActive.value === 1 ? 3 : 1;
|
||||
}, 3000);
|
||||
});
|
||||
onUnmounted(() => clearInterval(timer));
|
||||
return { args, phasesActive };
|
||||
},
|
||||
template: '<Phases v-bind="args" :phasesActive="phasesActive" />',
|
||||
});
|
||||
Switching.args = { ...base, phasesConfigured: 0, offeredCurrent: 12 };
|
||||
export const RealCurrents = Template.bind({});
|
||||
RealCurrents.args = {
|
||||
...OnePhase.args,
|
||||
phasesActive: 3,
|
||||
offeredCurrent: 13,
|
||||
chargeCurrents: [11, 9, 12],
|
||||
};
|
||||
|
||||
// --- overview: rows = scenario, columns = device phase configuration ---
|
||||
export const OnePhaseMoreAvailable = Template.bind({});
|
||||
OnePhaseMoreAvailable.args = {
|
||||
...OnePhase.args,
|
||||
offeredCurrent: 12,
|
||||
chargeCurrents: [6, 0.2, 0],
|
||||
};
|
||||
|
||||
const scenarios = [
|
||||
{ name: "NoData", args: NoData.args },
|
||||
{ name: "Expected1p", args: Expected1p.args },
|
||||
{ name: "Expected2p", args: Expected2p.args },
|
||||
{ name: "Expected3p", args: Expected3p.args },
|
||||
{ name: "Measured1p", args: Measured1p.args },
|
||||
{ name: "Measured2p", args: Measured2p.args },
|
||||
{ name: "Measured3p", args: Measured3p.args },
|
||||
{ name: "Asymmetric", args: Asymmetric.args },
|
||||
{ name: "OnlyL2", args: OnlyL2.args },
|
||||
{ name: "MainlyL3", args: MainlyL3.args },
|
||||
];
|
||||
export const TwoPhasesActive = Template.bind({});
|
||||
TwoPhasesActive.args = {
|
||||
...OnePhase.args,
|
||||
phasesActive: 2,
|
||||
offeredCurrent: 16,
|
||||
chargeCurrents: [16, 16, 0.3],
|
||||
};
|
||||
|
||||
// 1p3p auto (0) behaves like 3p
|
||||
const configs = [
|
||||
{ name: "1p device", phasesConfigured: 1 },
|
||||
{ name: "3p/auto", phasesConfigured: 3 },
|
||||
];
|
||||
export const AsymetricPhases = Template.bind({});
|
||||
AsymetricPhases.args = {
|
||||
...OnePhase.args,
|
||||
phasesActive: 2,
|
||||
offeredCurrent: 16,
|
||||
chargeCurrents: [8, 0.9, 14],
|
||||
};
|
||||
|
||||
export const Overview: StoryFn<typeof Phases> = () => ({
|
||||
components: { Phases },
|
||||
setup() {
|
||||
return { scenarios, configs };
|
||||
},
|
||||
template: `
|
||||
<div style="display: grid; grid-template-columns: 140px repeat(2, 120px); gap: 0.75rem 1.5rem; align-items: center; padding: 1rem; background: var(--evcc-box); border-radius: 0.5rem; color: var(--evcc-default-text); font-size: 0.85rem;">
|
||||
<div></div>
|
||||
<div v-for="c in configs" :key="c.name" style="opacity: 0.7;">{{ c.name }}</div>
|
||||
<template v-for="s in scenarios" :key="s.name">
|
||||
<div style="opacity: 0.7;">{{ s.name }}</div>
|
||||
<Phases v-for="c in configs" :key="c.name" v-bind="{ ...s.args, phasesConfigured: c.phasesConfigured }" />
|
||||
</template>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
Overview.parameters = { controls: { disable: true } };
|
||||
export const OnlySecondPhase = Template.bind({});
|
||||
OnlySecondPhase.args = {
|
||||
...OnePhase.args,
|
||||
phasesActive: 1,
|
||||
offeredCurrent: 13,
|
||||
chargeCurrents: [0, 13, 0],
|
||||
};
|
||||
|
||||
export const MainlyThirdPhase = Template.bind({});
|
||||
MainlyThirdPhase.args = {
|
||||
...OnePhase.args,
|
||||
phasesActive: 1,
|
||||
offeredCurrent: 10,
|
||||
chargeCurrents: [0.007, 0.009, 5.945],
|
||||
minCurrent: 6,
|
||||
maxCurrent: 20,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,23 +1,12 @@
|
|||
<template>
|
||||
<div
|
||||
v-tooltip="phasesMismatch ? $t('main.loadpoint.phasesMismatch') : undefined"
|
||||
class="phases d-flex"
|
||||
:class="{ 'phases-warning': phasesMismatch }"
|
||||
>
|
||||
<div :class="`phases d-flex justify-content-between`">
|
||||
<div
|
||||
v-for="num in [1, 2, 3]"
|
||||
:key="num"
|
||||
class="phase me-1"
|
||||
:class="{
|
||||
'phase-inactive': !isPhaseActive(num),
|
||||
'phase-hidden': !isPhaseActive(num) && (maxPhases === 1 || num > maxPhases),
|
||||
}"
|
||||
:class="{ 'phase-inactive': !isPhaseActive(num) }"
|
||||
>
|
||||
<div
|
||||
v-show="targetWidth() > 0"
|
||||
class="target"
|
||||
:style="{ width: `${targetWidth()}%` }"
|
||||
></div>
|
||||
<div class="target" :style="{ width: `${targetWidth()}%` }"></div>
|
||||
<div class="real" :style="{ width: `${realWidth(num)}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -34,7 +23,6 @@ export default defineComponent({
|
|||
offeredCurrent: { type: Number, default: 0 },
|
||||
chargeCurrents: { type: Array as PropType<number[]> },
|
||||
phasesActive: { type: Number as PropType<PHASES> },
|
||||
phasesConfigured: { type: Number, default: 3 },
|
||||
minCurrent: { type: Number, default: 6 },
|
||||
maxCurrent: { type: Number, default: 16 },
|
||||
},
|
||||
|
|
@ -43,17 +31,9 @@ export default defineComponent({
|
|||
if (!this.chargeCurrents) return false;
|
||||
return this.chargeCurrents.filter((c) => c >= MIN_ACTIVE_CURRENT).length > 0;
|
||||
},
|
||||
maxPhases() {
|
||||
// 0 = automatic 1p3p switching
|
||||
return this.phasesConfigured || 3;
|
||||
},
|
||||
phasesMismatch() {
|
||||
return [1, 2, 3].filter((num) => this.isPhaseActive(num)).length > this.maxPhases;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
targetWidth() {
|
||||
if (this.offeredCurrent <= 0) return 0;
|
||||
const current = Math.min(
|
||||
Math.max(this.minCurrent, this.offeredCurrent),
|
||||
this.maxCurrent
|
||||
|
|
@ -72,8 +52,7 @@ export default defineComponent({
|
|||
const current = this.chargeCurrents[num - 1];
|
||||
return current !== undefined && current >= MIN_ACTIVE_CURRENT;
|
||||
}
|
||||
// unknown active phases: assume all
|
||||
return num <= (this.phasesActive || this.maxPhases);
|
||||
return num <= (this.phasesActive || 0);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -84,14 +63,15 @@ export default defineComponent({
|
|||
width: 73px;
|
||||
}
|
||||
.phase {
|
||||
background-color: var(--evcc-gray-15);
|
||||
background-color: var(--bs-gray-bright);
|
||||
height: 4px;
|
||||
flex: 1 1 0;
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
border-radius: 1px;
|
||||
overflow: hidden;
|
||||
flex-basis: 100%;
|
||||
opacity: 1;
|
||||
transition-property: flex-basis, flex-grow, margin, opacity;
|
||||
transition-property: flex-basis, margin, opacity;
|
||||
transition-duration: var(--evcc-transition-slow);
|
||||
transition-timing-function: ease-in;
|
||||
}
|
||||
|
|
@ -99,13 +79,6 @@ html.dark .phase {
|
|||
background-color: var(--bs-gray-bright);
|
||||
}
|
||||
.phase-inactive {
|
||||
flex: 0 0 4px;
|
||||
}
|
||||
.phase-inactive .target,
|
||||
.phase-inactive .real {
|
||||
opacity: 0;
|
||||
}
|
||||
.phase-hidden {
|
||||
flex-basis: 0;
|
||||
margin-right: 0 !important;
|
||||
opacity: 0;
|
||||
|
|
@ -123,15 +96,9 @@ html.dark .phase {
|
|||
opacity: 1;
|
||||
}
|
||||
.target {
|
||||
background-color: color-mix(in srgb, var(--evcc-dark-green) 40%, white);
|
||||
background-color: var(--evcc-green);
|
||||
}
|
||||
.real {
|
||||
background-color: var(--evcc-dark-green);
|
||||
}
|
||||
.phases-warning .target {
|
||||
background-color: color-mix(in srgb, var(--bs-warning) 40%, white);
|
||||
}
|
||||
.phases-warning .real {
|
||||
background-color: var(--bs-warning);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
<template>
|
||||
<svg :style="svgStyle" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.825 15.8q.75-.35 1.113-1.2t-.163-1.6q-1.1-1.575-2.212-3.062T9.3 6.95q-.275-.35-.687-.162t-.313.637q.4 1.825.85 3.625t1 3.65q.25.875 1.038 1.163t1.637-.063m-7.7 4.2q-.525 0-1.012-.238t-.738-.712q-.7-1.15-1.025-2.437T2.025 14q0-1.725.588-3.463t1.862-3.112q.375-.425.8-.412t.7.262t.35.625t-.225.725q-.95 1.05-1.512 2.475T4.025 14q0 1.025.263 2.038T5.125 18H18.9q.525-.9.813-1.925T20 14q0-1.875-.812-3.475T17.025 7.8q-1.05-.875-2.375-1.35T11.95 6q-.4 0-.712-.262t-.363-.663t.15-.725t.6-.35q1.825-.075 3.563.525T18.35 6.3q1.675 1.4 2.663 3.375T22 14q0 1.4-.35 2.663t-1 2.387q-.275.475-.75.713t-1 .237zm6.9-6.025"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import icon from "@/mixins/icon";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Curtail",
|
||||
mixins: [icon],
|
||||
});
|
||||
</script>
|
||||
|
|
@ -146,7 +146,7 @@ export default defineComponent({
|
|||
...tooltipStyle(colors.text || ""),
|
||||
formatter: this.tooltipFormatter,
|
||||
},
|
||||
xAxis: slotXAxis(this.times, this.weekdayShort),
|
||||
xAxis: slotXAxis(this.times),
|
||||
yAxis: forecastYAxis({
|
||||
min: undefined,
|
||||
position: "right",
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isMidnight, slotXAxis } from "./chart";
|
||||
|
||||
// 15min slots starting at the given local hour
|
||||
function times(startHour: number, count: number): number[] {
|
||||
const start = new Date(2025, 0, 1, startHour).getTime();
|
||||
return Array.from({ length: count }, (_, i) => start + i * 15 * 60 * 1000);
|
||||
}
|
||||
|
||||
describe("slotXAxis", () => {
|
||||
const weekdayShort = () => "Thu";
|
||||
// 20:00 to 08:00, midnight at index 16
|
||||
const slots = times(20, 48);
|
||||
const axis = slotXAxis(slots, weekdayShort);
|
||||
const label = (i: number) => axis.axisLabel.formatter(String(slots[i]));
|
||||
|
||||
test("labels full hours only", () => {
|
||||
expect(label(0)).toBe("20"); // 20:00
|
||||
expect(label(1)).toBe(""); // 20:15
|
||||
expect(label(4)).toBe(""); // 21:00, not a step hour
|
||||
expect(label(32)).toBe("4"); // 04:00
|
||||
});
|
||||
|
||||
test("shows the weekday at midnight", () => {
|
||||
expect(label(16)).toBe("0\nThu");
|
||||
});
|
||||
|
||||
test("draws a split line at day boundaries only", () => {
|
||||
expect(isMidnight(slots[16])).toBe(true);
|
||||
expect(axis.splitLine.interval(16)).toBe(true);
|
||||
expect(axis.splitLine.interval(15)).toBe(false);
|
||||
// no line at the axis start, even if the first slot is midnight
|
||||
expect(slotXAxis(times(0, 4), weekdayShort).splitLine.interval(0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { xAxisLabelStyle } from "../Forecast/echarts";
|
||||
import colors from "@/colors";
|
||||
import type { BatteryDetail } from "@/types/evcc";
|
||||
|
||||
// loadpoint part of a vehicle entry title: "Carport (blue e-Golf)" → "Carport"
|
||||
|
|
@ -20,35 +19,22 @@ export function slotTimes(timestamp: string, dt: number[]): number[] {
|
|||
return res;
|
||||
}
|
||||
|
||||
export function isMidnight(time?: number): boolean {
|
||||
if (time === undefined) return false;
|
||||
const d = new Date(time);
|
||||
return d.getHours() === 0 && d.getMinutes() === 0;
|
||||
}
|
||||
|
||||
// category x axis over slot times, labels at full hours every 4h (6h on mobile),
|
||||
// day boundaries as split line with the weekday shown at 00:00
|
||||
export function slotXAxis(times: number[], weekdayShort: (d: Date) => string) {
|
||||
// category x axis over slot times, labels at full hours every 4h (6h on mobile)
|
||||
export function slotXAxis(times: number[]) {
|
||||
const step = window.innerWidth < 576 ? 6 : 4;
|
||||
return {
|
||||
type: "category",
|
||||
data: times.map(String),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: {
|
||||
show: true,
|
||||
// split lines sit at the left edge of their slot
|
||||
interval: (index: number) => index > 0 && isMidnight(times[index]),
|
||||
lineStyle: { color: colors.muted || "", type: "solid" },
|
||||
},
|
||||
splitLine: { show: false },
|
||||
axisLabel: {
|
||||
...xAxisLabelStyle(),
|
||||
interval: 0,
|
||||
formatter: (value: string) => {
|
||||
const d = new Date(Number(value));
|
||||
if (d.getMinutes() !== 0 || d.getHours() % step !== 0) return "";
|
||||
const label = String(d.getHours());
|
||||
return d.getHours() === 0 ? `${label}\n${weekdayShort(d)}` : label;
|
||||
return String(d.getHours());
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -60,11 +60,11 @@
|
|||
</th>
|
||||
<td>
|
||||
<template v-if="session.created">
|
||||
{{ fmtFullDateTime(new Date(session.created)) }}
|
||||
{{ fmtFullDateTime(new Date(session.created), false) }}
|
||||
</template>
|
||||
<br />
|
||||
<template v-if="session.finished">
|
||||
{{ fmtFullDateTime(new Date(session.finished)) }}
|
||||
{{ fmtFullDateTime(new Date(session.finished), false) }}
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@
|
|||
@click="showDetails(session.id)"
|
||||
>
|
||||
<td class="ps-0 tabular">
|
||||
{{ fmtWeekdayDayTime(new Date(session.created)) }}
|
||||
{{ fmtFullDateTime(new Date(session.created), true) }}
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell">
|
||||
{{ session.loadpoint }}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export default defineComponent({
|
|||
},
|
||||
computed: {
|
||||
docsLink() {
|
||||
return `${docsPrefix()}/faq#telemetry`;
|
||||
return `${docsPrefix()}/docs/faq#telemetry`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -16,91 +16,85 @@
|
|||
</a>
|
||||
</p>
|
||||
<div
|
||||
v-for="vehicle in vehicles"
|
||||
v-for="(vehicle, index) in vehicles"
|
||||
:key="vehicle.name"
|
||||
role="group"
|
||||
:aria-label="vehicle.title"
|
||||
class="d-flex gap-2 mb-3"
|
||||
:class="{ 'border-top pt-4': index > 0 }"
|
||||
class="mb-4"
|
||||
>
|
||||
<VehicleIcon :name="vehicle.icon" class="flex-shrink-0" />
|
||||
<div class="flex-grow-1 overflow-hidden ring-space">
|
||||
<div class="d-flex flex-wrap align-items-center column-gap-2 row-gap-1 mb-3">
|
||||
<strong class="text-truncate">{{ vehicle.title }}</strong>
|
||||
<Badge v-if="connectedLoadpoint(vehicle)" class="flex-shrink-0">
|
||||
{{
|
||||
$t("main.vehicleSettings.connectedTo", [
|
||||
connectedLoadpoint(vehicle)?.title ||
|
||||
$t("main.loadpoint.fallbackName"),
|
||||
])
|
||||
}}
|
||||
</Badge>
|
||||
<Badge v-else variant="muted" class="flex-shrink-0">
|
||||
{{ $t("main.vehicleSettings.notConnected") }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-6 col-lg-3 mb-3">
|
||||
<div class="small text-uppercase evcc-gray text-truncate">
|
||||
{{ $t("main.vehicleSettings.mode") }}
|
||||
</div>
|
||||
<CustomSelect
|
||||
:id="fieldId(vehicle, 'mode')"
|
||||
inline
|
||||
:options="modeOptions"
|
||||
:selected="vehicle.mode ?? ''"
|
||||
:ariaLabel="$t('main.vehicleSettings.mode')"
|
||||
@change="changeMode(vehicle, $event)"
|
||||
>
|
||||
<span class="text-decoration-underline text-primary">
|
||||
{{ optionName(modeOptions, vehicle.mode ?? "") }}
|
||||
</span>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<template v-if="socSupported(vehicle)">
|
||||
<div class="col-6 col-lg-3 mb-3">
|
||||
<div class="small text-uppercase evcc-gray text-truncate">
|
||||
{{ $t("main.vehicleSettings.minSoc") }}
|
||||
</div>
|
||||
<CustomSelect
|
||||
:id="fieldId(vehicle, 'minSoc')"
|
||||
inline
|
||||
:options="socOptions(vehicle, true)"
|
||||
:selected="vehicle.minSoc ?? 0"
|
||||
:ariaLabel="$t('main.vehicleSettings.minSoc')"
|
||||
@change="changeMinSoc(vehicle, $event)"
|
||||
>
|
||||
<span class="text-decoration-underline text-primary">
|
||||
{{
|
||||
optionName(
|
||||
socOptions(vehicle, true),
|
||||
vehicle.minSoc ?? 0
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3 mb-3">
|
||||
<div class="small text-uppercase evcc-gray text-truncate">
|
||||
{{ $t("main.vehicleSettings.limitSoc") }}
|
||||
</div>
|
||||
<CustomSelect
|
||||
:id="fieldId(vehicle, 'limitSoc')"
|
||||
inline
|
||||
:options="socOptions(vehicle)"
|
||||
:selected="vehicle.limitSoc || 100"
|
||||
:ariaLabel="$t('main.vehicleSettings.limitSoc')"
|
||||
@change="changeLimitSoc(vehicle, $event)"
|
||||
>
|
||||
<span class="text-decoration-underline text-primary">
|
||||
{{
|
||||
optionName(socOptions(vehicle), vehicle.limitSoc || 100)
|
||||
}}
|
||||
</span>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-items-center column-gap-2 row-gap-1 mb-3">
|
||||
<VehicleIcon :name="vehicle.icon" class="flex-shrink-0" />
|
||||
<strong class="text-truncate">{{ vehicle.title }}</strong>
|
||||
<Badge v-if="connectedLoadpoint(vehicle)" class="flex-shrink-0">
|
||||
{{
|
||||
$t("main.vehicleSettings.connectedTo", [
|
||||
connectedLoadpoint(vehicle)?.title ||
|
||||
$t("main.loadpoint.fallbackName"),
|
||||
])
|
||||
}}
|
||||
</Badge>
|
||||
<Badge v-else variant="muted" class="flex-shrink-0">
|
||||
{{ $t("main.vehicleSettings.notConnected") }}
|
||||
</Badge>
|
||||
</div>
|
||||
<SettingsFormRow
|
||||
:id="fieldId(vehicle, 'mode')"
|
||||
:label="$t('main.vehicleSettings.mode')"
|
||||
>
|
||||
<select
|
||||
:id="fieldId(vehicle, 'mode')"
|
||||
class="form-select form-select-sm"
|
||||
:value="vehicle.mode ?? ''"
|
||||
@change="changeMode(vehicle, $event)"
|
||||
>
|
||||
<option v-for="opt in modeOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.name }}
|
||||
</option>
|
||||
</select>
|
||||
</SettingsFormRow>
|
||||
<template v-if="socSupported(vehicle)">
|
||||
<SettingsFormRow
|
||||
:id="fieldId(vehicle, 'limitSoc')"
|
||||
:label="$t('main.vehicleSettings.limitSoc')"
|
||||
:description="$t('main.vehicleSettings.limitSocDescription')"
|
||||
>
|
||||
<select
|
||||
:id="fieldId(vehicle, 'limitSoc')"
|
||||
class="form-select form-select-sm"
|
||||
:value="vehicle.limitSoc || 100"
|
||||
@change="changeLimitSoc(vehicle, $event)"
|
||||
>
|
||||
<option
|
||||
v-for="opt in socOptions(vehicle)"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.name }}
|
||||
</option>
|
||||
</select>
|
||||
</SettingsFormRow>
|
||||
<SettingsFormRow
|
||||
:id="fieldId(vehicle, 'minSoc')"
|
||||
:label="$t('main.vehicleSettings.minSoc')"
|
||||
:description="$t('main.vehicleSettings.minSocDescription')"
|
||||
>
|
||||
<select
|
||||
:id="fieldId(vehicle, 'minSoc')"
|
||||
class="form-select form-select-sm"
|
||||
:value="vehicle.minSoc ?? 0"
|
||||
@change="changeMinSoc(vehicle, $event)"
|
||||
>
|
||||
<option
|
||||
v-for="opt in socOptions(vehicle, true)"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.name }}
|
||||
</option>
|
||||
</select>
|
||||
</SettingsFormRow>
|
||||
</template>
|
||||
</div>
|
||||
<p class="mb-0 border-top pt-4">
|
||||
<i18n-t keypath="main.vehicleSettings.editHint" tag="span" scope="global">
|
||||
|
|
@ -116,7 +110,7 @@
|
|||
<script lang="ts">
|
||||
import Badge from "../Helper/Badge.vue";
|
||||
import GenericModal from "../Helper/GenericModal.vue";
|
||||
import CustomSelect from "../Helper/CustomSelect.vue";
|
||||
import SettingsFormRow from "../Helper/SettingsFormRow.vue";
|
||||
import VehicleIcon from "../VehicleIcon";
|
||||
import api from "@/api";
|
||||
import formatter from "@/mixins/formatter";
|
||||
|
|
@ -130,7 +124,7 @@ const { OFF, PV, MINPV, NOW } = CHARGE_MODE;
|
|||
|
||||
export default defineComponent({
|
||||
name: "VehicleSettingsModal",
|
||||
components: { Badge, CustomSelect, GenericModal, VehicleIcon },
|
||||
components: { Badge, GenericModal, SettingsFormRow, VehicleIcon },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
vehicles: { type: Array as PropType<Vehicle[]>, default: () => [] },
|
||||
|
|
@ -143,7 +137,7 @@ export default defineComponent({
|
|||
},
|
||||
computed: {
|
||||
docsUrl(): string {
|
||||
return `${docsPrefix()}/features/limits`;
|
||||
return `${docsPrefix()}/docs/features/limits`;
|
||||
},
|
||||
modeOptions(): SelectOption<string>[] {
|
||||
return [
|
||||
|
|
@ -194,9 +188,6 @@ export default defineComponent({
|
|||
};
|
||||
});
|
||||
},
|
||||
optionName(options: SelectOption<string | number>[], value: string | number): string {
|
||||
return options.find((o) => o.value === value)?.name ?? String(value);
|
||||
},
|
||||
selectValue(event: Event): string {
|
||||
return (event.target as HTMLSelectElement).value;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
import Tooltip from "bootstrap/js/dist/tooltip";
|
||||
import type { Directive, DirectiveBinding } from "vue";
|
||||
|
||||
function update(el: HTMLElement, { value, oldValue }: DirectiveBinding<string | undefined>) {
|
||||
if (value === oldValue) return;
|
||||
const instance = Tooltip.getInstance(el);
|
||||
if (!value) {
|
||||
instance?.dispose();
|
||||
} else if (instance) {
|
||||
instance.setContent({ ".tooltip-inner": value });
|
||||
} else {
|
||||
new Tooltip(el, { title: value });
|
||||
}
|
||||
}
|
||||
|
||||
// v-tooltip="text": bootstrap tooltip bound to text, removed when text is empty
|
||||
const tooltip: Directive<HTMLElement, string | undefined> = {
|
||||
mounted: update,
|
||||
updated: update,
|
||||
beforeUnmount(el) {
|
||||
Tooltip.getInstance(el)?.dispose();
|
||||
},
|
||||
};
|
||||
|
||||
export default tooltip;
|
||||
|
|
@ -110,6 +110,7 @@ export async function ensureCurrentLocaleMessages(i18n: VueI18nInstance) {
|
|||
}
|
||||
|
||||
export function docsPrefix() {
|
||||
const lang = getLocale() === "de" ? "de" : "en";
|
||||
return `https://docs.evcc.io/${lang}`;
|
||||
const locale = getLocale();
|
||||
const path = locale === "de" ? "" : `/en`;
|
||||
return `https://docs.evcc.io${path}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { mount, config } from "@vue/test-utils";
|
|||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import formatter, { POWER_UNIT } from "./formatter";
|
||||
import * as units from "../units";
|
||||
import settings from "../settings";
|
||||
import { defineComponent } from "vue";
|
||||
import { CURRENCY } from "@/types/evcc";
|
||||
|
||||
|
|
@ -276,7 +275,7 @@ describe("12h/24h time format", () => {
|
|||
test("12h format", () => {
|
||||
is12hSpy.mockReturnValue(true);
|
||||
expect(fmt.fmtHourMinute(testDate)).toBe("3:30 PM");
|
||||
expect(fmt.fmtFullDateTime(testDate)).toBe("So., 15. Jan. 2023, 3:30 PM");
|
||||
expect(fmt.fmtFullDateTime(testDate, false)).toBe("So., 15. Jan., 3:30 PM");
|
||||
expect(fmt.fmtWeekdayTime(testDate)).toBe("So. 3:30 PM");
|
||||
expect(fmt.fmtAbsoluteDate(testDate)).toBe("So 3:30 PM");
|
||||
});
|
||||
|
|
@ -284,29 +283,12 @@ describe("12h/24h time format", () => {
|
|||
test("24h format", () => {
|
||||
is12hSpy.mockReturnValue(false);
|
||||
expect(fmt.fmtHourMinute(testDate)).toBe("15:30");
|
||||
expect(fmt.fmtFullDateTime(testDate)).toBe("So., 15. Jan. 2023, 15:30");
|
||||
expect(fmt.fmtFullDateTime(testDate, false)).toBe("So., 15. Jan., 15:30");
|
||||
expect(fmt.fmtWeekdayTime(testDate)).toBe("So., 15:30");
|
||||
expect(fmt.fmtAbsoluteDate(testDate)).toBe("So 15:30");
|
||||
});
|
||||
});
|
||||
|
||||
describe("date format", () => {
|
||||
test("should order day and month by preference", () => {
|
||||
is12hSpy.mockReturnValue(false);
|
||||
settings.dateFormat = "dmy";
|
||||
expect(fmt.fmtFullDateTime(testDate)).toBe("So. 15 Jan. 2023 15:30");
|
||||
settings.dateFormat = "mdy";
|
||||
expect(fmt.fmtFullDateTime(testDate)).toBe("So. Jan. 15, 2023 15:30");
|
||||
settings.dateFormat = "ymd";
|
||||
expect(fmt.fmtFullDateTime(testDate)).toBe("So. 2023-01-15 15:30");
|
||||
settings.dateFormat = "";
|
||||
});
|
||||
test("should format day and time", () => {
|
||||
is12hSpy.mockReturnValue(false);
|
||||
expect(fmt.fmtWeekdayDayTime(testDate)).toBe("So. 15, 15:30");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fmtTimeRange", () => {
|
||||
test("should format time ranges in 24h format", () => {
|
||||
is12hSpy.mockReturnValue(false);
|
||||
|
|
|
|||
|
|
@ -1,55 +1,6 @@
|
|||
import { defineComponent } from "vue";
|
||||
import { is12hFormat } from "@/units";
|
||||
import { CURRENCY } from "../types/evcc";
|
||||
import settings from "@/settings";
|
||||
import type { DateFormat } from "@/settings";
|
||||
|
||||
// Extract a single part in date context, where names can differ from their
|
||||
// standalone forms (German "So.", "Jan." vs "So", "Jan").
|
||||
function datePart(
|
||||
date: Date,
|
||||
locale: string | undefined,
|
||||
type: Intl.DateTimeFormatPartTypes
|
||||
): string {
|
||||
return (
|
||||
new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "short" })
|
||||
.formatToParts(date)
|
||||
.find((part) => part.type === type)?.value ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
// Day+month(+year) in the user's date order, e.g. "17 Mai" or "Mai 17, 2025".
|
||||
// Ordering and punctuation come from a reference locale (en-GB day-first,
|
||||
// en-US month-first), month names stay translated. ymd is the full ISO date.
|
||||
function formatDayMonth(
|
||||
date: Date,
|
||||
locale: string | undefined,
|
||||
fmt: DateFormat,
|
||||
year = false
|
||||
): string {
|
||||
if (fmt === "ymd") return isoDate(date);
|
||||
const orderLocale = fmt === "mdy" ? "en-US" : "en-GB";
|
||||
const month = datePart(date, locale, "month");
|
||||
// some locales (e.g. Czech) use numeric months in date context
|
||||
const name = /\p{L}/u.test(month)
|
||||
? month
|
||||
: new Intl.DateTimeFormat(locale, { month: "short" }).format(date);
|
||||
return new Intl.DateTimeFormat(orderLocale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: year ? "numeric" : undefined,
|
||||
})
|
||||
.formatToParts(date)
|
||||
.map((part) => (part.type === "month" ? name : part.value))
|
||||
.join("");
|
||||
}
|
||||
|
||||
// local-time ISO date, Intl cannot produce this reliably across locales
|
||||
function isoDate(date: Date): string {
|
||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getDate()).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
const CURRENCY_SYMBOLS: Record<CURRENCY, string> = {
|
||||
AUD: "$",
|
||||
|
|
@ -75,10 +26,6 @@ const CURRENCY_SYMBOLS: Record<CURRENCY, string> = {
|
|||
TRY: "₺",
|
||||
MYR: "RM",
|
||||
THB: "฿",
|
||||
BYN: "Br",
|
||||
UAH: "₴",
|
||||
RUB: "₽",
|
||||
KZT: "₸",
|
||||
};
|
||||
|
||||
// list of currencies where energy price should be displayed in subunits (factor 100)
|
||||
|
|
@ -98,7 +45,6 @@ const ENERGY_PRICE_IN_SUBUNIT: Partial<Record<CURRENCY, string>> = {
|
|||
SEK: "öre", // Swedish öre
|
||||
ZAR: "c", // South African cent
|
||||
TRY: "krş", // Türkiye kuruş
|
||||
BYN: "к.", // Belarusian kapeyka
|
||||
};
|
||||
|
||||
export enum POWER_UNIT {
|
||||
|
|
@ -116,11 +62,6 @@ export default defineComponent({
|
|||
fmtDigits: 1,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
dateFormat(): DateFormat {
|
||||
return settings.dateFormat || "";
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
energyPriceSubunit(currency: CURRENCY): string | undefined {
|
||||
if (currency === CURRENCY.CHF) {
|
||||
|
|
@ -324,33 +265,15 @@ export default defineComponent({
|
|||
hour12: is12hFormat(),
|
||||
}).format(date);
|
||||
},
|
||||
fmtFullDateTime(date: Date) {
|
||||
const locale = this.$i18n?.locale;
|
||||
const fmt = this.dateFormat;
|
||||
if (!fmt) {
|
||||
// auto: single Intl call preserves locale-native separators (e.g. German "So., 15. Jan.,")
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: is12hFormat(),
|
||||
}).format(date);
|
||||
}
|
||||
const time = new Intl.DateTimeFormat(locale, {
|
||||
fmtFullDateTime(date: Date, short: boolean) {
|
||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
weekday: short ? undefined : "short",
|
||||
month: short ? "numeric" : "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: is12hFormat(),
|
||||
}).format(date);
|
||||
const weekday = datePart(date, locale, "weekday");
|
||||
return `${weekday} ${formatDayMonth(date, locale, fmt, true)} ${time}`.trim();
|
||||
},
|
||||
// weekday + day of month + time, for lists within a known month
|
||||
fmtWeekdayDayTime(date: Date) {
|
||||
const weekday = datePart(date, this.$i18n?.locale, "weekday");
|
||||
return `${weekday} ${date.getDate()}, ${this.fmtHourMinute(date)}`;
|
||||
},
|
||||
fmtWeekdayTime(date: Date) {
|
||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
|
|
@ -372,17 +295,11 @@ export default defineComponent({
|
|||
}).format(date);
|
||||
},
|
||||
fmtDayMonth(date: Date) {
|
||||
const locale = this.$i18n?.locale;
|
||||
const fmt = this.dateFormat;
|
||||
if (!fmt) {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
}).format(date);
|
||||
}
|
||||
const weekday = datePart(date, locale, "weekday");
|
||||
return `${weekday} ${formatDayMonth(date, locale, fmt)}`.trim();
|
||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
}).format(date);
|
||||
},
|
||||
fmtDayMonthYear(date: Date) {
|
||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ const BATTERY_UNIT = "battery_unit";
|
|||
const SETTINGS_PRICE_ZOOM = "settings_price_zoom";
|
||||
const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin";
|
||||
const LAST_BATTERY_SMART_COST_LIMIT = "last_battery_smart_cost_limit";
|
||||
const SETTINGS_DATE_FORMAT = "settings_date_format";
|
||||
const LAST_TARGET_TIME = "last_target_time";
|
||||
const LAST_SOC_GOAL = "last_soc_goal";
|
||||
const LAST_ENERGY_GOAL = "last_energy_goal";
|
||||
|
|
@ -100,8 +99,6 @@ function saveJSON(key: string) {
|
|||
};
|
||||
}
|
||||
|
||||
export type DateFormat = "" | "dmy" | "mdy" | "ymd";
|
||||
|
||||
export interface LoadpointSettings {
|
||||
order?: number;
|
||||
visible?: boolean;
|
||||
|
|
@ -115,7 +112,6 @@ export interface Settings {
|
|||
theme: THEME | null;
|
||||
unit: string;
|
||||
is12hFormat: boolean;
|
||||
dateFormat: DateFormat;
|
||||
energyflowDetails: boolean;
|
||||
energyflowCo2: boolean;
|
||||
energyflowPv: boolean;
|
||||
|
|
@ -145,7 +141,6 @@ const settings: Settings = reactive({
|
|||
theme: read(SETTINGS_THEME),
|
||||
unit: read(SETTINGS_UNIT),
|
||||
is12hFormat: readBool(SETTINGS_12H_FORMAT),
|
||||
dateFormat: read(SETTINGS_DATE_FORMAT) || "",
|
||||
energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS),
|
||||
energyflowCo2: readBool(SETTINGS_ENERGYFLOW_CO2),
|
||||
energyflowPv: readBool(SETTINGS_ENERGYFLOW_PV),
|
||||
|
|
@ -174,7 +169,6 @@ watch(() => settings.locale, save(SETTINGS_LOCALE));
|
|||
watch(() => settings.theme, save(SETTINGS_THEME));
|
||||
watch(() => settings.unit, save(SETTINGS_UNIT));
|
||||
watch(() => settings.is12hFormat, saveBool(SETTINGS_12H_FORMAT));
|
||||
watch(() => settings.dateFormat, save(SETTINGS_DATE_FORMAT));
|
||||
watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS));
|
||||
watch(() => settings.energyflowCo2, saveBool(SETTINGS_ENERGYFLOW_CO2));
|
||||
watch(() => settings.energyflowPv, saveBool(SETTINGS_ENERGYFLOW_PV));
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export function getThemePreference(): THEME | null {
|
|||
if (theme && Object.values(THEME).includes(theme)) {
|
||||
return theme;
|
||||
}
|
||||
return window.evcc?.customTheme || THEME.AUTO;
|
||||
return THEME.AUTO;
|
||||
}
|
||||
|
||||
export function setThemePreference(theme: THEME) {
|
||||
|
|
|
|||
|
|
@ -8,14 +8,12 @@ declare global {
|
|||
app: any;
|
||||
evcc?: {
|
||||
version: string;
|
||||
commit: string;
|
||||
customCss: boolean;
|
||||
customLogo: boolean;
|
||||
customBrand: string;
|
||||
customWebsite: string;
|
||||
customEmail: string;
|
||||
customPhone: string;
|
||||
customTheme: THEME;
|
||||
};
|
||||
}
|
||||
interface Window {
|
||||
|
|
@ -461,10 +459,6 @@ export enum ConfigType {
|
|||
export type ConfigVehicle = Entity;
|
||||
export type ConfigMessenger = Entity;
|
||||
|
||||
export interface ConfigCurtailer extends Entity {
|
||||
deviceTitle?: string;
|
||||
}
|
||||
|
||||
export interface ConfigHems extends Entity {
|
||||
deviceProduct?: string;
|
||||
}
|
||||
|
|
@ -535,7 +529,6 @@ export enum SMART_COST_TYPE {
|
|||
CO2 = "co2",
|
||||
PRICE_DYNAMIC = "pricedynamic",
|
||||
PRICE_FORECAST = "priceforecast",
|
||||
PRICE_STATIC = "pricestatic",
|
||||
}
|
||||
|
||||
export enum LENGTH_UNIT {
|
||||
|
|
@ -816,10 +809,6 @@ export enum CURRENCY {
|
|||
TRY = "TRY",
|
||||
MYR = "MYR",
|
||||
THB = "THB",
|
||||
BYN = "BYN",
|
||||
UAH = "UAH",
|
||||
RUB = "RUB",
|
||||
KZT = "KZT",
|
||||
}
|
||||
|
||||
export enum ICON_SIZE {
|
||||
|
|
@ -1135,10 +1124,8 @@ export interface Battery {
|
|||
capacity: number;
|
||||
/** Charge level in %. Weighted by capacity across all batteries. */
|
||||
soc: number;
|
||||
/** Total discharged energy in kWh. */
|
||||
/** Total battery energy in kWh. */
|
||||
energy?: number;
|
||||
/** Total charged energy in kWh. */
|
||||
returnEnergy?: number;
|
||||
/** Measurement data per battery meter. */
|
||||
devices?: BatteryMeter[];
|
||||
/** Projected charge levels based on the solar and price forecast. */
|
||||
|
|
@ -1380,8 +1367,7 @@ export type DeviceType =
|
|||
| "loadpoint"
|
||||
| "messenger"
|
||||
| "tariff"
|
||||
| "hems"
|
||||
| "curtailer";
|
||||
| "hems";
|
||||
export type MeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext" | "consumer";
|
||||
export type MeterTemplateUsage = "grid" | "pv" | "battery" | "charge" | "aux";
|
||||
export type TariffType = "grid" | "feedIn" | "co2" | "planner" | "solar" | "temperature";
|
||||
|
|
@ -1398,7 +1384,6 @@ export interface SiteConfig {
|
|||
aux: string[] | null;
|
||||
ext: string[] | null;
|
||||
consumer: string[] | null;
|
||||
curtail: string[] | null;
|
||||
}
|
||||
|
||||
export type ValueOf<T> = T[keyof T];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import settings from "./settings";
|
||||
import type { DateFormat } from "./settings";
|
||||
import { LENGTH_UNIT } from "./types/evcc";
|
||||
|
||||
const MILES_FACTOR = 0.6213711922;
|
||||
|
|
@ -35,11 +34,3 @@ export function is12hFormat() {
|
|||
export function set12hFormat(value: boolean) {
|
||||
settings.is12hFormat = value;
|
||||
}
|
||||
|
||||
export function getDateFormat(): DateFormat {
|
||||
return settings.dateFormat || "";
|
||||
}
|
||||
|
||||
export function setDateFormat(value: DateFormat) {
|
||||
settings.dateFormat = value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ import { describe, expect, test } from "vite-plus/test";
|
|||
import {
|
||||
isDevelopment,
|
||||
isNightly,
|
||||
commitFromVersion,
|
||||
getReleaseName,
|
||||
getShortVersion,
|
||||
isNewVersionAvailable,
|
||||
} from "./version";
|
||||
|
||||
const DEV = "0.0.0";
|
||||
const NIGHTLY = "0.304.0-dev.1712345678";
|
||||
const NIGHTLY = "0.304.0-dev+abc1234";
|
||||
const STABLE = "0.303.1";
|
||||
|
||||
describe("isDevelopment", () => {
|
||||
|
|
@ -20,13 +21,20 @@ describe("isDevelopment", () => {
|
|||
});
|
||||
|
||||
describe("isNightly", () => {
|
||||
test("only pre-release versions", () => {
|
||||
test("only versions with build metadata", () => {
|
||||
expect(isNightly(DEV)).toBe(false);
|
||||
expect(isNightly(NIGHTLY)).toBe(true);
|
||||
expect(isNightly(STABLE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commitFromVersion", () => {
|
||||
test("extracts build metadata", () => {
|
||||
expect(commitFromVersion(NIGHTLY)).toBe("abc1234");
|
||||
expect(commitFromVersion(STABLE)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getReleaseName", () => {
|
||||
test("maps version to release name", () => {
|
||||
expect(getReleaseName(DEV)).toBe("development");
|
||||
|
|
@ -38,15 +46,14 @@ describe("getReleaseName", () => {
|
|||
describe("getShortVersion", () => {
|
||||
test("formats version", () => {
|
||||
expect(getShortVersion(DEV)).toBe("dev build");
|
||||
expect(getShortVersion(NIGHTLY)).toBe("v0.304.0-dev.1712345678");
|
||||
expect(getShortVersion(NIGHTLY)).toBe("v0.304.0-dev+abc1234");
|
||||
expect(getShortVersion(STABLE)).toBe("v0.303.1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNewVersionAvailable", () => {
|
||||
test("never for dev or nightly builds", () => {
|
||||
test("never for dev builds", () => {
|
||||
expect(isNewVersionAvailable(DEV, "0.303.1")).toBe(false);
|
||||
expect(isNewVersionAvailable(NIGHTLY, "0.304.0")).toBe(false);
|
||||
});
|
||||
test("only when available differs", () => {
|
||||
expect(isNewVersionAvailable(STABLE, "0.303.1")).toBe(false);
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@ export function isDevelopment(version: string): boolean {
|
|||
return version === "0.0.0";
|
||||
}
|
||||
|
||||
// untagged builds are pre-releases carrying the build timestamp: 0.304.0-dev.1712345678
|
||||
// untagged builds carry the commit as build metadata: 0.304.0-dev+abc1234
|
||||
export function isNightly(version: string): boolean {
|
||||
return version.includes("-dev.");
|
||||
return version.includes("+");
|
||||
}
|
||||
|
||||
export function commitFromVersion(version: string): string {
|
||||
return version.split("+")[1] ?? "";
|
||||
}
|
||||
|
||||
export function getReleaseName(version: string): string {
|
||||
|
|
@ -19,8 +23,7 @@ export function getShortVersion(version: string): string {
|
|||
}
|
||||
|
||||
export function isNewVersionAvailable(installed?: string, available?: string): boolean {
|
||||
const v = installed || "";
|
||||
return !!available && !isDevelopment(v) && !isNightly(v) && available !== installed;
|
||||
return !!available && !isDevelopment(installed || "") && available !== installed;
|
||||
}
|
||||
|
||||
export function isNewVersionUnacknowledged(
|
||||
|
|
|
|||
|
|
@ -105,7 +105,6 @@ export default defineComponent({
|
|||
aboutModalProps() {
|
||||
return {
|
||||
installed: window.evcc?.version,
|
||||
commit: window.evcc?.commit,
|
||||
customLogo: this.custom.logo,
|
||||
customBrand: this.custom.brand,
|
||||
customWebsite: this.custom.website,
|
||||
|
|
|
|||
|
|
@ -150,31 +150,6 @@
|
|||
data-testid="add-grid"
|
||||
@click="openModal('meter', { type: 'grid' })"
|
||||
/>
|
||||
<DeviceCard
|
||||
v-for="curtailer in curtailerDevices"
|
||||
:id="`curtailer_${curtailer.name}`"
|
||||
:key="curtailer.name"
|
||||
:name="curtailer.name"
|
||||
:title="curtailerTitle(curtailer)"
|
||||
:editable="!!curtailer.id"
|
||||
:error="hasDeviceError('curtailer', curtailer.name)"
|
||||
:banner="curtailmentBanner('curtailer', curtailer.name)"
|
||||
data-testid="curtailer"
|
||||
@edit="openModal('curtailer', { id: curtailer.id })"
|
||||
>
|
||||
<template #icon>
|
||||
<CurtailIcon />
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="deviceTags('curtailer', curtailer.name)" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<NewDeviceButton
|
||||
v-if="pvMeters.length > 0"
|
||||
:title="$t('config.main.addCurtailer')"
|
||||
data-testid="add-curtailer"
|
||||
@click="openModal('curtailer')"
|
||||
/>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
|
||||
|
|
@ -187,7 +162,7 @@
|
|||
meter-type="pv"
|
||||
:has-error="hasDeviceError('meter', meter.name)"
|
||||
:tags="deviceTags('meter', meter.name)"
|
||||
:banner="curtailmentBanner('meter', meter.name)"
|
||||
:banner="meterBanner(meter.name)"
|
||||
@edit="(type, id) => openModal('meter', { type, id })"
|
||||
@enable="handleDisable('meter', meter.id, false)"
|
||||
/>
|
||||
|
|
@ -419,7 +394,8 @@
|
|||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
:title="$t('config.remote.title')"
|
||||
v-if="experimental"
|
||||
:title="`${$t('config.remote.title')} 🧪`"
|
||||
editable
|
||||
:unconfigured="isUnconfigured(remoteTags)"
|
||||
data-testid="remote-access"
|
||||
|
|
@ -549,7 +525,6 @@
|
|||
<MessagingLegacyModal @changed="loadDirty" />
|
||||
<MessagingModal :messengers="messengers" @changed="loadDirty" />
|
||||
<MessengerModal @changed="messengerChanged" />
|
||||
<CurtailerModal @changed="curtailerChanged" />
|
||||
<TariffsLegacyModal @changed="loadDirty" />
|
||||
<TariffModal
|
||||
:currency="currency"
|
||||
|
|
@ -604,8 +579,6 @@ import CircuitsModal from "../components/Config/CircuitsModal.vue";
|
|||
import CircuitTags from "../components/Config/CircuitTags.vue";
|
||||
import collector from "../mixins/collector";
|
||||
import ControlModal from "../components/Config/ControlModal.vue";
|
||||
import CurtailerModal from "../components/Config/CurtailerModal.vue";
|
||||
import CurtailIcon from "../components/MaterialIcon/Curtail.vue";
|
||||
import DeviceCard from "../components/Config/DeviceCard.vue";
|
||||
import DeviceTags from "../components/Config/DeviceTags.vue";
|
||||
import EebusIcon from "../components/MaterialIcon/Eebus.vue";
|
||||
|
|
@ -660,7 +633,6 @@ import type {
|
|||
ConfigCharger,
|
||||
ConfigVehicle,
|
||||
ConfigCircuit,
|
||||
ConfigCurtailer,
|
||||
ConfigMessenger,
|
||||
ConfigHems,
|
||||
ConfigLoadpoint,
|
||||
|
|
@ -719,8 +691,6 @@ export default defineComponent({
|
|||
CircuitsModal,
|
||||
CircuitTags,
|
||||
ControlModal,
|
||||
CurtailerModal,
|
||||
CurtailIcon,
|
||||
DeviceCard,
|
||||
DeviceTags,
|
||||
EebusIcon,
|
||||
|
|
@ -779,7 +749,6 @@ export default defineComponent({
|
|||
data() {
|
||||
return {
|
||||
messengers: [] as ConfigMessenger[],
|
||||
curtailers: [] as ConfigCurtailer[],
|
||||
vehicles: [] as ConfigVehicle[],
|
||||
meters: [] as ConfigMeter[],
|
||||
loadpoints: [] as ConfigLoadpoint[],
|
||||
|
|
@ -803,7 +772,6 @@ export default defineComponent({
|
|||
aux: null as string[] | null,
|
||||
ext: null as string[] | null,
|
||||
consumer: null as string[] | null,
|
||||
curtail: null as string[] | null,
|
||||
} as SiteConfig,
|
||||
deviceValueTimeout: null as Timeout,
|
||||
deviceValues: {
|
||||
|
|
@ -813,7 +781,6 @@ export default defineComponent({
|
|||
loadpoint: {},
|
||||
messenger: {},
|
||||
tariff: {},
|
||||
curtailer: {},
|
||||
} as DeviceValuesMap,
|
||||
isComponentMounted: true,
|
||||
isPageVisible: true,
|
||||
|
|
@ -901,10 +868,8 @@ export default defineComponent({
|
|||
{
|
||||
slug: "grid",
|
||||
icon: "shopicon-regular-powersupply",
|
||||
count: (this.gridMeter ? 1 : 0) + this.curtailerDevices.length,
|
||||
error:
|
||||
(!!this.gridMeter && this.hasDeviceError("meter", this.gridMeter.name)) ||
|
||||
this.curtailerDevices.some((c) => this.hasDeviceError("curtailer", c.name)),
|
||||
count: this.gridMeter ? 1 : 0,
|
||||
error: !!this.gridMeter && this.hasDeviceError("meter", this.gridMeter.name),
|
||||
},
|
||||
{
|
||||
slug: "pv-battery",
|
||||
|
|
@ -912,7 +877,7 @@ export default defineComponent({
|
|||
count: pvAndBattery.length,
|
||||
error: meterError(pvAndBattery),
|
||||
warning:
|
||||
this.pvMeters.some((m) => this.curtailmentBanner("meter", m.name)) ||
|
||||
this.pvMeters.some((m) => this.meterBanner(m.name)) ||
|
||||
meterDisabled(pvAndBattery),
|
||||
},
|
||||
{
|
||||
|
|
@ -991,12 +956,6 @@ export default defineComponent({
|
|||
const names = this.site?.ext;
|
||||
return this.getMetersByNames(names);
|
||||
},
|
||||
curtailerDevices(): ConfigCurtailer[] {
|
||||
const names = this.site?.curtail || [];
|
||||
return names
|
||||
.map((name) => this.curtailers.find((c) => c.name === name))
|
||||
.filter((c): c is ConfigCurtailer => c !== undefined);
|
||||
},
|
||||
consumerMeters() {
|
||||
return this.getMetersByNames(this.site?.consumer);
|
||||
},
|
||||
|
|
@ -1091,9 +1050,7 @@ export default defineComponent({
|
|||
return result;
|
||||
},
|
||||
vehicleOptions(): VehicleOption[] {
|
||||
return this.vehicles
|
||||
.filter((v) => !v.deviceDisable)
|
||||
.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
|
||||
return this.vehicles.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
|
||||
},
|
||||
hems() {
|
||||
return store.state?.hems;
|
||||
|
|
@ -1334,7 +1291,6 @@ export default defineComponent({
|
|||
await this.loadLoadpoints();
|
||||
await this.loadCircuits();
|
||||
await this.loadMessengers();
|
||||
await this.loadCurtailers();
|
||||
await this.loadTariffs();
|
||||
await this.loadTariffRefs();
|
||||
await this.loadHems();
|
||||
|
|
@ -1355,9 +1311,6 @@ export default defineComponent({
|
|||
async loadMessengers() {
|
||||
this.messengers = (await this.loadConfig("devices/messenger")) || [];
|
||||
},
|
||||
async loadCurtailers() {
|
||||
this.curtailers = (await this.loadConfig("devices/curtailer")) || [];
|
||||
},
|
||||
async loadVehicles() {
|
||||
this.vehicles = (await this.loadConfig("devices/vehicle")) || [];
|
||||
},
|
||||
|
|
@ -1511,25 +1464,6 @@ export default defineComponent({
|
|||
this.loadMessengers();
|
||||
this.loadDirty();
|
||||
},
|
||||
curtailerTitle(curtailer: ConfigCurtailer): string {
|
||||
return (
|
||||
curtailer.deviceTitle ||
|
||||
curtailer.config?.template ||
|
||||
this.$t("config.curtailer.title")
|
||||
);
|
||||
},
|
||||
async curtailerChanged(result: ModalResult) {
|
||||
if (result.action === "added" && result.name) {
|
||||
this.site.curtail = [...(this.site.curtail || []), result.name];
|
||||
await this.saveSite("curtail");
|
||||
}
|
||||
if (result.action === "removed") {
|
||||
await this.loadSite();
|
||||
}
|
||||
await this.loadCurtailers();
|
||||
await this.loadDirty();
|
||||
this.updateValues();
|
||||
},
|
||||
siteChanged() {
|
||||
this.loadDirty();
|
||||
},
|
||||
|
|
@ -1570,7 +1504,6 @@ export default defineComponent({
|
|||
vehicle: this.vehicles,
|
||||
charger: this.chargers,
|
||||
tariff: this.tariffs,
|
||||
curtailer: this.curtailers,
|
||||
} as Record<DeviceType, any[]>;
|
||||
for (const type in devices) {
|
||||
for (const device of devices[type as DeviceType]) {
|
||||
|
|
@ -1590,10 +1523,9 @@ export default defineComponent({
|
|||
deviceTags(type: DeviceType, id: string) {
|
||||
return this.deviceValues[type][id] || {};
|
||||
},
|
||||
curtailmentBanner(type: DeviceType, name: string): string | undefined {
|
||||
// devices report the allowed feed-in percent, 100 = uncurtailed
|
||||
const value = this.deviceTags(type, name)["curtailed"]?.value;
|
||||
return typeof value === "number" && value < 100
|
||||
meterBanner(name: string): string | undefined {
|
||||
// the tag is only present while curtailing, a zero percent limit is still one
|
||||
return this.deviceTags("meter", name)["curtailed"]?.value !== undefined
|
||||
? this.$t("config.deviceValue.productionLimited")
|
||||
: undefined;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -262,7 +262,7 @@
|
|||
<input
|
||||
v-model.number="logCount"
|
||||
type="number"
|
||||
class="form-control text-end"
|
||||
class="form-control text-end log-count-input"
|
||||
min="0"
|
||||
step="25"
|
||||
/>
|
||||
|
|
@ -687,6 +687,11 @@ export default defineComponent({
|
|||
<style scoped>
|
||||
@import "../../css/breakpoints.css";
|
||||
|
||||
.log-count-input::-webkit-outer-spin-button,
|
||||
.log-count-input::-webkit-inner-spin-button {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
@media (--md-and-up) {
|
||||
.log-lines-input {
|
||||
width: 170px;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import (
|
|||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
|
|
@ -368,24 +367,21 @@ func (wb *AlpitronicHYC) TotalEnergy() (float64, error) {
|
|||
var _ api.Identifier = (*AlpitronicHYC)(nil)
|
||||
|
||||
// Identify implements the api.Identifier interface
|
||||
func (wb *AlpitronicHYC) Identify() ([]string, error) {
|
||||
func (wb *AlpitronicHYC) Identify() (string, error) {
|
||||
b, err := wb.inputG()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res []string
|
||||
|
||||
// the idTag register holds the rfid uid as null-padded ascii string
|
||||
if idTag := hycInput(b, hycRegIdTag, 20); !allZero(idTag) {
|
||||
res = append(res, strings.ToLower(strings.TrimRight(string(idTag), "\x00")))
|
||||
return "", err
|
||||
}
|
||||
|
||||
if vid := hycInput(b, hycRegVID, 8); !allZero(vid) {
|
||||
res = append(res, hex.EncodeToString(vid))
|
||||
return hex.EncodeToString(vid), nil
|
||||
}
|
||||
|
||||
return res, nil
|
||||
if idTag := hycInput(b, hycRegIdTag, 20); !allZero(idTag) {
|
||||
return hex.EncodeToString(idTag), nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var _ api.Battery = (*AlpitronicHYC)(nil)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package charger
|
|||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -10,6 +11,7 @@ import (
|
|||
"github.com/andig/mbserver"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util/modbus"
|
||||
"github.com/evcc-io/evcc/util/sponsor"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -216,7 +218,7 @@ func TestAlpitronicIdentify(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
assert.Empty(t, id)
|
||||
|
||||
// tag before vehicle id
|
||||
// vehicle id takes precedence over the tag
|
||||
regs := hycRegs(1, hycStateCharging)
|
||||
regs[hycReg(1, hycRegVID)+1] = 0xAABB
|
||||
regs[hycReg(1, hycRegVID)+2] = 0xCCDD
|
||||
|
|
@ -227,9 +229,9 @@ func TestAlpitronicIdentify(t *testing.T) {
|
|||
|
||||
id, err = wb.Identify()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"ab", "0000aabbccddeeff"}, id)
|
||||
assert.Equal(t, "0000aabbccddeeff", id)
|
||||
|
||||
// tag only
|
||||
// tag as fallback
|
||||
regs = hycRegs(1, hycStateCharging)
|
||||
regs[hycReg(1, hycRegIdTag)] = 0x4142
|
||||
|
||||
|
|
@ -237,17 +239,7 @@ func TestAlpitronicIdentify(t *testing.T) {
|
|||
|
||||
id, err = wb.Identify()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"ab"}, id)
|
||||
|
||||
// vehicle id only
|
||||
regs = hycRegs(1, hycStateCharging)
|
||||
regs[hycReg(1, hycRegVID)+3] = 0xEEFF
|
||||
|
||||
wb, _ = hycTestCharger(t, 1, regs)
|
||||
|
||||
id, err = wb.Identify()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"000000000000eeff"}, id)
|
||||
assert.Equal(t, "4142"+strings.Repeat("0", 36), id)
|
||||
}
|
||||
|
||||
func TestAlpitronicEnable(t *testing.T) {
|
||||
|
|
@ -361,3 +353,14 @@ func TestAlpitronicReadFailure(t *testing.T) {
|
|||
_, err = wb.CurrentPower()
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAlpitronicSponsorGate(t *testing.T) {
|
||||
// go-e tests set the global sponsor.Subject and never reset it
|
||||
old := sponsor.Subject
|
||||
sponsor.Subject = ""
|
||||
t.Cleanup(func() { sponsor.Subject = old })
|
||||
|
||||
// tests run without sponsorship: the public constructor must refuse
|
||||
_, err := NewAlpitronicHYC(t.Context(), modbus.TcpSettings{URI: "localhost:0", ID: 1}, 1)
|
||||
assert.ErrorIs(t, err, api.ErrSponsorRequired)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -299,13 +299,13 @@ func (wb *Amperfied) Voltages() (float64, float64, float64, error) {
|
|||
var _ api.Identifier = (*Amperfied)(nil)
|
||||
|
||||
// Identify implements the api.Identifier interface
|
||||
func (wb *Amperfied) Identify() ([]string, error) {
|
||||
func (wb *Amperfied) Identify() (string, error) {
|
||||
b, err := wb.conn.ReadInputRegisters(ampRegRfidUID, 6)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
return []string{hex.EncodeToString(b)}, nil
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
var _ api.Diagnosis = (*Amperfied)(nil)
|
||||
|
|
|
|||
|
|
@ -504,33 +504,24 @@ func (wb *BenderCC) getPhases() (int, error) {
|
|||
}
|
||||
|
||||
// identify implements the api.Identifier interface
|
||||
func (wb *BenderCC) identify() ([]string, error) {
|
||||
var ids []string
|
||||
|
||||
func (wb *BenderCC) identify() (string, error) {
|
||||
if !wb.legacy && !wb.mennekes4 {
|
||||
b, err := wb.conn.ReadHoldingRegisters(bendRegSmartVehicleDetected, 1)
|
||||
if err == nil && binary.BigEndian.Uint16(b) != 0 {
|
||||
b, err = wb.conn.ReadHoldingRegisters(bendRegEVCCID, 6)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if id := bytesAsString(b); id != "" {
|
||||
ids = append(ids, id)
|
||||
if id := bytesAsString(b); id != "" || err != nil {
|
||||
return id, err
|
||||
}
|
||||
}
|
||||
|
||||
b, err := wb.conn.ReadHoldingRegisters(bendRegUserID, 10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
if id := bytesAsString(b); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
return bytesAsString(b), nil
|
||||
}
|
||||
|
||||
// soc implements the api.Battery interface
|
||||
|
|
|
|||
|
|
@ -242,11 +242,11 @@ func (wb *CfosPowerBrain) WakeUp() error {
|
|||
var _ api.Identifier = (*CfosPowerBrain)(nil)
|
||||
|
||||
// Identify implements the api.Identifier interface
|
||||
func (wb *CfosPowerBrain) Identify() ([]string, error) {
|
||||
func (wb *CfosPowerBrain) Identify() (string, error) {
|
||||
b, err := wb.conn.ReadHoldingRegisters(cfosRegLastRfid, 15)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
return []string{bytesAsString(b)}, nil
|
||||
return bytesAsString(b), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,12 +118,7 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.C
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("identify: %w", err)
|
||||
}
|
||||
if identify != nil {
|
||||
implement.Has(c, implement.Identifier(func() ([]string, error) {
|
||||
id, err := identify()
|
||||
return []string{id}, err
|
||||
}))
|
||||
}
|
||||
implement.May(c, implement.Identifier(identify))
|
||||
|
||||
// decorate wakeup
|
||||
if cc.Wakeup != nil {
|
||||
|
|
|
|||
|
|
@ -289,13 +289,13 @@ func (wb *Compleo) ChargeDuration() (time.Duration, error) {
|
|||
var _ api.Identifier = (*Compleo)(nil)
|
||||
|
||||
// Identify implements the api.Identifier interface
|
||||
func (wb *Compleo) Identify() ([]string, error) {
|
||||
func (wb *Compleo) Identify() (string, error) {
|
||||
b, err := wb.conn.ReadInputRegisters(compleoRegIdTag+wb.offset, 10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
return []string{bytesAsString(b)}, nil
|
||||
return bytesAsString(b), nil
|
||||
}
|
||||
|
||||
var _ loadpoint.Controller = (*Compleo)(nil)
|
||||
|
|
|
|||
|
|
@ -291,13 +291,13 @@ func (wb *Dadapower) Phases1p3p(phases int) error {
|
|||
var _ api.Identifier = (*Dadapower)(nil)
|
||||
|
||||
// Identify implements the api.Identifier interface
|
||||
func (wb *Dadapower) Identify() ([]string, error) {
|
||||
func (wb *Dadapower) Identify() (string, error) {
|
||||
u, err := wb.conn.ReadInputRegisters(dadapowerRegIdentification+wb.regOffset, 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
return []string{bytesAsString(u)}, nil
|
||||
return bytesAsString(u), nil
|
||||
}
|
||||
|
||||
var _ api.Diagnosis = (*Dadapower)(nil)
|
||||
|
|
|
|||
|
|
@ -318,14 +318,12 @@ func (wb *DaheimLaden) Voltages() (float64, float64, float64, error) {
|
|||
var _ api.Identifier = (*DaheimLaden)(nil)
|
||||
|
||||
// Identify implements the api.Identifier interface. Only usable with PRO
|
||||
func (wb *DaheimLaden) Identify() ([]string, error) {
|
||||
func (wb *DaheimLaden) Identify() (string, error) {
|
||||
b, err := wb.conn.ReadHoldingRegisters(dlRegCardId, 16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
id, err := utf16BEBytesAsString(b)
|
||||
return []string{id}, err
|
||||
return utf16BEBytesAsString(b)
|
||||
}
|
||||
|
||||
// phases1p3p implements the api.PhaseSwitcher interface
|
||||
|
|
|
|||
|
|
@ -354,13 +354,13 @@ func (wb *Delta) CurrentPower() (float64, error) {
|
|||
var _ api.Identifier = (*Delta)(nil)
|
||||
|
||||
// Identify implements the api.Identifier interface
|
||||
func (wb *Delta) Identify() ([]string, error) {
|
||||
func (wb *Delta) Identify() (string, error) {
|
||||
b, err := wb.conn.ReadInputRegisters(wb.base+deltaRegEvseRfidUID, 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
return []string{bytesAsString(b)}, nil
|
||||
return bytesAsString(b), nil
|
||||
}
|
||||
|
||||
var _ api.Diagnosis = (*Delta)(nil)
|
||||
|
|
|
|||
|
|
@ -748,17 +748,16 @@ var _ api.Identifier = (*E3dc)(nil)
|
|||
|
||||
// Identify implements the api.Identifier interface
|
||||
// Returns the RFID tag ID from WB_SESSION_AUTH_DATA if a session is active
|
||||
func (wb *E3dc) Identify() ([]string, error) {
|
||||
func (wb *E3dc) Identify() (string, error) {
|
||||
wb.mu.Lock()
|
||||
defer wb.mu.Unlock()
|
||||
|
||||
msg, found, err := wb.sessionMessage(rscp.WB_SESSION_AUTH_DATA)
|
||||
if err != nil || !found {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
id, err := rscpString(msg)
|
||||
return []string{id}, err
|
||||
return rscpString(msg)
|
||||
}
|
||||
|
||||
var _ api.PhaseSwitcher = (*E3dc)(nil)
|
||||
|
|
|
|||
|
|
@ -850,11 +850,11 @@ func (c *Easee) StatusReason() (api.Reason, error) {
|
|||
var _ api.Identifier = (*Easee)(nil)
|
||||
|
||||
// Currents implements the api.PhaseCurrents interface
|
||||
func (c *Easee) Identify() ([]string, error) {
|
||||
func (c *Easee) Identify() (string, error) {
|
||||
c.mux.Lock()
|
||||
defer c.mux.Unlock()
|
||||
|
||||
return []string{c.rfid}, nil
|
||||
return c.rfid, nil
|
||||
}
|
||||
|
||||
// Set smart charging status to update the chargers led (smart=blue, fast=white)
|
||||
|
|
|
|||
|
|
@ -520,26 +520,19 @@ func (c *EEBus) currents() (float64, float64, float64, error) {
|
|||
var _ api.Identifier = (*EEBus)(nil)
|
||||
|
||||
// Identify implements the api.Identifier interface
|
||||
func (c *EEBus) Identify() ([]string, error) {
|
||||
func (c *EEBus) Identify() (string, error) {
|
||||
evEntity, ok := c.isEvConnected()
|
||||
if !ok {
|
||||
return nil, nil
|
||||
return "", nil
|
||||
}
|
||||
|
||||
identification, err := c.cem.EvCC.Identifications(evEntity)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
if identification, err := c.cem.EvCC.Identifications(evEntity); err == nil && len(identification) > 0 {
|
||||
// return the first identification for now
|
||||
// later this could be multiple, e.g. MAC Address and PCID
|
||||
return identification[0].Value, nil
|
||||
}
|
||||
|
||||
// may be multiple, e.g. MAC address and PCID
|
||||
var res []string
|
||||
for _, i := range identification {
|
||||
if i.Value != "" {
|
||||
res = append(res, i.Value)
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var _ api.Battery = (*EEBus)(nil)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import (
|
|||
spineapi "github.com/enbility/spine-go/api"
|
||||
"github.com/enbility/spine-go/model"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/api/implement"
|
||||
"github.com/evcc-io/evcc/server/eebus"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
)
|
||||
|
|
@ -28,7 +27,6 @@ import (
|
|||
// enabling the charger schedules or resumes the optional consumption, disabling
|
||||
// it pauses or aborts the running process.
|
||||
type EEBusOHPCF struct {
|
||||
implement.Caps
|
||||
*embed
|
||||
cem *eebus.CustomerEnergyManagement
|
||||
ma *eebus.MonitoringAppliance
|
||||
|
|
@ -60,11 +58,10 @@ func init() {
|
|||
// NewEEBusOHPCFFromConfig creates an EEBus OHPCF charger from generic config
|
||||
func NewEEBusOHPCFFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
|
||||
cc := struct {
|
||||
embed `mapstructure:",squash"`
|
||||
Ski string
|
||||
Ip string
|
||||
Reboost time.Duration
|
||||
DisablePowerLimiter bool
|
||||
embed `mapstructure:",squash"`
|
||||
Ski string
|
||||
Ip string
|
||||
Reboost time.Duration
|
||||
}{
|
||||
embed: embed{
|
||||
Icon_: "heatpump",
|
||||
|
|
@ -77,19 +74,18 @@ func NewEEBusOHPCFFromConfig(ctx context.Context, other map[string]any) (api.Cha
|
|||
return nil, err
|
||||
}
|
||||
|
||||
return NewEEBusOHPCF(ctx, &cc.embed, cc.Ski, cc.Ip, cc.Reboost, cc.DisablePowerLimiter)
|
||||
return NewEEBusOHPCF(ctx, &cc.embed, cc.Ski, cc.Ip, cc.Reboost)
|
||||
}
|
||||
|
||||
// NewEEBusOHPCF creates an EEBus OHPCF charger, registers it with the EEBus
|
||||
// instance and waits for the connection.
|
||||
func NewEEBusOHPCF(ctx context.Context, embed *embed, ski, ip string, reboost time.Duration, disablePowerLimiter bool) (api.Charger, error) {
|
||||
func NewEEBusOHPCF(ctx context.Context, embed *embed, ski, ip string, reboost time.Duration) (api.Charger, error) {
|
||||
inst, err := eebus.Instance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := &EEBusOHPCF{
|
||||
Caps: implement.New(),
|
||||
embed: embed,
|
||||
log: util.NewLogger("eebus-ohpcf"),
|
||||
cem: inst.CustomerEnergyManagement(),
|
||||
|
|
@ -100,10 +96,6 @@ func NewEEBusOHPCF(ctx context.Context, embed *embed, ski, ip string, reboost ti
|
|||
reboost: reboost,
|
||||
}
|
||||
|
||||
if !disablePowerLimiter {
|
||||
implement.Has(c, implement.PowerLimiter(c.getMinMaxPower))
|
||||
}
|
||||
|
||||
if err := inst.RegisterDevice(ski, ip, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -468,8 +460,11 @@ func (c *EEBusOHPCF) apply(enable bool) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// getMinMaxPower is the callback used to provide the optional power limits.
|
||||
func (c *EEBusOHPCF) getMinMaxPower() (float64, float64, error) {
|
||||
var _ api.PowerLimiter = (*EEBusOHPCF)(nil)
|
||||
|
||||
// GetMinMaxPower implements the api.PowerLimiter interface, reporting the
|
||||
// optional consumption as expected min/max or ErrNotAvailable if none.
|
||||
func (c *EEBusOHPCF) GetMinMaxPower() (float64, float64, error) {
|
||||
entity, ok := c.connectedCompressor()
|
||||
if !ok {
|
||||
return 0, 0, errNotConnected
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ type Enovates struct {
|
|||
}
|
||||
|
||||
const (
|
||||
enovatesRegMaxAmp = 51 // uint16 RO, max amp per phase
|
||||
enovatesRegCurrents = 200 // 3x uint16 RO, mA
|
||||
enovatesRegVoltages = 203 // 3x int16 RO, V
|
||||
enovatesRegPower = 206 // int16 RO, W (active power total)
|
||||
|
|
@ -48,7 +49,7 @@ const (
|
|||
enovatesRegMode3StateStr = 301 // 2x register RO, IEC 61851 state, e.g. "C2"
|
||||
enovatesRegEMSLimit = 400 // int16 RW, mA (-1 = no limit, 0 = disabled)
|
||||
enovatesRegToken = 401 // 16x register RO, ASCII
|
||||
enovatesRegCurrentOffered = 417 // uint16 RO, mA, current actually offered to the vehicle
|
||||
enovatesRegCurrentOffered = 417 // uint16 RO, mA
|
||||
enovatesRegSerial = 5032 // 16x register RO, ASCII
|
||||
enovatesRegModel = 5048 // 16x register RO, ASCII
|
||||
enovatesRegFirmware = 5064 // 16x register RO, ASCII
|
||||
|
|
@ -91,9 +92,9 @@ func NewEnovates(ctx context.Context, settings modbus.TcpSettings) (api.Charger,
|
|||
current: 6000,
|
||||
}
|
||||
|
||||
// the transaction token is only readable when EMS over Modbus TCP is enabled
|
||||
if _, err := wb.conn.ReadHoldingRegisters(enovatesRegToken, 16); err != nil {
|
||||
return nil, fmt.Errorf("ems over modbus tcp not enabled: %w", err)
|
||||
// verify connection and EMS register availability
|
||||
if _, err := wb.conn.ReadHoldingRegisters(enovatesRegMode3StateStr, 2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return wb, nil
|
||||
|
|
@ -159,13 +160,22 @@ var _ api.CurrentGetter = (*Enovates)(nil)
|
|||
|
||||
// GetMaxCurrent implements the api.CurrentGetter interface
|
||||
func (wb *Enovates) GetMaxCurrent() (float64, error) {
|
||||
// the EMS limit register may read -1 (no limit), hence use the actually offered current
|
||||
b, err := wb.conn.ReadHoldingRegisters(enovatesRegCurrentOffered, 1)
|
||||
b, err := wb.conn.ReadHoldingRegisters(enovatesRegEMSLimit, 1)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return float64(binary.BigEndian.Uint16(b)) / 1e3, nil
|
||||
// -1 signals no EMS limit, fall back to the hardware maximum
|
||||
if limit := int16(binary.BigEndian.Uint16(b)); limit >= 0 {
|
||||
return float64(limit) / 1e3, nil
|
||||
}
|
||||
|
||||
b, err = wb.conn.ReadHoldingRegisters(enovatesRegMaxAmp, 1)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return rs485.RTUUint16ToFloat64(b), nil
|
||||
}
|
||||
|
||||
var _ api.Meter = (*Enovates)(nil)
|
||||
|
|
@ -223,13 +233,13 @@ func (wb *Enovates) Voltages() (float64, float64, float64, error) {
|
|||
var _ api.Identifier = (*Enovates)(nil)
|
||||
|
||||
// Identify implements the api.Identifier interface
|
||||
func (wb *Enovates) Identify() ([]string, error) {
|
||||
func (wb *Enovates) Identify() (string, error) {
|
||||
b, err := wb.conn.ReadHoldingRegisters(enovatesRegToken, 16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
return []string{trimModbusString(b)}, nil
|
||||
return trimModbusString(b), nil
|
||||
}
|
||||
|
||||
var _ api.Diagnosis = (*Enovates)(nil)
|
||||
|
|
|
|||
|
|
@ -378,11 +378,11 @@ func (wb *EVECUBE) phases1p3p(phases int) error {
|
|||
}
|
||||
|
||||
// identify implements the api.Identifier interface
|
||||
func (wb *EVECUBE) identify() ([]string, error) {
|
||||
func (wb *EVECUBE) identify() (string, error) {
|
||||
status, err := wb.getAutomationStatus()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
return []string{status.AuthTag.Tag}, nil
|
||||
return status.AuthTag.Tag, nil
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue