diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100644 index 000000000..b01847c3f --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -0,0 +1,73 @@ +name: Build + +# Forgejo bevorzugt .forgejo/workflows gegenüber .github/workflows. Die 23 +# Upstream-Workflows laufen hier also nicht mit — die verlangen ohnehin +# GitHub-spezifische Runner-Label (depot-ubuntu-24.04-arm) und Secrets. + +on: + push: + branches: [master] + workflow_dispatch: + +jobs: + verify: + name: Go build & test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + # main.go bindet dist/ per //go:embed ein. Das Verzeichnis wird vom + # Frontend-Build erzeugt und ist gitignored — für den reinen Compile-Check + # reicht ein Platzhalter, go:embed braucht nur mindestens eine Datei. + - name: Stub embedded assets + run: mkdir -p dist && echo stub > dist/index.html + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./util/sponsor/... ./charger/... ./core/... ./server/... + + - name: Test + run: CGO_ENABLED=0 go test ./... + + binary: + name: evcc binary + runs-on: ubuntu-latest + needs: verify + + steps: + # fetch-depth 0: das Makefile leitet VERSION aus git describe --tags ab + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - uses: actions/setup-node@v4 + with: + node-version: 26 + cache: npm + + - name: Install UI deps + run: npm ci + + - name: Build UI + run: make ui + + - name: Build binary + run: make build + + - uses: actions/upload-artifact@v3 + with: + name: evcc + path: evcc diff --git a/.github/actions/build-toolchain/action.yml b/.github/actions/build-toolchain/action.yml index 7d0f17453..89adc042f 100644 --- a/.github/actions/build-toolchain/action.yml +++ b/.github/actions/build-toolchain/action.yml @@ -13,7 +13,7 @@ inputs: runs: using: composite steps: - - uses: actions/setup-go@v6 + - uses: actions/setup-go@v7 with: go-version-file: go.mod cache: false # managed explicitly below to keep the scoped namespaces diff --git a/.github/actions/docker-cache/action.yml b/.github/actions/docker-cache/action.yml index 250fecf31..e543a5469 100644 --- a/.github/actions/docker-cache/action.yml +++ b/.github/actions/docker-cache/action.yml @@ -32,6 +32,12 @@ 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: diff --git a/.github/scripts/release-tag.sh b/.github/scripts/release-tag.sh index fd3bf867d..4219ae8c4 100755 --- a/.github/scripts/release-tag.sh +++ b/.github/scripts/release-tag.sh @@ -3,7 +3,9 @@ # # 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. +# shipping everything that landed on master since. Either way the tag must build +# on the previous release of its own line, so a tag pushed from the wrong branch +# cannot ship an older commit under a newer version. # # Prints `latest=` for consumption as a GitHub step output. Only the # newest release may move the `latest` pointers (docker tag, homebrew formula, @@ -27,13 +29,43 @@ 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=$(git tag --list | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort --version-sort | tail -1) || true + newest=$(printf '%s\n' "$releases" | tail -1) if [[ $newest == "$tag" ]]; then echo "latest=true" @@ -55,10 +87,12 @@ 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 @@ -76,6 +110,8 @@ 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 diff --git a/.github/workflows/command-backport.yml b/.github/workflows/command-backport.yml index 3c6d8139f..4b3e610b2 100644 --- a/.github/workflows/command-backport.yml +++ b/.github/workflows/command-backport.yml @@ -57,11 +57,33 @@ jobs: core.setFailed(message); }; - const { data: pr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: context.payload.issue.number, - }); + // 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)); + } if (!pr.merged) { fail(`pull request #${pr.number} is not merged`); return; @@ -157,18 +179,15 @@ jobs: TITLE: ${{ steps.pr.outputs.title }} ACTOR: ${{ github.event.comment.user.login }} run: | - # the release line the branch was cut from, 0.313 for release/0.313.1 - line=$(echo "${TARGET#release/}" | cut -d. -f1,2) - # the pull request is authored by the deploy token, so assign the # requester. the body stays empty, squashing it would carry the text # into the release branch as the commit description. - # the marker trails the title so the changelog filters and groups still - # see the original prefix, goreleaser strips it from the release notes + # the title stays untouched so the changelog filters and groups still + # see the original prefix, the backport label identifies the pull request url=$(gh pr create \ --base "$TARGET" \ --head "$BRANCH" \ - --title "$TITLE [$line backport]" \ + --title "$TITLE" \ --label backport \ --assignee "$ACTOR" \ --body "") diff --git a/.github/workflows/command-nightly.yml b/.github/workflows/command-nightly.yml index c3f2e1dc7..d0b1aee23 100644 --- a/.github/workflows/command-nightly.yml +++ b/.github/workflows/command-nightly.yml @@ -60,6 +60,7 @@ jobs: permissions: contents: read actions: read + id-token: write # depot OIDC secrets: inherit report: diff --git a/.github/workflows/nightly-docker.yml b/.github/workflows/nightly-docker.yml index 9db513a49..4800fe0af 100644 --- a/.github/workflows/nightly-docker.yml +++ b/.github/workflows/nightly-docker.yml @@ -13,6 +13,7 @@ jobs: permissions: contents: read + id-token: write # depot OIDC steps: - uses: actions/checkout@v7 @@ -27,14 +28,10 @@ jobs: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASS }} - - 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 }} + # 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: Define tags id: meta @@ -46,8 +43,9 @@ jobs: type=raw,value=nightly.{{date 'YYYYMMDD'}}-{{sha}} - name: Publish - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0 with: + project: ${{ secrets.DEPOT_PROJECT_ID }} context: . platforms: linux/amd64,linux/arm64,linux/arm/v6 push: true diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 3aaacc9f1..2bdbebabd 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -48,6 +48,7 @@ jobs: permissions: contents: read actions: read + id-token: write # depot OIDC secrets: inherit hassio: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 622d37156..914b468be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,6 +46,7 @@ jobs: runs-on: depot-ubuntu-24.04-arm permissions: contents: read + id-token: write # depot OIDC steps: - uses: actions/checkout@v7 @@ -59,14 +60,10 @@ jobs: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASS }} - - 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 }} + # 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: Meta id: meta @@ -79,8 +76,9 @@ jobs: latest=${{ needs.guard.outputs.latest }} - name: Publish - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0 with: + project: ${{ secrets.DEPOT_PROJECT_ID }} context: . platforms: linux/amd64,linux/arm64,linux/arm/v6 push: true @@ -146,6 +144,12 @@ 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: diff --git a/.golangci.yml b/.golangci.yml index 8c1836a1e..ae69e7c0f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -23,6 +23,7 @@ linters: settings: modernize: disable: + - embedlit - stringsbuilder staticcheck: checks: diff --git a/.goreleaser-nightly.yml b/.goreleaser-nightly.yml index 427faeedf..a9a957db2 100644 --- a/.goreleaser-nightly.yml +++ b/.goreleaser-nightly.yml @@ -11,8 +11,7 @@ builds: - -trimpath - -tags=release ldflags: - # 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 + - -X github.com/evcc-io/evcc/util.Version={{ .Version }} -X github.com/evcc-io/evcc/util.Commit={{ .ShortCommit }} -s -w env: - CGO_ENABLED=0 goos: @@ -57,10 +56,9 @@ checksum: name_template: "checksums.txt" snapshot: - # 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 }}" + # semver pre-release of the upcoming version, the timestamp keeps the package + # version increasing monotonically for apt + version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}" changelog: sort: asc diff --git a/.goreleaser.yml b/.goreleaser.yml index 56c7630cf..4782813ce 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -16,8 +16,7 @@ builds: - -trimpath - -tags=release ldflags: - # 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 + - -X github.com/evcc-io/evcc/util.Version={{ .Version }} -s -w env: - CGO_ENABLED=0 goos: @@ -60,19 +59,29 @@ 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 makes the package - # version increase monotonically: apt compares it numerically, while the commit - # alone would sort randomly. - version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}+{{ .ShortCommit }}" + # semver pre-release of the upcoming version, the timestamp keeps the package + # version increasing monotonically for apt + version_template: "{{ incminor .Version }}-dev.{{ .Timestamp }}" changelog: sort: asc - # drop the marker the backport command appends to the pull request title - format: '{{ .SHA }} {{ reReplaceAll ` \[\d+\.\d+ backport\]` .Message "" }}' filters: exclude: - "^chore" @@ -126,7 +135,7 @@ nfpms: preremove: ./packaging/scripts/preremove.sh postremove: ./packaging/scripts/postremove.sh -brews: +homebrew_casks: - skip_upload: '{{ if eq (envOrDefault "MAKE_LATEST" "true") "true" }}false{{ else }}true{{ end }}' repository: owner: evcc-io @@ -134,15 +143,7 @@ brews: commit_author: name: andig email: info@evcc.io - directory: Formula homepage: "https://evcc.io" description: "Sonne tanken ☀️🚘" - 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" + binaries: + - evcc diff --git a/.storybook/main.ts b/.storybook/main.ts index efab537f9..854f1da7c 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,4 +1,11 @@ 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)"], @@ -10,4 +17,21 @@ 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; diff --git a/.storybook/preview.ts b/.storybook/preview.ts index b38177500..a9d191d0f 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -2,6 +2,7 @@ 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"; @@ -35,6 +36,7 @@ export default { setup((app) => { app.use(setupI18n()); + app.directive("tooltip", tooltip); // Mock router-link for Storybook app.component("router-link", { diff --git a/AGENTS.md b/AGENTS.md index 36e0f8a8d..aba006103 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,6 +200,7 @@ 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 diff --git a/Dockerfile b/Dockerfile index 7dafc4a83..116f98106 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,9 @@ FROM --platform=$BUILDPLATFORM node:26-alpine AS node RUN apk update && apk add --no-cache make curl bash && curl -fsSL https://vite.plus | bash -# the installer only wires vp into interactive shell rc files, which RUN steps don't source -ENV PATH="/root/.vite-plus/bin:${PATH}" +# 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}" WORKDIR /build @@ -24,7 +25,7 @@ RUN make ui # STEP 2 build executable binary -FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.27.0-alpine AS builder # Install git + SSL ca certificates. # Git is required for fetching the dependencies. diff --git a/FORK.md b/FORK.md new file mode 100644 index 000000000..4508b30fd --- /dev/null +++ b/FORK.md @@ -0,0 +1,54 @@ +# Fork-Hinweise + +Fork von [evcc-io/evcc](https://github.com/evcc-io/evcc). + +## Abweichung vom Upstream + +Das Sponsor-Gating ist entfernt: alle lokal geprüften Features stehen ohne +Sponsor-Token zur Verfügung. Der Eingriff sitzt an einer einzigen Stelle in +`util/sponsor/auth.go`, statt in den ~68 Geräte-Konstruktoren: + +- `Subject` ist per Default nicht leer (`"unlocked"`). Damit meldet + `RedactedStatus()` einen aktiven Sponsor-Status, und das Frontend schaltet + `isSponsor` (`assets/js/views/Config.vue`) frei — Geräte-Dialoge zeigen kein + „Sponsor-Token erforderlich" mehr. +- `IsAuthorized()` liefert immer `true`. Das öffnet alle Aufrufer, u. a. die + Charger-/Vehicle-Konstruktoren, `server/modbus/proxy.go` und den + Optimizer-Gate in `core/site_optimizer.go`. +- `ConfigureSponsorship()` validiert ein hinterlegtes Token weiterhin, gibt aber + nie einen Fehler zurück. Ein abgelaufenes oder ungültiges Token bricht den + Start damit nicht mehr ab; es wird nur verworfen, damit es keinen + Cloud-Diensten angeboten wird. + +Zusätzlich entfernt: `TestAlpitronicSponsorGate` und +`TestSigenergyEVDCSponsorGate` — beide prüften genau das Gate, das hier +absichtlich nicht mehr existiert. + +## Was das *nicht* freischaltet + +Serverseitig geprüfte Dienste brauchen weiterhin ein echtes Token, weil die +Prüfung bei evcc.io stattfindet und nicht im Client: + +- Optimizer-API (`core/site_optimizer.go` sendet `Bearer `) +- 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: + +## 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. diff --git a/Makefile b/Makefile index ff5bf1092..70a2acd9b 100644 --- a/Makefile +++ b/Makefile @@ -6,15 +6,16 @@ GIT_TAG := $(shell test -d .git && git describe --abbrev=0 --tags) # commits since the latest tag, empty or 0 means the commit is tagged 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 -TAG_NAME ?= $(if $(filter-out 0,$(GIT_DIST)),$(NEXT_TAG)-dev+$(SHA),$(GIT_TAG)) +# 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)) 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)' @@ -33,7 +34,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 @@ -99,7 +100,7 @@ porcelain:: test -z "$$(git status --porcelain)" || (git status; git diff; false) build:: - @echo Version: $(VERSION) $(SHA) $(BUILD_DATE) + @echo Version: $(VERSION) $(SHA) CGO_ENABLED=0 go build -v $(BUILD_TAGS) $(BUILD_ARGS) snapshot:: @@ -109,15 +110,15 @@ release:: goreleaser --clean docker:: - @echo Version: $(VERSION) $(SHA) $(BUILD_DATE) + @echo Version: $(VERSION) $(SHA) docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):$(DOCKER_TAG) --push . publish-nightly:: - @echo Version: $(VERSION) $(SHA) $(BUILD_DATE) + @echo Version: $(VERSION) $(SHA) docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):nightly --push . publish-release:: - @echo Version: $(VERSION) $(SHA) $(BUILD_DATE) + @echo Version: $(VERSION) $(SHA) docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):latest --tag $(DOCKER_IMAGE):$(VERSION) --build-arg RELEASE=1 --push . apt-nightly:: @@ -153,7 +154,7 @@ gok-update:: ${GOK} update yes soc:: - @echo Version: $(VERSION) $(SHA) $(BUILD_DATE) + @echo Version: $(VERSION) $(SHA) 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 diff --git a/api/api.go b/api/api.go index 3535b9dc7..c5c735462 100644 --- a/api/api.go +++ b/api/api.go @@ -130,9 +130,10 @@ type ChargeRater interface { ChargedEnergy() (float64, error) } -// Identifier identifies a vehicle and is implemented by the charger +// Identifier identifies a vehicle and is implemented by the charger. +// A charger may know more than one identity, e.g. an RFID tag and a vehicle id. type Identifier interface { - Identify() (string, error) + Identify() ([]string, error) } // Authorizer authorizes a charging session by supplying RFID credentials diff --git a/api/error.go b/api/error.go index 2b5ffe961..f71d16c34 100644 --- a/api/error.go +++ b/api/error.go @@ -7,23 +7,28 @@ import ( "github.com/cenkalti/backoff/v4" ) -// permanentError is a sentinel error that keeps matching errors.Is after -// backoff has stripped the backoff.Permanent wrapper. +// permanentError is a sentinel error that signals permanence to backoff while +// remaining distinguishable from the other permanent sentinels. type permanentError struct { msg string } func (e *permanentError) Error() string { return e.msg } -// Is matches the wrapped sentinel, too -func (e *permanentError) Is(target error) bool { - var t *permanentError - return errors.As(target, &t) && t == e +// 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 } // permanent creates a permanent sentinel error func permanent(msg string) error { - return backoff.Permanent(&permanentError{msg}) + return &permanentError{msg} } // ErrNotAvailable indicates that a feature is not available diff --git a/api/error_test.go b/api/error_test.go index 4dfceebe1..3f8231789 100644 --- a/api/error_test.go +++ b/api/error_test.go @@ -8,20 +8,47 @@ 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 _, tc := range []struct{ err, other error }{ - {ErrNotAvailable, ErrUnsupportedPlatform}, - {ErrUnsupportedPlatform, ErrNotAvailable}, - {ErrMissingCredentials, ErrMissingToken}, - {ErrMissingToken, ErrMissingCredentials}, - } { + for _, err := range permanentSentinels { _, unwrapped := backoff.RetryWithData(func() (int, error) { - return 0, tc.err + return 0, err }, &backoff.StopBackOff{}) - assert.ErrorIs(t, unwrapped, tc.err) - assert.ErrorIs(t, fmt.Errorf("wrapped: %w", unwrapped), tc.err) - assert.NotErrorIs(t, unwrapped, tc.other) + assert.ErrorIs(t, unwrapped, err) + assert.ErrorIs(t, fmt.Errorf("wrapped: %w", unwrapped), err) + assert.ErrorIs(t, fmt.Errorf("wrapped: %w", err), err) } } + +// Permanent sentinels must not match each other, whether returned directly or +// unwrapped by backoff. +func TestPermanentSentinelIdentity(t *testing.T) { + for _, err := range permanentSentinels { + _, unwrapped := backoff.RetryWithData(func() (int, error) { + return 0, err + }, &backoff.StopBackOff{}) + + for _, other := range permanentSentinels { + if other == err { + continue + } + + assert.NotErrorIs(t, err, other) + assert.NotErrorIs(t, unwrapped, other) + assert.NotErrorIs(t, fmt.Errorf("wrapped: %w", err), other) + } + } + + // login required is permanent, too + assert.NotErrorIs(t, LoginRequiredError("foo"), ErrNotAvailable) +} diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go index eace135f8..40e565037 100644 --- a/api/globalconfig/types.go +++ b/api/globalconfig/types.go @@ -64,6 +64,7 @@ type All struct { Site map[string]any Loadpoints []config.Named Circuits []config.Named + Curtailers []config.Named } type Javascript struct { diff --git a/api/implement/implementations.go b/api/implement/implementations.go index 7c3ce1da1..ccadc39a7 100644 --- a/api/implement/implementations.go +++ b/api/implement/implementations.go @@ -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,6 +378,21 @@ 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 diff --git a/api/mock.go b/api/mock.go index 22470c2e3..dd59b7f5f 100644 --- a/api/mock.go +++ b/api/mock.go @@ -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 } diff --git a/assets/css/app.css b/assets/css/app.css index c6d9d023f..d39e45dcd 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -50,6 +50,7 @@ --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); @@ -951,3 +952,9 @@ 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; +} diff --git a/assets/js/app.ts b/assets/js/app.ts index b453c8527..1649b44f4 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -6,6 +6,7 @@ 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"; @@ -99,6 +100,7 @@ app.use(i18n); app.use(router); app.use(head); app.mixin(VueHeadMixin); +app.directive("tooltip", tooltip); window.app = app.mount("#app"); watchThemeChanges(); diff --git a/assets/js/components/AboutModal.stories.ts b/assets/js/components/AboutModal.stories.ts index 4b23c3fb1..dbeb8031d 100644 --- a/assets/js/components/AboutModal.stories.ts +++ b/assets/js/components/AboutModal.stories.ts @@ -52,6 +52,11 @@ 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; @@ -94,10 +99,22 @@ StableUpdateWithUpdater.args = { export const Nightly = Template.bind({}); Nightly.args = { - installed: "0.304.0-dev+5ce7be4", + installed: "0.304.0-dev.1712345678", + commit: "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", +}; diff --git a/assets/js/components/AboutModal.vue b/assets/js/components/AboutModal.vue index b0df8929e..751187df1 100644 --- a/assets/js/components/AboutModal.vue +++ b/assets/js/components/AboutModal.vue @@ -1,5 +1,5 @@