Compare commits

...

134 commits

Author SHA1 Message Date
19348d3a37 Add Forgejo CI workflow
Some checks failed
Build / Go build & test (push) Has been cancelled
Build / evcc binary (push) Has been cancelled
Verify job compiles and tests the Go side with a stubbed dist/, which
main.go embeds and the frontend build produces. Binary job builds the UI
and the real binary.

Living in .forgejo/workflows means Forgejo uses this instead of the 23
upstream .github workflows, which need GitHub-only runner labels.
2026-08-30 10:52:56 +02:00
13215c74c2 Remove sponsor token gating
Some checks failed
CodeQL / Analyze (go) (push) Failing after 1m16s
CodeQL / Analyze (actions) (push) Failing after 4s
CodeQL / Analyze (javascript-typescript) (push) Failing after 7s
Default / Clean (push) Has been cancelled
Default / Build (push) Has been cancelled
Default / Test (push) Has been cancelled
Default / Lint (push) Has been cancelled
Default / UI (push) Has been cancelled
Default / Integration (push) Has been cancelled
Default / Warm fork cache (Go) (push) Has been cancelled
Default / Warm fork cache (UI) (push) Has been cancelled
Unlock all locally gated features without a sponsor token. The change sits
in util/sponsor/auth.go instead of the ~68 device constructors:

- Subject defaults to a non-empty value, so RedactedStatus reports an active
  sponsorship and the frontend unlocks isSponsor
- IsAuthorized always returns true, opening every caller including the
  modbus proxy and the optimizer gate
- ConfigureSponsorship still validates a configured token but never fails,
  so an expired token no longer aborts startup

Drops TestAlpitronicSponsorGate and TestSigenergyEVDCSponsorGate, which
asserted exactly the gate that is removed here.

Cloud-backed services (optimizer API, remote access, cloud vehicles,
telemetry) still require a real token - those are checked server side.

See FORK.md.
2026-08-30 10:41:18 +02:00
andig
d379b7802e
EEBus: clamp the curtailment percent to the documented maximum (#33300) 2026-08-30 08:31:40 +00:00
github-actions[bot]
dbe100b24b
HomeWizard: support 1p kWh meters (#33293) 2026-08-30 08:15:52 +00:00
github-actions[bot]
490f209e57
EEBus: apply the configured failsafe limits consistently (#33288) 2026-08-30 10:15:51 +02:00
Michael Geers
f193eb199a
Home Assistant: link charge status docs (#33260) 2026-08-29 13:34:42 +02:00
andig
8abae0fd68
OpenDTU: tolerate missing radio_stats in energy filter (#33279) 2026-08-29 11:13:32 +00:00
Michael Geers
7dd4007cfa
Add BYN, UAH, RUB and KZT to currency list (#33259) 2026-08-29 13:10:43 +02:00
Gabik21
e8b5477373
Cardata: fix retrying failed container setup (#33243) 2026-08-29 11:01:53 +02:00
Gabik21
19048976ab
chore: add curtailment to meter template (#33237) 2026-08-28 17:31:07 +02:00
github-actions[bot]
ea8e2a72c0
Site: add returnEnergy to aggregated battery state (#33241)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Michael Geers <michael@geers.tv>
2026-08-28 09:05:47 +02:00
Michael Geers
8ccd72e141
UI: show inactive phases as dots in phase indicator (#33173) 2026-08-28 08:50:57 +02:00
andig
fb120252a0
Škoda: add MyŠkoda public api vehicle (#33234) 2026-08-27 20:55:34 +02:00
andig
f40d67ea72
chore: reject release tags that do not build on their release line (#33232) 2026-08-27 20:46:32 +02:00
Michael Geers
8905c2777b
About modal: powered by footer for branded installations, update status badge (#33233) 2026-08-27 20:45:50 +02:00
Michael Geers
27bbdce298
Energyflow: clip collapsed details to remove phantom scroll area (#33229) 2026-08-27 15:31:22 +02:00
andig
7549beaf44
Services: add sunspec/read for reading a point value from a device (#33230) 2026-08-27 15:29:50 +02:00
andig
0d0815963c
Charging plan: show strategy settings without dynamic tariff (#33168) 2026-08-27 15:08:48 +02:00
andig
f693454502
openWB Pro: add return energy (#33216) 2026-08-27 11:30:11 +02:00
andig
8d425d3920
Phases: allow 1p scale-down when the loadpoint never disables (#33214) 2026-08-27 06:56:33 +00:00
andig
10b0c99f17
chore: make dev versions increase monotonically (#33205) 2026-08-26 19:11:46 +00:00
andig
01ffc7d12f
chore: accept both vite+ bin locations in Docker build (#33203) 2026-08-26 18:42:20 +00:00
andig
a80ad57c9e
chore: upgrade dependencies 2026-08-26 20:09:00 +02:00
github-actions[bot]
fd095120d8
warp-ws: use wss when charger URI is https (#33184) 2026-08-26 15:59:36 +02:00
andig
c314e8e838
Keep permanent sentinel errors distinguishable (#33194) 2026-08-26 14:48:33 +02:00
Weblate (bot)
f8c1dec7b6
Translations update from Hosted Weblate (#32952)
Co-authored-by: Łukasz Jernaś <deejay1@srem.org>
Co-authored-by: pjv <ezelspinguin@gmail.com>
Co-authored-by: RTTTC <94727758+RTTTC@users.noreply.github.com>
Co-authored-by: shpokas <ivars.strazdins@gmail.com>
Co-authored-by: Jonas Gate <tidy.koala8317@brot.se>
Co-authored-by: Richard E <richard@ebbersonline.nl>
Co-authored-by: Eduard Costa <kostus@gmail.com>
Co-authored-by: frankb-CZ <frantisek.budinsky@gmail.com>
2026-08-26 14:30:14 +02:00
github-actions[bot]
36614fea46
Add SENEC.Connect cloud API (#33029)
Co-authored-by: premultiply <4681172+premultiply@users.noreply.github.com>
2026-08-26 10:44:29 +00:00
Michael Geers
cba812d884
Vehicle settings: compact rows layout (#33169) 2026-08-26 10:22:58 +02:00
Tilo Alexander
063088c070
Demo tariffs: increase forecast to 5 days (#33174) 2026-08-26 10:11:40 +02:00
Jakob Altevolmer
887ec9ecd2
Solax X1/X3-HAC: add CurrentLimiter (#33181) 2026-08-25 22:39:28 +02:00
Michael Geers
88a4004d4d
chore: update npm dependencies (#33172) 2026-08-25 14:50:35 +00:00
github-actions[bot]
1defa5f7fc
UI: fix docs links after docs site restructuring (#33160)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Michael Geers <michael@geers.tv>
2026-08-25 14:32:15 +00:00
Dennis Klipp
9beca9b22f
Go plugin: keep float parameters typed (#33162) 2026-08-25 14:23:53 +02:00
Dennis Klipp
022171ffde
Deye 3p hybrid: fix curtailed script for whole-number limits (#33161) 2026-08-25 14:22:45 +02:00
andig
e4e5c2db26
Enovates: verify EMS mode and use actually offered current (#33163) 2026-08-25 13:43:39 +02:00
andig
b3db9e93fe
Fix tariff API route not matching co2 (#33166) 2026-08-25 13:41:40 +02:00
andig
2b87fe2b1d
Grünstromindex: fall back to co2_g_oekostrom when co2_g_standard is null (#33164) 2026-08-25 13:22:49 +02:00
harmendp
a66fa2fa04
Youless: add return energy (#33155)
Co-authored-by: harmendp <harmen.depaep@gmail.com>
2026-08-25 09:33:09 +02:00
Michael Geers
442113e9ea
Remote Access: not experimental any more (#33148) 2026-08-24 21:26:45 +02:00
Michael Geers
10c646e668
UI: fix blurred energy flow bar labels in Safari (#33144) 2026-08-24 13:57:33 +00:00
Tilo Alexander
4adbf141d6
Provider auth: require authenticated session for OAuth login/logout (#33115)
Co-authored-by: Michael Geers <michael@geers.tv>
2026-08-24 13:47:06 +00:00
andig
226425e22e
chore: expand GOROOT lazily in Makefile (#33143) 2026-08-24 15:28:01 +02:00
github-actions[bot]
2d207d5109
chore: fix vite+ bin PATH (#33142) 2026-08-24 13:27:44 +00:00
andig
5960e9fcfc
Optimizer: limit precondition demand to required charging duration (#33137) 2026-08-24 13:19:39 +02:00
andig
1416134c1d
pvnode: revert to end-labelled interval resampling (#33133) 2026-08-24 11:30:53 +02:00
andig
b15f81a85a
Soc: extrapolate from charged energy when vehicle api is unavailable (#33122) 2026-08-24 10:45:02 +02:00
github-actions[bot]
7f33bb9317
Config UI: hide disabled vehicles from loadpoint default-vehicle dropdown (#33125) 2026-08-24 06:22:48 +00:00
Hendrik
f216a440e1
Energy Price Forecast: add api key (#33081) 2026-08-24 08:20:57 +02:00
Christian Schöner
56eccb08ea
Sungrow: add grid and battery returnenergy (#33129) 2026-08-23 21:14:20 +02:00
github-actions[bot]
0812904b48
Mqtt: always render preset timeout (BC) (#33084) 2026-08-23 15:51:13 +00:00
Christian
6b94ac6b01
OneKey: make mqtt timeout configurable, default 90s (#33120) 2026-08-23 16:09:38 +02:00
andig
531a0bc653
chore: log use case events as Evnt: <ski> <event> (#33117) 2026-08-23 14:21:44 +02:00
Johannes Rudolph
c1067111e4
Loadpoint: don't reserve surplus when paused by feed-in priority (#33072) 2026-08-23 11:10:24 +00:00
kscholty
cd08f3d36c
chore: update docs (#32816) 2026-08-23 10:48:44 +00:00
andig
f7b3f394f3
Shelly: no return energy register on gen1 relay and plug meters (#33111) 2026-08-23 10:43:13 +00:00
andig
3159126248
Loadpoint: ignore charge interruption during phase switch (#33109) 2026-08-23 12:41:42 +02:00
andig
c1960b378a
Metrics: fall back to power integration when a meter loses its energy register (#33103) 2026-08-23 12:28:36 +02:00
andig
48c125e93b
Deye 3p hybrid: fix energy scaling for HV pre-1098 firmware (#33074) 2026-08-23 12:25:02 +02:00
Hendrik
62cca5e885
Energy Price Forecast CO2: add regions (#33080) 2026-08-23 12:20:09 +02:00
andig
defe9403de
Tariff (solar): resample sub-slots along the source curve (#33105) 2026-08-23 12:05:20 +02:00
andig
f43499ea97
pvnode: resample against the preceding interval, not the following one (#33102) 2026-08-23 11:31:54 +02:00
andig
af4d14f65c
OCPP: keep websocket alive between heartbeats (#33099) 2026-08-23 09:16:44 +00:00
andig
83d322b5e9 chore: upgrade modules 2026-08-22 14:21:04 +02:00
andig
bf57d398c2
Loadpoint: project continuous device min power shortfall into PV disable gate (#33075) 2026-08-22 14:02:27 +02:00
Voltie EV Charging Solutions
fbbe12a0ec
Voltie: only offer phase switching where the power board supports it (#33070) 2026-08-22 13:48:55 +02:00
andig
ebe064ad97
Loadpoint: disable PV mode when idle integrated device lacks surplus (#33073) 2026-08-22 13:47:27 +02:00
andig
469ccb83ab
EEBus OHPCF: add disablepowerlimiter option (#33071) 2026-08-22 13:25:36 +02:00
andig
8edf92a103
Sonoff: add switch socket charger (#33058) 2026-08-22 13:14:09 +02:00
andig
8661c66fc7
Curtailment devices: new device class for feed-in limitation (BC) (#32416) 2026-08-22 11:10:59 +00:00
Christian
7c0ddf1828
Add 1SINQ OneKey heat pump (sgready via MQTT) (#33069) 2026-08-22 13:01:56 +02:00
VolkerK62
7df861f66e
DaheimLaden: fix firmware init (#33032) 2026-08-21 22:03:17 +02:00
andig
c28f2ff623
Digest auth: reuse challenges across requests (#32842) 2026-08-21 17:32:25 +02:00
Alexandre JARDON
37d8aecbb2
UI: Add date format preference in user interface settings (#29321)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Michael Geers <michael@geers.tv>
2026-08-21 15:18:51 +00:00
Hendrik
28bb5ba859
Tariff: add Energy Price Forecast regions and currencies (#33051) 2026-08-21 16:53:47 +02:00
andig
4e45dc79bb
chore: use math/rand/v2 and Go 1.27 generic rand.N (#33055) 2026-08-21 14:33:19 +00:00
Hendrik
9600fc5b9f
Tariff: make Energy Price Forecast cacheable (#33049) 2026-08-21 14:46:19 +02:00
Michael Geers
a8c4dfd372
UI Customize: add custom-theme flag for default theme (#33042) 2026-08-21 14:00:50 +02:00
andig
eeb2f593bf
EEBus: keep pairing requests pending while devices are being configured (#33046) 2026-08-21 11:14:28 +00:00
andig
176509d094
chore: sponsorship (#33045) 2026-08-21 10:59:37 +00:00
github-actions[bot]
13db309c94
Battery boost: resume from hold when limit is lowered (#32999) 2026-08-21 11:05:53 +02:00
Tilo Alexander
769a6a1aa5
API: bound jq evaluation on /api/state against unauthenticated DoS (#33028) 2026-08-21 09:24:41 +02:00
tbaehler
20058f6e0d
Zaptec: optional fix for auto-charging (#32996) 2026-08-21 08:52:11 +02:00
andig
a578893edd
Site: separate measurement from power calculation in update cycle (#32770) 2026-08-20 18:19:56 +00:00
premultiply
e7b694e550
Afore: remove Modbus TCP option (#32964) 2026-08-20 20:15:53 +02:00
andig
e2c59fc6e1
Meter (SunSpec): read max AC power from nameplate (#32732) 2026-08-20 20:14:13 +02:00
andig
fb35c0905f
Templates: add ErrNotAvailable default to battery mode switches (#33023) 2026-08-20 17:36:28 +00:00
andig
1b909edd9b
Sunspec: serialize access to a shared device tree (#33014) 2026-08-20 17:30:05 +02:00
Michael Geers
9f70a18501
Charts: prevent long-press text selection (#33012) 2026-08-20 14:47:56 +00:00
andig
de0bac2bc8
Sunspec: share block reads across values on the same connection (#33011) 2026-08-20 16:40:13 +02:00
VolkerK62
1a0a05c9ad
DaheimLaden: improve error handling (#33010) 2026-08-20 14:39:42 +00:00
Michael Geers
ab8d778fc5
docs: add inline style convention to agent rules (#33007) 2026-08-20 16:07:04 +02:00
github-actions[bot]
7d9fe8c997
my-PV meters: skip TLS verification for local data.jsn requests (#33003) 2026-08-20 16:06:40 +02:00
Michael Geers
f6e6db9eae
Issue: remove obsolete number input spinner styles (#33006) 2026-08-20 15:51:52 +02:00
Andreas Ertel
9a87bd105e
Add StecaCharger (#32990)
Co-authored-by: andreas.ertel <andreas.ertel@esystems-mtg.de>
2026-08-20 13:51:19 +02:00
Thijs W.
6715711d49
Home Assistant: auto-detect Supervisor instance and token (#33000) 2026-08-20 13:37:50 +02:00
andig
e63bae0c8b
chore: upgrade Go to 1.27 (#30601) 2026-08-20 13:36:53 +02:00
andig
748d36d130
Phases: disable instead of scaling down to 1p without sufficient surplus (#32991) 2026-08-19 19:14:57 +00:00
andig
41319dfa39
SolarMax MAX.STORAGE: drop the whole-kWh energy register (#32986) 2026-08-19 18:17:04 +02:00
Stef van Hooijdonk
80a6231a11
[BUGFIX] Update url / backup example curl command (#32984) 2026-08-19 16:44:58 +02:00
shennings1992
45f49aac52
Sungrow: add curtailment (#32976) 2026-08-19 14:04:05 +02:00
andig
859c905640
Battery control: apply battery mode per battery and only on change (#32967) 2026-08-19 09:36:46 +02:00
andig
829f11f23e
EEBus: don't deny pairing while devices are still being configured (#32966) 2026-08-19 09:22:37 +02:00
andig
72b7e27057
Loadpoint: sync charger phases on vehicle connect (#32962) 2026-08-19 08:50:24 +02:00
andig
c745672437
Afore hybrid: add RS485 (#32961) 2026-08-19 06:34:40 +00:00
andig
0be2e0d803
api: let Identify return multiple identities (#32949) 2026-08-18 17:21:32 +02:00
andig
0ae85295dc
Templates: add http service for parameter choices (#32951) 2026-08-18 15:22:48 +02:00
github-actions[bot]
e7ab19d7ca
SMA EV Charger: add setup instructions (#32862)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: premultiply <4681172+premultiply@users.noreply.github.com>
2026-08-18 11:09:24 +00:00
Weblate (bot)
2524b46204
Translations update from Hosted Weblate (#32432)
Co-authored-by: Kejserjorn <kejserjorn@gmail.com>
Co-authored-by: Richard E <richard@ebbersonline.nl>
Co-authored-by: FraBoCH <19388397+FraBoCH@users.noreply.github.com>
Co-authored-by: RTTTC <94727758+RTTTC@users.noreply.github.com>
Co-authored-by: Joni Lehtola <joni.lehtola+github@iki.fi>
Co-authored-by: shpokas <ivars.strazdins@gmail.com>
Co-authored-by: Filipe Lima <filipe.limadacunha@outlook.com>
Co-authored-by: Dominik Trzebiński <trzebinik@gmail.com>
Co-authored-by: Eduard Costa <kostus@gmail.com>
Co-authored-by: Jonas Gate <tidy.koala8317@brot.se>
Co-authored-by: dingoduffel <dirk@katalise.be>
Co-authored-by: aaronchase64 <kiko64@gmail.com>
Co-authored-by: Fernando Lopes <flopes2007-3@yahoo.com>
Co-authored-by: Dario Cambié <dario@cambie.it>
Co-authored-by: Алексей Новиков <novikov.aleksei9@gmail.com>
Co-authored-by: amtssp <amtssp@gmail.com>
Co-authored-by: Tomasz Cielecki <tomasz@ostebaronen.dk>
Co-authored-by: Łukasz Jernaś <deejay1@srem.org>
Co-authored-by: 19atlas <o.ustuncelik@gmail.com>
Co-authored-by: Pere Montpeó <peremontpeo@gmail.com>
Co-authored-by: premultiply <4681172+premultiply@users.noreply.github.com>
2026-08-18 12:53:55 +02:00
andig
6d276526a6
Revert "PV: reserve only min power for higher-priority loadpoints starting up" (#32947) 2026-08-18 10:38:26 +00:00
Michael Geers
88b641301a
chore: sign and notarize macOS release binaries (#32946) 2026-08-18 10:35:22 +00:00
Michael Geers
f4b289f745
Sponsor: include device serial in hardware check metadata (#32927) 2026-08-18 12:02:29 +02:00
mggevaer
cc92f3938a
Fix site.pvPower estimation for battery without pv (#32930) 2026-08-18 11:43:39 +02:00
andig
50ff9ab777
chore: wait for the pull request to merge in the backport command (#32942) 2026-08-18 10:42:01 +02:00
Michael Geers
adfbd057b0
Vehicle: don't instantiate disabled vehicles (#32919) 2026-08-18 10:41:47 +02:00
Alexandre JARDON
8cf5a44a32
Marstek: add discharge and optimise batterymode (#32571) 2026-08-18 10:34:39 +02:00
andig
35ed5e5b91
chore: add ship-go rollup with mdns re-announcement leak fix (#32940) 2026-08-18 07:45:20 +00:00
andig
de3f8995cd
Templates: support class-local include files (#32871) 2026-08-18 09:23:03 +02:00
github-actions[bot]
9034f45355
Tesla Powerwall Fleet: fix template not selectable in ui (#32934) 2026-08-18 08:15:51 +02:00
andig
4cc75b1a45
Optimizer: extend horizon to 48h plus end of day (#32893) 2026-08-17 14:50:55 +02:00
github-actions[bot]
64d48e5800
Loadpoint: fix config error for disabled loadpoints (#32880)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: andig <cpuidle@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Michael Geers <michael@geers.tv>
2026-08-17 07:30:49 +00:00
Ingo
47fd73be33
Site: feed the optimizer a trailing percentile solar scale (#32849) 2026-08-16 21:17:29 +02:00
andig
bc38df1ed6
Add Solplanet hybrid inverter (Modbus TCP) (#32888) 2026-08-16 17:45:18 +00:00
andig
ba8596be96
Optimizer chart: day boundaries with weekday at 00:00 (#32894) 2026-08-16 19:35:44 +02:00
andig
d22b67fd18 Reapply "Optimizer: respect session energy limit when vehicle soc is unknown (#32895)"
This reverts commit fa6f757618.
2026-08-16 19:32:33 +02:00
andig
fa6f757618 Revert "Optimizer: respect session energy limit when vehicle soc is unknown (#32895)"
This reverts commit bb6854b1ed.
2026-08-16 18:24:18 +02:00
Wolle141
dfad373ab3
Add CHINT ECH10K-TH-EU inverter (#32858) 2026-08-16 18:22:31 +02:00
andig
bb6854b1ed
Optimizer: respect session energy limit when vehicle soc is unknown (#32895) 2026-08-16 17:18:34 +02:00
andig
24498ad1f6
Modbus templates: share block reads (#32887) 2026-08-16 13:26:51 +02:00
andig
eec0a8f46c
GoodWe: list ESA series under hybrid template (#32884) 2026-08-16 13:00:39 +02:00
andig
3c25327f7e
chore: build the release and nightly images on depot (#32883) 2026-08-16 12:16:36 +02:00
andig
52ab97c939
chore: make the restored docker cache mounts writable (#32882) 2026-08-16 12:15:58 +02:00
andig
f0e2dd39d3
chore: fix release notes changelog template (#32877) 2026-08-16 11:20:28 +02:00
andig
e772d8413a
chore: publish the homebrew package as a cask (#32874) 2026-08-16 11:10:03 +02:00
Alex
b5907192f5
SunEnergyXT 500: fix battery control (#32866) 2026-08-16 08:23:04 +00:00
544 changed files with 13030 additions and 3807 deletions

View file

@ -0,0 +1,73 @@
name: Build
# Forgejo bevorzugt .forgejo/workflows gegenüber .github/workflows. Die 23
# Upstream-Workflows laufen hier also nicht mit — die verlangen ohnehin
# GitHub-spezifische Runner-Label (depot-ubuntu-24.04-arm) und Secrets.
on:
push:
branches: [master]
workflow_dispatch:
jobs:
verify:
name: Go build & test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# main.go bindet dist/ per //go:embed ein. Das Verzeichnis wird vom
# Frontend-Build erzeugt und ist gitignored — für den reinen Compile-Check
# reicht ein Platzhalter, go:embed braucht nur mindestens eine Datei.
- name: Stub embedded assets
run: mkdir -p dist && echo stub > dist/index.html
- name: Build
run: go build ./...
- name: Vet
run: go vet ./util/sponsor/... ./charger/... ./core/... ./server/...
- name: Test
run: CGO_ENABLED=0 go test ./...
binary:
name: evcc binary
runs-on: ubuntu-latest
needs: verify
steps:
# fetch-depth 0: das Makefile leitet VERSION aus git describe --tags ab
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v4
with:
node-version: 26
cache: npm
- name: Install UI deps
run: npm ci
- name: Build UI
run: make ui
- name: Build binary
run: make build
- uses: actions/upload-artifact@v3
with:
name: evcc
path: evcc

View file

@ -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

View file

@ -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:

View file

@ -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=<true|false>` 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

View file

@ -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 "")

View file

@ -60,6 +60,7 @@ jobs:
permissions:
contents: read
actions: read
id-token: write # depot OIDC
secrets: inherit
report:

View file

@ -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

View file

@ -48,6 +48,7 @@ jobs:
permissions:
contents: read
actions: read
id-token: write # depot OIDC
secrets: inherit
hassio:

View file

@ -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:

View file

@ -23,6 +23,7 @@ linters:
settings:
modernize:
disable:
- embedlit
- stringsbuilder
staticcheck:
checks:

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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", {

View file

@ -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

View file

@ -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.

54
FORK.md Normal file
View file

@ -0,0 +1,54 @@
# Fork-Hinweise
Fork von [evcc-io/evcc](https://github.com/evcc-io/evcc).
## Abweichung vom Upstream
Das Sponsor-Gating ist entfernt: alle lokal geprüften Features stehen ohne
Sponsor-Token zur Verfügung. Der Eingriff sitzt an einer einzigen Stelle in
`util/sponsor/auth.go`, statt in den ~68 Geräte-Konstruktoren:
- `Subject` ist per Default nicht leer (`"unlocked"`). Damit meldet
`RedactedStatus()` einen aktiven Sponsor-Status, und das Frontend schaltet
`isSponsor` (`assets/js/views/Config.vue`) frei — Geräte-Dialoge zeigen kein
„Sponsor-Token erforderlich" mehr.
- `IsAuthorized()` liefert immer `true`. Das öffnet alle Aufrufer, u. a. die
Charger-/Vehicle-Konstruktoren, `server/modbus/proxy.go` und den
Optimizer-Gate in `core/site_optimizer.go`.
- `ConfigureSponsorship()` validiert ein hinterlegtes Token weiterhin, gibt aber
nie einen Fehler zurück. Ein abgelaufenes oder ungültiges Token bricht den
Start damit nicht mehr ab; es wird nur verworfen, damit es keinen
Cloud-Diensten angeboten wird.
Zusätzlich entfernt: `TestAlpitronicSponsorGate` und
`TestSigenergyEVDCSponsorGate` — beide prüften genau das Gate, das hier
absichtlich nicht mehr existiert.
## Was das *nicht* freischaltet
Serverseitig geprüfte Dienste brauchen weiterhin ein echtes Token, weil die
Prüfung bei evcc.io stattfindet und nicht im Client:
- Optimizer-API (`core/site_optimizer.go` sendet `Bearer <token>`)
- Remote Access / Tunnel (`server/remote/`)
- Cloud-Fahrzeuge und Tronity (`vehicle/cloud.go`, `vehicle/tronity.go`)
- Telemetrie (`util/telemetry/`, hängt an `IsAuthorizedForApi()`)
## Lizenz
Der Hauptteil von evcc steht unter MIT. `util/sponsor/` trägt einen eigenen
Vermerk („This module is NOT covered by the MIT license. All rights reserved.").
Diese Änderung betrifft genau dieses Modul.
evcc finanziert sich über Sponsoring: <https://sponsor.evcc.io>
## Upstream-Sync
```
git remote add upstream https://github.com/evcc-io/evcc.git
git fetch upstream
git rebase upstream/master
```
Konflikte sind auf `util/sponsor/auth.go` beschränkt; `FORK.md` liegt bewusst
außerhalb der Upstream-Dateien.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)
}

View file

@ -64,6 +64,7 @@ type All struct {
Site map[string]any
Loadpoints []config.Named
Circuits []config.Named
Curtailers []config.Named
}
type Javascript struct {

View file

@ -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

View file

@ -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
}

View file

@ -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;
}

View file

@ -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();

View file

@ -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<typeof AboutModal>;
@ -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",
};

View file

@ -1,5 +1,5 @@
<template>
<GenericModal id="aboutModal" :size="modalSize" @opened="acknowledge">
<GenericModal id="aboutModal" @opened="acknowledge">
<template #title>
<a :href="websiteUrl" target="_blank" rel="noopener noreferrer">
<template v-if="customLogo">
@ -44,14 +44,12 @@
>
v{{ installed }}
</a>
<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>
<Badge v-if="newVersionAvailable">
{{ $t("footer.version.availableLong") }}
</Badge>
<Badge v-else-if="!nightly" variant="muted">
{{ $t("footer.version.latestVersion") }}
</Badge>
</div>
</td>
</tr>
@ -115,8 +113,22 @@
<!-- open source -->
<hr />
<p class="mb-0 small d-flex flex-wrap column-gap-1">
<i18n-t keypath="footer.version.madeByCommunity" tag="span">
<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">
<a
:href="githubRepoUrl"
target="_blank"
@ -125,15 +137,13 @@
>{{ $t("footer.version.community") }}</a
>
</i18n-t>
<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>
<a
class="evcc-gray"
:href="githubDependenciesUrl"
target="_blank"
rel="noopener noreferrer"
>{{ $t("footer.version.openSource") }}</a
>
</p>
</div>
</GenericModal>
@ -144,16 +154,11 @@ 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,
commitFromVersion,
isNewVersionAvailable,
} from "@/utils/version";
import { isDevelopment, isNightly, getReleaseName, isNewVersionAvailable } from "@/utils/version";
import { defineComponent } from "vue";
const GITHUB_REPO = "https://github.com/evcc-io/evcc";
@ -161,9 +166,10 @@ const EVCC_WEBSITE = "https://evcc.io/";
export default defineComponent({
name: "AboutModal",
components: { GenericModal, Logo },
components: { GenericModal, Logo, Badge },
props: {
installed: { type: String, default: "" },
commit: { type: String, default: "" },
availableVersion: String,
releaseNotes: String,
hasUpdater: Boolean,
@ -204,14 +210,14 @@ export default defineComponent({
githubRepoUrl() {
return GITHUB_REPO;
},
evccWebsiteUrl() {
return EVCC_WEBSITE;
},
githubDependenciesUrl() {
return `${GITHUB_REPO}/network/dependencies`;
},
githubCommitUrl() {
return `${GITHUB_REPO}/commit/${commitFromVersion(this.installed)}`;
},
modalSize() {
return this.newVersionAvailable ? undefined : "sm";
return `${GITHUB_REPO}/commit/${this.commit}`;
},
cleanedReleaseNotes() {
if (!this.releaseNotes) return "";
@ -246,6 +252,9 @@ export default defineComponent({
.about-logo {
height: 2.5rem;
}
.footer-logo {
height: 1rem;
}
.custom-logo {
height: 4rem;
width: auto;

View file

@ -61,7 +61,7 @@ export default defineComponent({
},
computed: {
resetUrl() {
return `${docsPrefix()}/docs/faq#password-reset`;
return `${docsPrefix()}/faq#password-reset`;
},
modalSize() {
return this.demoMode ? "md" : "sm";

View file

@ -1,13 +1,7 @@
<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 }"
@ -22,13 +16,7 @@
</div>
<div class="collapsible-wrapper" :class="{ open }">
<div class="collapsible-content ring-space">
<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="row">
<div class="col-12 col-lg-6 mb-3">
<div class="row">
<label
@ -39,6 +27,15 @@
</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"
@ -67,6 +64,17 @@
</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"
@ -86,7 +94,11 @@
</div>
</div>
<div class="small text-muted mt-1">
{{ $t("main.chargingPlan.precondition.description") }}
{{
$t(
`main.chargingPlan.precondition.${disabled ? "disabledDescription" : "description"}`
)
}}
</div>
</div>
</div>
@ -101,6 +113,13 @@ 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 },
@ -109,7 +128,6 @@ 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"],
@ -121,6 +139,10 @@ 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;
},
@ -129,14 +151,16 @@ export default defineComponent({
},
summary(): string {
if (this.disabled) {
return this.$t("main.chargingPlan.strategyDisabledDescription");
return this.$t("main.chargingPlan.precondition.summaryAll");
}
const parts = [
this.$t(
`main.chargingPlan.optimization.${this.continuous ? "continuous" : this.cheapestKey}`
),
];
if (this.precondition) {
if (this.precondition >= EVERYTHING) {
parts.push(this.$t("main.chargingPlan.precondition.summaryAll"));
} else if (this.precondition) {
parts.push(
this.$t("main.chargingPlan.precondition.summary", {
precondition: this.fmtDurationLong(this.precondition),
@ -146,6 +170,9 @@ 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"
@ -154,13 +181,6 @@ 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)

View file

@ -36,7 +36,6 @@
:id="id"
:precondition="effectivePlanStrategy?.precondition"
:continuous="effectivePlanStrategy?.continuous"
:disabled="strategyDisabled"
:smart-cost-type="smartCostType"
@update="updatePlanStrategy"
/>
@ -162,13 +161,6 @@ 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) {

View file

@ -1,18 +1,18 @@
<template>
<p class="mb-3 root" data-testid="plan-warnings">
<span v-if="targetIsAboveLimit" class="d-block evcc-gray mb-1">
<p class="mb-0 root" data-testid="plan-warnings">
<span v-if="targetIsAboveLimit" class="d-block evcc-gray mt-1">
{{ $t("main.targetCharge.targetIsAboveLimit", { limit: limitFmt }) }}
</span>
<span v-if="mode && ['off', 'now'].includes(mode)" class="d-block text-warning mb-1">
<span v-if="mode && ['off', 'now'].includes(mode)" class="d-block text-warning mt-1">
{{ $t("main.targetCharge.onlyInPvMode") }}
</span>
<span v-if="timeTooFarInTheFuture" class="d-block evcc-gray mb-1">
<span v-if="timeTooFarInTheFuture" class="d-block evcc-gray mt-1">
{{ $t("main.targetCharge.targetIsTooFarInTheFuture") }}
</span>
<span v-if="notReachableInTime" class="d-block text-warning mb-1">
<span v-if="notReachableInTime" class="d-block text-warning mt-1">
{{ $t("main.targetCharge.notReachableInTime", { overrun: overrunFmt }) }}
</span>
<span v-if="targetIsAboveVehicleLimit" class="d-block text-warning mb-1">
<span v-if="targetIsAboveVehicleLimit" class="d-block text-warning mt-1">
{{ $t("main.targetCharge.targetIsAboveVehicleLimit") }}
</span>
</p>

View file

@ -97,11 +97,31 @@
</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"
:disabled="
!selectedReset.sessions &&
!selectedReset.settings &&
!selectedReset.remote
"
>
{{ $t("config.system.backupRestore.reset.action") }}
</button>
@ -192,6 +212,7 @@ export default defineComponent({
selectedReset: {
sessions: false,
settings: false,
remote: false,
},
file: null as File | null,
confirmType: "" as "backup" | "restore" | "reset" | "",
@ -228,6 +249,7 @@ export default defineComponent({
this.selectedReset = {
sessions: false,
settings: false,
remote: false,
};
this.file = null;
this.navigateHomeAfterRestart = false;

View file

@ -3,7 +3,7 @@
name="circuits"
:title="$t('config.circuits.title')"
:description="$t('config.circuits.description')"
docs="/docs/features/loadmanagement"
docs="/features/loadmanagement"
:defaultYaml="defaultYaml"
removeKey="circuits"
endpoint="/config/circuits"

View file

@ -15,7 +15,7 @@
:label="$t('config.control.labelInterval')"
:help="$t('config.control.descriptionInterval')"
example="30 s"
docsLink="/docs/reference/configuration/interval"
docsLink="/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="/docs/reference/configuration/site#residualpower"
docsLink="/reference/configuration/site#residualpower"
>
<div class="input-group input-width">
<input

View file

@ -0,0 +1,97 @@
<template>
<DeviceModalBase
:id="id"
name="curtailer"
device-type="curtailer"
:modal-title="$t(`config.curtailer.${isNew ? 'titleAdd' : 'titleEdit'}`)"
:provide-template-options="provideTemplateOptions"
:initial-values="initialValues"
:on-template-change="handleTemplateChange"
:preserve-on-template-change="preserveFields"
@added="(name) => $emit('changed', { action: 'added', name })"
@updated="$emit('changed', { action: 'updated' })"
@removed="$emit('changed', { action: 'removed' })"
>
<template #description>
<p class="mt-0 mb-4">{{ $t("config.curtailer.description") }}</p>
</template>
<template #before-template="{ values }">
<FormRow id="curtailerParamDeviceTitle" :label="$t('config.general.title')">
<PropertyField
id="curtailerParamDeviceTitle"
v-model.trim="values.deviceTitle"
type="String"
size="w-100"
class="me-2"
required
/>
</FormRow>
</template>
</DeviceModalBase>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import DeviceModalBase from "./DeviceModal/DeviceModalBase.vue";
import FormRow from "./FormRow.vue";
import PropertyField from "./PropertyField.vue";
import type { DeviceValues, Product } from "./DeviceModal";
import { type TemplateGroup, customTemplateOption } from "./DeviceModal/TemplateSelector.vue";
import { ConfigType } from "@/types/evcc";
import defaultCurtailerYaml from "./defaultYaml/curtailer.yaml?raw";
import { getModal } from "@/configModal";
const initialValues = {
type: ConfigType.Template,
deviceTitle: "",
template: null,
};
export default defineComponent({
name: "CurtailerModal",
components: {
DeviceModalBase,
FormRow,
PropertyField,
},
emits: ["changed"],
data() {
return {
initialValues,
preserveFields: ["deviceTitle"],
};
},
computed: {
id(): number | undefined {
return getModal("curtailer")?.id;
},
isNew(): boolean {
return this.id === undefined;
},
},
methods: {
provideTemplateOptions(products: Product[]): TemplateGroup[] {
return [
{
label: "generic",
options: [
...products.filter((p: Product) => p.group === "generic"),
customTemplateOption(this.$t("config.general.customOption")),
],
},
{
label: "specific",
options: products.filter((p: Product) => p.group !== "generic"),
},
];
},
handleTemplateChange(value: string, values: DeviceValues) {
if (value === ConfigType.Custom) {
values.type = ConfigType.Custom;
values.yaml = defaultCurtailerYaml;
}
},
},
});
</script>

View file

@ -34,7 +34,7 @@ export default defineComponent({
},
computed: {
docsLink() {
return `${docsPrefix()}/docs/devices/plugins#${this.type}`;
return `${docsPrefix()}/user-defined-devices#${this.type}`;
},
},
watch: {

View file

@ -3,7 +3,7 @@
name="eebus"
:title="$t('config.eebus.title')"
:description="$t('config.eebus.description')"
docs="/docs/reference/configuration/eebus"
docs="/reference/configuration/eebus"
endpoint="/config/eebus"
state-key="eebus.config"
:no-buttons="fromYaml"

View file

@ -208,7 +208,7 @@ export default defineComponent({
return (this as any).fmtTimeAgo(ms - Date.now());
},
docsLink(): string {
return `${docsPrefix()}/docs/external-limit`;
return `${docsPrefix()}/external-limit`;
},
experimental(): boolean {
return store.state?.experimental === true;

View file

@ -3,7 +3,7 @@
name="influx"
:title="$t('config.influx.title')"
:description="$t('config.influx.description')"
docs="/docs/reference/configuration/influx"
docs="/reference/configuration/influx"
endpoint="/config/influx"
state-key="influx"
@changed="$emit('changed')"

View file

@ -54,7 +54,7 @@ export default defineComponent({
return `claude mcp add --transport http evcc ${this.mcpUrl}`;
},
docsLink(): string {
return `${docsPrefix()}/docs/integrations/mcp`;
return `${docsPrefix()}/integrations/mcp`;
},
},
});

View file

@ -55,7 +55,7 @@
/>
<div class="text-end small text-gray mt-1">
<a
:href="`${docsPrefix()}/docs/notifications#placeholders`"
:href="`${docsPrefix()}/notifications#placeholders`"
target="_blank"
class="text-gray"
>

View file

@ -4,7 +4,7 @@
name="messaginglegacy"
:title="`${$t('config.messaging.title')} (${$t('config.general.legacy')})`"
:description="$t('config.messaging.description')"
docs="/docs/reference/configuration/messaging"
docs="/reference/configuration/messaging"
:defaultYaml="defaultYaml"
endpoint="/config/messaging"
removeKey="messaging"

View file

@ -4,7 +4,7 @@
name="messaging"
:title="$t('config.messaging.title')"
:description="$t('config.messaging.description')"
docs="/docs/notifications"
docs="/notifications"
endpoint="/config/messagingEvents"
state-key="messagingEvents"
data-testid="messaging-modal"

View file

@ -3,7 +3,7 @@
name="modbusproxy"
:title="$t('config.modbusproxy.title')"
:description="$t('config.modbusproxy.description')"
docs="/docs/reference/configuration/modbusproxy"
docs="/reference/configuration/modbusproxy"
endpoint="/config/modbusproxy"
state-key="modbusproxy"
store-values-in-array

View file

@ -3,7 +3,7 @@
name="mqtt"
:title="$t('config.mqtt.title')"
:description="$t('config.mqtt.description')"
docs="/docs/reference/configuration/mqtt"
docs="/reference/configuration/mqtt"
endpoint="/config/mqtt"
state-key="mqtt"
@changed="$emit('changed')"

View file

@ -4,7 +4,7 @@
tabindex="0"
@click="$emit('click')"
>
<shopicon-regular-plus class="me-1"></shopicon-regular-plus>
<shopicon-regular-plus class="me-1 flex-shrink-0"></shopicon-regular-plus>
<span class="text-start">{{ title }}</span>
</button>
</template>

View file

@ -62,7 +62,7 @@ export default defineComponent({
return !!store.state?.evopt;
},
docsLink(): string {
return `${docsPrefix()}/docs/features/optimizer`;
return `${docsPrefix()}/features/optimizer`;
},
},
methods: {

View file

@ -6,15 +6,18 @@
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") }}</p>
<p>
{{ $t("config.remote.description") }}
<i18n-t tag="span" keypath="config.remote.privacy" scope="global">
<a :href="privacyPolicyLink" target="_blank">
{{ $t("config.remote.privacyLink") }}
</a>
</i18n-t>
</p>
<div class="form-check form-switch my-3">
<input
id="remoteEnabled"
@ -138,6 +141,10 @@ 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":
@ -145,7 +152,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");
}
},
},

View file

@ -127,9 +127,9 @@ export default defineComponent({
},
curlExample(): string {
const key = this.revealedKey ?? "";
const url = `${window.location.origin}/api/system/backup`;
const url = `${window.location.origin}/api/db/backup`;
return [
`curl -X POST ${url} \\`,
`curl -X GET ${url} \\`,
` -H "Authorization: Bearer ${key}" \\`,
` -o evcc-backup.db`,
].join("\n");

View file

@ -3,7 +3,7 @@
name="shm"
:title="$t('config.shm.title')"
:description="$t('config.shm.description')"
docs="/docs/reference/configuration/hems"
docs="/reference/configuration/hems"
endpoint="/config/shm"
state-key="shm"
disable-remove

View file

@ -128,7 +128,7 @@ export default {
return this.token && !this.fromYaml;
},
trialTokenLink() {
return `${docsPrefix()}/docs/sponsorship#trial`;
return `${docsPrefix()}/sponsorship#trial`;
},
},
methods: {

View file

@ -1,11 +1,9 @@
<template>
<span
ref="root"
v-tooltip="tooltip"
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 />
@ -14,7 +12,6 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import Tooltip from "bootstrap/js/dist/tooltip";
type Variant = "success" | "warning" | "danger" | "muted";
@ -24,9 +21,6 @@ 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) {
@ -41,30 +35,6 @@ export default defineComponent({
}
},
},
watch: {
tooltip() {
this.initTooltip();
},
},
mounted() {
this.initTooltip();
},
beforeUnmount() {
this.tooltipInstance?.dispose();
},
methods: {
initTooltip() {
this.$nextTick(() => {
this.tooltipInstance?.dispose();
this.tooltipInstance = null;
const el = this.$refs["root"] as Element | undefined;
if (el && this.tooltip) {
// explicit title: bootstrap clears the attr, so :title alone goes stale
this.tooltipInstance = new Tooltip(el, { title: this.tooltip });
}
});
},
},
});
</script>

View file

@ -3,7 +3,7 @@
name="tariffsLegacy"
:title="`${$t('config.tariff.title')} (${$t('config.general.legacy')})`"
:description="$t('config.tariff.description')"
docs="/docs/tariffs"
docs="/tariffs"
:defaultYaml="defaultYaml"
removeKey="tariffs"
endpoint="/config/tariffs"

View file

@ -0,0 +1,28 @@
## required attributes
# feed-in limit in percent of nominal production power (0..100, 100 = uncurtailed)
curtail: # set feed-in limit
source: js
script: console.log(curtail)
curtailed: # current feed-in limit
source: const
value: 100
## alternative examples
#curtail:
# source: http
# uri: http://inverter.local/api/limit?percent={{ .curtail }}
# method: POST
#curtailed:
# source: http
# uri: http://inverter.local/api/limit
# jq: .percent
#curtail:
# source: modbus
# uri: 192.168.179.200:502
# id: 1
# register: { type: writesingle, address: 40016, encoding: uint16 }
#curtailed:
# source: error
# error: ErrNotAvailable

View file

@ -16,6 +16,9 @@ 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
@ -50,3 +53,6 @@ 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)

View file

@ -656,7 +656,7 @@ export default defineComponent({
height: 0;
opacity: 0;
transform: scale(0.98);
overflow: visible;
overflow: hidden;
transition-property: height, opacity, transform;
transition-duration: 0;
transition-timing-function: cubic-bezier(0.5, 0.5, 0.5, 1.15);

View file

@ -236,6 +236,8 @@ 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);

View file

@ -2,7 +2,4 @@
overflow-x: auto;
padding-bottom: 4px;
margin-bottom: 0.5rem;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}

View file

@ -59,6 +59,19 @@
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>
@ -91,14 +104,24 @@ import {
removeLocalePreference,
} from "@/i18n.ts";
import { getThemePreference, setThemePreference } from "@/theme.ts";
import { getUnits, setUnits, is12hFormat, set12hFormat } from "@/units";
import {
getUnits,
setUnits,
is12hFormat,
set12hFormat,
getDateFormat,
setDateFormat,
} from "@/units";
import { isApp } from "@/utils/native";
import { 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: () => [] },
},
@ -115,6 +138,17 @@ 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] };

View file

@ -1,6 +1,6 @@
import Loadpoints from "./Loadpoints.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
import { CURRENCY, SMART_COST_TYPE } from "@/types/evcc";
import { CHARGE_MODE, CURRENCY, SMART_COST_TYPE } from "@/types/evcc";
// Create LoadpointCompact structure for the Loadpoints component
const createLoadpoint = (opts: any = {}) => {
@ -117,11 +117,18 @@ TwoLoadpoints.args = {
index: 0,
vehicleName: "vehicle_3",
charging: true,
chargePower: 8050,
power: 8050,
chargePower: 1380,
power: 1380,
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!!!",

View file

@ -1,5 +1,6 @@
import Phases from "./Phases.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
import { ref, onMounted, onUnmounted } from "vue";
export default {
title: "Loadpoints/Phases",
@ -9,6 +10,7 @@ export default {
},
argTypes: {
phasesActive: { control: { type: "number" } },
phasesConfigured: { control: { type: "number" } },
minCurrent: { control: { type: "number" } },
maxCurrent: { control: { type: "number" } },
offeredCurrent: { control: { type: "number" } },
@ -24,72 +26,117 @@ const Template: StoryFn<typeof Phases> = (args) => ({
template: '<Phases v-bind="args" />',
});
export const OnePhase = Template.bind({});
OnePhase.args = {
phasesActive: 1,
const base = {
phasesConfigured: 3,
minCurrent: 6,
maxCurrent: 16,
offeredCurrent: 8,
chargeCurrents: undefined,
};
export const TwoPhase = Template.bind({});
TwoPhase.args = {
...OnePhase.args,
phasesActive: 2,
};
// --- expected phases only (no per-phase measurement) ---
export const ThreePhase = Template.bind({});
ThreePhase.args = {
...OnePhase.args,
phasesActive: 3,
};
export const NoData = Template.bind({});
NoData.args = { ...base, phasesActive: undefined, offeredCurrent: 0 };
export const RealCurrents = Template.bind({});
RealCurrents.args = {
...OnePhase.args,
phasesActive: 3,
offeredCurrent: 13,
chargeCurrents: [11, 9, 12],
};
export const Expected1p = Template.bind({});
Expected1p.args = { ...base, phasesActive: 1 };
export const OnePhaseMoreAvailable = Template.bind({});
OnePhaseMoreAvailable.args = {
...OnePhase.args,
offeredCurrent: 12,
chargeCurrents: [6, 0.2, 0],
};
export const Expected2p = Template.bind({});
Expected2p.args = { ...base, phasesActive: 2 };
export const TwoPhasesActive = Template.bind({});
TwoPhasesActive.args = {
...OnePhase.args,
phasesActive: 2,
offeredCurrent: 16,
chargeCurrents: [16, 16, 0.3],
};
export const Expected3p = Template.bind({});
Expected3p.args = { ...base, phasesActive: 3 };
export const AsymetricPhases = Template.bind({});
AsymetricPhases.args = {
...OnePhase.args,
phasesActive: 2,
offeredCurrent: 16,
chargeCurrents: [8, 0.9, 14],
};
// --- measured per-phase currents ---
export const OnlySecondPhase = Template.bind({});
OnlySecondPhase.args = {
...OnePhase.args,
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: 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,
offeredCurrent: 20,
maxCurrent: 20,
chargeCurrents: [0.007, 0.009, 5.945],
};
// 1p device but 3 phases measured: configuration error, warning color + tooltip
export const Mismatch1p = Template.bind({});
Mismatch1p.args = {
...base,
phasesConfigured: 1,
phasesActive: 3,
offeredCurrent: 10,
chargeCurrents: [10, 10, 10],
};
// cycles 1p → 3p → 1p every few seconds to inspect the transition
export const Switching: StoryFn<typeof Phases> = (args) => ({
components: { Phases },
setup() {
const phasesActive = ref(1);
let timer: ReturnType<typeof setInterval>;
onMounted(() => {
timer = setInterval(() => {
phasesActive.value = phasesActive.value === 1 ? 3 : 1;
}, 3000);
});
onUnmounted(() => clearInterval(timer));
return { args, phasesActive };
},
template: '<Phases v-bind="args" :phasesActive="phasesActive" />',
});
Switching.args = { ...base, phasesConfigured: 0, offeredCurrent: 12 };
// --- overview: rows = scenario, columns = device phase configuration ---
const scenarios = [
{ name: "NoData", args: NoData.args },
{ name: "Expected1p", args: Expected1p.args },
{ name: "Expected2p", args: Expected2p.args },
{ name: "Expected3p", args: Expected3p.args },
{ name: "Measured1p", args: Measured1p.args },
{ name: "Measured2p", args: Measured2p.args },
{ name: "Measured3p", args: Measured3p.args },
{ name: "Asymmetric", args: Asymmetric.args },
{ name: "OnlyL2", args: OnlyL2.args },
{ name: "MainlyL3", args: MainlyL3.args },
];
// 1p3p auto (0) behaves like 3p
const configs = [
{ name: "1p device", phasesConfigured: 1 },
{ name: "3p/auto", phasesConfigured: 3 },
];
export const Overview: StoryFn<typeof Phases> = () => ({
components: { Phases },
setup() {
return { scenarios, configs };
},
template: `
<div style="display: grid; grid-template-columns: 140px repeat(2, 120px); gap: 0.75rem 1.5rem; align-items: center; padding: 1rem; background: var(--evcc-box); border-radius: 0.5rem; color: var(--evcc-default-text); font-size: 0.85rem;">
<div></div>
<div v-for="c in configs" :key="c.name" style="opacity: 0.7;">{{ c.name }}</div>
<template v-for="s in scenarios" :key="s.name">
<div style="opacity: 0.7;">{{ s.name }}</div>
<Phases v-for="c in configs" :key="c.name" v-bind="{ ...s.args, phasesConfigured: c.phasesConfigured }" />
</template>
</div>
`,
});
Overview.parameters = { controls: { disable: true } };

View file

@ -1,12 +1,23 @@
<template>
<div :class="`phases d-flex justify-content-between`">
<div
v-tooltip="phasesMismatch ? $t('main.loadpoint.phasesMismatch') : undefined"
class="phases d-flex"
:class="{ 'phases-warning': phasesMismatch }"
>
<div
v-for="num in [1, 2, 3]"
:key="num"
class="phase me-1"
:class="{ 'phase-inactive': !isPhaseActive(num) }"
:class="{
'phase-inactive': !isPhaseActive(num),
'phase-hidden': !isPhaseActive(num) && (maxPhases === 1 || num > maxPhases),
}"
>
<div class="target" :style="{ width: `${targetWidth()}%` }"></div>
<div
v-show="targetWidth() > 0"
class="target"
:style="{ width: `${targetWidth()}%` }"
></div>
<div class="real" :style="{ width: `${realWidth(num)}%` }"></div>
</div>
</div>
@ -23,6 +34,7 @@ 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 },
},
@ -31,9 +43,17 @@ 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
@ -52,7 +72,8 @@ export default defineComponent({
const current = this.chargeCurrents[num - 1];
return current !== undefined && current >= MIN_ACTIVE_CURRENT;
}
return num <= (this.phasesActive || 0);
// unknown active phases: assume all
return num <= (this.phasesActive || this.maxPhases);
},
},
});
@ -63,15 +84,14 @@ export default defineComponent({
width: 73px;
}
.phase {
background-color: var(--bs-gray-bright);
background-color: var(--evcc-gray-15);
height: 4px;
flex-grow: 1;
flex: 1 1 0;
position: relative;
border-radius: 1px;
border-radius: 2px;
overflow: hidden;
flex-basis: 100%;
opacity: 1;
transition-property: flex-basis, margin, opacity;
transition-property: flex-basis, flex-grow, margin, opacity;
transition-duration: var(--evcc-transition-slow);
transition-timing-function: ease-in;
}
@ -79,6 +99,13 @@ 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;
@ -96,9 +123,15 @@ html.dark .phase {
opacity: 1;
}
.target {
background-color: var(--evcc-green);
background-color: color-mix(in srgb, var(--evcc-dark-green) 40%, white);
}
.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>

View file

@ -0,0 +1,18 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12.825 15.8q.75-.35 1.113-1.2t-.163-1.6q-1.1-1.575-2.212-3.062T9.3 6.95q-.275-.35-.687-.162t-.313.637q.4 1.825.85 3.625t1 3.65q.25.875 1.038 1.163t1.637-.063m-7.7 4.2q-.525 0-1.012-.238t-.738-.712q-.7-1.15-1.025-2.437T2.025 14q0-1.725.588-3.463t1.862-3.112q.375-.425.8-.412t.7.262t.35.625t-.225.725q-.95 1.05-1.512 2.475T4.025 14q0 1.025.263 2.038T5.125 18H18.9q.525-.9.813-1.925T20 14q0-1.875-.812-3.475T17.025 7.8q-1.05-.875-2.375-1.35T11.95 6q-.4 0-.712-.262t-.363-.663t.15-.725t.6-.35q1.825-.075 3.563.525T18.35 6.3q1.675 1.4 2.663 3.375T22 14q0 1.4-.35 2.663t-1 2.387q-.275.475-.75.713t-1 .237zm6.9-6.025"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "Curtail",
mixins: [icon],
});
</script>

View file

@ -146,7 +146,7 @@ export default defineComponent({
...tooltipStyle(colors.text || ""),
formatter: this.tooltipFormatter,
},
xAxis: slotXAxis(this.times),
xAxis: slotXAxis(this.times, this.weekdayShort),
yAxis: forecastYAxis({
min: undefined,
position: "right",

View file

@ -0,0 +1,35 @@
import { describe, expect, test } from "vite-plus/test";
import { isMidnight, slotXAxis } from "./chart";
// 15min slots starting at the given local hour
function times(startHour: number, count: number): number[] {
const start = new Date(2025, 0, 1, startHour).getTime();
return Array.from({ length: count }, (_, i) => start + i * 15 * 60 * 1000);
}
describe("slotXAxis", () => {
const weekdayShort = () => "Thu";
// 20:00 to 08:00, midnight at index 16
const slots = times(20, 48);
const axis = slotXAxis(slots, weekdayShort);
const label = (i: number) => axis.axisLabel.formatter(String(slots[i]));
test("labels full hours only", () => {
expect(label(0)).toBe("20"); // 20:00
expect(label(1)).toBe(""); // 20:15
expect(label(4)).toBe(""); // 21:00, not a step hour
expect(label(32)).toBe("4"); // 04:00
});
test("shows the weekday at midnight", () => {
expect(label(16)).toBe("0\nThu");
});
test("draws a split line at day boundaries only", () => {
expect(isMidnight(slots[16])).toBe(true);
expect(axis.splitLine.interval(16)).toBe(true);
expect(axis.splitLine.interval(15)).toBe(false);
// no line at the axis start, even if the first slot is midnight
expect(slotXAxis(times(0, 4), weekdayShort).splitLine.interval(0)).toBe(false);
});
});

View file

@ -1,4 +1,5 @@
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"
@ -19,22 +20,35 @@ export function slotTimes(timestamp: string, dt: number[]): number[] {
return res;
}
// category x axis over slot times, labels at full hours every 4h (6h on mobile)
export function slotXAxis(times: number[]) {
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) {
const step = window.innerWidth < 576 ? 6 : 4;
return {
type: "category",
data: times.map(String),
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
splitLine: {
show: true,
// split lines sit at the left edge of their slot
interval: (index: number) => index > 0 && isMidnight(times[index]),
lineStyle: { color: colors.muted || "", type: "solid" },
},
axisLabel: {
...xAxisLabelStyle(),
interval: 0,
formatter: (value: string) => {
const d = new Date(Number(value));
if (d.getMinutes() !== 0 || d.getHours() % step !== 0) return "";
return String(d.getHours());
const label = String(d.getHours());
return d.getHours() === 0 ? `${label}\n${weekdayShort(d)}` : label;
},
},
};

View file

@ -60,11 +60,11 @@
</th>
<td>
<template v-if="session.created">
{{ fmtFullDateTime(new Date(session.created), false) }}
{{ fmtFullDateTime(new Date(session.created)) }}
</template>
<br />
<template v-if="session.finished">
{{ fmtFullDateTime(new Date(session.finished), false) }}
{{ fmtFullDateTime(new Date(session.finished)) }}
</template>
</td>
</tr>

View file

@ -133,7 +133,7 @@
@click="showDetails(session.id)"
>
<td class="ps-0 tabular">
{{ fmtFullDateTime(new Date(session.created), true) }}
{{ fmtWeekdayDayTime(new Date(session.created)) }}
</td>
<td class="d-none d-md-table-cell">
{{ session.loadpoint }}

View file

@ -47,7 +47,7 @@ export default defineComponent({
},
computed: {
docsLink() {
return `${docsPrefix()}/docs/faq#telemetry`;
return `${docsPrefix()}/faq#telemetry`;
},
},
methods: {

View file

@ -16,85 +16,91 @@
</a>
</p>
<div
v-for="(vehicle, index) in vehicles"
v-for="vehicle in vehicles"
:key="vehicle.name"
role="group"
:aria-label="vehicle.title"
:class="{ 'border-top pt-4': index > 0 }"
class="mb-4"
class="d-flex gap-2 mb-3"
>
<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>
<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>
<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">
@ -110,7 +116,7 @@
<script lang="ts">
import Badge from "../Helper/Badge.vue";
import GenericModal from "../Helper/GenericModal.vue";
import SettingsFormRow from "../Helper/SettingsFormRow.vue";
import CustomSelect from "../Helper/CustomSelect.vue";
import VehicleIcon from "../VehicleIcon";
import api from "@/api";
import formatter from "@/mixins/formatter";
@ -124,7 +130,7 @@ const { OFF, PV, MINPV, NOW } = CHARGE_MODE;
export default defineComponent({
name: "VehicleSettingsModal",
components: { Badge, GenericModal, SettingsFormRow, VehicleIcon },
components: { Badge, CustomSelect, GenericModal, VehicleIcon },
mixins: [formatter],
props: {
vehicles: { type: Array as PropType<Vehicle[]>, default: () => [] },
@ -137,7 +143,7 @@ export default defineComponent({
},
computed: {
docsUrl(): string {
return `${docsPrefix()}/docs/features/limits`;
return `${docsPrefix()}/features/limits`;
},
modeOptions(): SelectOption<string>[] {
return [
@ -188,6 +194,9 @@ export default defineComponent({
};
});
},
optionName(options: SelectOption<string | number>[], value: string | number): string {
return options.find((o) => o.value === value)?.name ?? String(value);
},
selectValue(event: Event): string {
return (event.target as HTMLSelectElement).value;
},

View file

@ -0,0 +1,25 @@
import Tooltip from "bootstrap/js/dist/tooltip";
import type { Directive, DirectiveBinding } from "vue";
function update(el: HTMLElement, { value, oldValue }: DirectiveBinding<string | undefined>) {
if (value === oldValue) return;
const instance = Tooltip.getInstance(el);
if (!value) {
instance?.dispose();
} else if (instance) {
instance.setContent({ ".tooltip-inner": value });
} else {
new Tooltip(el, { title: value });
}
}
// v-tooltip="text": bootstrap tooltip bound to text, removed when text is empty
const tooltip: Directive<HTMLElement, string | undefined> = {
mounted: update,
updated: update,
beforeUnmount(el) {
Tooltip.getInstance(el)?.dispose();
},
};
export default tooltip;

View file

@ -110,7 +110,6 @@ export async function ensureCurrentLocaleMessages(i18n: VueI18nInstance) {
}
export function docsPrefix() {
const locale = getLocale();
const path = locale === "de" ? "" : `/en`;
return `https://docs.evcc.io${path}`;
const lang = getLocale() === "de" ? "de" : "en";
return `https://docs.evcc.io/${lang}`;
}

View file

@ -2,6 +2,7 @@ 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";
@ -275,7 +276,7 @@ describe("12h/24h time format", () => {
test("12h format", () => {
is12hSpy.mockReturnValue(true);
expect(fmt.fmtHourMinute(testDate)).toBe("3:30 PM");
expect(fmt.fmtFullDateTime(testDate, false)).toBe("So., 15. Jan., 3:30 PM");
expect(fmt.fmtFullDateTime(testDate)).toBe("So., 15. Jan. 2023, 3:30 PM");
expect(fmt.fmtWeekdayTime(testDate)).toBe("So. 3:30 PM");
expect(fmt.fmtAbsoluteDate(testDate)).toBe("So 3:30 PM");
});
@ -283,12 +284,29 @@ describe("12h/24h time format", () => {
test("24h format", () => {
is12hSpy.mockReturnValue(false);
expect(fmt.fmtHourMinute(testDate)).toBe("15:30");
expect(fmt.fmtFullDateTime(testDate, false)).toBe("So., 15. Jan., 15:30");
expect(fmt.fmtFullDateTime(testDate)).toBe("So., 15. Jan. 2023, 15:30");
expect(fmt.fmtWeekdayTime(testDate)).toBe("So., 15:30");
expect(fmt.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);

View file

@ -1,6 +1,55 @@
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: "$",
@ -26,6 +75,10 @@ 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)
@ -45,6 +98,7 @@ 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 {
@ -62,6 +116,11 @@ export default defineComponent({
fmtDigits: 1,
};
},
computed: {
dateFormat(): DateFormat {
return settings.dateFormat || "";
},
},
methods: {
energyPriceSubunit(currency: CURRENCY): string | undefined {
if (currency === CURRENCY.CHF) {
@ -265,15 +324,33 @@ export default defineComponent({
hour12: is12hFormat(),
}).format(date);
},
fmtFullDateTime(date: Date, short: boolean) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: short ? undefined : "short",
month: short ? "numeric" : "short",
day: "numeric",
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, {
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, {
@ -295,11 +372,17 @@ export default defineComponent({
}).format(date);
},
fmtDayMonth(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: "short",
day: "numeric",
month: "short",
}).format(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();
},
fmtDayMonthYear(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {

View file

@ -23,6 +23,7 @@ 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";
@ -99,6 +100,8 @@ function saveJSON(key: string) {
};
}
export type DateFormat = "" | "dmy" | "mdy" | "ymd";
export interface LoadpointSettings {
order?: number;
visible?: boolean;
@ -112,6 +115,7 @@ export interface Settings {
theme: THEME | null;
unit: string;
is12hFormat: boolean;
dateFormat: DateFormat;
energyflowDetails: boolean;
energyflowCo2: boolean;
energyflowPv: boolean;
@ -141,6 +145,7 @@ 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),
@ -169,6 +174,7 @@ 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));

View file

@ -9,7 +9,7 @@ export function getThemePreference(): THEME | null {
if (theme && Object.values(THEME).includes(theme)) {
return theme;
}
return THEME.AUTO;
return window.evcc?.customTheme || THEME.AUTO;
}
export function setThemePreference(theme: THEME) {

View file

@ -8,12 +8,14 @@ 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 {
@ -459,6 +461,10 @@ 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;
}
@ -529,6 +535,7 @@ export enum SMART_COST_TYPE {
CO2 = "co2",
PRICE_DYNAMIC = "pricedynamic",
PRICE_FORECAST = "priceforecast",
PRICE_STATIC = "pricestatic",
}
export enum LENGTH_UNIT {
@ -809,6 +816,10 @@ export enum CURRENCY {
TRY = "TRY",
MYR = "MYR",
THB = "THB",
BYN = "BYN",
UAH = "UAH",
RUB = "RUB",
KZT = "KZT",
}
export enum ICON_SIZE {
@ -1124,8 +1135,10 @@ export interface Battery {
capacity: number;
/** Charge level in %. Weighted by capacity across all batteries. */
soc: number;
/** Total battery energy in kWh. */
/** Total discharged 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. */
@ -1367,7 +1380,8 @@ export type DeviceType =
| "loadpoint"
| "messenger"
| "tariff"
| "hems";
| "hems"
| "curtailer";
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";
@ -1384,6 +1398,7 @@ export interface SiteConfig {
aux: string[] | null;
ext: string[] | null;
consumer: string[] | null;
curtail: string[] | null;
}
export type ValueOf<T> = T[keyof T];

View file

@ -1,4 +1,5 @@
import settings from "./settings";
import type { DateFormat } from "./settings";
import { LENGTH_UNIT } from "./types/evcc";
const MILES_FACTOR = 0.6213711922;
@ -34,3 +35,11 @@ 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;
}

View file

@ -2,14 +2,13 @@ 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+abc1234";
const NIGHTLY = "0.304.0-dev.1712345678";
const STABLE = "0.303.1";
describe("isDevelopment", () => {
@ -21,20 +20,13 @@ describe("isDevelopment", () => {
});
describe("isNightly", () => {
test("only versions with build metadata", () => {
test("only pre-release versions", () => {
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");
@ -46,14 +38,15 @@ describe("getReleaseName", () => {
describe("getShortVersion", () => {
test("formats version", () => {
expect(getShortVersion(DEV)).toBe("dev build");
expect(getShortVersion(NIGHTLY)).toBe("v0.304.0-dev+abc1234");
expect(getShortVersion(NIGHTLY)).toBe("v0.304.0-dev.1712345678");
expect(getShortVersion(STABLE)).toBe("v0.303.1");
});
});
describe("isNewVersionAvailable", () => {
test("never for dev builds", () => {
test("never for dev or nightly 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);

View file

@ -2,13 +2,9 @@ export function isDevelopment(version: string): boolean {
return version === "0.0.0";
}
// untagged builds carry the commit as build metadata: 0.304.0-dev+abc1234
// untagged builds are pre-releases carrying the build timestamp: 0.304.0-dev.1712345678
export function isNightly(version: string): boolean {
return version.includes("+");
}
export function commitFromVersion(version: string): string {
return version.split("+")[1] ?? "";
return version.includes("-dev.");
}
export function getReleaseName(version: string): string {
@ -23,7 +19,8 @@ export function getShortVersion(version: string): string {
}
export function isNewVersionAvailable(installed?: string, available?: string): boolean {
return !!available && !isDevelopment(installed || "") && available !== installed;
const v = installed || "";
return !!available && !isDevelopment(v) && !isNightly(v) && available !== installed;
}
export function isNewVersionUnacknowledged(

View file

@ -105,6 +105,7 @@ 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,

View file

@ -150,6 +150,31 @@
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>
@ -162,7 +187,7 @@
meter-type="pv"
:has-error="hasDeviceError('meter', meter.name)"
:tags="deviceTags('meter', meter.name)"
:banner="meterBanner(meter.name)"
:banner="curtailmentBanner('meter', meter.name)"
@edit="(type, id) => openModal('meter', { type, id })"
@enable="handleDisable('meter', meter.id, false)"
/>
@ -394,8 +419,7 @@
</template>
</DeviceCard>
<DeviceCard
v-if="experimental"
:title="`${$t('config.remote.title')} 🧪`"
:title="$t('config.remote.title')"
editable
:unconfigured="isUnconfigured(remoteTags)"
data-testid="remote-access"
@ -525,6 +549,7 @@
<MessagingLegacyModal @changed="loadDirty" />
<MessagingModal :messengers="messengers" @changed="loadDirty" />
<MessengerModal @changed="messengerChanged" />
<CurtailerModal @changed="curtailerChanged" />
<TariffsLegacyModal @changed="loadDirty" />
<TariffModal
:currency="currency"
@ -579,6 +604,8 @@ 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";
@ -633,6 +660,7 @@ import type {
ConfigCharger,
ConfigVehicle,
ConfigCircuit,
ConfigCurtailer,
ConfigMessenger,
ConfigHems,
ConfigLoadpoint,
@ -691,6 +719,8 @@ export default defineComponent({
CircuitsModal,
CircuitTags,
ControlModal,
CurtailerModal,
CurtailIcon,
DeviceCard,
DeviceTags,
EebusIcon,
@ -749,6 +779,7 @@ export default defineComponent({
data() {
return {
messengers: [] as ConfigMessenger[],
curtailers: [] as ConfigCurtailer[],
vehicles: [] as ConfigVehicle[],
meters: [] as ConfigMeter[],
loadpoints: [] as ConfigLoadpoint[],
@ -772,6 +803,7 @@ 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: {
@ -781,6 +813,7 @@ export default defineComponent({
loadpoint: {},
messenger: {},
tariff: {},
curtailer: {},
} as DeviceValuesMap,
isComponentMounted: true,
isPageVisible: true,
@ -868,8 +901,10 @@ export default defineComponent({
{
slug: "grid",
icon: "shopicon-regular-powersupply",
count: this.gridMeter ? 1 : 0,
error: !!this.gridMeter && this.hasDeviceError("meter", this.gridMeter.name),
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)),
},
{
slug: "pv-battery",
@ -877,7 +912,7 @@ export default defineComponent({
count: pvAndBattery.length,
error: meterError(pvAndBattery),
warning:
this.pvMeters.some((m) => this.meterBanner(m.name)) ||
this.pvMeters.some((m) => this.curtailmentBanner("meter", m.name)) ||
meterDisabled(pvAndBattery),
},
{
@ -956,6 +991,12 @@ 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);
},
@ -1050,7 +1091,9 @@ export default defineComponent({
return result;
},
vehicleOptions(): VehicleOption[] {
return this.vehicles.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
return this.vehicles
.filter((v) => !v.deviceDisable)
.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
},
hems() {
return store.state?.hems;
@ -1291,6 +1334,7 @@ export default defineComponent({
await this.loadLoadpoints();
await this.loadCircuits();
await this.loadMessengers();
await this.loadCurtailers();
await this.loadTariffs();
await this.loadTariffRefs();
await this.loadHems();
@ -1311,6 +1355,9 @@ 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")) || [];
},
@ -1464,6 +1511,25 @@ 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();
},
@ -1504,6 +1570,7 @@ 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]) {
@ -1523,9 +1590,10 @@ export default defineComponent({
deviceTags(type: DeviceType, id: string) {
return this.deviceValues[type][id] || {};
},
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
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
? this.$t("config.deviceValue.productionLimited")
: undefined;
},

View file

@ -262,7 +262,7 @@
<input
v-model.number="logCount"
type="number"
class="form-control text-end log-count-input"
class="form-control text-end"
min="0"
step="25"
/>
@ -687,11 +687,6 @@ 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;

View file

@ -22,6 +22,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/evcc-io/evcc/api"
@ -367,21 +368,24 @@ 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 "", err
return nil, err
}
var res []string
// the idTag register holds the rfid uid as null-padded ascii string
if idTag := hycInput(b, hycRegIdTag, 20); !allZero(idTag) {
res = append(res, strings.ToLower(strings.TrimRight(string(idTag), "\x00")))
}
if vid := hycInput(b, hycRegVID, 8); !allZero(vid) {
return hex.EncodeToString(vid), nil
res = append(res, hex.EncodeToString(vid))
}
if idTag := hycInput(b, hycRegIdTag, 20); !allZero(idTag) {
return hex.EncodeToString(idTag), nil
}
return "", nil
return res, nil
}
var _ api.Battery = (*AlpitronicHYC)(nil)

View file

@ -3,7 +3,6 @@ package charger
import (
"context"
"net"
"strings"
"sync"
"testing"
"time"
@ -11,7 +10,6 @@ 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"
)
@ -218,7 +216,7 @@ func TestAlpitronicIdentify(t *testing.T) {
require.NoError(t, err)
assert.Empty(t, id)
// vehicle id takes precedence over the tag
// tag before vehicle id
regs := hycRegs(1, hycStateCharging)
regs[hycReg(1, hycRegVID)+1] = 0xAABB
regs[hycReg(1, hycRegVID)+2] = 0xCCDD
@ -229,9 +227,9 @@ func TestAlpitronicIdentify(t *testing.T) {
id, err = wb.Identify()
require.NoError(t, err)
assert.Equal(t, "0000aabbccddeeff", id)
assert.Equal(t, []string{"ab", "0000aabbccddeeff"}, id)
// tag as fallback
// tag only
regs = hycRegs(1, hycStateCharging)
regs[hycReg(1, hycRegIdTag)] = 0x4142
@ -239,7 +237,17 @@ func TestAlpitronicIdentify(t *testing.T) {
id, err = wb.Identify()
require.NoError(t, err)
assert.Equal(t, "4142"+strings.Repeat("0", 36), id)
assert.Equal(t, []string{"ab"}, id)
// vehicle id only
regs = hycRegs(1, hycStateCharging)
regs[hycReg(1, hycRegVID)+3] = 0xEEFF
wb, _ = hycTestCharger(t, 1, regs)
id, err = wb.Identify()
require.NoError(t, err)
assert.Equal(t, []string{"000000000000eeff"}, id)
}
func TestAlpitronicEnable(t *testing.T) {
@ -353,14 +361,3 @@ 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)
}

View file

@ -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 "", err
return nil, err
}
return hex.EncodeToString(b), nil
return []string{hex.EncodeToString(b)}, nil
}
var _ api.Diagnosis = (*Amperfied)(nil)

View file

@ -504,24 +504,33 @@ func (wb *BenderCC) getPhases() (int, error) {
}
// identify implements the api.Identifier interface
func (wb *BenderCC) identify() (string, error) {
func (wb *BenderCC) identify() ([]string, error) {
var ids []string
if !wb.legacy && !wb.mennekes4 {
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 != "" || err != nil {
return id, err
if id := bytesAsString(b); id != "" {
ids = append(ids, id)
}
}
b, err := wb.conn.ReadHoldingRegisters(bendRegUserID, 10)
if err != nil {
return "", err
return nil, err
}
return bytesAsString(b), nil
if id := bytesAsString(b); id != "" {
ids = append(ids, id)
}
return ids, nil
}
// soc implements the api.Battery interface

View file

@ -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 "", err
return nil, err
}
return bytesAsString(b), nil
return []string{bytesAsString(b)}, nil
}

View file

@ -118,7 +118,12 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.C
if err != nil {
return nil, fmt.Errorf("identify: %w", err)
}
implement.May(c, implement.Identifier(identify))
if identify != nil {
implement.Has(c, implement.Identifier(func() ([]string, error) {
id, err := identify()
return []string{id}, err
}))
}
// decorate wakeup
if cc.Wakeup != nil {

View file

@ -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 "", err
return nil, err
}
return bytesAsString(b), nil
return []string{bytesAsString(b)}, nil
}
var _ loadpoint.Controller = (*Compleo)(nil)

View file

@ -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 "", err
return nil, err
}
return bytesAsString(u), nil
return []string{bytesAsString(u)}, nil
}
var _ api.Diagnosis = (*Dadapower)(nil)

View file

@ -318,12 +318,14 @@ 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 "", err
return nil, err
}
return utf16BEBytesAsString(b)
id, err := utf16BEBytesAsString(b)
return []string{id}, err
}
// phases1p3p implements the api.PhaseSwitcher interface
@ -361,27 +363,37 @@ func (wb *DaheimLaden) getPhases() (int, error) {
}
func (wb *DaheimLaden) checkStation() error {
b, err := wb.conn.ReadHoldingRegisters(dlRegEvseMaxCurrent, 22)
if err != nil {
return api.ErrSponsorRequired
}
// station id starts (dlRegStationId-dlRegEvseMaxCurrent) registers into the block
s, err := utf16BEBytesAsString(b[2*(dlRegStationId-dlRegEvseMaxCurrent):])
if err != nil || s == "" {
return api.ErrSponsorRequired
}
// map may still be zero right after the wake-up call- poll briefly until populated
for range 5 {
b, err := wb.conn.ReadHoldingRegisters(dlRegEvseMaxCurrent, 22)
if err != nil {
return err
}
for _, r := range s {
if r < 0x20 || r > 0x7e {
s, err := utf16BEBytesAsString(b[2*(dlRegStationId-dlRegEvseMaxCurrent):])
if err != nil {
return err
}
if len(s) == 0 {
time.Sleep(200 * time.Millisecond)
continue
}
if strings.Contains(strings.ToLower(s), "heidelbridge") {
return api.ErrSponsorRequired
}
for _, r := range s {
if r < 0x20 || r > 0x7e {
return api.ErrSponsorRequired
}
}
return nil
}
if strings.Contains(strings.ToLower(s), "heidelbridge") {
return api.ErrSponsorRequired
}
return nil
return api.ErrSponsorRequired
}
var _ api.Diagnosis = (*DaheimLaden)(nil)

View file

@ -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 "", err
return nil, err
}
return bytesAsString(b), nil
return []string{bytesAsString(b)}, nil
}
var _ api.Diagnosis = (*Delta)(nil)

View file

@ -748,16 +748,17 @@ 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 "", err
return nil, err
}
return rscpString(msg)
id, err := rscpString(msg)
return []string{id}, err
}
var _ api.PhaseSwitcher = (*E3dc)(nil)

View file

@ -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 c.rfid, nil
return []string{c.rfid}, nil
}
// Set smart charging status to update the chargers led (smart=blue, fast=white)

View file

@ -520,19 +520,26 @@ 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
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
identification, err := c.cem.EvCC.Identifications(evEntity)
if err != nil {
return nil, nil
}
return "", nil
// may be multiple, e.g. MAC address and PCID
var res []string
for _, i := range identification {
if i.Value != "" {
res = append(res, i.Value)
}
}
return res, nil
}
var _ api.Battery = (*EEBus)(nil)

View file

@ -15,6 +15,7 @@ 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"
)
@ -27,6 +28,7 @@ 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
@ -58,10 +60,11 @@ 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
embed `mapstructure:",squash"`
Ski string
Ip string
Reboost time.Duration
DisablePowerLimiter bool
}{
embed: embed{
Icon_: "heatpump",
@ -74,18 +77,19 @@ 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)
return NewEEBusOHPCF(ctx, &cc.embed, cc.Ski, cc.Ip, cc.Reboost, cc.DisablePowerLimiter)
}
// 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) (api.Charger, error) {
func NewEEBusOHPCF(ctx context.Context, embed *embed, ski, ip string, reboost time.Duration, disablePowerLimiter bool) (api.Charger, error) {
inst, err := eebus.Instance()
if err != nil {
return nil, err
}
c := &EEBusOHPCF{
Caps: implement.New(),
embed: embed,
log: util.NewLogger("eebus-ohpcf"),
cem: inst.CustomerEnergyManagement(),
@ -96,6 +100,10 @@ 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
}
@ -460,11 +468,8 @@ func (c *EEBusOHPCF) apply(enable bool) error {
return nil
}
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) {
// getMinMaxPower is the callback used to provide the optional power limits.
func (c *EEBusOHPCF) getMinMaxPower() (float64, float64, error) {
entity, ok := c.connectedCompressor()
if !ok {
return 0, 0, errNotConnected

View file

@ -41,7 +41,6 @@ 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)
@ -49,7 +48,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
enovatesRegCurrentOffered = 417 // uint16 RO, mA, current actually offered to the vehicle
enovatesRegSerial = 5032 // 16x register RO, ASCII
enovatesRegModel = 5048 // 16x register RO, ASCII
enovatesRegFirmware = 5064 // 16x register RO, ASCII
@ -92,9 +91,9 @@ func NewEnovates(ctx context.Context, settings modbus.TcpSettings) (api.Charger,
current: 6000,
}
// verify connection and EMS register availability
if _, err := wb.conn.ReadHoldingRegisters(enovatesRegMode3StateStr, 2); err != nil {
return nil, err
// 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)
}
return wb, nil
@ -160,22 +159,13 @@ var _ api.CurrentGetter = (*Enovates)(nil)
// GetMaxCurrent implements the api.CurrentGetter interface
func (wb *Enovates) GetMaxCurrent() (float64, error) {
b, err := wb.conn.ReadHoldingRegisters(enovatesRegEMSLimit, 1)
// the EMS limit register may read -1 (no limit), hence use the actually offered current
b, err := wb.conn.ReadHoldingRegisters(enovatesRegCurrentOffered, 1)
if err != nil {
return 0, err
}
// -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
return float64(binary.BigEndian.Uint16(b)) / 1e3, nil
}
var _ api.Meter = (*Enovates)(nil)
@ -233,13 +223,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 "", err
return nil, err
}
return trimModbusString(b), nil
return []string{trimModbusString(b)}, nil
}
var _ api.Diagnosis = (*Enovates)(nil)

View file

@ -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 "", err
return nil, err
}
return status.AuthTag.Tag, nil
return []string{status.AuthTag.Tag}, nil
}

Some files were not shown because too many files have changed in this diff Show more