diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 6617d79aa0..6dd2566e0c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -39,9 +39,9 @@ body: render: yaml description: > Show evcc configuration file evcc.yaml - + Please make sure your report does NOT contain **passwords**, **sponsor token** or other **credentials**! - + To quickly dump a redacted configuration without secrets, you can use the `evcc dump --cfg` command. - type: textarea @@ -69,6 +69,15 @@ body: - macOS - other + - type: checkboxes + id: external + attributes: + label: External automation + description: Make sure the observed issue is caused by evcc and not by external automation + options: + - label: I have made sure that no external automation like HomeAssistant or Node-RED is active or accessing any of the mentioned devices when this issue occurs. + required: true + - type: checkboxes id: nightly attributes: diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 5ebdada5f8..19a02f7851 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -1,8 +1,5 @@ name: Default -env: - GO_VERSION: ^1.23 - on: push: branches: @@ -13,14 +10,13 @@ on: jobs: clean: name: Clean - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: ${{ env.GO_VERSION }} cache: false id: go @@ -58,7 +54,6 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: ${{ env.GO_VERSION }} cache: false id: go @@ -91,7 +86,6 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: ${{ env.GO_VERSION }} cache: false id: go @@ -117,7 +111,6 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: ${{ env.GO_VERSION }} cache: false # avoid cache thrashing id: go @@ -183,7 +176,6 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: ${{ env.GO_VERSION }} cache: false id: go @@ -208,14 +200,12 @@ jobs: - name: Build Go run: make build + - name: Install Playwright + run: npx playwright install --with-deps chromium + - name: Run tests run: npx playwright test - # - name: Run tests - # uses: docker://mcr.microsoft.com/playwright:v1.34.3-jammy - # with: - # args: npx playwright test - - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 033fa69796..96a0f6d063 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -1,8 +1,5 @@ name: Deploy updated templates -env: - GO_VERSION: ^1.23 - on: schedule: - cron: "0 2 * * *" # same time as nightly is triggered @@ -19,8 +16,6 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} id: go - name: Build docs diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index f1985d8f6f..b91a932929 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1,8 +1,5 @@ name: Nightly Build -env: - GO_VERSION: ^1.23 - on: schedule: # runs on the default branch: master - cron: "0 2 * * *" # run at 2 AM UTC @@ -62,16 +59,59 @@ jobs: - name: Setup Buildx uses: docker/setup-buildx-action@v3 + - name: Define tags + id: meta + uses: docker/metadata-action@v5 + with: + images: evcc/evcc + tags: | + type=raw,value=nightly + type=raw,value=nightly.{{date 'YYYYMMDD'}}-{{sha}} + - name: Publish uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64,linux/arm/v6 push: true - build-args: | - TESLA_CLIENT_ID=${{ secrets.TESLA_CLIENT_ID }} - tags: | - evcc/evcc:nightly + tags: ${{ steps.meta.outputs.tags }} + + - name: Delete old nightly.* tags + run: | + old_tags=$(curl -s "https://hub.docker.com/v2/repositories/evcc/evcc/tags/?page_size=100" | jq -r '.results | map(select(.name | startswith("nightly."))) | sort_by(.last_updated) | reverse | .[1:] | .[].name') + for tag in $old_tags; do + echo "Deleting tag: $tag" + curl -s -H "Authorization: Bearer ${{ secrets.DOCKER_PASS }}" -X DELETE "https://hub.docker.com/v2/repositories/evcc/evcc/tags/$tag/" + done + + hassio: + name: Hassio Addon :nightly + needs: + - docker + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@master + with: + repository: evcc-io/hassio-addon + token: ${{ secrets.GH_TOKEN }} + path: ./hassio + + - name: Update version + run: | + current_date=$(date +%Y%m%d) + short_sha=$(echo "${{ github.sha }}" | cut -c 1-7) + sed -i -e "s/version:.*/version: nightly.${current_date}-${short_sha}/" ./hassio/evcc-nightly/config.yaml + + - name: Push + run: | + cd ./hassio + git add . + git config user.name github-actions + git config user.email github-actions@github.com + git commit -am "Mirror evcc nightly release" + git push apt: name: Publish APT nightly @@ -85,8 +125,6 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} id: go - name: Patch ASN1 @@ -106,7 +144,6 @@ jobs: args: --snapshot -f .goreleaser-nightly.yml --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TESLA_CLIENT_ID: ${{ secrets.TESLA_CLIENT_ID }} - uses: actions/setup-python@v5 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb7662f8d3..a61e05a165 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,5 @@ name: Release -env: - GO_VERSION: ^1.23 - on: push: tags: @@ -48,7 +45,6 @@ jobs: push: true build-args: | RELEASE=1 - TESLA_CLIENT_ID=${{ secrets.TESLA_CLIENT_ID }} tags: ${{ steps.meta.outputs.tags }} apt: @@ -64,8 +60,6 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} id: go - name: Patch ASN1 @@ -100,7 +94,6 @@ jobs: env: # use GH_TOKEN for access to evcc-io/homebrew-tap GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} - TESLA_CLIENT_ID: ${{ secrets.TESLA_CLIENT_ID }} - uses: actions/setup-python@v5 with: diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 22350181b0..e2cedc92d7 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -1,8 +1,5 @@ name: Deploy data to website -env: - GO_VERSION: ^1.23 - on: release: types: [created] @@ -17,8 +14,6 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} id: go - name: Build docs diff --git a/.gitignore b/.gitignore index 0e9fef08c6..f9a1e6bddd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ __debug_bin* .vscode +!.vscode/extensions.json .cache .DS_store *.gz diff --git a/.golangci.yml b/.golangci.yml index 573d971b88..3999f00c82 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,5 +1,5 @@ run: - go: "1.23" + go: "1.24" issues: exclude: diff --git a/.goreleaser-nightly.yml b/.goreleaser-nightly.yml index bba2360ceb..52fb556871 100644 --- a/.goreleaser-nightly.yml +++ b/.goreleaser-nightly.yml @@ -11,7 +11,7 @@ builds: - -trimpath - -tags=release ldflags: - - -X github.com/evcc-io/evcc/server.Version={{ .Tag }} -X github.com/evcc-io/evcc/server.Commit={{ .ShortCommit }} -X github.com/evcc-io/evcc/vehicle/tesla.TESLA_CLIENT_ID={{ .Env.TESLA_CLIENT_ID }} -s -w + - -X github.com/evcc-io/evcc/server.Version={{ .Tag }} -X github.com/evcc-io/evcc/server.Commit={{ .ShortCommit }} -s -w env: - CGO_ENABLED=0 goos: @@ -33,14 +33,6 @@ builds: flags: - -trimpath - -tags=release,timetzdata - - goos: darwin - goarch: arm64 - ldflags: - - -X github.com/evcc-io/evcc/server.Version={{ .Tag }} -X github.com/evcc-io/evcc/server.Commit={{ .ShortCommit }} -X github.com/evcc-io/evcc/vehicle/tesla.TESLA_CLIENT_ID={{ .Env.TESLA_CLIENT_ID }} -s -w -B gobuildid - - goos: darwin - goarch: amd64 - ldflags: - - -X github.com/evcc-io/evcc/server.Version={{ .Tag }} -X github.com/evcc-io/evcc/server.Commit={{ .ShortCommit }} -X github.com/evcc-io/evcc/vehicle/tesla.TESLA_CLIENT_ID={{ .Env.TESLA_CLIENT_ID }} -s -w -B gobuildid archives: - builds: diff --git a/.goreleaser.yml b/.goreleaser.yml index 344981cac9..bf54ece2b8 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -14,7 +14,7 @@ builds: - -trimpath - -tags=release ldflags: - - -X github.com/evcc-io/evcc/server.Version={{ .Version }} -X github.com/evcc-io/evcc/vehicle/tesla.TESLA_CLIENT_ID={{ .Env.TESLA_CLIENT_ID }} -s -w + - -X github.com/evcc-io/evcc/server.Version={{ .Version }} -s -w env: - CGO_ENABLED=0 goos: @@ -38,14 +38,6 @@ builds: flags: - -trimpath - -tags=release,timetzdata - - goos: darwin - goarch: arm64 - ldflags: - - -X github.com/evcc-io/evcc/server.Version={{ .Version }} -X github.com/evcc-io/evcc/vehicle/tesla.TESLA_CLIENT_ID={{ .Env.TESLA_CLIENT_ID }} -s -w -B gobuildid - - goos: darwin - goarch: amd64 - ldflags: - - -X github.com/evcc-io/evcc/server.Version={{ .Version }} -X github.com/evcc-io/evcc/vehicle/tesla.TESLA_CLIENT_ID={{ .Env.TESLA_CLIENT_ID }} -s -w -B gobuildid env: - CGO_ENABLED=0 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000..dc9cabb64f --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "Vue.volar", + "vitest.explorer", + "golang.go", + "esbenp.prettier-vscode", + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4c9406c7b..4cfd19763c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ #### Development environment -Developing evcc requires [Go][1] 1.23 and [Node][2] 22. We recommend VSCode with the [Go](https://marketplace.visualstudio.com/items?itemName=golang.Go), [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) and [Vetur](https://marketplace.visualstudio.com/items?itemName=octref.vetur) extensions. +Developing evcc requires [Go][1] and [Node][2]. We recommend VSCode with the [Go](https://marketplace.visualstudio.com/items?itemName=golang.Go), [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) and [Vetur](https://marketplace.visualstudio.com/items?itemName=octref.vetur) extensions. Alternatively, if you use VS Code and [devcontainers](https://code.visualstudio.com/docs/devcontainers/containers), you can use the "Dev containers: Clone repository in container volume" action. This will create a devcontainer with the required toolchain and install the prerequisites as explained below. Wait until the startup log says "Done. Press any key to close the terminal." and check for any errors. diff --git a/Dockerfile b/Dockerfile index f366ef6287..5fb53332f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ RUN make ui # STEP 2 build executable binary -FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder # Install git + SSL ca certificates. # Git is required for fetching the dependencies. @@ -39,7 +39,6 @@ RUN go mod download # install tools COPY Makefile . -COPY tools.go . COPY cmd/decorate/ cmd/decorate/ COPY api/ api/ RUN make install @@ -58,9 +57,6 @@ ARG TARGETARCH ARG TARGETVARIANT ARG GOARM=${TARGETVARIANT#v} -ARG TESLA_CLIENT_ID -ENV TESLA_CLIENT_ID=${TESLA_CLIENT_ID} - RUN RELEASE=${RELEASE} GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${GOARM} make build diff --git a/Makefile b/Makefile index 832337dd0f..751a92661f 100644 --- a/Makefile +++ b/Makefile @@ -9,8 +9,7 @@ endif VERSION := $(if $(TAG_NAME),$(TAG_NAME),$(SHA)) BUILD_DATE := $(shell date -u '+%Y-%m-%d_%H:%M:%S') BUILD_TAGS := -tags=release -TESLA_CLIENT_ID := ${TESLA_CLIENT_ID} -LD_FLAGS := -X github.com/evcc-io/evcc/server.Version=$(VERSION) -X github.com/evcc-io/evcc/server.Commit=$(COMMIT) -X github.com/evcc-io/evcc/vehicle/tesla.TESLA_CLIENT_ID=$(TESLA_CLIENT_ID) -s -w +LD_FLAGS := -X github.com/evcc-io/evcc/server.Version=$(VERSION) -X github.com/evcc-io/evcc/server.Commit=$(COMMIT) -s -w BUILD_ARGS := -trimpath -ldflags='$(LD_FLAGS)' # docker @@ -19,8 +18,9 @@ DOCKER_TAG := testing PLATFORM := linux/amd64,linux/arm64,linux/arm/v6 # gokrazy image -IMAGE_FILE := evcc_$(TAG_NAME).image -IMAGE_OPTIONS := -hostname evcc -http_port 8080 github.com/gokrazy/serial-busybox github.com/gokrazy/breakglass github.com/gokrazy/mkfs github.com/gokrazy/wifi github.com/evcc-io/evcc +GOK_DIR := packaging/gokrazy +GOK := gok -i evcc --parent_dir $(GOK_DIR) +IMAGE_FILE := evcc_$(TAG_NAME).img # deb PACKAGES = ./release @@ -37,7 +37,7 @@ clean:: rm -rf dist/ install:: - go install $$(go list -e -f '{{join .Imports " "}}' tools.go) + go install tool install-ui:: npm ci @@ -84,15 +84,15 @@ release:: docker:: @echo Version: $(VERSION) $(SHA) $(BUILD_DATE) - docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):$(DOCKER_TAG) --build-arg TESLA_CLIENT_ID=$(TESLA_CLIENT_ID) --push . + docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):$(DOCKER_TAG) --push . publish-nightly:: @echo Version: $(VERSION) $(SHA) $(BUILD_DATE) - docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):nightly --build-arg TESLA_CLIENT_ID=$(TESLA_CLIENT_ID) --push . + docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):nightly --push . publish-release:: @echo Version: $(VERSION) $(SHA) $(BUILD_DATE) - docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):latest --tag $(DOCKER_IMAGE):$(VERSION) --build-arg TESLA_CLIENT_ID=$(TESLA_CLIENT_ID) --build-arg RELEASE=1 --push . + docker buildx build --platform $(PLATFORM) --tag $(DOCKER_IMAGE):latest --tag $(DOCKER_IMAGE):$(VERSION) --build-arg RELEASE=1 --push . apt-nightly:: $(foreach file, $(wildcard $(PACKAGES)/*.deb), \ @@ -104,25 +104,27 @@ apt-release:: cloudsmith push deb evcc/stable/any-distro/any-version $(file); \ ) -# gokrazy image -gokrazy:: - go install github.com/gokrazy/tools/cmd/gokr-packer@main - mkdir -p flags/github.com/gokrazy/breakglass - echo "-forward=private-network" > flags/github.com/gokrazy/breakglass/flags.txt - mkdir -p env/github.com/evcc-io/evcc - echo "EVCC_NETWORK_PORT=80" > env/github.com/evcc-io/evcc/env.txt - echo "EVCC_DATABASE_DSN=/perm/evcc.db" >> env/github.com/evcc-io/evcc/env.txt - mkdir -p buildflags/github.com/evcc-io/evcc - echo "$(BUILD_TAGS),gokrazy" > buildflags/github.com/evcc-io/evcc/buildflags.txt - echo "-ldflags=$(LD_FLAGS)" >> buildflags/github.com/evcc-io/evcc/buildflags.txt - gokr-packer -hostname evcc -http_port 8080 -overwrite=$(IMAGE_FILE) -target_storage_bytes=1258299392 $(IMAGE_OPTIONS) +# gokrazy +gok:: + which gok || go install github.com/gokrazy/tools/cmd/gok@main + # https://stackoverflow.com/questions/1250079/how-to-escape-single-quotes-within-single-quoted-strings + sed 's!"GoBuildFlags": null!"GoBuildFlags": ["$(BUILD_TAGS) -trimpath -ldflags='"'"'$(LD_FLAGS)'"'"'"]!g' $(GOK_DIR)/config.tmpl.json > $(GOK_DIR)/evcc/config.json + ${GOK} add . + # ${GOK} add tailscale.com/cmd/tailscaled + # ${GOK} add tailscale.com/cmd/tailscale + +# build image +gok-image:: gok + ${GOK} overwrite --full=$(IMAGE_FILE) --target_storage_bytes=1258299392 # gzip -f $(IMAGE_FILE) -gokrazy-run:: - MACHINE=arm64 IMAGE_FILE=$(IMAGE_FILE) ./packaging/gokrazy/run.sh +# run qemu +gok-vm:: gok + ${GOK} vm run --netdev user,id=net0,hostfwd=tcp::8080-:80,hostfwd=tcp::8022-:22,hostfwd=tcp::8888-:8080 -gokrazy-update:: - gokr-packer -update yes $(IMAGE_OPTIONS) +# update instance +gok-update:: + ${GOK} update yes soc:: @echo Version: $(VERSION) $(SHA) $(BUILD_DATE) diff --git a/api/api.go b/api/api.go index 9a01f7de1c..605de89590 100644 --- a/api/api.go +++ b/api/api.go @@ -7,7 +7,7 @@ import ( "time" ) -//go:generate mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,Tariff,BatteryController,Circuit +//go:generate go tool mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,Tariff,BatteryController,Circuit // Meter provides total active power in W type Meter interface { @@ -44,8 +44,8 @@ type BatteryCapacity interface { Capacity() float64 } -// MaxACPower provides max AC power in W -type MaxACPower interface { +// MaxACPowerGetter provides max AC power in W +type MaxACPowerGetter interface { MaxACPower() float64 } @@ -120,7 +120,8 @@ type Authorizer interface { Authorize(key string) error } -// PhaseDescriber returns the number of available phases +// PhaseDescriber returns the number of physically connected phases +// Used for vehicles and to limit switch sockets to 1p only type PhaseDescriber interface { Phases() int } diff --git a/api/batterymode.go b/api/batterymode.go index 01bd617038..4ca00b3f12 100644 --- a/api/batterymode.go +++ b/api/batterymode.go @@ -3,7 +3,7 @@ package api // BatteryMode is the home battery operation mode. Valid values are normal, locked and charge type BatteryMode int -//go:generate enumer -type BatteryMode -trimprefix Battery -transform=lower +//go:generate go tool enumer -type BatteryMode -trimprefix Battery -transform=lower const ( BatteryUnknown BatteryMode = iota BatteryNormal diff --git a/api/error.go b/api/error.go index ce4d78accd..672dddd3f3 100644 --- a/api/error.go +++ b/api/error.go @@ -9,10 +9,13 @@ var ErrNotAvailable = errors.New("not available") var ErrMustRetry = errors.New("must retry") // ErrSponsorRequired indicates that a sponsor token is required -var ErrSponsorRequired = errors.New("sponsorship required, see https://github.com/evcc-io/evcc#sponsorship") +var ErrSponsorRequired = errors.New("sponsorship required, see https://docs.evcc.io/docs/sponsorship") // ErrMissingCredentials indicates that user/password are missing -var ErrMissingCredentials = errors.New("missing credentials") +var ErrMissingCredentials = errors.New("missing user/password credentials") + +// ErrMissingToken indicates that access/refresh tokens are missing +var ErrMissingToken = errors.New("missing token credentials") // ErrOutdated indicates that result is outdated var ErrOutdated = errors.New("outdated") diff --git a/api/feature.go b/api/feature.go index 44b5240c5a..4909652305 100644 --- a/api/feature.go +++ b/api/feature.go @@ -2,7 +2,7 @@ package api type Feature int -//go:generate enumer -type Feature -text +//go:generate go tool enumer -type Feature -text const ( _ Feature = iota Offline diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go index 8e9b6e30fb..03365666da 100644 --- a/api/globalconfig/types.go +++ b/api/globalconfig/types.go @@ -7,7 +7,7 @@ import ( "time" "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/provider/mqtt" + "github.com/evcc-io/evcc/plugin/mqtt" "github.com/evcc-io/evcc/push" "github.com/evcc-io/evcc/server/eebus" "github.com/evcc-io/evcc/util/config" @@ -38,7 +38,7 @@ type All struct { Vehicles []config.Named Tariffs Tariffs Site map[string]interface{} - Loadpoints []map[string]interface{} + Loadpoints []config.Named Circuits []config.Named } @@ -54,8 +54,8 @@ type Go struct { type ModbusProxy struct { Port int - ReadOnly string - modbus.Settings `mapstructure:",squash"` + ReadOnly string `yaml:",omitempty" json:",omitempty"` + modbus.Settings `mapstructure:",squash" yaml:",inline,omitempty" json:",omitempty"` } var _ api.Redactor = (*Hems)(nil) @@ -134,12 +134,17 @@ type Messaging struct { Services []config.Typed } +func (c Messaging) Configured() bool { + return len(c.Services) > 0 || len(c.Events) > 0 +} + type Tariffs struct { Currency string Grid config.Typed FeedIn config.Typed Co2 config.Typed Planner config.Typed + Solar []config.Typed } type Network struct { diff --git a/api/rates.go b/api/rates.go index 14ccfb1085..59891322c1 100644 --- a/api/rates.go +++ b/api/rates.go @@ -1,7 +1,7 @@ package api import ( - "fmt" + "encoding/json" "slices" "time" ) @@ -13,8 +13,8 @@ type Rate struct { Price float64 `json:"price"` } -// IsEmpty returns is the rate is the zero value -func (r Rate) IsEmpty() bool { +// IsZero returns is the rate is the zero value +func (r Rate) IsZero() bool { return r.Start.IsZero() && r.End.IsZero() && r.Price == 0 } @@ -22,23 +22,32 @@ func (r Rate) IsEmpty() bool { type Rates []Rate // Sort rates by start time -func (r Rates) Sort() { - slices.SortStableFunc(r, func(i, j Rate) int { +func (rr Rates) Sort() { + slices.SortStableFunc(rr, func(i, j Rate) int { return i.Start.Compare(j.Start) }) } -// Current returns the rates current rate or error -func (r Rates) Current(now time.Time) (Rate, error) { - for _, rr := range r { - if !rr.Start.After(now) && rr.End.After(now) { - return rr, nil +// At returns the rate for given timestamp or error. +// Rates MUST be sorted by start time. +func (rr Rates) At(ts time.Time) (Rate, error) { + if i, ok := slices.BinarySearchFunc(rr, ts, func(r Rate, ts time.Time) int { + switch { + case ts.Before(r.Start): + return +1 + case !ts.Before(r.End): + return -1 + default: + return 0 } + }); ok { + return rr[i], nil } - if len(r) == 0 { - return Rate{}, fmt.Errorf("no matching rate for: %s", now.Local().Format(time.RFC3339)) - } - return Rate{}, fmt.Errorf("no matching rate for: %s, %d rates (%s to %s)", - now.Local().Format(time.RFC3339), len(r), r[0].Start.Local().Format(time.RFC3339), r[len(r)-1].End.Local().Format(time.RFC3339)) + return Rate{}, ErrNotAvailable +} + +// MarshalMQTT implements server.MQTTMarshaler +func (r Rates) MarshalMQTT() ([]byte, error) { + return json.Marshal(r) } diff --git a/api/rates_test.go b/api/rates_test.go new file mode 100644 index 0000000000..97baa13138 --- /dev/null +++ b/api/rates_test.go @@ -0,0 +1,38 @@ +package api + +import ( + "testing" + "time" + + "github.com/benbjohnson/clock" + "github.com/stretchr/testify/assert" +) + +func TestRates(t *testing.T) { + clock := clock.NewMock() + rate := func(start int, val float64) Rate { + return Rate{ + Start: clock.Now().Add(time.Duration(start) * time.Hour), + End: clock.Now().Add(time.Duration(start+1) * time.Hour), + Price: val, + } + } + + rr := Rates{rate(1, 1), rate(2, 2), rate(3, 3), rate(4, 4)} + + _, err := rr.At(clock.Now()) + assert.Error(t, err) + + for i := 1; i <= 4; i++ { + r, err := rr.At(clock.Now().Add(time.Duration(i) * time.Hour)) + assert.NoError(t, err) + assert.Equal(t, float64(i), r.Price) + + r, err = rr.At(clock.Now().Add(time.Duration(i)*time.Hour + 30*time.Minute)) + assert.NoError(t, err) + assert.Equal(t, float64(i), r.Price) + } + + _, err = rr.At(clock.Now().Add(5 * time.Hour)) + assert.Error(t, err) +} diff --git a/api/reason.go b/api/reason.go index 8d3439c556..4d8900ec39 100644 --- a/api/reason.go +++ b/api/reason.go @@ -2,7 +2,7 @@ package api type Reason int -//go:generate enumer -type Reason -trimprefix Reason -transform=lower +//go:generate go tool enumer -type Reason -trimprefix Reason -transform=lower const ( ReasonUnknown Reason = iota ReasonWaitingForAuthorization diff --git a/api/tariff.go b/api/tariff.go index dcb5f6dbff..ca6063b684 100644 --- a/api/tariff.go +++ b/api/tariff.go @@ -1,6 +1,7 @@ package api -//go:generate enumer -type TariffType -trimprefix TariffType -transform=lower -text +//go:generate go tool enumer -type TariffType -trimprefix TariffType -transform=lower -text +//go:generate go tool enumer -type TariffUsage -trimprefix TariffUsage -transform=lower type TariffType int @@ -10,4 +11,16 @@ const ( TariffTypePriceDynamic TariffTypePriceForecast TariffTypeCo2 + TariffTypeSolar +) + +type TariffUsage int + +const ( + _ TariffUsage = iota + TariffUsageCo2 + TariffUsageFeedIn + TariffUsageGrid + TariffUsagePlanner + TariffUsageSolar ) diff --git a/api/tarifftype_enumer.go b/api/tarifftype_enumer.go index b44f8cd7be..12272d9bc8 100644 --- a/api/tarifftype_enumer.go +++ b/api/tarifftype_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _TariffTypeName = "pricestaticpricedynamicpriceforecastco2" +const _TariffTypeName = "pricestaticpricedynamicpriceforecastco2solar" -var _TariffTypeIndex = [...]uint8{0, 11, 23, 36, 39} +var _TariffTypeIndex = [...]uint8{0, 11, 23, 36, 39, 44} -const _TariffTypeLowerName = "pricestaticpricedynamicpriceforecastco2" +const _TariffTypeLowerName = "pricestaticpricedynamicpriceforecastco2solar" func (i TariffType) String() string { i -= 1 @@ -29,9 +29,10 @@ func _TariffTypeNoOp() { _ = x[TariffTypePriceDynamic-(2)] _ = x[TariffTypePriceForecast-(3)] _ = x[TariffTypeCo2-(4)] + _ = x[TariffTypeSolar-(5)] } -var _TariffTypeValues = []TariffType{TariffTypePriceStatic, TariffTypePriceDynamic, TariffTypePriceForecast, TariffTypeCo2} +var _TariffTypeValues = []TariffType{TariffTypePriceStatic, TariffTypePriceDynamic, TariffTypePriceForecast, TariffTypeCo2, TariffTypeSolar} var _TariffTypeNameToValueMap = map[string]TariffType{ _TariffTypeName[0:11]: TariffTypePriceStatic, @@ -42,6 +43,8 @@ var _TariffTypeNameToValueMap = map[string]TariffType{ _TariffTypeLowerName[23:36]: TariffTypePriceForecast, _TariffTypeName[36:39]: TariffTypeCo2, _TariffTypeLowerName[36:39]: TariffTypeCo2, + _TariffTypeName[39:44]: TariffTypeSolar, + _TariffTypeLowerName[39:44]: TariffTypeSolar, } var _TariffTypeNames = []string{ @@ -49,6 +52,7 @@ var _TariffTypeNames = []string{ _TariffTypeName[11:23], _TariffTypeName[23:36], _TariffTypeName[36:39], + _TariffTypeName[39:44], } // TariffTypeString retrieves an enum value from the enum constants string name. diff --git a/api/tariffusage_enumer.go b/api/tariffusage_enumer.go new file mode 100644 index 0000000000..2eadfda902 --- /dev/null +++ b/api/tariffusage_enumer.go @@ -0,0 +1,91 @@ +// Code generated by "enumer -type TariffUsage -trimprefix TariffUsage -transform=lower"; DO NOT EDIT. + +package api + +import ( + "fmt" + "strings" +) + +const _TariffUsageName = "co2feedingridplannersolar" + +var _TariffUsageIndex = [...]uint8{0, 3, 9, 13, 20, 25} + +const _TariffUsageLowerName = "co2feedingridplannersolar" + +func (i TariffUsage) String() string { + i -= 1 + if i < 0 || i >= TariffUsage(len(_TariffUsageIndex)-1) { + return fmt.Sprintf("TariffUsage(%d)", i+1) + } + return _TariffUsageName[_TariffUsageIndex[i]:_TariffUsageIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _TariffUsageNoOp() { + var x [1]struct{} + _ = x[TariffUsageCo2-(1)] + _ = x[TariffUsageFeedIn-(2)] + _ = x[TariffUsageGrid-(3)] + _ = x[TariffUsagePlanner-(4)] + _ = x[TariffUsageSolar-(5)] +} + +var _TariffUsageValues = []TariffUsage{TariffUsageCo2, TariffUsageFeedIn, TariffUsageGrid, TariffUsagePlanner, TariffUsageSolar} + +var _TariffUsageNameToValueMap = map[string]TariffUsage{ + _TariffUsageName[0:3]: TariffUsageCo2, + _TariffUsageLowerName[0:3]: TariffUsageCo2, + _TariffUsageName[3:9]: TariffUsageFeedIn, + _TariffUsageLowerName[3:9]: TariffUsageFeedIn, + _TariffUsageName[9:13]: TariffUsageGrid, + _TariffUsageLowerName[9:13]: TariffUsageGrid, + _TariffUsageName[13:20]: TariffUsagePlanner, + _TariffUsageLowerName[13:20]: TariffUsagePlanner, + _TariffUsageName[20:25]: TariffUsageSolar, + _TariffUsageLowerName[20:25]: TariffUsageSolar, +} + +var _TariffUsageNames = []string{ + _TariffUsageName[0:3], + _TariffUsageName[3:9], + _TariffUsageName[9:13], + _TariffUsageName[13:20], + _TariffUsageName[20:25], +} + +// TariffUsageString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func TariffUsageString(s string) (TariffUsage, error) { + if val, ok := _TariffUsageNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _TariffUsageNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to TariffUsage values", s) +} + +// TariffUsageValues returns all values of the enum +func TariffUsageValues() []TariffUsage { + return _TariffUsageValues +} + +// TariffUsageStrings returns a slice of all String values of the enum +func TariffUsageStrings() []string { + strs := make([]string, len(_TariffUsageNames)) + copy(strs, _TariffUsageNames) + return strs +} + +// IsATariffUsage returns "true" if the value is listed in the enum definition. "false" otherwise +func (i TariffUsage) IsATariffUsage() bool { + for _, v := range _TariffUsageValues { + if i == v { + return true + } + } + return false +} diff --git a/assets/css/app.css b/assets/css/app.css index 1a83bf9de1..5e26d9bad6 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -25,8 +25,9 @@ --evcc-green: #baffcb; --evcc-dark-green: #0fde41ff; --evcc-darker-green: #0ba631ff; + --evcc-darker-green-rgb: 11, 166, 49; --evcc-darkest-green: #076f20ff; - --evcc-darkest-green-rgb: 11, 166, 49; + --evcc-darkest-green-rgb: 7, 111, 32; --evcc-yellow: #faf000; --evcc-dark-yellow: #bbb400; --evcc-orange: #ff9000; @@ -46,6 +47,8 @@ --evcc-pv: var(--evcc-dark-green); --evcc-battery: var(--evcc-darker-green); --evcc-export: var(--evcc-yellow); + --evcc-price: #ff912fff; + --evcc-co2: #00916eff; --evcc-background: var(--bs-gray-bright); --evcc-box: var(--bs-white); --evcc-box-border: var(--bs-gray-brighter); @@ -53,7 +56,7 @@ --evcc-gray: var(--bs-gray-medium); --evcc-gray-50: #93949e80; --evcc-gray-25: #93949e40; - + --evcc-gray-10: #93949e20; --evcc-accent1: var(--evcc-dark-yellow); --evcc-accent2: var(--evcc-darker-green); --evcc-accent3: var(--evcc-darkest-green); @@ -164,6 +167,14 @@ h5 { color: var(--bs-primary) !important; } +.text-price { + color: var(--evcc-price) !important; +} + +.text-co2 { + color: var(--evcc-co2) !important; +} + a { color: var(--bs-primary); } @@ -269,10 +280,16 @@ a:hover { --bs-btn-hover-border-color: var(--bs-gray-medium); } -.btn-link:hover { +.btn-link:hover, +.btn-link:focus { color: var(--bs-primary); } +.btn-link:focus { + outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color); + outline-width: var(--bs-focus-ring-width); +} + .dark .btn-outline-secondary { --bs-btn-color: var(--bs-gray-bright); --bs-btn-border-color: var(--bs-gray-bright); @@ -364,6 +381,16 @@ a:hover { transition-timing-function: linear; } +.modal.fade-left .modal-dialog { + transform: translate(-50px, 0); +} +.modal.fade-right .modal-dialog { + transform: translate(50px, 0); +} +.modal.show .modal-dialog { + transform: none; +} + .modal-header { padding: 0 0 1rem 0; border: none; @@ -517,8 +544,13 @@ input::-webkit-datetime-edit { .form-control { color: var(--evcc-default-text); } -.form-control:disabled { - color: var(--bs-gray-dark); +.form-control:disabled, +.form-control:read-only, +.dark .form-control:disabled, +.dark .form-control:read-only { + background-color: var(--evcc-gray-10); + color: var(--bs-default-text); + opacity: 1; } .dark .form-control { @@ -633,6 +665,12 @@ html.app .modal-dialog { color: var(--evcc-darkest-green); } +.hyphenate { + overflow-wrap: break-word; + word-wrap: break-word; + hyphens: auto; +} + input::-webkit-date-and-time-value { text-align: left; } diff --git a/assets/js/auth.js b/assets/js/auth.js index b5faed53e1..befa07ad3c 100644 --- a/assets/js/auth.js +++ b/assets/js/auth.js @@ -7,7 +7,8 @@ import { isSystemError } from "./utils/fatal"; const auth = reactive({ configured: true, loggedIn: null, // true / false / null (unknown) - nextUrl: null, + nextUrl: null, // url to navigate to after login + nextModal: null, // modal instance to show after login }); export async function updateAuthStatus() { @@ -64,8 +65,15 @@ export function getAndClearNextUrl() { return nextUrl; } -export function openLoginModal(nextUrl = null) { +export function getAndClearNextModal() { + const nextModal = auth.nextModal; + auth.nextModal = null; + return nextModal; +} + +export function openLoginModal(nextUrl = null, nextModal = null) { auth.nextUrl = nextUrl; + auth.nextModal = nextModal; const modal = Modal.getOrCreateInstance(document.getElementById("loginModal")); modal.show(); } diff --git a/assets/js/colors.js b/assets/js/colors.js index 2e536a66f8..25f2be0766 100644 --- a/assets/js/colors.js +++ b/assets/js/colors.js @@ -15,9 +15,10 @@ const colors = reactive({ grid: null, co2PerKWh: null, pricePerKWh: null, - price: "#FF912FFF", - co2: "#00916EFF", + price: null, + co2: null, background: null, + light: null, selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"], palette: [ "#03C1EFFF", @@ -35,6 +36,18 @@ const colors = reactive({ ], }); +export const dimColor = (color) => { + return color.toLowerCase().replace(/ff$/, "20"); +}; + +export const lighterColor = (color) => { + return color.toLowerCase().replace(/ff$/, "aa"); +}; + +export const fullColor = (color) => { + return color.toLowerCase().replace(/20$/, "ff"); +}; + function updateCssColors() { const style = window.getComputedStyle(document.documentElement); colors.text = style.getPropertyValue("--evcc-default-text"); @@ -42,22 +55,21 @@ function updateCssColors() { colors.border = style.getPropertyValue("--bs-border-color-translucent"); colors.self = style.getPropertyValue("--evcc-self"); colors.grid = style.getPropertyValue("--evcc-grid"); + colors.price = style.getPropertyValue("--evcc-price"); + colors.co2 = style.getPropertyValue("--evcc-co2"); colors.background = style.getPropertyValue("--evcc-background"); colors.pricePerKWh = style.getPropertyValue("--bs-gray-medium"); colors.co2PerKWh = style.getPropertyValue("--bs-gray-medium"); + colors.light = style.getPropertyValue("--bs-gray-light"); } // update colors on theme change const darkModeMatcher = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)"); -darkModeMatcher?.addEventListener("change", updateCssColors); +if (darkModeMatcher && darkModeMatcher.addEventListener) { + darkModeMatcher.addEventListener("change", updateCssColors); +} +// initialize colors updateCssColors(); - -export const dimColor = (color) => { - return color.toLowerCase().replace(/ff$/, "20"); -}; - -export const fullColor = (color) => { - return color.toLowerCase().replace(/20$/, "ff"); -}; +window.requestAnimationFrame(updateCssColors); export default colors; diff --git a/assets/js/components/BatterySettingsModal.vue b/assets/js/components/BatterySettingsModal.vue index 5727e0122c..bc82bb390e 100644 --- a/assets/js/components/BatterySettingsModal.vue +++ b/assets/js/components/BatterySettingsModal.vue @@ -7,7 +7,7 @@ @open="modalVisible" @closed="modalInvisible" > -