Repository: evcc-io/evcc
Pull request #14855 (2024-07-12 18:54:18)

Add battery grid charging
This PR adds charging from grid. Replaces https://github.com/evcc-io/evcc/pull/10814.

Battery mode is updated as follows:

SmartCost or FastCharge|GridCharge|Mode
-|-|-
-|-|Unverändert
x|-|Hold (falls Änderung)
(-)|x|Charge (falls Änderung)

- [x] add ui @naltatis 
- [x] add e2e tests @naltatis 

Out of scope

- [ ] Netzdienliche Steuerung berücksichtigen

naltatis: Erster Test (Sungrow SHR10):

Setzen, Entfernen via API klappt wie erwartet. Auch die Status-Änderungen in evcc passen.
Allerdings hat meine Batterie nicht wie erwartet geladen.

Auffällig ist, dass die Modus Änderung immer wieder gesendet wird:

```
[site  ] DEBUG 2024/07/13 18:38:22 set battery mode: charge
[modbus] TRACE 2024/07/13 18:38:22 modbus: send 00 58 00 00 00 06 01 04 13 98 00 02
[modbus] TRACE 2024/07/13 18:38:22 modbus: send 00 59 00 00 00 06 01 04 13 98 00 02
[modbus] TRACE 2024/07/13 18:38:22 modbus: recv 00 59 00 00 00 07 01 04 04 05 0f 00 00
[modbus] TRACE 2024/07/13 18:38:22 modbus: send 00 5a 00 00 00 06 01 04 32 ca 00 02
[modbus] TRACE 2024/07/13 18:38:22 modbus: recv 00 5a 00 00 00 07 01 04 04 87 54 00 00
[site  ] DEBUG 2024/07/13 18:38:22 pv power: 2950W
[modbus] TRACE 2024/07/13 18:38:22 modbus: send 00 5b 00 00 00 06 01 04 32 dd 00 01
[modbus] TRACE 2024/07/13 18:38:22 modbus: recv 00 5b 00 00 00 05 01 04 02 09 a2
[modbus] TRACE 2024/07/13 18:38:22 modbus: send 00 5c 00 00 00 06 01 04 32 c8 00 01
[modbus] TRACE 2024/07/13 18:38:22 modbus: recv 00 5c 00 00 00 05 01 04 02 00 a3
[modbus] TRACE 2024/07/13 18:38:22 modbus: send 00 5d 00 00 00 06 01 04 32 c8 00 01
[modbus] TRACE 2024/07/13 18:38:23 modbus: send 00 5e 00 00 00 06 01 04 32 dd 00 01
[modbus] TRACE 2024/07/13 18:38:23 modbus: recv 00 5e 00 00 00 05 01 04 02 09 a9
[modbus] TRACE 2024/07/13 18:38:23 modbus: send 00 5f 00 00 00 06 01 04 32 c8 00 01
[modbus] TRACE 2024/07/13 18:38:23 modbus: recv 00 5f 00 00 00 05 01 04 02 00 a3
[modbus] TRACE 2024/07/13 18:38:23 modbus: send 00 60 00 00 00 06 01 04 32 c8 00 01
[modbus] TRACE 2024/07/13 18:38:23 modbus: recv 00 60 00 00 00 05 01 04 02 00 a3
[modbus] TRACE 2024/07/13 18:38:23 modbus: send 00 61 00 00 00 06 01 04 32 e2 00 02
[modbus] TRACE 2024/07/13 18:38:23 modbus: recv 00 61 00 00 00 07 01 04 04 3c ba 00 00
[modbus] TRACE 2024/07/13 18:38:23 modbus: send 00 62 00 00 00 06 01 04 32 de 00 01
[modbus] TRACE 2024/07/13 18:38:23 modbus: recv 00 62 00 00 00 05 01 04 02 00 32
[site  ] DEBUG 2024/07/13 18:38:23 battery soc: 5%
[site  ] DEBUG 2024/07/13 18:38:23 battery power: -2473W
[modbus] TRACE 2024/07/13 18:38:23 modbus: send 00 63 00 00 00 06 01 04 32 d1 00 02
[modbus] TRACE 2024/07/13 18:38:23 modbus: recv 00 63 00 00 00 07 01 04 04 ff bb ff ff
[site  ] DEBUG 2024/07/13 18:38:23 grid meter: 69W
[modbus] TRACE 2024/07/13 18:38:23 modbus: send 00 64 00 00 00 06 01 04 32 ec 00 02
[modbus] TRACE 2024/07/13 18:38:23 modbus: recv 00 64 00 00 00 07 01 04 04 62 3b 00 00
[site  ] DEBUG 2024/07/13 18:38:23 site power: -2304W
[lp-1  ] DEBUG 2024/07/13 18:38:23 !! session: chargeRater.chargedEnergy=9.7 - chargedAtStartup=0.0
[lp-1  ] DEBUG 2024/07/13 18:38:23 charge total import: 18959.581kWh
[lp-1  ] DEBUG 2024/07/13 18:38:23 charger status: A
[site  ] DEBUG 2024/07/13 18:38:32 ----
[lp-1  ] DEBUG 2024/07/13 18:38:32 charge power: 0W
[lp-1  ] DEBUG 2024/07/13 18:38:32 charge currents: [0 0 0]A
[lp-2  ] DEBUG 2024/07/13 18:38:32 charge power: 0W
[lp-3  ] DEBUG 2024/07/13 18:38:32 charge power: 0W
[lp-3  ] DEBUG 2024/07/13 18:38:32 charge currents: [0 0 0]A
[lp-4  ] DEBUG 2024/07/13 18:38:32 charge power: 0W
[site  ] DEBUG 2024/07/13 18:38:32 set battery mode: charge
[modbus] TRACE 2024/07/13 18:38:32 modbus: send 00 65 00 00 00 06 01 04 13 98 00 02
[modbus] TRACE 2024/07/13 18:38:32 modbus: send 00 66 00 00 00 06 01 04 13 98 00 02
[modbus] TRACE 2024/07/13 18:38:32 modbus: recv 00 66 00 00 00 07 01 04 04 05 16 00 00
[modbus] TRACE 2024/07/13 18:38:32 modbus: send 00 67 00 00 00 06 01 04 32 ca 00 02
[modbus] TRACE 2024/07/13 18:38:32 modbus: recv 00 67 00 00 00 07 01 04 04 87 54 00 00
[site  ] DEBUG 2024/07/13 18:38:32 pv power: 2964W
[modbus] TRACE 2024/07/13 18:38:32 modbus: send 00 68 00 00 00 06 01 04 32 dd 00 01
[modbus] TRACE 2024/07/13 18:38:33 modbus: send 00 69 00 00 00 06 01 04 32 dd 00 01
[modbus] TRACE 2024/07/13 18:38:33 modbus: recv 00 69 00 00 00 05 01 04 02 09 c5
[modbus] TRACE 2024/07/13 18:38:33 modbus: send 00 6a 00 00 00 06 01 04 32 c8 00 01
[modbus] TRACE 2024/07/13 18:38:33 modbus: recv 00 6a 00 00 00 05 01 04 02 00 a3
[modbus] TRACE 2024/07/13 18:38:33 modbus: send 00 6b 00 00 00 06 01 04 32 c8 00 01
[modbus] TRACE 2024/07/13 18:38:33 modbus: recv 00 6b 00 00 00 05 01 04 02 00 a3
[modbus] TRACE 2024/07/13 18:38:33 modbus: send 00 6c 00 00 00 06 01 04 32 e2 00 02
[modbus] TRACE 2024/07/13 18:38:33 modbus: recv 00 6c 00 00 00 07 01 04 04 3c ba 00 00
[modbus] TRACE 2024/07/13 18:38:33 modbus: send 00 6d 00 00 00 06 01 04 32 de 00 01
[modbus] TRACE 2024/07/13 18:38:33 modbus: recv 00 6d 00 00 00 05 01 04 02 00 32
[site  ] DEBUG 2024/07/13 18:38:33 battery soc: 5%
[site  ] DEBUG 2024/07/13 18:38:33 battery power: -2501W
[modbus] TRACE 2024/07/13 18:38:33 modbus: send 00 6e 00 00 00 06 01 04 32 d1 00 02
[modbus] TRACE 2024/07/13 18:38:33 modbus: recv 00 6e 00 00 00 07 01 04 04 ff cb ff ff
[site  ] DEBUG 2024/07/13 18:38:33 grid meter: 53W
[modbus] TRACE 2024/07/13 18:38:33 modbus: send 00 6f 00 00 00 06 01 04 32 ec 00 02
[modbus] TRACE 2024/07/13 18:38:33 modbus: recv 00 6f 00 00 00 07 01 04 04 62 3b 00 00
[site  ] DEBUG 2024/07/13 18:38:33 site power: -2348W
[lp-2  ] DEBUG 2024/07/13 18:38:33 charge total import: 666.330kWh
[lp-2  ] DEBUG 2024/07/13 18:38:33 charger status: B
[lp-2  ] DEBUG 2024/07/13 18:38:33 !! active phases: 3p = min(0p measured 0p vehicle 3p physical 0p charger)
[lp-2  ] DEBUG 2024/07/13 18:38:33 pv charge current: 3.4A = 0A + 3.4A (-2348W @ 3p)
[site  ] DEBUG 2024/07/13 18:38:42 ----
[lp-1  ] DEBUG 2024/07/13 18:38:42 charge power: 0W
[lp-1  ] DEBUG 2024/07/13 18:38:42 charge currents: [0 0 0]A
[lp-2  ] DEBUG 2024/07/13 18:38:42 charge power: 0W
[lp-3  ] DEBUG 2024/07/13 18:38:42 charge power: 0W
[lp-3  ] DEBUG 2024/07/13 18:38:42 charge currents: [0 0 0]A
[lp-4  ] DEBUG 2024/07/13 18:38:42 charge power: 0W
[site  ] DEBUG 2024/07/13 18:38:42 set battery mode: charge
[modbus] TRACE 2024/07/13 18:38:42 modbus: send 00 70 00 00 00 06 01 04 13 98 00 02
[modbus] TRACE 2024/07/13 18:38:42 modbus: send 00 71 00 00 00 06 01 04 13 98 00 02
[modbus] TRACE 2024/07/13 18:38:42 modbus: recv 00 71 00 00 00 07 01 04 04 05 22 00 00
[modbus] TRACE 2024/07/13 18:38:42 modbus: send 00 72 00 00 00 06 01 04 32 ca 00 02
[modbus] TRACE 2024/07/13 18:38:42 modbus: recv 00 72 00 00 00 07 01 04 04 87 54 00 00
[site  ] DEBUG 2024/07/13 18:38:43 pv power: 2986W
[modbus] TRACE 2024/07/13 18:38:43 modbus: send 00 73 00 00 00 06 01 04 32 dd 00 01
^C[modbus] TRACE 2024/07/13 18:38:43 modbus: send 00 74 00 00 00 06 01 06 32 f9 00 00
[modbus] TRACE 2024/07/13 18:38:43 modbus: recv 00 74 00 00 00 06 01 06 32 f9 00 00
[modbus] TRACE 2024/07/13 18:38:43 modbus: send 00 75 00 00 00 06 01 06 32 fa 00 cc
[modbus] TRACE 2024/07/13 18:38:43 modbus: recv 00 75 00 00 00 06 01 06 32 fa 00 cc
[modbus] TRACE 2024/07/13 18:38:43 modbus: send 00 76 00 00 00 06 01 04 32 dd 00 01
[modbus] TRACE 2024/07/13 18:38:43 modbus: recv 00 76 00 00 00 05 01 04 02 09 df
[modbus] TRACE 2024/07/13 18:38:43 modbus: send 00 77 00 00 00 06 01 04 32 c8 00 01
[modbus] TRACE 2024/07/13 18:38:43 modbus: recv 00 77 00 00 00 05 01 04 02 00 a3
[modbus] TRACE 2024/07/13 18:38:43 modbus: send 00 78 00 00 00 06 01 04 32 c8 00 01
[modbus] TRACE 2024/07/13 18:38:43 modbus: recv 00 78 00 00 00 05 01 04 02 00 a3
[modbus] TRACE 2024/07/13 18:38:43 modbus: send 00 79 00 00 00 06 01 04 32 e2 00 02
[modbus] TRACE 2024/07/13 18:38:43 modbus: recv 00 79 00 00 00 07 01 04 04 3c ba 00 00
[modbus] TRACE 2024/07/13 18:38:43 modbus: send 00 7a 00 00 00 06 01 04 32 de 00 01
[modbus] TRACE 2024/07/13 18:38:43 modbus: recv 00 7a 00 00 00 05 01 04 02 00 32
[site  ] DEBUG 2024/07/13 18:38:43 battery soc: 5%
```


Beim Logging passt das mit den Pointern noch nicht:

```
[site  ] DEBUG 2024/07/13 18:37:19 set smart cost limit: 0x140021addb0
```
naltatis: Dass die Batterie nicht lädt liegt vmtl. an der batterycontrol Implementierung bei Sungrow. Ich hab gerade den Issue/Discussion nicht spontan gefunden. Aber ich meine wir hatten da noch ein offenes Thema, dass neben dem Modus auch noch die Max-Ladeleistung (die sich resettet) immer gesetzt werden muss. Bin mir nicht sicher, wie das damals in der Diskussion ausgegangen ist. \\cc @premultiply 
andig: > Dass die Batterie nicht lädt liegt vmtl. an der batterycontrol Implementierung bei Sungrow.

Die ganze BatteryMode Logik passt noch nicht.
andig: @naltatis komplett neuer Test notwendig. Bisher wurde der Modus gar nicht auf die Batterie geschrieben. Die etwas verwobene Logik ist jetzt komplett entzerrt. `smartCostNextActive`- da für Site unerheblich- wandert komplett in den Loadpoint.
GrimmiMeloni: @andig ich könnte das mit der Powerwall mal lokal testen, sobald der Branch wieder grün ist.
andig: Der sollte laufen, die Tests haben einen generellen Fehler. 
naltatis: **Battery settings**

- 🧪 experimental only
- wider modal when feature is available

small

![settings small](https://github.com/user-attachments/assets/451b156d-878b-4cf6-8e0a-c268d0e5d7ef)

medium

![settings medium](https://github.com/user-attachments/assets/cedc60c8-6549-41ac-ba9c-49eb1180fc96)

large

![settings large](https://github.com/user-attachments/assets/e646a89f-4d39-4104-8b4c-f5d959b717b6)

**Grid charge active**

medium

![active tablet](https://github.com/user-attachments/assets/6d68fa8f-38f9-4312-9547-683078276285)

large

![active desktop](https://github.com/user-attachments/assets/b34d5e6f-0e3e-4106-86f7-355815b6f686)

andig: Vermischen wir in der UI jetzt nicht Themen? Einmal Verwendung der Batterieladung (aus Solar) und andererseits Herstellung der Batterieladung (aus dem Netz). Da der Dialog ohnehin schon sehr komplex ist- wäre es möglich, dass wir das auf zwei Tabs (oder 2 Dialoge aufteilen)?
naltatis: Inhaltlich ist das da schon richtig. Das Setting betrifft ja rein die Hausbatterie. Ich bin bei dir, dass wir da jetzt einige unterschiedliche Teilthemen im gleichen Dialog spielen (Überschussladeverhalten/Priorität, Entladesperre, Netzladen). Mehrere Batteriedialoge find ich schwierig. Würdest du die Entladesperre dann auch rausziehen? Thematisch passt die ganz gut zum Priotitätenthema oben, technisch (batterymode) hat die mehr mit dem neuen Feature gemein. Wie würdest du die Dialoge dann nennen? Tabs probiere ich generell eher nicht zu verwenden. Im Plan-Dialog gibts die aktuell (noch), ist aber aus Discoverabilitysicht deutlich schlechter als eine scrollbare Liste mit allen Optionen. Merken wir ja im Feedback auch immer wieder. Im Config-UI gibts aus dem Grund auch keine Tabs, dafür längere Dialoge mit Zwischenüberschriften. Auf großen Screens hat man dann alles im Blick und auf mobil kann gut gescrollt werden.
andig: Mhhm. Spätestens large clean grid wirkt sehr unruhig und unübersichtlich. Da würden mir Tabs besser gefallen (Batterieverwendung vs. Batterieladung oder Solar vs. Grid).
naltatis: > large clean grid wirkt sehr unruhig 

Ja, bin ich bei dir, gerade weil der obere Content die zusätzliche Breite nicht braucht bzw. sie aktuell noch nicht gut nutzt. Den Dialog in der Breite zu beschränken (wie medium) würde es harmonischer machen, aber gerade auf großen Screens weniger funktional. Bei den Loadpoint-Settings hatten wir damals auch relativ schnell den Nutzerwunsch, möglichst viel der Preis-Vorhersage immer anzuzeigen.

Ich mach mir noch mal Gedanken zu großen Breakpoint, wie wir den Platz besser aufgeteilt bekommen.
andig: Vorschlag: Tabs ;). In meinem Kopf sind das zwei unterschiedliche Dinge die man auch separat voneinander betrachten kann.
naltatis: Hier ein Entwurf, der auch das Problem adressiert, dass die Buffer SoC und Priorität SoC Einstellungen heute in der UI zu erklärungsbedürftig sind und Nutzer oft nicht sofort herausfinden, dass sie auf die Prozentzahlen an der Batterie klicken müssen. Also etwas ausführlichere Beschreibungstexte an Lade/Auto/Haus-Icon.

Die Funktionen sind jetzt in drei Bereiche gruppiert:

- Solar Charging
- Battery State
- [Cheap|Clean] Grid Charging

![Bildschirmfoto 2024-07-22 um 19 34 50](https://github.com/user-attachments/assets/e798d8b9-3cc2-48af-aadc-4de191fbdc68)

![Bildschirmfoto 2024-07-22 um 19 34 55](https://github.com/user-attachments/assets/8bb10fec-5794-43ae-b232-6b60c1927f5c)

@andig Zu den Tabs: Siehe Argument im Kommentar darüber.

Der Stand ist jetzt erst mal lokal und relativ roh gebaut. Noch nicht committet. Bringt aber mMn. deutlich mehr Struktur in den Dialog im Vergleich zu der Version darüber.

\\cc @premultiply @GrimmiMeloni @VolkerK62 
GrimmiMeloni: Den Modal finde ich in der letzten Version sehr gut. Was für mich nicht ersichtlich wird, ist wie die Navigation in dieses Modal verbessert wurde. @naltatis ?
naltatis: Das Öffnen des Modals bleibt bis bisher über den Hausbatterie-Eintrag in der Hauptnavigation (oder die Prozentzahl in in den ausgeklappten Details.

Was ich mit meinem Kommentar meinte, ist das Verändern der Buffer und Priority Grenze. Da habe ich die Beschreibungstexte ausführlicher gemacht und das verändern der Grenzwerte direkt dort auch erlaubt (bsp. "Prioitize vehicle charging when home battery is above **25%**"). 
andig: Sieht aufgeräumter aus, aber immer noch flächig. Ich fände es immer noch gut wenn wir Tabs wenigstens mal ausprobieren könnten.
naltatis: Ich hab jetzt noch mal nen Entwurf mit Tabs gemacht. Ja, einzelnen Zustände sehen jetzt natürlich aufgeräumter aus. Das geht aber zulasten von Überblick und Auffindbarkeit in der Benutzung. Das Ändern des Preislimits braucht immer 'nen Klick mehr und auf großen Screens nutzen wir den Platz nicht aus. Mein Favorit ist immer noch die "alles auf einen Blick bzw. in einer Liste" Variante.

**large**

<img width="1115" alt="large solar" src="https://github.com/user-attachments/assets/d9ba585c-bdbf-43b0-b0fc-de7ede38dd91">

<img width="1115" alt="large grid" src="https://github.com/user-attachments/assets/9d4a09e9-253a-412c-ab8f-3508036d3ac1">

**medium**

<img width="897" alt="medium grid" src="https://github.com/user-attachments/assets/e85436dc-bd8a-49e6-b8a2-a711efdc324a">

<img width="897" alt="medium solar" src="https://github.com/user-attachments/assets/8a1f7b5a-dfa9-4404-9b7e-e6f3c2450aa1">

**mobil**

<img width="366" alt="mobile solar" src="https://github.com/user-attachments/assets/fb5dc42e-e57c-4267-9e90-beaf393b8ed6">

<img width="368" alt="mobile grid" src="https://github.com/user-attachments/assets/8063258f-7e3f-44f3-adb1-88282185d8ae">

andig: Ich finde die Tabs ganz gut. Jetzt müssen wir uns entscheiden. Würde bei Solar auf Big Screen aber wieder beide Blöcke nebeneinander anordnen?
GrimmiMeloni: Optisch ist beides Ok. Ich würde @naltatis die Entscheidung überlassen, bzw. seinen Vorschlag unterstützen. Er ist der UI / UX lead. 
naltatis: > Würde bei Solar auf Big Screen aber wieder beide Blöcke nebeneinander anordnen?

Ja, falschen Screenshot gepostet. So sähe das aus:

<img width="1115" alt="large solar" src="https://github.com/user-attachments/assets/06fac36b-a918-4848-b8e3-7905d9d57f63">

andig: Die aktuelle Lösung wirkt auf mich stimmiger bzgl. Platznutzung:

<img width="517" alt="Screenshot 2024-07-24 at 12 53 13" src="https://github.com/user-attachments/assets/b7b205cb-d669-4552-85db-26b26535ba44">

Muß der Batteriestand- auch wenn es common Denominator ist- außerhalb der Tabs stehen?

Können die Tabs unterschiedliche Größe haben an die der Dialog sich anpassen würde?
andig: Wir müssen berücksichtigen, dass auch der Bezug des Speichers unter netzdienliche Steuerung fällt. Im Zweifel dürften wir den Speicher also _nicht_ laden falls ein Limit aktiv ist (...und wir die Ladeleistung des Speichers nicht kennen). TODO ergänzt.

naltatis: > Im Zweifel dürften wir den Speicher also nicht laden falls ein Limit aktiv ist

Dir gehts hier um SteuVE und LM, richtig? Wollen wir das zu nem separaten Issue rausziehen, um das hier erstmal unter die Leute (und vertestet) zu bekommen.
xerion3800: Hallo zusammen, habe gerade das Issue gefunden. Finde ich gut das ihr das angeht. Aktuell habe ich das außerhalb von evcc gelöst. Wenn ich das richtig gesehen habe, dann wollt ihr mit diesem PR erstmal "nur" das Laden implementieren.  Habt ihr auch vor, das man eine Preisschwelle definieren kann, wann wieder entladen wird. Also bei den günstigsten Strompreisen laden bei den teuersten Slots entladen.
andig: Das würde eine Prognose des Bedarfes erfordern- aktuell nicht vorhergesehen. Wir kennen nur die Preise.
naltatis: Hier eine Variante, die die Eigenschaften von davor zusammenzieht. Die Beschreibungen für priority und buffer sind überarbeitet und klarer. Die Tab-Höhen auf großem Bildschirm sind identisch (kann je nach Sprache aber abweichen). 

🖥️ Large

![desktop usage](https://github.com/user-attachments/assets/979f7eda-1c95-444e-b637-b0affba4ded4)

![desktop grid](https://github.com/user-attachments/assets/ab4fd224-918f-4282-b41c-3d0a860f1823)

💻 Medium

![tablet  usage](https://github.com/user-attachments/assets/a82ad558-8bdd-4bda-8ab7-7c2884c8c098)

![tablet grid](https://github.com/user-attachments/assets/aae86b73-ab71-4bab-9610-f9221cccd6c4)

📱 Small

![mobile usage](https://github.com/user-attachments/assets/7169d695-3ed7-4908-8f50-1320a5b1e05b)

![mobile grid](https://github.com/user-attachments/assets/3ab4a299-cc9c-4b7a-9e60-f3a208f2d0c2)

🇩🇪 Deutsche Texte

![texte grid deutsch](https://github.com/user-attachments/assets/03d1853e-33fc-4ca4-a3d3-6559c8204a4e)

![texte usage deutsch](https://github.com/user-attachments/assets/b64037a4-8f7b-46f4-94da-a573f28e1242)

![status deutsch](https://github.com/user-attachments/assets/55773b29-bd71-4407-b651-097df930fb14)

naltatis: Wenn Netzladen nicht möglich ist (kein dynamischer Tarif, steuerbarer Speicher oder experimental) ⤵︎

![Bildschirmfoto 2024-07-31 um 18 14 22](https://github.com/user-attachments/assets/8dc794d0-3865-419c-8133-30913c6cef0a)

xerion3800: > Das würde eine Prognose des Bedarfes erfordern- aktuell nicht vorhergesehen. Wir kennen nur die Preise.

Soweit würde ich für den Anfang gar nicht gehen. Der Bedarf wäre für das Entladen erstmal m.E. uninteressant. Wenn der Speicher in den günstigen Slots geladen wurde. Muss dieser danach auf Hold gesetzt werden. So wie man dann eine Preisgrenze für das Laden hat, wann unterhalb der Preisgrenze geladen wird benötigt man eine Preisgrenze ab wann wieder oberhalb der Grenze  entladen werden darf. Das könnte man schon mit den heutigen Modis der Speichersperre doch nutzen, oder liege ich da falsch?
Was würde denn heute passieren wenn der Speicher geladen wird. Wird der danach dann wieder sofort zum Entladen freigegen oder was ist dort geplant?
naltatis: > Was würde denn heute passieren wenn der Speicher geladen wird. Wird der danach dann wieder sofort zum Entladen freigegen oder was ist dort geplant?

Ja, das ist der Modus, um den es hier geht. Eine weitere preisbasierte Entladesperre würden wir hier nicht sehen. Ist eher ein Thema für ne separate Diskussion.
UDicke: Ich bin begeistert von evcc!! 💯 🥇 
Und jetzt freue ich mich auf das "battery grid charging".
Das was ich hier als Benutzeroberfläche sehe gefällt mir sehr gut!

Ich vermisse allerdings den "Ladeplan". Ich hatte das so verstanden, dass über den "Plan" und die zu ladende Kapazität das Ladefenster ermittelt wird. Das würde ich dann gerne nicht nur für die Fahrzeuge sondern auch für die Hausbatterie nutzen wollen im Winter und mit dynamischen Tarifen.

Gerne würde ich irgendwie einen Beitrag leisten. Ich habe einen SAJ-H2 bzw. Ampere.Storage.Pro Speicher. Ich habe den "von Hand" über Modbus schon mal dazu gebracht aus dem Netz zu laden. Doku und listing von notwendigen Registern habe ich. Testen würde ich gerne wenn ich weiß wie.
  
xerion3800: > > Was würde denn heute passieren wenn der Speicher geladen wird. Wird der danach dann wieder sofort zum Entladen freigegen oder was ist dort geplant?
> 
> Ja, das ist der Modus, um den es hier geht. Eine weitere preisbasierte Entladesperre würden wir hier nicht sehen. Ist eher ein Thema für ne separate Diskussion.

Ich hoffe ich habe dich richtig verstanden. In diesem Modus der aktuell geplant ist, setze ich eine Preisschwelle von z.B. 20 Cent. Dann werden alle Preisslots die kleiner 20 Cent sind zum laden des Speicher benutzt. Und sobald die Schwelle größer 20 Cent ist also z.B. 20,1 Cent dann wird sofort wieder entladen solange der Preis oberhalb der 20 Cent bleibt? Habe ich das richtig verstanden?
UDicke: @xerion3800, es gibt auch noch die Option"Plan" bei den Autos:
![grafik](https://github.com/user-attachments/assets/686033ee-aefd-4ff5-81b8-1e015933fcbf)
Damit sucht evcc selbstständig das optimale Ladefenster aus:
![grafik](https://github.com/user-attachments/assets/98afbd55-fbc4-4467-998c-15ee816bd85b)
Ich habe noch keinen dynamischen Tarif und habe zum testen einfach mal awattar konfiguriert. Ich sehe nur den reinen Börsenpreis.

Diese Option finde ich sehr interessant auch für die Hausbatterie.
xerion3800: Ja das kenne ich aber auch das würde ja nur die das laden gelten.

UDicke: Klar, finde ich habe deutlich flexibler bzw. effektiver als eine manuell zu setzende Preisgrenze.

naltatis: Ja, hier gehts nur um die Ladegrenze nach Preis. Das ist erst mal das Einfachste für den Start in das Thema. Externe Automatisierung über API ist damit auch ermöglicht. Mehr Komfort und Intelligenz kommt dann später. Ist aber eher Discussion und nicht Teil dieses PRs.
UDicke: Ok, nehme ich mal zur Kenntnis, auch wenn ich nicht verstehe wo der Unterschied zwischen dem Laden eines Autos und einer Hausbatterie ist. Wenn doch der Code für die Kalkulation der optimalen Ladezeit bei bekanntem Ladebedarf fertig ist, warum kann das nicht genutzt werden?
naltatis: > Wenn doch der Code für die Kalkulation der optimalen Ladezeit bei bekanntem Ladebedarf fertig ist

@UDicke das ist bei der Hausbatterie ne andere Nummer. Durch Nutzung kann sich der SoC nach unten bewegen. Beim Fahrzeug geht der idR. nur hoch so lange es angesteckt ist. Außerdem kennen wir die Ladeleistung und Ladekurven der Hausbatterien nicht und müssen auch komplexere Szenarien (mehrere Hausbatterien, nur teilweise steuerbar) bedenken. Daher hier erst mal "nur" die einfache Preislogik. Mehr dann in weiteren Schritten.

Patch:
diff --git a/api/rates.go b/api/rates.go
index afe05fd3..578a59aa 100644
--- a/api/rates.go
+++ b/api/rates.go
@@ -31,7 +31,7 @@ func (r Rates) Sort() {
 // Current returns the rates current rate or error
 func (r Rates) Current(now time.Time) (Rate, error) {
 	for _, rr := range r {
-		if (rr.Start.Before(now) || rr.Start.Equal(now)) && rr.End.After(now) {
+		if !rr.Start.After(now) && rr.End.After(now) {
 			return rr, nil
 		}
 	}
diff --git a/assets/css/app.css b/assets/css/app.css
index f7880b5e..4c7a5265 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -184,6 +184,13 @@ a:hover {
 	--bs-btn-border-width: 2px;
 }
 
+.btn-reset {
+	border: none;
+	background: none;
+	padding: 0;
+	color: inherit;
+}
+
 .btn-primary,
 .btn-primary:focus {
 	background-color: var(--bs-primary);
diff --git a/assets/js/components/BatterySettingsModal.vue b/assets/js/components/BatterySettingsModal.vue
index 9fa382ba..a5554035 100644
--- a/assets/js/components/BatterySettingsModal.vue
+++ b/assets/js/components/BatterySettingsModal.vue
@@ -1,252 +1,270 @@
 <template>
-	<Teleport to="body">
-		<div
-			id="batterySettingsModal"
-			ref="modal"
-			class="modal fade text-dark modal-l"
-			data-bs-backdrop="true"
-			tabindex="-1"
-			role="dialog"
-			aria-hidden="true"
-		>
-			<div class="modal-dialog modal-dialog-centered" role="document">
-				<div class="modal-content">
-					<div class="modal-header">
-						<h5 class="modal-title">
-							{{ $t("batterySettings.modalTitle") }}
-						</h5>
-						<button
-							type="button"
-							class="btn-close"
-							data-bs-dismiss="modal"
-							aria-label="Close"
-						></button>
+	<GenericModal
+		id="batterySettingsModal"
+		:title="$t('batterySettings.modalTitle')"
+		size="xl"
+		data-testid="battery-settings-modal"
+		@open="modalVisible"
+		@closed="modalInvisible"
+	>
+		<ul v-if="gridChargePossible" class="nav nav-tabs mb-4">
+			<li class="nav-item">
+				<a
+					class="nav-link"
+					:class="{ active: usageTabActive }"
+					href="#"
+					@click.prevent="showUsageTab"
+				>
+					{{ $t("batterySettings.usageTab") }}
+				</a>
+			</li>
+			<li class="nav-item">
+				<a
+					class="nav-link"
+					:class="{ active: gridTabActive }"
+					href="#"
+					@click.prevent="showGridTab"
+				>
+					{{ $t("batterySettings.gridChargeTab") }} 🧪
+				</a>
+			</li>
+		</ul>
+
+		<div class="row" v-show="usageTabActive">
+			<p class="text-center text-md-start col-md-6 order-md-2 col-lg-3 order-lg-3 pt-lg-2">
+				{{ $t("batterySettings.batteryLevel") }}:
+				<strong>{{ fmtSoc(batterySoc) }}</strong>
+				<small v-for="(line, index) in batteryDetails" :key="index" class="d-block">
+					{{ line }}
+				</small>
+			</p>
+			<div
+				class="col-md-6 order-md-1 col-lg-3 order-lg-2 mb-5 mb-lg-0 battery justify-content-center justify-content-md-start"
+			>
+				<div class="batteryLimits">
+					<CustomSelect
+						id="batterySettingsBuffer"
+						:options="bufferOptions"
+						:selected="selectedBufferSoc"
+						class="bufferSoc p-2 end-0"
+						:class="{
+							'bufferSoc--hidden': selectedBufferSoc === selectedPrioritySoc,
+						}"
+						:style="{ top: `${topHeight}%` }"
+						@change="changeBufferSoc"
+					>
+						<span class="text-decoration-underline text-nowrap pe-none">
+							{{ fmtSoc(selectedBufferSoc) }}
+						</span>
+					</CustomSelect>
+
+					<CustomSelect
+						id="batterySettingsPriority"
+						:options="priorityOptions"
+						:selected="selectedPrioritySoc"
+						class="prioritySoc p-2 end-0"
+						:style="{ top: `${100 - bottomHeight}%` }"
+						@change="changePrioritySoc"
+					>
+						<span class="text-decoration-underline text-nowrap pe-none">
+							{{ fmtSoc(selectedPrioritySoc) }}
+						</span>
+					</CustomSelect>
+				</div>
+				<div class="progress me-md-0">
+					<div
+						class="bg-dark-green progress-bar text-light align-items-center"
+						role="button"
+						:style="{ height: `${topHeight}%` }"
+						@click="toggleBufferStart"
+					>
+						<shopicon-regular-lightning
+							size="m"
+							class="icon"
+							:style="iconStyle(topHeight)"
+						></shopicon-regular-lightning>
+					</div>
+					<div
+						class="bg-darker-green progress-bar text-light align-items-center"
+						:style="{ height: `${middleHeight}%` }"
+					>
+						<shopicon-regular-car3
+							size="m"
+							class="icon"
+							:style="iconStyle(middleHeight)"
+						></shopicon-regular-car3>
 					</div>
-					<div class="modal-body">
-						<div
-							class="d-flex justify-content-between align-items-center align-items-sm-start pb-2 flex-column flex-sm-row-reverse"
+					<div
+						class="bg-darkest-green progress-bar text-light align-items-center"
+						:style="{ height: `${bottomHeight}%` }"
+					>
+						<shopicon-regular-home
+							size="m"
+							class="icon"
+							:style="iconStyle(bottomHeight)"
+						></shopicon-regular-home>
+					</div>
+					<div
+						class="batterySoc ps-0 bg-white pe-none"
+						:style="{ top: `${100 - batterySoc}%` }"
+					></div>
+					<div
+						class="bufferStartIndicator pe-none"
+						:class="{
+							'bufferStartIndicator--hidden':
+								!selectedBufferStartSoc || selectedBufferSoc === 100,
+						}"
+						:style="{ top: `${bufferStartTop}%` }"
+					>
+						<div class="bufferStartIndicator__left"></div>
+						<div class="bufferStartIndicator__right"></div>
+					</div>
+				</div>
+			</div>
+			<div class="col-md-12 order-md-3 col-lg-6 order-lg-1 legend pt-lg-2">
+				<p class="d-flex">
+					<shopicon-regular-lightning
+						size="s"
+						class="flex-shrink-0 me-2"
+					></shopicon-regular-lightning>
+					<span class="d-block">
+						{{ $t("batterySettings.legendTopName") }}
+						<i18n-t
+							keypath="batterySettings.legendTopSubline"
+							tag="small"
+							class="d-block"
+							scope="global"
 						>
-							<div class="battery mb-5 mb-sm-3 me-5 me-sm-0 w-sm-50">
-								<div class="batteryLimits">
-									<label
-										class="bufferSoc p-2 end-0"
-										role="button"
-										:class="{
-											'bufferSoc--hidden':
-												selectedBufferSoc === selectedPrioritySoc,
-										}"
-										:style="{ top: `${topHeight}%` }"
-									>
-										<select
-											:value="selectedBufferSoc"
-											class="custom-select"
-											@change="changeBufferSoc"
-										>
-											<option
-												v-for="{ value, name, disabled } in bufferOptions"
-												:key="value"
-												:value="value"
-												:disabled="disabled"
-											>
-												{{ name }}
-											</option>
-										</select>
-										<span class="text-decoration-underline text-nowrap pe-none">
-											{{ fmtSoc(selectedBufferSoc) }}
-										</span>
-									</label>
-									<label
-										class="prioritySoc p-2 end-0"
-										role="button"
-										:style="{ top: `${100 - bottomHeight}%` }"
-									>
-										<select
-											:value="selectedPrioritySoc"
-											class="custom-select"
-											@change="changePrioritySoc"
-										>
-											<option
-												v-for="{ value, name, disabled } in priorityOptions"
-												:key="value"
-												:value="value"
-												:disabled="disabled"
-											>
-												{{ name }}
-											</option>
-										</select>
-										<span class="text-decoration-underline text-nowrap pe-none">
-											{{ fmtSoc(selectedPrioritySoc) }}
-										</span>
-									</label>
-								</div>
-								<div class="progress">
-									<div
-										class="bg-dark-green progress-bar text-light align-items-center"
-										role="button"
-										:style="{ height: `${topHeight}%` }"
-										@click="toggleBufferStart"
-									>
-										<shopicon-regular-lightning
-											size="m"
-											class="icon"
-											:style="iconStyle(topHeight)"
-										></shopicon-regular-lightning>
-									</div>
-									<div
-										class="bg-darker-green progress-bar text-light align-items-center"
-										:style="{ height: `${middleHeight}%` }"
-									>
-										<shopicon-regular-car3
-											size="m"
-											class="icon"
-											:style="iconStyle(middleHeight)"
-										></shopicon-regular-car3>
-									</div>
-									<div
-										class="bg-darkest-green progress-bar text-light align-items-center"
-										:style="{ height: `${bottomHeight}%` }"
-									>
-										<shopicon-regular-home
-											size="m"
-											class="icon"
-											:style="iconStyle(bottomHeight)"
-										></shopicon-regular-home>
-									</div>
-									<div
-										class="batterySoc ps-0 bg-white pe-none"
-										:style="{ top: `${100 - batterySoc}%` }"
-									></div>
-									<div
-										class="bufferStartIndicator pe-none"
-										:class="{
-											'bufferStartIndicator--hidden':
-												!selectedBufferStartSoc ||
-												selectedBufferSoc === 100,
-										}"
-										:style="{ top: `${bufferStartTop}%` }"
-									>
-										<div class="bufferStartIndicator__left"></div>
-										<div class="bufferStartIndicator__right"></div>
-									</div>
-								</div>
-							</div>
-							<div class="legend me-sm-4 align-self-start w-sm-50">
-								<p>
-									{{ $t("batterySettings.batteryLevel") }}:
-									<strong>{{ fmtSoc(batterySoc) }}</strong>
-									<small
-										v-for="(line, index) in batteryDetails"
-										:key="index"
-										class="d-block"
-									>
-										{{ line }}
-									</small>
-								</p>
-								<p>{{ $t("batterySettings.legendTitle") }}</p>
-								<p class="d-flex">
-									<shopicon-regular-lightning
-										size="s"
-										class="flex-shrink-0 me-2"
-									></shopicon-regular-lightning>
-									<span class="d-block">
-										{{ $t("batterySettings.legendTopName") }}
-										<small v-if="selectedBufferSoc == 100" class="d-block">
-											{{ $t("batterySettings.legendTopSubline") }}
-										</small>
-										<small v-else class="d-block">
-											{{ $t("batterySettings.legendTopAutostart") }}
-											<label
-												for="bufferStartSelect"
-												class="position-relative d-block"
-											>
-												<select
-													id="bufferStartSelect"
-													class="custom-select"
-													:value="selectedBufferStartSoc"
-													@change="changeBufferStart"
-												>
-													<option
-														v-for="option in bufferStartOptions"
-														:key="option.value"
-														:value="option.value"
-													>
-														{{ option.name }}
-													</option>
-												</select>
-												<span class="text-decoration-underline">
-													{{ bufferStartOption.name }}
-												</span>
-											</label>
-										</small>
+							<template #soc>
+								<CustomSelect
+									id="batterySettingsBufferTop"
+									class="custom-select-inline"
+									:options="bufferOptions"
+									:selected="selectedBufferSoc"
+									@change="changeBufferSoc"
+								>
+									<span class="text-decoration-underline">
+										{{ fmtSoc(selectedBufferSoc) }}
 									</span>
-								</p>
-								<p class="d-flex">
-									<shopicon-regular-car3
-										size="s"
-										class="flex-shrink-0 me-2"
-									></shopicon-regular-car3>
-									<span class="d-block">
-										{{ $t("batterySettings.legendMiddleName") }}
-										<small class="d-block">
-											{{ $t("batterySettings.legendMiddleSubline") }}
-										</small>
+								</CustomSelect>
+							</template>
+						</i18n-t>
+
+						<small class="d-block">
+							{{ $t("batterySettings.legendTopAutostart") }}
+							<CustomSelect
+								id="batterySettingsBufferStart"
+								class="custom-select-inline"
+								:selected="selectedBufferStartSoc"
+								:options="bufferStartOptions"
+								@change="changeBufferStart"
+							>
+								<span class="text-decoration-underline">
+									{{ bufferStartOption.name }}
+								</span>
+							</CustomSelect>
+						</small>
+					</span>
+				</p>
+				<p class="d-flex">
+					<shopicon-regular-car3
+						size="s"
+						class="flex-shrink-0 me-2"
+					></shopicon-regular-car3>
+					<span class="d-block">
+						{{ $t("batterySettings.legendMiddleName") }}
+						<i18n-t
+							keypath="batterySettings.legendMiddleSubline"
+							tag="small"
+							class="d-block"
+							scope="global"
+						>
+							<template #soc>
+								<CustomSelect
+									id="batterySettingsPriorityMiddle"
+									class="custom-select-inline"
+									:options="priorityOptions"
+									:selected="selectedPrioritySoc"
+									@change="changePrioritySoc"
+								>
+									<span class="text-decoration-underline">
+										{{ fmtSoc(selectedPrioritySoc) }}
 									</span>
-								</p>
-								<p class="d-flex">
-									<shopicon-regular-home
-										size="s"
-										class="flex-shrink-0 me-2"
-									></shopicon-regular-home>
-									<span class="d-block">
-										{{ $t("batterySettings.legendBottomName") }}
-										<small class="d-block">
-											{{ $t("batterySettings.legendBottomSubline") }}
-										</small>
+								</CustomSelect>
+							</template>
+						</i18n-t>
+					</span>
+				</p>
+				<p class="d-flex">
+					<shopicon-regular-home
+						size="s"
+						class="flex-shrink-0 me-2"
+					></shopicon-regular-home>
+					<span class="d-block">
+						{{ $t("batterySettings.legendBottomName") }}
+						<i18n-t
+							keypath="batterySettings.legendBottomSubline"
+							tag="small"
+							class="d-block"
+							scope="global"
+						>
+							<template #soc>
+								<CustomSelect
+									id="batterySettingsPriorityBottom"
+									class="custom-select-inline"
+									:options="priorityOptions"
+									:selected="selectedPrioritySoc"
+									@change="changePrioritySoc"
+								>
+									<span class="text-decoration-underline">
+										{{ fmtSoc(selectedPrioritySoc) }}
 									</span>
-								</p>
-								<p class="small text-muted">
-									<strong class="text-evcc">
-										{{ $t("batterySettings.disclaimerHint") }}
-									</strong>
-									{{ $t("batterySettings.disclaimerText") }}
-								</p>
-							</div>
-						</div>
-						<div v-if="controllable">
-							<div class="form-check form-switch">
-								<input
-									id="batteryDischargeControl"
-									:checked="batteryDischargeControl"
-									class="form-check-input"
-									type="checkbox"
-									role="switch"
-									@change="changeDischargeControl"
-								/>
-								<div class="form-check-label">
-									<label for="batteryDischargeControl">
-										{{ $t("batterySettings.discharge") }}
-									</label>
-								</div>
-							</div>
-						</div>
+								</CustomSelect>
+							</template>
+						</i18n-t>
+					</span>
+				</p>
+				<div class="form-check form-switch mt-4" v-if="controllable">
+					<input
+						id="batteryDischargeControl"
+						:checked="batteryDischargeControl"
+						class="form-check-input"
+						type="checkbox"
+						role="switch"
+						@change="changeDischargeControl"
+					/>
+					<div class="form-check-label">
+						<label for="batteryDischargeControl">
+							{{ $t("batterySettings.discharge") }}
+						</label>
 					</div>
 				</div>
 			</div>
 		</div>
-	</Teleport>
+		<SmartCostLimit
+			v-if="gridChargePossible"
+			v-show="gridTabActive"
+			v-bind="smartCostLimitProps"
+		/>
+	</GenericModal>
 </template>
 
 <script>
 import "@h2d2/shopicons/es/regular/lightning";
 import "@h2d2/shopicons/es/regular/car3";
 import "@h2d2/shopicons/es/regular/home";
+import SmartCostLimit from "./SmartCostLimit.vue";
+import CustomSelect from "./CustomSelect.vue";
+import GenericModal from "./GenericModal.vue";
 import formatter from "../mixins/formatter";
 import collector from "../mixins/collector";
 import api from "../api";
+import smartCostAvailable from "../utils/smartCostAvailable";
 
 export default {
 	name: "BatterySettingsModal",
 	mixins: [formatter, collector],
+	components: { SmartCostLimit, CustomSelect, GenericModal },
 	props: {
 		bufferSoc: Number,
 		prioritySoc: Number,
@@ -254,6 +272,10 @@ export default {
 		bufferStartSoc: Number,
 		batteryDischargeControl: Boolean,
 		battery: { type: Array, default: () => [] },
+		batteryGridChargeLimit: Number,
+		smartCostType: String,
+		tariffGrid: Number,
+		currency: String,
 	},
 	data: function () {
 		return {
@@ -261,9 +283,13 @@ export default {
 			selectedBufferSoc: 100,
 			selectedPrioritySoc: 0,
 			selectedBufferStartSoc: 0,
+			gridTabActive: false,
 		};
 	},
 	computed: {
+		usageTabActive() {
+			return !this.gridTabActive;
+		},
 		priorityOptions() {
 			const options = [];
 			for (let i = 100; i >= 0; i -= 5) {
@@ -278,6 +304,14 @@ export default {
 		controllable() {
 			return this.battery.some(({ controllable }) => controllable);
 		},
+		gridChargePossible() {
+			return (
+				this.controllable &&
+				this.isModalVisible &&
+				this.smartCostAvailable &&
+				this.$hiddenFeatures()
+			);
+		},
 		bufferOptions() {
 			const options = [];
 			for (let i = 100; i >= 5; i -= 5) {
@@ -345,6 +379,15 @@ export default {
 					return `${name}${formattedEnergy}${formattedSoc}`;
 				});
 		},
+		smartCostLimitProps() {
+			return {
+				...this.collectProps(SmartCostLimit),
+				smartCostLimit: this.batteryGridChargeLimit,
+			};
+		},
+		smartCostAvailable() {
+			return smartCostAvailable(this.smartCostType);
+		},
 	},
 	watch: {
 		prioritySoc(soc) {
@@ -361,14 +404,14 @@ export default {
 		this.selectedBufferSoc = this.bufferSoc || 100;
 		this.selectedPrioritySoc = this.prioritySoc;
 		this.selectedBufferStartSoc = this.bufferStartSoc;
-		this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
-		this.$refs.modal.addEventListener("hidden.bs.modal", this.modalInvisible);
-	},
-	unmounted() {
-		this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
-		this.$refs.modal?.removeEventListener("hidden.bs.modal", this.modalInvisible);
 	},
 	methods: {
+		showGridTab() {
+			this.gridTabActive = true;
+		},
+		showUsageTab() {
+			this.gridTabActive = false;
+		},
 		modalVisible: function () {
 			this.isModalVisible = true;
 		},
@@ -451,14 +494,10 @@ export default {
 
 <style scoped>
 .battery {
-	height: 300px;
+	height: 285px;
 	display: flex;
 }
 
-.legend {
-	min-width: 260px;
-}
-
 .batteryLimits {
 	width: 50px;
 	position: relative;
@@ -467,7 +506,7 @@ export default {
 .bufferStart,
 .bufferSoc,
 .prioritySoc {
-	position: absolute;
+	position: absolute !important;
 	transform: translateY(-50%);
 	transition-property: top, opacity;
 	transition-timing-function: linear;
@@ -523,7 +562,8 @@ export default {
 	flex: 1;
 	height: 100%;
 	min-width: 100px;
-	max-width: 150px;
+	max-width: 130px;
+	margin-right: 50px;
 	flex-direction: column;
 	position: relative;
 	border-radius: 1rem;
@@ -537,14 +577,7 @@ export default {
 	z-index: 1;
 	border-radius: 0.5rem;
 }
-.custom-select {
-	left: 0;
-	top: 0;
-	bottom: 0;
-	right: 0;
-	cursor: pointer;
-	position: absolute;
-	opacity: 0;
-	-webkit-appearance: menulist-button;
+.custom-select-inline {
+	display: inline-block !important;
 }
 </style>
diff --git a/assets/js/components/Config/UserInterfaceSettings.vue b/assets/js/components/Config/UserInterfaceSettings.vue
index da0a905b..815021a3 100644
--- a/assets/js/components/Config/UserInterfaceSettings.vue
+++ b/assets/js/components/Config/UserInterfaceSettings.vue
@@ -57,11 +57,7 @@
 				</div>
 			</div>
 		</FormRow>
-		<FormRow
-			v-if="fullscreenAvailable"
-			id="settingsFullscreen"
-			:label="$t('settings.fullscreen.label')"
-		>
+		<FormRow v-if="fullscreenAvailable" :label="$t('settings.fullscreen.label')">
 			<button
 				v-if="fullscreenActive"
 				class="btn btn-sm btn-outline-secondary"
diff --git a/assets/js/components/CustomSelect.vue b/assets/js/components/CustomSelect.vue
index 7a38b3dc..83d82776 100644
--- a/assets/js/components/CustomSelect.vue
+++ b/assets/js/components/CustomSelect.vue
@@ -19,7 +19,7 @@ export default {
 	name: "CustomSelect",
 	props: {
 		options: { type: Array },
-		selected: { type: String },
+		selected: { type: [String, Number] },
 		id: { type: String },
 	},
 	emits: ["change"],
diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue
index 8074a336..c2bcc203 100644
--- a/assets/js/components/Energyflow/Energyflow.vue
+++ b/assets/js/components/Energyflow/Energyflow.vue
@@ -160,7 +160,17 @@
 							:detailsFmt="batteryFmt"
 							detailsClickable
 							@details-clicked="openBatterySettingsModal"
-						/>
+						>
+							<template v-if="batteryGridChargeActive" #subline>
+								<button
+									type="button"
+									class="btn-reset d-flex justify-content-between"
+									@click.stop="openBatterySettingsModal"
+								>
+									{{ batteryGridChargeText }} (≤ {{ batteryGridChargeLimitFmt }})
+								</button>
+							</template>
+						</EnergyflowEntry>
 						<EnergyflowEntry
 							v-if="pvPossible"
 							:name="$t('main.energyflow.pvExport')"
@@ -211,6 +221,8 @@ export default {
 		batteryPower: { type: Number, default: 0 },
 		batterySoc: { type: Number, default: 0 },
 		batteryDischargeControl: { type: Boolean },
+		batteryGridChargeLimit: { type: Number },
+		batteryGridChargeActive: { type: Boolean },
 		batteryMode: { type: String },
 		tariffGrid: { type: Number },
 		tariffFeedIn: { type: Number },
@@ -307,6 +319,26 @@ export default {
 		pvPossible() {
 			return this.pvConfigured || this.gridConfigured;
 		},
+		batteryGridChargeText() {
+			return this.$t(
+				`main.energyflow.${this.co2Available ? "clean" : "cheap"}BatteryGridCharge`
+			);
+		},
+		batteryGridChargeNow() {
+			if (this.co2Available) {
+				return this.fmtCo2Short(this.tariffCo2);
+			}
+			return this.fmtPricePerKWh(this.tariffGrid, this.currency, true);
+		},
+		batteryGridChargeLimitFmt() {
+			if (this.batteryGridChargeLimit === null || this.batteryGridChargeLimit === undefined) {
+				return;
+			}
+			if (this.co2Available) {
+				return this.fmtCo2Short(this.batteryGridChargeLimit);
+			}
+			return this.fmtPricePerKWh(this.batteryGridChargeLimit, this.currency, true);
+		},
 	},
 	watch: {
 		pvConfigured() {
diff --git a/assets/js/components/Energyflow/EnergyflowEntry.vue b/assets/js/components/Energyflow/EnergyflowEntry.vue
index 0a055aef..81d042f3 100644
--- a/assets/js/components/Energyflow/EnergyflowEntry.vue
+++ b/assets/js/components/Energyflow/EnergyflowEntry.vue
@@ -1,29 +1,34 @@
 <template>
-	<div class="d-flex justify-content-between mb-2 entry" :class="{ 'evcc-gray': !active }">
-		<span class="d-flex flex-nowrap">
-			<BatteryIcon v-if="isBattery" :soc="soc" />
-			<VehicleIcon v-else-if="isVehicle" :names="vehicleIcons" />
-			<component :is="`shopicon-regular-${icon}`" v-else></component>
-		</span>
-		<span class="text-nowrap flex-grow-1 ms-3 text-truncate">
-			{{ name }}
-		</span>
-		<span class="text-end text-nowrap ps-1 fw-bold d-flex">
-			<div
-				ref="details"
-				class="fw-normal"
-				:class="{ 'text-decoration-underline': detailsClickable }"
-				data-testid="energyflow-entry-details"
-				data-bs-toggle="tooltip"
-				:tabindex="detailsClickable ? 0 : undefined"
-				@click="detailsClicked"
-			>
-				<AnimatedNumber v-if="!isNaN(details)" :to="details" :format="detailsFmt" />
+	<div class="mb-2 entry" :class="{ 'evcc-gray': !active }">
+		<div class="d-flex justify-content-between">
+			<span class="d-flex flex-nowrap">
+				<BatteryIcon v-if="isBattery" :soc="soc" />
+				<VehicleIcon v-else-if="isVehicle" :names="vehicleIcons" />
+				<component :is="`shopicon-regular-${icon}`" v-else></component>
+			</span>
+			<div class="d-block text-nowrap flex-grow-1 ms-3 text-truncate">
+				{{ name }}
 			</div>
-			<div ref="power" class="power" data-bs-toggle="tooltip" @click="powerClicked">
-				<AnimatedNumber ref="powerNumber" :to="power" :format="kw" />
-			</div>
-		</span>
+			<span class="text-end text-nowrap ps-1 fw-bold d-flex">
+				<div
+					ref="details"
+					class="fw-normal"
+					:class="{ 'text-decoration-underline': detailsClickable }"
+					data-testid="energyflow-entry-details"
+					data-bs-toggle="tooltip"
+					:tabindex="detailsClickable ? 0 : undefined"
+					@click="detailsClicked"
+				>
+					<AnimatedNumber v-if="!isNaN(details)" :to="details" :format="detailsFmt" />
+				</div>
+				<div ref="power" class="power" data-bs-toggle="tooltip" @click="powerClicked">
+					<AnimatedNumber ref="powerNumber" :to="power" :format="kw" />
+				</div>
+			</span>
+		</div>
+		<div v-if="$slots.subline" class="ms-4 ps-3">
+			<slot name="subline" />
+		</div>
 	</div>
 </template>
 
diff --git a/assets/js/components/LoadpointSettingsModal.vue b/assets/js/components/LoadpointSettingsModal.vue
index 2789a6eb..df9dff9f 100644
--- a/assets/js/components/LoadpointSettingsModal.vue
+++ b/assets/js/components/LoadpointSettingsModal.vue
@@ -30,9 +30,9 @@
 								v-bind="smartCostLimitProps"
 								class="mt-2"
 							/>
-							<h4 class="d-flex align-items-center mb-3 mt-5 text-evcc">
+							<h6>
 								{{ $t("main.loadpointSettings.currents") }}
-							</h4>
+							</h6>
 							<div v-if="phasesOptions.length" class="mb-3 row">
 								<label
 									:for="formId(`phases_${phasesOptions[0]}`)"
diff --git a/assets/js/components/Site.vue b/assets/js/components/Site.vue
index 58a09fdc..7e904a7a 100644
--- a/assets/js/components/Site.vue
+++ b/assets/js/components/Site.vue
@@ -72,7 +72,8 @@ export default {
 		batteryPower: Number,
 		batterySoc: Number,
 		batteryDischargeControl: Boolean,
-		batterySmartCostLimit: Number,
+		batteryGridChargeLimit: Number,
+		batteryGridChargeActive: Boolean,
 		batteryMode: String,
 		battery: Array,
 		gridCurrents: Array,
diff --git a/assets/js/components/SmartCostLimit.vue b/assets/js/components/SmartCostLimit.vue
index 08491e01..4db1dc2e 100644
--- a/assets/js/components/SmartCostLimit.vue
+++ b/assets/js/components/SmartCostLimit.vue
@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<h4 class="text-evcc mb-3">{{ title }}</h4>
+		<h6 class="mt-0" v-if="isLoadpoint">{{ title }}</h6>
 		<p>
 			{{ description }}
 		</p>
@@ -15,7 +15,7 @@
 					class="form-select form-select-sm mb-1"
 					@change="changeSmartCostLimit"
 				>
-					<option value="0">{{ $t("smartCost.none") }}</option>
+					<option value="null">{{ $t("smartCost.none") }}</option>
 					<option v-for="{ value, name } in costOptions" :key="value" :value="value">
 						{{ name }}
 					</option>
@@ -94,7 +94,7 @@ export default {
 	},
 	data: function () {
 		return {
-			selectedSmartCostLimit: 0,
+			selectedSmartCostLimit: null,
 			tariff: null,
 			startTime: null,
 			activeIndex: null,
@@ -110,9 +110,7 @@ export default {
 			const stepSize = this.optionStepSize;
 			for (let i = 1; i <= 100; i++) {
 				const value = this.optionStartValue + stepSize * i;
-				if (value != 0) {
-					values.push(this.roundLimit(value));
-				}
+				values.push(this.roundLimit(value));
 			}
 			// add special entry if currently selected value is not in the scale
 			const selected = this.selectedSmartCostLimit;
@@ -172,7 +170,7 @@ export default {
 				// TODO: handle multiple matching time slots
 				const price = this.findSlotInRange(start, end, rates)?.price;
 				const charging =
-					price <= this.selectedSmartCostLimit && this.selectedSmartCostLimit !== 0;
+					price <= this.selectedSmartCostLimit && this.selectedSmartCostLimit !== null;
 				const selectable = price !== undefined;
 				result.push({ day, price, startHour, endHour, charging, selectable });
 			}
@@ -221,6 +219,9 @@ export default {
 		formId() {
 			return `smartCostLimit-${this.loadpointId || "battery"}`;
 		},
+		isLoadpoint() {
+			return !!this.loadpointId;
+		},
 	},
 	watch: {
 		tariffGrid() {
@@ -236,7 +237,7 @@ export default {
 	},
 	methods: {
 		roundLimit(limit) {
-			return limit ? Math.round(limit * 1000) / 1000 : 0;
+			return limit === null ? null : Math.round(limit * 1000) / 1000;
 		},
 		updateTariff: async function () {
 			try {
@@ -288,10 +289,6 @@ export default {
 			this.activeIndex = index;
 		},
 		setSelectedSmartCostLimit(limit) {
-			if (limit === 0) {
-				this.selectedSmartCostLimit = 0;
-				return;
-			}
 			const nextOption = this.costOptions.find(({ value }) => value >= limit);
 			if (nextOption) {
 				this.selectedSmartCostLimit = nextOption.value;
@@ -308,13 +305,18 @@ export default {
 			this.saveSmartCostLimit($event.target.value);
 		},
 		async saveSmartCostLimit(limit) {
-			const isLoadpoint = !!this.loadpointId;
-			const url = isLoadpoint
+			const url = this.isLoadpoint
 				? `loadpoints/${this.loadpointId}/smartcostlimit`
-				: "batterysmartcostlimit"; // currently not implemented
+				: "batterygridchargelimit";
+
+			// delete
 			try {
-				await api.post(`${url}/${encodeURIComponent(limit)}`);
-				if (isLoadpoint && this.multipleLoadpoints) {
+				if (limit === "null") {
+					await api.delete(url);
+				} else {
+					await api.post(`${url}/${encodeURIComponent(limit)}`);
+				}
+				if (this.isLoadpoint && this.multipleLoadpoints) {
 					this.applyToAllVisible = true;
 				}
 			} catch (err) {
@@ -323,7 +325,13 @@ export default {
 		},
 		async applyToAll() {
 			try {
-				await api.post(`smartcostlimit/${encodeURIComponent(this.selectedSmartCostLimit)}`);
+				if (this.selectedSmartCostLimit === null) {
+					await api.delete("smartcostlimit");
+				} else {
+					await api.post(
+						`smartcostlimit/${encodeURIComponent(this.selectedSmartCostLimit)}`
+					);
+				}
 				this.applyToAllVisible = false;
 			} catch (err) {
 				console.error(err);
diff --git a/assets/js/components/SponsorTokenExpires.vue b/assets/js/components/SponsorTokenExpires.vue
index 53e55d0c..cc57d079 100644
--- a/assets/js/components/SponsorTokenExpires.vue
+++ b/assets/js/components/SponsorTokenExpires.vue
@@ -1,6 +1,6 @@
 <template>
 	<div v-if="expiresSoon" class="alert alert-warning my-4" role="alert">
-		<i18n-t tag="div" keypath="settings.sponsorToken.expires">
+		<i18n-t tag="div" keypath="settings.sponsorToken.expires" scope="global">
 			<template #inXDays>
 				{{ inXDays }}
 			</template>
diff --git a/assets/js/components/TelemetrySettings.vue b/assets/js/components/TelemetrySettings.vue
index a9aadfd0..a973ea60 100644
--- a/assets/js/components/TelemetrySettings.vue
+++ b/assets/js/components/TelemetrySettings.vue
@@ -13,7 +13,12 @@
 		<div class="form-check-label">
 			<label for="telemetryEnabled">
 				{{ $t("footer.telemetry.optIn") }}
-				<i18n-t v-if="sponsorActive" tag="span" keypath="footer.telemetry.optInMoreDetails">
+				<i18n-t
+					v-if="sponsorActive"
+					tag="span"
+					keypath="footer.telemetry.optInMoreDetails"
+					scope="global"
+				>
 					<a :href="docsLink" target="_blank">
 						{{ $t("footer.telemetry.optInMoreDetailsLink") }}
 					</a>
diff --git a/charger/abb.go b/charger/abb.go
index 6d24b399..3fb47ce7 100644
--- a/charger/abb.go
+++ b/charger/abb.go
@@ -65,7 +65,7 @@ func NewABBFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewABB(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewABB(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewABB creates ABB charger
diff --git a/charger/alphatec.go b/charger/alphatec.go
index 56fa6dee..7e76df63 100644
--- a/charger/alphatec.go
+++ b/charger/alphatec.go
@@ -55,7 +55,7 @@ func NewAlphatecFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewAlphatec(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewAlphatec(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewAlphatec creates Alphatec charger
diff --git a/charger/config.go b/charger/config.go
index 0c0617ea..2dcc7e68 100644
--- a/charger/config.go
+++ b/charger/config.go
@@ -1,31 +1,21 @@
 package charger
 
 import (
+	"context"
 	"fmt"
 	"strings"
 
 	"github.com/evcc-io/evcc/api"
+	reg "github.com/evcc-io/evcc/util/registry"
 )
 
-type chargerRegistry map[string]func(map[string]interface{}) (api.Charger, error)
+var registry = reg.New[api.Charger]("charger")
 
-func (r chargerRegistry) Add(name string, factory func(map[string]interface{}) (api.Charger, error)) {
-	if _, exists := r[name]; exists {
-		panic(fmt.Sprintf("cannot register duplicate charger type: %s", name))
-	}
-	r[name] = factory
+// Types returns the list of types
+func Types() []string {
+	return registry.Types()
 }
 
-func (r chargerRegistry) Get(name string) (func(map[string]interface{}) (api.Charger, error), error) {
-	factory, exists := r[name]
-	if !exists {
-		return nil, fmt.Errorf("invalid charger type: %s", name)
-	}
-	return factory, nil
-}
-
-var registry chargerRegistry = make(map[string]func(map[string]interface{}) (api.Charger, error))
-
 // NewFromConfig creates charger from configuration
 func NewFromConfig(typ string, other map[string]interface{}) (api.Charger, error) {
 	factory, err := registry.Get(strings.ToLower(typ))
@@ -33,7 +23,7 @@ func NewFromConfig(typ string, other map[string]interface{}) (api.Charger, error
 		return nil, err
 	}
 
-	v, err := factory(other)
+	v, err := factory(context.TODO(), other)
 	if err != nil {
 		err = fmt.Errorf("cannot create charger type '%s': %w", typ, err)
 	}
diff --git a/charger/delta.go b/charger/delta.go
index 68005f96..42371ee6 100644
--- a/charger/delta.go
+++ b/charger/delta.go
@@ -76,7 +76,7 @@ func NewDeltaFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewDelta(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID, cc.Connector)
+	return NewDelta(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Settings.Protocol(), cc.ID, cc.Connector)
 }
 
 // NewDelta creates Delta charger
diff --git a/charger/evsedin.go b/charger/evsedin.go
index 049b4e9d..277cc8f0 100644
--- a/charger/evsedin.go
+++ b/charger/evsedin.go
@@ -39,7 +39,7 @@ func NewEvseDINFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewEvseDIN(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewEvseDIN(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewEvseDIN creates EVSE DIN charger
diff --git a/charger/heidelberg-ec.go b/charger/heidelberg-ec.go
index 1a3c8829..f0ce71e4 100644
--- a/charger/heidelberg-ec.go
+++ b/charger/heidelberg-ec.go
@@ -70,7 +70,7 @@ func NewHeidelbergECFromConfig(other map[string]interface{}) (api.Charger, error
 		return nil, err
 	}
 
-	return NewHeidelbergEC(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewHeidelbergEC(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewHeidelbergEC creates HeidelbergEC charger
diff --git a/charger/mennekes-compact.go b/charger/mennekes-compact.go
index 36415328..d334ab35 100644
--- a/charger/mennekes-compact.go
+++ b/charger/mennekes-compact.go
@@ -83,7 +83,7 @@ func NewMennekesCompactFromConfig(other map[string]interface{}) (api.Charger, er
 		return nil, err
 	}
 
-	return NewMennekesCompact(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID, cc.Timeout)
+	return NewMennekesCompact(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Settings.Protocol(), cc.ID, cc.Timeout)
 }
 
 // NewMennekesCompact creates Mennekes charger
diff --git a/charger/obo.go b/charger/obo.go
index 6b3c0342..314adda8 100644
--- a/charger/obo.go
+++ b/charger/obo.go
@@ -39,7 +39,7 @@ func NewOboFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewObo(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewObo(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewObo creates OBO Bettermann charger
diff --git a/charger/phoenix-ev-ser.go b/charger/phoenix-ev-ser.go
index e612fd58..3e5366d1 100644
--- a/charger/phoenix-ev-ser.go
+++ b/charger/phoenix-ev-ser.go
@@ -34,7 +34,7 @@ func NewPhoenixEVSerFromConfig(other map[string]interface{}) (api.Charger, error
 		return nil, err
 	}
 
-	return NewPhoenixEVSer(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewPhoenixEVSer(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewPhoenixEVSer creates a Phoenix charger
diff --git a/charger/prachtalpha.go b/charger/prachtalpha.go
index 989c17d8..6bdeb75e 100644
--- a/charger/prachtalpha.go
+++ b/charger/prachtalpha.go
@@ -66,7 +66,7 @@ func NewPrachtAlphaFromConfig(other map[string]interface{}) (api.Charger, error)
 		return nil, err
 	}
 
-	return NewPrachtAlpha(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID, cc.Timeout, cc.Connector)
+	return NewPrachtAlpha(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Settings.Protocol(), cc.ID, cc.Timeout, cc.Connector)
 }
 
 // NewPrachtAlpha creates PrachtAlpha charger
diff --git a/charger/pulsares.go b/charger/pulsares.go
index be66ed37..73122eca 100644
--- a/charger/pulsares.go
+++ b/charger/pulsares.go
@@ -62,7 +62,7 @@ func NewPulsaresFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	wb, err := NewPulsares(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	wb, err := NewPulsares(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 	if err != nil {
 		return nil, err
 	}
diff --git a/charger/smartevse.go b/charger/smartevse.go
index 91e286a6..d8e9c717 100644
--- a/charger/smartevse.go
+++ b/charger/smartevse.go
@@ -77,7 +77,7 @@ func NewsmartEVSEFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewsmartEVSE(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewsmartEVSE(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewsmartEVSE creates a new charger
diff --git a/charger/solax.go b/charger/solax.go
index 818022e5..e7708a66 100644
--- a/charger/solax.go
+++ b/charger/solax.go
@@ -70,7 +70,7 @@ func NewSolaxFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewSolax(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewSolax(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewSolax creates Solax charger
diff --git a/charger/sungrow.go b/charger/sungrow.go
index f696f0fe..03a29f28 100644
--- a/charger/sungrow.go
+++ b/charger/sungrow.go
@@ -75,7 +75,7 @@ func NewSungrowFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewSungrow(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewSungrow(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewSungrow creates Sungrow charger
diff --git a/cmd/demo.yaml b/cmd/demo.yaml
index dcfe211e..dc1a327f 100644
--- a/cmd/demo.yaml
+++ b/cmd/demo.yaml
@@ -72,6 +72,11 @@ meters:
         if (state.batterySoc > 90) state.batterySoc = 10;
         state.batterySoc;
     capacity: 13.4
+    batterymode:
+      source: js
+      vm: shared
+      script: |
+        1
 
   - name: meter_charger_1
     type: custom
@@ -251,7 +256,14 @@ tariffs:
   currency: EUR # three letter ISO-4217 currency code (default EUR)
   grid:
     type: fixed
-    price: 0.399 # EUR/kWh
+    price: 0.2 # EUR/kWh
+    zones:
+      - hours: 5-7
+        price: 0.6
+      - hours: 11-14
+        price: 0.05
+      - hours: 17-21
+        price: 0.5
   feedin:
     type: fixed
     price: 0.08 # EUR/kWh
diff --git a/cmd/setup.go b/cmd/setup.go
index 7dc7764f..96011aea 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -204,6 +204,8 @@ NEXT:
 }
 
 func configureMeters(static []config.Named, names ...string) error {
+	g, _ := errgroup.WithContext(context.Background())
+
 	for i, cc := range static {
 		if cc.Name == "" {
 			return fmt.Errorf("cannot create meter %d: missing name", i+1)
@@ -217,14 +219,18 @@ func configureMeters(static []config.Named, names ...string) error {
 			log.WARN.Printf("create meter %d: %v", i+1, err)
 		}
 
-		instance, err := meter.NewFromConfig(cc.Type, cc.Other)
-		if err != nil {
-			return &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
-		}
+		g.Go(func() error {
+			instance, err := meter.NewFromConfig(cc.Type, cc.Other)
+			if err != nil {
+				return &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
+			}
 
-		if err := config.Meters().Add(config.NewStaticDevice(cc, instance)); err != nil {
-			return &DeviceError{cc.Name, err}
-		}
+			if err := config.Meters().Add(config.NewStaticDevice(cc, instance)); err != nil {
+				return &DeviceError{cc.Name, err}
+			}
+
+			return nil
+		})
 	}
 
 	// append devices from database
@@ -234,25 +240,29 @@ func configureMeters(static []config.Named, names ...string) error {
 	}
 
 	for _, conf := range configurable {
-		cc := conf.Named()
+		g.Go(func() error {
+			cc := conf.Named()
 
-		if len(names) > 0 && !slices.Contains(names, cc.Name) {
-			return nil
-		}
+			if len(names) > 0 && !slices.Contains(names, cc.Name) {
+				return nil
+			}
 
-		// TOTO add fake devices
+			// TODO add fake devices
 
-		instance, err := meter.NewFromConfig(cc.Type, cc.Other)
-		if err != nil {
-			return &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
-		}
+			instance, err := meter.NewFromConfig(cc.Type, cc.Other)
+			if err != nil {
+				return &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
+			}
 
-		if err := config.Meters().Add(config.NewConfigurableDevice(conf, instance)); err != nil {
-			return &DeviceError{cc.Name, err}
-		}
+			if err := config.Meters().Add(config.NewConfigurableDevice(conf, instance)); err != nil {
+				return &DeviceError{cc.Name, err}
+			}
+
+			return nil
+		})
 	}
 
-	return nil
+	return g.Wait()
 }
 
 func configureChargers(static []config.Named, names ...string) error {
@@ -299,7 +309,7 @@ func configureChargers(static []config.Named, names ...string) error {
 				return nil
 			}
 
-			// TOTO add fake devices
+			// TODO add fake devices
 
 			instance, err := charger.NewFromConfig(cc.Type, cc.Other)
 			if err != nil {
diff --git a/core/helper.go b/core/helper.go
index 5110d384..846287e2 100644
--- a/core/helper.go
+++ b/core/helper.go
@@ -1,6 +1,7 @@
 package core
 
 import (
+	"fmt"
 	"time"
 
 	"github.com/cenkalti/backoff/v4"
@@ -51,3 +52,19 @@ func sitePower(log *util.Logger, maxGrid, grid, battery, residual float64) float
 
 	return grid + battery + residual
 }
+
+// printPtr returns a string representation of a pointer value
+func printPtr[T any](format string, v *T) string {
+	if v == nil {
+		return "<nil>"
+	}
+	return fmt.Sprintf(format, *v)
+}
+
+func ptrValueEqual[T comparable](a, b *T) bool {
+	if (a == nil) != (b == nil) {
+		return false
+	}
+
+	return a == nil && b == nil || (*a) == (*b)
+}
diff --git a/core/keys/site.go b/core/keys/site.go
index 6602b78e..07254c0d 100644
--- a/core/keys/site.go
+++ b/core/keys/site.go
@@ -39,6 +39,8 @@ const (
 	// battery settings
 	BatteryCapacity                   = "batteryCapacity"
 	BatteryDischargeControl           = "batteryDischargeControl"
+	BatteryGridChargeLimit            = "batteryGridChargeLimit"
+	BatteryGridChargeActive           = "batteryGridChargeActive"
 	BufferSoc                         = "bufferSoc"
 	BufferStartSoc                    = "bufferStartSoc"
 	MaxGridSupplyWhileBatteryCharging = "maxGridSupplyWhileBatteryCharging"
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 5f32ced5..be6bb03e 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -121,12 +121,12 @@ type Loadpoint struct {
 	MinCurrent_       float64       `mapstructure:"minCurrent"`
 	MaxCurrent_       float64       `mapstructure:"maxCurrent"`
 
-	minCurrent       float64 // PV mode: start current	Min+PV mode: min current
-	maxCurrent       float64 // Max allowed current. Physically ensured by the charger
-	configuredPhases int     // Charger configured phase mode 0/1/3
-	limitSoc         int     // Session limit for soc
-	limitEnergy      float64 // Session limit for energy
-	smartCostLimit   float64 // always charge if cost is below this value
+	minCurrent       float64  // PV mode: start current	Min+PV mode: min current
+	maxCurrent       float64  // Max allowed current. Physically ensured by the charger
+	configuredPhases int      // Charger configured phase mode 0/1/3
+	limitSoc         int      // Session limit for soc
+	limitEnergy      float64  // Session limit for energy
+	smartCostLimit   *float64 // always charge if cost is below this value
 
 	mode                api.ChargeMode
 	enabled             bool      // Charger enabled state
@@ -337,8 +337,9 @@ func (lp *Loadpoint) restoreSettings() {
 		lp.setLimitEnergy(v)
 	}
 	if v, err := lp.settings.Float(keys.SmartCostLimit); err == nil {
-		lp.SetSmartCostLimit(v)
+		lp.SetSmartCostLimit(&v)
 	}
+
 	t, err1 := lp.settings.Time(keys.PlanTime)
 	v, err2 := lp.settings.Float(keys.PlanEnergy)
 	if err1 == nil && err2 == nil {
@@ -862,6 +863,11 @@ func (lp *Loadpoint) setLimit(chargeCurrent float64) error {
 		lp.setAndPublishEnabled(enabled)
 		lp.chargerSwitched = lp.clock.Now()
 
+		// ensure we always re-set current when enabling charger
+		if !enabled {
+			lp.chargeCurrent = 0
+		}
+
 		lp.bus.Publish(evChargeCurrent, chargeCurrent)
 
 		// start/stop vehicle wake-up timer
@@ -1632,9 +1638,18 @@ func (lp *Loadpoint) phaseSwitchCompleted() bool {
 }
 
 // Update is the main control function. It reevaluates meters and charger state
-func (lp *Loadpoint) Update(sitePower float64, smartCostActive bool, smartCostNextStart time.Time, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64) {
+func (lp *Loadpoint) Update(sitePower float64, rates api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64) {
+	// smart cost
+	smartCostActive := lp.smartCostActive(rates)
 	lp.publish(keys.SmartCostActive, smartCostActive)
+
+	var smartCostNextStart time.Time
+	if !smartCostActive {
+		smartCostNextStart = lp.smartCostNextStart(rates)
+	}
 	lp.publish(keys.SmartCostNextStart, smartCostNextStart)
+
+	// long-running tasks
 	lp.processTasks()
 
 	// read and publish meters first- charge power and currents have already been updated by the site
diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go
index 9eb97505..a6f4f308 100644
--- a/core/loadpoint/api.go
+++ b/core/loadpoint/api.go
@@ -110,9 +110,9 @@ type API interface {
 	//
 
 	// GetSmartChargingActive determines if smart charging is active
-	GetSmartCostLimit() float64
+	GetSmartCostLimit() *float64
 	// SetSmartCostLimit sets the smart cost limit
-	SetSmartCostLimit(limit float64)
+	SetSmartCostLimit(limit *float64)
 
 	//
 	// power and energy
diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go
index cf7578bb..b1f4f3fe 100644
--- a/core/loadpoint/mock.go
+++ b/core/loadpoint/mock.go
@@ -380,10 +380,10 @@ func (mr *MockAPIMockRecorder) GetRemainingEnergy() *gomock.Call {
 }
 
 // GetSmartCostLimit mocks base method.
-func (m *MockAPI) GetSmartCostLimit() float64 {
+func (m *MockAPI) GetSmartCostLimit() *float64 {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "GetSmartCostLimit")
-	ret0, _ := ret[0].(float64)
+	ret0, _ := ret[0].(*float64)
 	return ret0
 }
 
@@ -602,7 +602,7 @@ func (mr *MockAPIMockRecorder) SetPriority(arg0 any) *gomock.Call {
 }
 
 // SetSmartCostLimit mocks base method.
-func (m *MockAPI) SetSmartCostLimit(arg0 float64) {
+func (m *MockAPI) SetSmartCostLimit(arg0 *float64) {
 	m.ctrl.T.Helper()
 	m.ctrl.Call(m, "SetSmartCostLimit", arg0)
 }
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index 738dc302..b4b87eb0 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -489,23 +489,29 @@ func (lp *Loadpoint) StartVehicleDetection() {
 }
 
 // GetSmartCostLimit gets the smart cost limit
-func (lp *Loadpoint) GetSmartCostLimit() float64 {
+func (lp *Loadpoint) GetSmartCostLimit() *float64 {
 	lp.RLock()
 	defer lp.RUnlock()
 	return lp.smartCostLimit
 }
 
 // SetSmartCostLimit sets the smart cost limit
-func (lp *Loadpoint) SetSmartCostLimit(val float64) {
+func (lp *Loadpoint) SetSmartCostLimit(val *float64) {
 	lp.Lock()
 	defer lp.Unlock()
 
-	lp.log.DEBUG.Println("set smart cost limit:", val)
+	lp.log.DEBUG.Println("set smart cost limit:", printPtr("%.1f", val))
 
-	if lp.smartCostLimit != val {
+	if !ptrValueEqual(lp.smartCostLimit, val) {
 		lp.smartCostLimit = val
-		lp.settings.SetFloat(keys.SmartCostLimit, lp.smartCostLimit)
-		lp.publish(keys.SmartCostLimit, lp.smartCostLimit)
+
+		if val == nil {
+			lp.settings.SetString(keys.SmartCostLimit, "")
+			lp.publish(keys.SmartCostLimit, nil)
+		} else {
+			lp.settings.SetFloat(keys.SmartCostLimit, *val)
+			lp.publish(keys.SmartCostLimit, *val)
+		}
 	}
 }
 
diff --git a/core/loadpoint_smartcost.go b/core/loadpoint_smartcost.go
new file mode 100644
index 00000000..64594a75
--- /dev/null
+++ b/core/loadpoint_smartcost.go
@@ -0,0 +1,31 @@
+package core
+
+import (
+	"time"
+
+	"github.com/evcc-io/evcc/api"
+)
+
+func (lp *Loadpoint) smartCostActive(rates api.Rates) bool {
+	// potential error has already been logged by site, ignore
+	rate, _ := rates.Current(time.Now())
+	limit := lp.GetSmartCostLimit()
+	return limit != nil && !rate.IsEmpty() && rate.Price <= *limit
+}
+
+// smartCostNextStart returns the next start time for a smart cost rate below the limit
+func (lp *Loadpoint) smartCostNextStart(rates api.Rates) time.Time {
+	limit := lp.GetSmartCostLimit()
+	if limit == nil || rates == nil {
+		return time.Time{}
+	}
+
+	now := time.Now()
+	for _, slot := range rates {
+		if slot.Start.After(now) && slot.Price <= *limit {
+			return slot.Start
+		}
+	}
+
+	return time.Time{}
+}
diff --git a/core/planner/helper.go b/core/planner/helper.go
index 6c2977d1..66d558e4 100644
--- a/core/planner/helper.go
+++ b/core/planner/helper.go
@@ -52,7 +52,7 @@ func AverageCost(plan api.Rates) float64 {
 // SlotAt returns the slot for the given time or an empty slot
 func SlotAt(time time.Time, plan api.Rates) api.Rate {
 	for _, slot := range plan {
-		if (slot.Start.Before(time) || slot.Start.Equal(time)) && slot.End.After(time) {
+		if !slot.Start.After(time) && slot.End.After(time) {
 			return slot
 		}
 	}
diff --git a/core/planner/planner.go b/core/planner/planner.go
index 74929a4a..a9e3301f 100644
--- a/core/planner/planner.go
+++ b/core/planner/planner.go
@@ -89,12 +89,12 @@ func (t *Planner) continuousPlan(rates api.Rates, start, end time.Time) api.Rate
 	res := make(api.Rates, 0, len(rates)+2)
 	for _, r := range rates {
 		// slot before continuous plan
-		if r.End.Before(start) || r.End.Equal(start) {
+		if !r.End.After(start) {
 			continue
 		}
 
 		// slot after continuous plan
-		if r.Start.After(end) || r.Start.Equal(end) {
+		if !r.Start.Before(end) {
 			continue
 		}
 
diff --git a/core/site.go b/core/site.go
index f069063b..6a5a1350 100644
--- a/core/site.go
+++ b/core/site.go
@@ -37,7 +37,7 @@ const standbyPower = 10 // consider less than 10W as charger in standby
 // updater abstracts the Loadpoint implementation for testing
 type updater interface {
 	loadpoint.API
-	Update(availablePower float64, smartCostActive bool, smartCostNextStart time.Time, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64)
+	Update(availablePower float64, rates api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64)
 }
 
 // meterMeasurement is used as slice element for publishing structured data
@@ -85,10 +85,11 @@ type Site struct {
 	auxMeters     []api.Meter // Auxiliary meters
 
 	// battery settings
-	prioritySoc             float64 // prefer battery up to this Soc
-	bufferSoc               float64 // continue charging on battery above this Soc
-	bufferStartSoc          float64 // start charging on battery above this Soc
-	batteryDischargeControl bool    // prevent battery discharge for fast and planned charging
+	prioritySoc             float64  // prefer battery up to this Soc
+	bufferSoc               float64  // continue charging on battery above this Soc
+	bufferStartSoc          float64  // start charging on battery above this Soc
+	batteryDischargeControl bool     // prevent battery discharge for fast and planned charging
+	batteryGridChargeLimit  *float64 // grid charging limit
 
 	loadpoints  []*Loadpoint             // Loadpoints
 	tariffs     *tariff.Tariffs          // Tariffs
@@ -298,6 +299,10 @@ func (site *Site) restoreSettings() error {
 			return err
 		}
 	}
+	if v, err := settings.Float(keys.BatteryGridChargeLimit); err == nil {
+		site.SetBatteryGridChargeLimit(&v)
+	}
+
 	return nil
 }
 
@@ -792,19 +797,25 @@ func (site *Site) update(lp updater) {
 		flexiblePower = site.prioritizer.GetChargePowerFlexibility(lp)
 	}
 
-	var smartCostActive bool
-	if rate, err := site.plannerRate(); err == nil {
-		smartCostActive = site.smartCostActive(lp, rate)
-	} else {
-		site.log.WARN.Println("smartCostActive:", err)
+	// battery mode handling
+	rates, err := site.plannerRates()
+	if err != nil {
+		site.log.WARN.Println("planner:", err)
+	}
+
+	rate, err := rates.Current(time.Now())
+	if rates != nil && err != nil {
+		site.log.WARN.Println("planner:", err)
 	}
 
-	var smartCostNextStart time.Time
-	if !smartCostActive {
-		if rates, err := site.plannerRates(); err == nil {
-			smartCostNextStart = site.smartCostNextStart(lp, rates)
+	batteryGridChargeActive := site.batteryGridChargeActive(rate)
+	site.publish(keys.BatteryGridChargeActive, batteryGridChargeActive)
+
+	if batteryMode := site.requiredBatteryMode(batteryGridChargeActive, rate); batteryMode != api.BatteryUnknown {
+		if err := site.applyBatteryMode(batteryMode); err == nil {
+			site.SetBatteryMode(batteryMode)
 		} else {
-			site.log.WARN.Println("smartCostNextStart:", err)
+			site.log.ERROR.Println("battery mode:", err)
 		}
 	}
 
@@ -820,7 +831,7 @@ func (site *Site) update(lp updater) {
 		greenShareHome := site.greenShare(0, homePower)
 		greenShareLoadpoints := site.greenShare(nonChargePower, nonChargePower+totalChargePower)
 
-		lp.Update(sitePower, smartCostActive, smartCostNextStart, batteryBuffered, batteryStart, greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints))
+		lp.Update(sitePower, rates, batteryBuffered, batteryStart, greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints))
 
 		site.Health.Update()
 
@@ -833,10 +844,6 @@ func (site *Site) update(lp updater) {
 		site.log.ERROR.Println(err)
 	}
 
-	if site.GetBatteryDischargeControl() {
-		site.updateBatteryMode()
-	}
-
 	site.stats.Update(site)
 }
 
diff --git a/core/site/api.go b/core/site/api.go
index 2dc9430f..6fa731f7 100644
--- a/core/site/api.go
+++ b/core/site/api.go
@@ -40,6 +40,11 @@ type API interface {
 	GetMaxGridSupplyWhileBatteryCharging() float64
 	SetMaxGridSupplyWhileBatteryCharging(float64) error
 
+	// GetBatteryGridChargeLimit get the grid charge limit
+	GetBatteryGridChargeLimit() *float64
+	// SetBatteryGridChargeLimit sets the grid charge limit
+	SetBatteryGridChargeLimit(limit *float64)
+
 	//
 	// power and energy
 	//
diff --git a/core/site_api.go b/core/site_api.go
index 55f28d03..4a98bafc 100644
--- a/core/site_api.go
+++ b/core/site_api.go
@@ -347,13 +347,6 @@ func (site *Site) SetBatteryDischargeControl(val bool) error {
 	defer site.Unlock()
 
 	if site.batteryDischargeControl != val {
-		// reset to normal when disabling
-		if mode := site.batteryMode; !val && batteryModeModified(mode) {
-			if err := site.applyBatteryMode(api.BatteryNormal); err != nil {
-				return err
-			}
-		}
-
 		site.batteryDischargeControl = val
 		settings.SetBool(keys.BatteryDischargeControl, val)
 		site.publish(keys.BatteryDischargeControl, val)
@@ -361,3 +354,28 @@ func (site *Site) SetBatteryDischargeControl(val bool) error {
 
 	return nil
 }
+
+func (site *Site) GetBatteryGridChargeLimit() *float64 {
+	site.RLock()
+	defer site.RUnlock()
+	return site.batteryGridChargeLimit
+}
+
+func (site *Site) SetBatteryGridChargeLimit(val *float64) {
+	site.log.DEBUG.Println("set grid charge limit:", printPtr("%.1f", val))
+
+	site.Lock()
+	defer site.Unlock()
+
+	if !ptrValueEqual(site.batteryGridChargeLimit, val) {
+		site.batteryGridChargeLimit = val
+
+		if val == nil {
+			settings.SetString(keys.BatteryGridChargeLimit, "")
+			site.publish(keys.BatteryGridChargeLimit, nil)
+		} else {
+			settings.SetFloat(keys.BatteryGridChargeLimit, *val)
+			site.publish(keys.BatteryGridChargeLimit, *val)
+		}
+	}
+}
diff --git a/core/site_battery.go b/core/site_battery.go
index 3804aebc..e292fad0 100644
--- a/core/site_battery.go
+++ b/core/site_battery.go
@@ -1,8 +1,6 @@
 package core
 
 import (
-	"time"
-
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/core/keys"
 	"github.com/evcc-io/evcc/core/loadpoint"
@@ -37,10 +35,25 @@ func (site *Site) SetBatteryMode(batMode api.BatteryMode) {
 	}
 }
 
-// applyBatteryMode applies the mode to each battery and updates
-// internal state if successful (requires lock)
+// requiredBatteryMode determines required battery mode based on grid charge and rate
+func (site *Site) requiredBatteryMode(batteryGridChargeActive bool, rate api.Rate) api.BatteryMode {
+	var res api.BatteryMode
+	batMode := site.GetBatteryMode()
+
+	switch {
+	case batteryGridChargeActive && batMode != api.BatteryCharge:
+		res = api.BatteryCharge
+	case !batteryGridChargeActive && site.dischargeControlActive(rate) && batMode != api.BatteryHold:
+		res = api.BatteryHold
+	case batteryModeModified(batMode):
+		res = api.BatteryNormal
+	}
+
+	return res
+}
+
+// applyBatteryMode applies the mode to each battery
 func (site *Site) applyBatteryMode(mode api.BatteryMode) error {
-	// update batteries
 	for _, meter := range site.batteryMeters {
 		if batCtrl, ok := meter.(api.BatteryController); ok {
 			if err := batCtrl.SetBatteryMode(mode); err != nil {
@@ -49,9 +62,6 @@ func (site *Site) applyBatteryMode(mode api.BatteryMode) error {
 		}
 	}
 
-	// update state and publish
-	site.setBatteryMode(mode)
-
 	return nil
 }
 
@@ -64,62 +74,27 @@ func (site *Site) plannerRates() (api.Rates, error) {
 	return tariff.Rates()
 }
 
-func (site *Site) plannerRate() (*api.Rate, error) {
-	rates, err := site.plannerRates()
-	if rates == nil || err != nil {
-		return nil, err
-	}
-
-	rate, err := rates.Current(time.Now())
-	if err != nil {
-		return nil, err
-	}
-
-	return &rate, nil
-}
-
-func (site *Site) smartCostActive(lp loadpoint.API, rate *api.Rate) bool {
+func (site *Site) smartCostActive(lp loadpoint.API, rate api.Rate) bool {
 	limit := lp.GetSmartCostLimit()
-	return limit != 0 && rate != nil && rate.Price <= limit
+	return limit != nil && !rate.IsEmpty() && rate.Price <= *limit
 }
 
-func (site *Site) smartCostNextStart(lp loadpoint.API, rate api.Rates) time.Time {
-	limit := lp.GetSmartCostLimit()
-	if limit == 0 || rate == nil {
-		return time.Time{}
-	}
-
-	now := time.Now()
-	for _, slot := range rate {
-		if slot.Start.After(now) && slot.Price <= limit {
-			return slot.Start
-		}
-	}
-
-	return time.Time{}
+func (site *Site) batteryGridChargeActive(rate api.Rate) bool {
+	limit := site.GetBatteryGridChargeLimit()
+	return limit != nil && !rate.IsEmpty() && rate.Price <= *limit
 }
 
-func (site *Site) updateBatteryMode() {
-	mode := api.BatteryNormal
-
-	rate, err := site.plannerRate()
-	if err != nil {
-		site.log.WARN.Println("smart cost:", err)
+func (site *Site) dischargeControlActive(rate api.Rate) bool {
+	if !site.GetBatteryDischargeControl() {
+		return false
 	}
 
 	for _, lp := range site.Loadpoints() {
 		smartCostActive := site.smartCostActive(lp, rate)
 		if lp.GetStatus() == api.StatusC && (smartCostActive || lp.IsFastChargingActive()) {
-			mode = api.BatteryHold
-			break
+			return true
 		}
 	}
 
-	if batMode := site.GetBatteryMode(); mode != batMode {
-		site.Lock()
-		if err := site.applyBatteryMode(mode); err != nil {
-			site.log.ERROR.Println("battery mode:", err)
-		}
-		site.Unlock()
-	}
+	return false
 }
diff --git a/go.mod b/go.mod
index 8b84e7c2..17b14b1c 100644
--- a/go.mod
+++ b/go.mod
@@ -89,7 +89,7 @@ require (
 	github.com/teslamotors/vehicle-command v0.0.2
 	github.com/traefik/yaegi v0.16.1
 	github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c
-	github.com/volkszaehler/mbmd v0.0.0-20240611142726-33463eb0324e
+	github.com/volkszaehler/mbmd v0.0.0-20240727104742-3191c0dbfb9e
 	github.com/writeas/go-strip-markdown/v2 v2.1.1
 	gitlab.com/bboehmke/sunny v0.16.0
 	go.uber.org/mock v0.4.0
@@ -197,6 +197,8 @@ require (
 
 replace gopkg.in/yaml.v3 => github.com/andig/yaml v0.0.0-20240531135838-1ff5761ab467
 
+replace github.com/grid-x/modbus => github.com/evcc-io/modbus v0.0.0-20240503125516-9fd99fe0e438
+
 replace github.com/enbility/spine-go => github.com/enbility/spine-go v0.0.0-20240726200332-a983de1e34b8
 
 replace github.com/enbility/ship-go => github.com/enbility/ship-go v0.0.0-20240731093131-37b1302bca66
diff --git a/go.sum b/go.sum
index 5fa521d3..39dc84fe 100644
--- a/go.sum
+++ b/go.sum
@@ -132,6 +132,8 @@ github.com/enbility/zeroconf/v2 v2.0.0-20240210101930-d0004078577b/go.mod h1:Bjz
 github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/evcc-io/modbus v0.0.0-20240503125516-9fd99fe0e438 h1:I14MN2UauarS2Lo+prnNXEj31MxJS1mEfLUrEnOUBsw=
+github.com/evcc-io/modbus v0.0.0-20240503125516-9fd99fe0e438/go.mod h1:WpbUAyptAAi0VAriSRopZa6uhiJOJCTz7KFvgGtNRXc=
 github.com/evcc-io/ocpp-go v0.0.0-20240730071053-d69e53b0fce9 h1:FLv1vmLnfc8DanI5U1qOTe1Zr0OgZ/tuOvHALtZ2sOI=
 github.com/evcc-io/ocpp-go v0.0.0-20240730071053-d69e53b0fce9/go.mod h1:ZynYDWGw6CslG3vyPuucLsy6AyE+h3XXYlr39jhNiQY=
 github.com/evcc-io/tesla-proxy-client v0.0.0-20240221194046-4168b3759701 h1:3JplY3KS6KMDVDNAU+3+KWmSWmoHIU34qwuIpW6SiHk=
@@ -276,10 +278,6 @@ github.com/gregdel/pushover v1.3.1 h1:4bMLITOZ15+Zpi6qqoGqOPuVHCwSUvMCgVnN5Xhilf
 github.com/gregdel/pushover v1.3.1/go.mod h1:EcaO66Nn1StkpEm1iKtBTV3d2A16SoMsVER1PthX7to=
 github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA=
 github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
-github.com/grid-x/modbus v0.0.0-20210714071042-7af2b65ec03b/go.mod h1:YaK0rKJenZ74vZFcSSLlAQqtG74PMI68eDjpDCDDmTw=
-github.com/grid-x/modbus v0.0.0-20240503115206-582f2ab60a18 h1:8V5xRtdD70kGC4/IHqFq+kcBSWr4k6nscAUgWwJ6A5k=
-github.com/grid-x/modbus v0.0.0-20240503115206-582f2ab60a18/go.mod h1:WpbUAyptAAi0VAriSRopZa6uhiJOJCTz7KFvgGtNRXc=
-github.com/grid-x/serial v0.0.0-20191104121038-e24bc9bf6f08/go.mod h1:kdOd86/VGFWRrtkNwf1MPk0u1gIjc4Y7R2j7nhwc7Rk=
 github.com/grid-x/serial v0.0.0-20211107191517-583c7356b3aa h1:Rsn6ARgNkXrsXJIzhkE4vQr5Gbx2LvtEMv4BJOK4LyU=
 github.com/grid-x/serial v0.0.0-20211107191517-583c7356b3aa/go.mod h1:kdOd86/VGFWRrtkNwf1MPk0u1gIjc4Y7R2j7nhwc7Rk=
 github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
@@ -662,8 +660,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
 github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
 github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
 github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
-github.com/volkszaehler/mbmd v0.0.0-20240611142726-33463eb0324e h1:1iAo0qalenDcyo9ySgRWjDuMTpfyA/rBDKklMMNahZk=
-github.com/volkszaehler/mbmd v0.0.0-20240611142726-33463eb0324e/go.mod h1:p1nUKfszvbZ+mMMsMi1vaKGA/eBAXC34VSKCk5slyG4=
+github.com/volkszaehler/mbmd v0.0.0-20240727104742-3191c0dbfb9e h1:d0KoYuIPXTI/vn85axsoD7w616oUalAzmDLZwv9yB9Q=
+github.com/volkszaehler/mbmd v0.0.0-20240727104742-3191c0dbfb9e/go.mod h1:MA3vKWI4KozcpWvD9dwN6CL+WqwdK9XGXT5Iv49IsUM=
 github.com/writeas/go-strip-markdown/v2 v2.1.1 h1:hAxUM21Uhznf/FnbVGiJciqzska6iLei22Ijc3q2e28=
 github.com/writeas/go-strip-markdown/v2 v2.1.1/go.mod h1:UvvgPJgn1vvN8nWuE5e7v/+qmDu3BSVnKAB6Gl7hFzA=
 github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
diff --git a/i18n/de.toml b/i18n/de.toml
index 257f6a79..3af110a2 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -5,20 +5,21 @@ control = "Batteriesteuerung"
 discharge = "Verhindere Entladung im Schnell-Modus und bei geplantem Laden."
 disclaimerHint = "Hinweis:"
 disclaimerText = "Nur relevant im PV-Modus. Das Ladeverhalten wird entsprechend angepasst."
-legendBottomName = "Haus hat Priorität"
-legendBottomSubline = "nicht zum Laden verwenden"
-legendMiddleName = "Fahrzeug zuerst"
-legendMiddleSubline = "dann das Haus"
-legendTitle = "Wie soll Sonnenstrom genutzt werden?"
-legendTopAutostart = "startet automatisch"
-legendTopName = "Batterieunterstütztes Laden"
-legendTopSubline = "ohne Unterbrechungen"
+gridChargeTab = "Netzladen"
+legendBottomName = "Priorisiere die Hausbatterie"
+legendBottomSubline = "bis sie {soc} erreicht hat."
+legendMiddleName = "Priorisiere Fahrzeugladen,"
+legendMiddleSubline = "wenn Hausbatterie über {soc} ist."
+legendTopAutostart = "Starte automatisch,"
+legendTopName = "Batterieunterstütztes Fahrzeugladen,"
+legendTopSubline = "wenn Hausbatterie über {soc} ist."
 modalTitle = "Hausbatterie"
+usageTab = "Batterienutzung"
 
 [batterySettings.bufferStart]
-above = "wenn über {soc}"
-full = "wenn auf {soc}"
-never = "nur mit genug PV-Überschuss"
+above = "wenn über {soc}."
+full = "wenn auf {soc}."
+never = "nur mit genug PV-Überschuss."
 
 [config]
 
@@ -389,6 +390,8 @@ batteryCharge = "Batterie laden"
 batteryDischarge = "Batterie entladen"
 batteryHold = "Batterie (gesperrt)"
 batteryTooltip = "{energy} von {total} ({soc})"
+cheapBatteryGridCharge = "günstige Netzenergie"
+cleanBatteryGridCharge = "saubere Netzenergie"
 gridImport = "Netzbezug"
 homePower = "Verbrauch"
 loadpoints = "Ladepunkt | Ladepunkt | {count} Ladepunkte"
diff --git a/i18n/en.toml b/i18n/en.toml
index 9ebaaed5..655bbf33 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -5,20 +5,21 @@ control = "Battery control"
 discharge = "Prevent discharge in fast mode and planned charging."
 disclaimerHint = "Note:"
 disclaimerText = "These settings only affect solar mode. Charging behaviour is adjusted accordingly."
-legendBottomName = "home priority"
-legendBottomSubline = "not used for charging"
-legendMiddleName = "vehicle first"
-legendMiddleSubline = "home second"
-legendTitle = "How should solar energy be used?"
-legendTopAutostart = "starts automatically"
-legendTopName = "battery supported charging"
-legendTopSubline = "without interruptions"
+gridChargeTab = "Grid charging"
+legendBottomName = "Prioritize home battery charging"
+legendBottomSubline = "until it reaches {soc}."
+legendMiddleName = "Prioritize vehicle charging"
+legendMiddleSubline = "when home battery is above {soc}."
+legendTopAutostart = "Start automatically"
+legendTopName = "Battery-supported vehicle charging"
+legendTopSubline = "when home battery is above {soc}."
 modalTitle = "Home Battery"
+usageTab = "Battery usage"
 
 [batterySettings.bufferStart]
-above = "when above {soc}"
-full = "when at {soc}"
-never = "only with enough surplus"
+above = "when above {soc}."
+full = "when at {soc}."
+never = "only with enough surplus."
 
 [config]
 
@@ -391,6 +392,8 @@ batteryCharge = "Battery charging"
 batteryDischarge = "Battery discharging"
 batteryHold = "Battery (locked)"
 batteryTooltip = "{energy} of {total} ({soc})"
+cheapBatteryGridCharge = "cheap grid energy"
+cleanBatteryGridCharge = "clean grid energy"
 gridImport = "Grid use"
 homePower = "Consumption"
 loadpoints = "Charger| Charger | {count} chargers"
diff --git a/meter/config.go b/meter/config.go
index 68555c6b..3a122899 100644
--- a/meter/config.go
+++ b/meter/config.go
@@ -1,31 +1,21 @@
 package meter
 
 import (
+	"context"
 	"fmt"
 	"strings"
 
 	"github.com/evcc-io/evcc/api"
+	reg "github.com/evcc-io/evcc/util/registry"
 )
 
-type meterRegistry map[string]func(map[string]interface{}) (api.Meter, error)
+var registry = reg.New[api.Meter]("meter")
 
-func (r meterRegistry) Add(name string, factory func(map[string]interface{}) (api.Meter, error)) {
-	if _, exists := r[name]; exists {
-		panic(fmt.Sprintf("cannot register duplicate meter type: %s", name))
-	}
-	r[name] = factory
+// Types returns the list of types
+func Types() []string {
+	return registry.Types()
 }
 
-func (r meterRegistry) Get(name string) (func(map[string]interface{}) (api.Meter, error), error) {
-	factory, exists := r[name]
-	if !exists {
-		return nil, fmt.Errorf("invalid meter type: %s", name)
-	}
-	return factory, nil
-}
-
-var registry meterRegistry = make(map[string]func(map[string]interface{}) (api.Meter, error))
-
 // NewFromConfig creates meter from configuration
 func NewFromConfig(typ string, other map[string]interface{}) (api.Meter, error) {
 	factory, err := registry.Get(strings.ToLower(typ))
@@ -33,7 +23,7 @@ func NewFromConfig(typ string, other map[string]interface{}) (api.Meter, error)
 		return nil, err
 	}
 
-	v, err := factory(other)
+	v, err := factory(context.TODO(), other)
 	if err != nil {
 		err = fmt.Errorf("cannot create meter type '%s': %w", typ, err)
 	}
diff --git a/meter/mbmd.go b/meter/mbmd.go
index 55164484..29876ea5 100644
--- a/meter/mbmd.go
+++ b/meter/mbmd.go
@@ -57,20 +57,19 @@ func NewModbusMbmdFromConfig(other map[string]interface{}) (api.Meter, error) {
 		cc.RTU = &b
 	}
 
-	conn, err := modbus.NewConnection(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	modbus.Lock()
+	defer modbus.Unlock()
+
+	conn, err := modbus.NewConnection(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Settings.Protocol(), cc.ID)
 	if err != nil {
 		return nil, err
 	}
 
-	// set non-default delay
-	if cc.Delay > 0 {
-		conn.Delay(cc.Delay)
-	}
-
 	// set non-default timeout
-	if cc.Timeout > 0 {
-		conn.Timeout(cc.Timeout)
-	}
+	conn.Timeout(cc.Timeout)
+
+	// set non-default delay
+	conn.Delay(cc.Delay)
 
 	log := util.NewLogger("modbus")
 	conn.Logger(log.TRACE)
diff --git a/provider/modbus.go b/provider/modbus.go
index b92f55d0..51ac94b3 100644
--- a/provider/modbus.go
+++ b/provider/modbus.go
@@ -41,25 +41,22 @@ func NewModbusFromConfig(other map[string]interface{}) (Provider, error) {
 		return nil, err
 	}
 
-	conn, err := modbus.NewConnection(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	modbus.Lock()
+	defer modbus.Unlock()
+
+	conn, err := modbus.NewConnection(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Settings.Protocol(), cc.ID)
 	if err != nil {
 		return nil, err
 	}
 
 	// set non-default timeout
-	if cc.Timeout > 0 {
-		conn.Timeout(cc.Timeout)
-	}
+	conn.Timeout(cc.Timeout)
 
 	// set non-default delay
-	if cc.Delay > 0 {
-		conn.Delay(cc.Delay)
-	}
+	conn.Delay(cc.Delay)
 
 	// set non-default connect delay
-	if cc.ConnectDelay > 0 {
-		conn.ConnectDelay(cc.ConnectDelay)
-	}
+	conn.ConnectDelay(cc.ConnectDelay)
 
 	log := util.NewLogger("modbus")
 	conn.Logger(log.TRACE)
diff --git a/provider/modbus_sunspec.go b/provider/modbus_sunspec.go
index 39d7aec1..6b5df688 100644
--- a/provider/modbus_sunspec.go
+++ b/provider/modbus_sunspec.go
@@ -44,25 +44,22 @@ func NewModbusSunspecFromConfig(other map[string]interface{}) (Provider, error)
 		return nil, err
 	}
 
+	modbus.Lock()
+	defer modbus.Unlock()
+
 	conn, err := modbus.NewConnection(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.Tcp, cc.ID)
 	if err != nil {
 		return nil, err
 	}
 
 	// set non-default timeout
-	if cc.Timeout > 0 {
-		conn.Timeout(cc.Timeout)
-	}
+	conn.Timeout(cc.Timeout)
 
 	// set non-default delay
-	if cc.Delay > 0 {
-		conn.Delay(cc.Delay)
-	}
+	conn.Delay(cc.Delay)
 
 	// set non-default connect delay
-	if cc.ConnectDelay > 0 {
-		conn.ConnectDelay(cc.ConnectDelay)
-	}
+	conn.ConnectDelay(cc.ConnectDelay)
 
 	log := util.NewLogger("sunspec")
 	conn.Logger(log.TRACE)
diff --git a/server/http.go b/server/http.go
index 2573bac7..e394241e 100644
--- a/server/http.go
+++ b/server/http.go
@@ -105,10 +105,13 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
 		"buffersoc":               {"POST", "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
 		"bufferstartsoc":          {"POST", "/bufferstartsoc/{value:[0-9.]+}", floatHandler(site.SetBufferStartSoc, site.GetBufferStartSoc)},
 		"batterydischargecontrol": {"POST", "/batterydischargecontrol/{value:[a-z]+}", boolHandler(site.SetBatteryDischargeControl, site.GetBatteryDischargeControl)},
+		"batterygridcharge":       {"POST", "/batterygridchargelimit/{value:-?[0-9.]+}", floatPtrHandler(pass(site.SetBatteryGridChargeLimit), site.GetBatteryGridChargeLimit)},
+		"batterygridchargedelete": {"DELETE", "/batterygridchargelimit", floatPtrHandler(pass(site.SetBatteryGridChargeLimit), site.GetBatteryGridChargeLimit)},
 		"maxgridsupply":           {"POST", "/maxgridsupply/{value:[0-9.]+}", floatHandler(site.SetMaxGridSupplyWhileBatteryCharging, site.GetMaxGridSupplyWhileBatteryCharging)},
 		"prioritysoc":             {"POST", "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},
 		"residualpower":           {"POST", "/residualpower/{value:-?[0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
 		"smartcost":               {"POST", "/smartcostlimit/{value:-?[0-9.]+}", updateSmartCostLimit(site)},
+		"smartcostdelete":         {"DELETE", "/smartcostlimit", updateSmartCostLimit(site)},
 		"tariff":                  {"GET", "/tariff/{tariff:[a-z]+}", tariffHandler(site)},
 		"sessions":                {"GET", "/sessions", sessionHandler},
 		"updatesession":           {"PUT", "/session/{id:[0-9]+}", updateSessionHandler},
@@ -173,7 +176,8 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
 			"remotedemand":     {"POST", "/remotedemand/{demand:[a-z]+}/{source:[0-9a-zA-Z_-]+}", remoteDemandHandler(lp)},
 			"enableThreshold":  {"POST", "/enable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetEnableThreshold), lp.GetEnableThreshold)},
 			"disableThreshold": {"POST", "/disable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetDisableThreshold), lp.GetDisableThreshold)},
-			"smartCostLimit":   {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
+			"smartCost":        {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
+			"smartCostDelete":  {"DELETE", "/smartcostlimit", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
 			// "priority":         {"POST", "/priority/{value:[0-9.]+}", floatHandler(pass(lp.SetPriority), lp.GetPriority)},
 		}
 
diff --git a/server/http_site_handler.go b/server/http_site_handler.go
index 7f6e2f3b..622af418 100644
--- a/server/http_site_handler.go
+++ b/server/http_site_handler.go
@@ -131,6 +131,20 @@ func floatHandler(set func(float64) error, get func() float64) http.HandlerFunc
 	return handler(parseFloat, set, get)
 }
 
+// floatPtrHandler updates float-pointer api
+func floatPtrHandler(set func(*float64) error, get func() *float64) http.HandlerFunc {
+	return handler(func(s string) (*float64, error) {
+		var val *float64
+		f, err := parseFloat(s)
+		if err == nil {
+			val = &f
+		} else if s == "" {
+			err = nil
+		}
+		return val, err
+	}, set, get)
+}
+
 // intHandler updates int-param api
 func intHandler(set func(int) error, get func() int) http.HandlerFunc {
 	return handler(strconv.Atoi, set, get)
@@ -152,11 +166,16 @@ func boolGetHandler(get func() bool) http.HandlerFunc {
 func updateSmartCostLimit(site site.API) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		vars := mux.Vars(r)
+		var val *float64
 
-		val, err := parseFloat(vars["value"])
-		if err != nil {
-			jsonError(w, http.StatusBadRequest, err)
-			return
+		if r.Method != http.MethodDelete {
+			f, err := parseFloat(vars["value"])
+			if err != nil {
+				jsonError(w, http.StatusBadRequest, err)
+				return
+			}
+
+			val = &f
 		}
 
 		for _, lp := range site.Loadpoints() {
diff --git a/server/modbus/handler.go b/server/modbus/handler.go
index caae01aa..d5a3b9e0 100644
--- a/server/modbus/handler.go
+++ b/server/modbus/handler.go
@@ -98,7 +98,7 @@ LOOP:
 
 func (h *handler) HandleDiscreteInputs(req *mbserver.DiscreteInputsRequest) ([]bool, error) {
 	h.log.TRACE.Printf("read discrete: id %d addr %d qty %d", req.UnitId, req.Addr, req.Quantity)
-	b, err := h.conn.ReadDiscreteInputsWithSlave(req.UnitId, req.Addr, req.Quantity)
+	b, err := h.conn.Clone(req.UnitId).ReadDiscreteInputs(req.Addr, req.Quantity)
 	return h.bytesToBoolResult("read discrete", req.Quantity, b, err)
 }
 
@@ -120,24 +120,24 @@ func (h *handler) HandleCoils(req *mbserver.CoilsRequest) ([]bool, error) {
 				u = 0xFF00
 			}
 
-			b, err := h.conn.WriteSingleCoilWithSlave(req.UnitId, req.Addr, u)
+			b, err := h.conn.Clone(req.UnitId).WriteSingleCoil(req.Addr, u)
 			return h.bytesToBoolResult("write coil", req.Quantity, b, err)
 		}
 
 		h.log.TRACE.Printf("write coils: id %d addr %d qty %d val %v", req.UnitId, req.Addr, req.Quantity, req.Args)
 		args := coilsToBytes(req.Args)
-		b, err := h.conn.WriteMultipleCoilsWithSlave(req.UnitId, req.Addr, req.Quantity, args)
+		b, err := h.conn.Clone(req.UnitId).WriteMultipleCoils(req.Addr, req.Quantity, args)
 		return h.bytesToBoolResult("write coils", req.Quantity, b, err)
 	}
 
 	h.log.TRACE.Printf("read coils: id %d addr %d qty %d", req.UnitId, req.Addr, req.Quantity)
-	b, err := h.conn.ReadCoilsWithSlave(req.UnitId, req.Addr, req.Quantity)
+	b, err := h.conn.Clone(req.UnitId).ReadCoils(req.Addr, req.Quantity)
 	return h.bytesToBoolResult("read coils", req.Quantity, b, err)
 }
 
 func (h *handler) HandleInputRegisters(req *mbserver.InputRegistersRequest) ([]uint16, error) {
 	h.log.TRACE.Printf("read input: id %d addr %d qty %d", req.UnitId, req.Addr, req.Quantity)
-	b, err := h.conn.ReadInputRegistersWithSlave(req.UnitId, req.Addr, req.Quantity)
+	b, err := h.conn.Clone(req.UnitId).ReadInputRegisters(req.Addr, req.Quantity)
 	return h.exceptionToUint16AndError("read input", b, err)
 }
 
@@ -154,16 +154,16 @@ func (h *handler) HandleHoldingRegisters(req *mbserver.HoldingRegistersRequest)
 
 		if req.WriteFuncCode == gridx.FuncCodeWriteSingleRegister {
 			h.log.TRACE.Printf("write holding: id %d addr %d val %04x", req.UnitId, req.Addr, req.Args[0])
-			b, err := h.conn.WriteSingleRegisterWithSlave(req.UnitId, req.Addr, req.Args[0])
+			b, err := h.conn.Clone(req.UnitId).WriteSingleRegister(req.Addr, req.Args[0])
 			return h.exceptionToUint16AndError("write holding", b, err)
 		}
 
 		h.log.TRACE.Printf("write holdings: id %d addr %d qty %d val %0x", req.UnitId, req.Addr, req.Quantity, asBytes(req.Args))
-		b, err := h.conn.WriteMultipleRegistersWithSlave(req.UnitId, req.Addr, req.Quantity, asBytes(req.Args))
+		b, err := h.conn.Clone(req.UnitId).WriteMultipleRegisters(req.Addr, req.Quantity, asBytes(req.Args))
 		return h.exceptionToUint16AndError("write multiple holding", b, err)
 	}
 
 	h.log.TRACE.Printf("read holdings: id %d addr %d qty %d", req.UnitId, req.Addr, req.Quantity)
-	b, err := h.conn.ReadHoldingRegistersWithSlave(req.UnitId, req.Addr, req.Quantity)
+	b, err := h.conn.Clone(req.UnitId).ReadHoldingRegisters(req.Addr, req.Quantity)
 	return h.exceptionToUint16AndError("read holding", b, err)
 }
diff --git a/server/modbus/proxy.go b/server/modbus/proxy.go
index 096b6d7a..12123432 100644
--- a/server/modbus/proxy.go
+++ b/server/modbus/proxy.go
@@ -12,7 +12,7 @@ import (
 )
 
 func StartProxy(port int, config modbus.Settings, readOnly ReadOnlyMode) error {
-	conn, err := modbus.NewConnection(config.URI, config.Device, config.Comset, config.Baudrate, modbus.ProtocolFromRTU(config.RTU), config.ID)
+	conn, err := modbus.NewConnection(config.URI, config.Device, config.Comset, config.Baudrate, config.Protocol(), config.ID)
 	if err != nil {
 		return err
 	}
diff --git a/server/mqtt.go b/server/mqtt.go
index f0f39932..c44a4043 100644
--- a/server/mqtt.go
+++ b/server/mqtt.go
@@ -178,12 +178,12 @@ func (m *MQTT) listenSiteSetters(topic string, site site.API) error {
 		{"/batteryDischargeControl", boolSetter(site.SetBatteryDischargeControl)},
 		{"/prioritySoc", floatSetter(site.SetPrioritySoc)},
 		{"/residualPower", floatSetter(site.SetResidualPower)},
-		{"/smartCostLimit", floatSetter(func(limit float64) error {
+		{"/smartCostLimit", floatPtrSetter(pass(func(limit *float64) {
 			for _, lp := range site.Loadpoints() {
 				lp.SetSmartCostLimit(limit)
 			}
-			return nil
-		})},
+		}))},
+		{"/batteryGridChargeLimit", floatPtrSetter(pass(site.SetBatteryGridChargeLimit))},
 	} {
 		if err := m.Handler.ListenSetter(topic+s.topic, s.fun); err != nil {
 			return err
@@ -203,7 +203,7 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint.
 		{"/limitEnergy", floatSetter(pass(lp.SetLimitEnergy))},
 		{"/enableThreshold", floatSetter(pass(lp.SetEnableThreshold))},
 		{"/disableThreshold", floatSetter(pass(lp.SetDisableThreshold))},
-		{"/smartCostLimit", floatSetter(pass(lp.SetSmartCostLimit))},
+		{"/smartCostLimit", floatPtrSetter(pass(lp.SetSmartCostLimit))},
 		{"/planEnergy", func(payload string) error {
 			var plan struct {
 				Time  time.Time `json:"time"`
diff --git a/server/mqtt_setter.go b/server/mqtt_setter.go
index a1a648d9..e45cac70 100644
--- a/server/mqtt_setter.go
+++ b/server/mqtt_setter.go
@@ -25,6 +25,18 @@ func floatSetter(set func(float64) error) func(string) error {
 	return setterFunc(parseFloat, set)
 }
 
+func floatPtrSetter(set func(*float64) error) func(string) error {
+	return func(s string) error {
+		var val *float64
+		if f, err := parseFloat(s); err == nil {
+			val = &f
+		} else if s != "" {
+			return err
+		}
+		return set(val)
+	}
+}
+
 func intSetter(set func(int) error) func(string) error {
 	return setterFunc(strconv.Atoi, set)
 }
diff --git a/tariff/config.go b/tariff/config.go
index 34dda811..a1cc7a94 100644
--- a/tariff/config.go
+++ b/tariff/config.go
@@ -1,31 +1,21 @@
 package tariff
 
 import (
+	"context"
 	"fmt"
 	"strings"
 
 	"github.com/evcc-io/evcc/api"
+	reg "github.com/evcc-io/evcc/util/registry"
 )
 
-type tariffRegistry map[string]func(map[string]interface{}) (api.Tariff, error)
+var registry = reg.New[api.Tariff]("tariff")
 
-func (r tariffRegistry) Add(name string, factory func(map[string]interface{}) (api.Tariff, error)) {
-	if _, exists := r[name]; exists {
-		panic(fmt.Sprintf("cannot register duplicate tariff type: %s", name))
-	}
-	r[name] = factory
+// Types returns the list of types
+func Types() []string {
+	return registry.Types()
 }
 
-func (r tariffRegistry) Get(name string) (func(map[string]interface{}) (api.Tariff, error), error) {
-	factory, exists := r[name]
-	if !exists {
-		return nil, fmt.Errorf("invalid tariff type: %s", name)
-	}
-	return factory, nil
-}
-
-var registry tariffRegistry = make(map[string]func(map[string]interface{}) (api.Tariff, error))
-
 // NewFromConfig creates tariff from configuration
 func NewFromConfig(typ string, other map[string]interface{}) (api.Tariff, error) {
 	factory, err := registry.Get(strings.ToLower(typ))
@@ -33,7 +23,7 @@ func NewFromConfig(typ string, other map[string]interface{}) (api.Tariff, error)
 		return nil, err
 	}
 
-	v, err := factory(other)
+	v, err := factory(context.TODO(), other)
 	if err != nil {
 		err = fmt.Errorf("cannot create tariff type '%s': %w", typ, err)
 	}
diff --git a/templates/definition/charger/versicharge.yaml b/templates/definition/charger/versicharge.yaml
index 2100ae86..838dcfe7 100644
--- a/templates/definition/charger/versicharge.yaml
+++ b/templates/definition/charger/versicharge.yaml
@@ -6,8 +6,8 @@ products:
 requirements:
   evcc: ["sponsorship"]
   description:
-    de: Erfordert Firmware >= 2.121.5
-    en: Requires firmware >= 2.121.5
+    de: Erfordert Firmware >= 2.135
+    en: Requires firmware >= 2.135
 params:
   - name: modbus
     choice: ["tcpip"]
diff --git a/templates/definition/meter/goodwe-hybrid.yaml b/templates/definition/meter/goodwe-hybrid.yaml
index 74fe50e7..a2598422 100644
--- a/templates/definition/meter/goodwe-hybrid.yaml
+++ b/templates/definition/meter/goodwe-hybrid.yaml
@@ -8,7 +8,7 @@ params:
   - name: usage
     choice: ["grid", "pv", "battery"]
   - name: modbus
-    choice: ["rs485", "tcpip"]
+    choice: ["rs485", "tcpip", "udp"]
     baudrate: 9600
     id: 247
   - name: capacity
diff --git a/util/modbus/connection.go b/util/modbus/connection.go
new file mode 100644
index 00000000..672a44ff
--- /dev/null
+++ b/util/modbus/connection.go
@@ -0,0 +1,113 @@
+package modbus
+
+import (
+	"sync"
+	"time"
+
+	"github.com/grid-x/modbus"
+	"github.com/volkszaehler/mbmd/meters"
+)
+
+// Connection is a logical modbus connection per slave ID sharing a physical connection
+type Connection struct {
+	meters.Connection
+	mu      sync.Mutex
+	logger  *logger
+	logical meters.Logger
+	delay   time.Duration
+}
+
+func (c *Connection) Delay(delay time.Duration) {
+	c.delay = delay
+}
+
+func (c *Connection) Clone(slaveID uint8) *Connection {
+	return &Connection{
+		Connection: c.Connection.Clone(slaveID),
+		logger:     c.logger,
+	}
+}
+
+// TODO resolve conflicts
+func (c *Connection) ConnectDelay(delay time.Duration) {
+	if delay > 0 {
+		c.Connection.ConnectDelay(delay)
+	}
+}
+
+// TODO resolve conflicts
+func (c *Connection) Timeout(timeout time.Duration) {
+	if timeout > 0 {
+		_ = c.Connection.Timeout(timeout)
+	}
+}
+
+func (c *Connection) Logger(l modbus.Logger) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	c.logical = l
+}
+
+func (c *Connection) prepare() {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	time.Sleep(c.delay)
+	c.logger.Logger(c.logical)
+}
+
+func (c *Connection) ReadCoils(address, quantity uint16) ([]byte, error) {
+	c.prepare()
+	return c.ModbusClient().ReadCoils(address, quantity)
+}
+
+func (c *Connection) WriteSingleCoil(address, value uint16) ([]byte, error) {
+	c.prepare()
+	return c.ModbusClient().WriteSingleCoil(address, value)
+}
+
+func (c *Connection) ReadInputRegisters(address, quantity uint16) ([]byte, error) {
+	c.prepare()
+	return c.ModbusClient().ReadInputRegisters(address, quantity)
+}
+
+func (c *Connection) ReadHoldingRegisters(address, quantity uint16) ([]byte, error) {
+	c.prepare()
+	return c.ModbusClient().ReadHoldingRegisters(address, quantity)
+}
+
+func (c *Connection) WriteSingleRegister(address, value uint16) ([]byte, error) {
+	c.prepare()
+	return c.ModbusClient().WriteSingleRegister(address, value)
+}
+
+func (c *Connection) WriteMultipleRegisters(address, quantity uint16, value []byte) ([]byte, error) {
+	c.prepare()
+	return c.ModbusClient().WriteMultipleRegisters(address, quantity, value)
+}
+
+func (c *Connection) ReadDiscreteInputs(address, quantity uint16) (results []byte, err error) {
+	c.prepare()
+	return c.ModbusClient().ReadDiscreteInputs(address, quantity)
+}
+
+func (c *Connection) WriteMultipleCoils(address, quantity uint16, value []byte) (results []byte, err error) {
+	c.prepare()
+	return c.ModbusClient().WriteMultipleCoils(address, quantity, value)
+}
+
+func (c *Connection) ReadWriteMultipleRegisters(readAddress, readQuantity, writeAddress, writeQuantity uint16, value []byte) (results []byte, err error) {
+	c.prepare()
+	return c.ModbusClient().ReadWriteMultipleRegisters(readAddress, readQuantity, writeAddress, writeQuantity, value)
+}
+
+func (c *Connection) MaskWriteRegister(address, andMask, orMask uint16) (results []byte, err error) {
+	c.prepare()
+	return c.ModbusClient().MaskWriteRegister(address, andMask, orMask)
+}
+
+func (c *Connection) ReadFIFOQueue(address uint16) (results []byte, err error) {
+	c.prepare()
+	return c.ModbusClient().ReadFIFOQueue(address)
+}
diff --git a/util/modbus/log.go b/util/modbus/log.go
new file mode 100644
index 00000000..59495eb2
--- /dev/null
+++ b/util/modbus/log.go
@@ -0,0 +1,29 @@
+package modbus
+
+import (
+	"sync"
+
+	"github.com/grid-x/modbus"
+	"github.com/volkszaehler/mbmd/meters"
+)
+
+type logger struct {
+	mu     sync.RWMutex
+	logger meters.Logger
+}
+
+func (l *logger) Logger(logger modbus.Logger) {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+
+	l.logger = logger
+}
+
+func (l *logger) Printf(format string, v ...interface{}) {
+	l.mu.RLock()
+	defer l.mu.RUnlock()
+
+	if l.logger != nil {
+		l.logger.Printf(format, v...)
+	}
+}
diff --git a/util/modbus/modbus.go b/util/modbus/modbus.go
index d8833e30..9201e1d3 100644
--- a/util/modbus/modbus.go
+++ b/util/modbus/modbus.go
@@ -5,7 +5,6 @@ import (
 	"fmt"
 	"strings"
 	"sync"
-	"time"
 
 	"github.com/evcc-io/evcc/util"
 	"github.com/volkszaehler/mbmd/meters"
@@ -19,6 +18,7 @@ const (
 	Tcp Protocol = iota
 	Rtu
 	Ascii
+	Udp
 
 	CoilOn uint16 = 0xFF00
 )
@@ -38,9 +38,22 @@ type Settings struct {
 	SubDevice           int
 	URI, Device, Comset string
 	Baudrate            int
+	UDP                 bool
 	RTU                 *bool // indicates RTU over TCP if true
 }
 
+// Protocol identifies the wire format from the RTU setting
+func (s Settings) Protocol() Protocol {
+	switch {
+	case s.UDP:
+		return Udp
+	case s.RTU != nil && *s.RTU:
+		return Rtu
+	default:
+		return Tcp
+	}
+}
+
 func (s *Settings) String() string {
 	if s.URI != "" {
 		return s.URI
@@ -48,180 +61,6 @@ func (s *Settings) String() string {
 	return s.Device
 }
 
-// Connection decorates a meters.Connection with transparent slave id and error handling
-type Connection struct {
-	slaveID uint8
-	mu      sync.Mutex
-	conn    meters.Connection
-	delay   time.Duration
-}
-
-func (mb *Connection) prepare(slaveID uint8) {
-	mb.conn.Slave(slaveID)
-	if mb.delay > 0 {
-		time.Sleep(mb.delay)
-	}
-}
-
-func (mb *Connection) handle(res []byte, err error) ([]byte, error) {
-	if err != nil {
-		mb.conn.Close()
-	}
-	return res, err
-}
-
-// Delay sets delay so use between subsequent modbus operations
-func (mb *Connection) Delay(delay time.Duration) {
-	mb.delay = delay
-}
-
-// ConnectDelay sets the initial delay after connecting before starting communication
-func (mb *Connection) ConnectDelay(delay time.Duration) {
-	mb.conn.ConnectDelay(delay)
-}
-
-// Logger sets logger implementation
-func (mb *Connection) Logger(logger meters.Logger) {
-	mb.conn.Logger(logger)
-}
-
-// Timeout sets the connection timeout (not idle timeout)
-func (mb *Connection) Timeout(timeout time.Duration) {
-	mb.conn.Timeout(timeout)
-}
-
-// ReadCoils wraps the underlying implementation
-func (mb *Connection) ReadCoilsWithSlave(slaveID uint8, address, quantity uint16) ([]byte, error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().ReadCoils(address, quantity))
-}
-
-// WriteSingleCoil wraps the underlying implementation
-func (mb *Connection) WriteSingleCoilWithSlave(slaveID uint8, address, value uint16) ([]byte, error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().WriteSingleCoil(address, value))
-}
-
-// ReadInputRegisters wraps the underlying implementation
-func (mb *Connection) ReadInputRegistersWithSlave(slaveID uint8, address, quantity uint16) ([]byte, error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().ReadInputRegisters(address, quantity))
-}
-
-// ReadHoldingRegisters wraps the underlying implementation
-func (mb *Connection) ReadHoldingRegistersWithSlave(slaveID uint8, address, quantity uint16) ([]byte, error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().ReadHoldingRegisters(address, quantity))
-}
-
-// WriteSingleRegister wraps the underlying implementation
-func (mb *Connection) WriteSingleRegisterWithSlave(slaveID uint8, address, value uint16) ([]byte, error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().WriteSingleRegister(address, value))
-}
-
-// WriteMultipleRegisters wraps the underlying implementation
-func (mb *Connection) WriteMultipleRegistersWithSlave(slaveID uint8, address, quantity uint16, value []byte) ([]byte, error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().WriteMultipleRegisters(address, quantity, value))
-}
-
-// ReadDiscreteInputs wraps the underlying implementation
-func (mb *Connection) ReadDiscreteInputsWithSlave(slaveID uint8, address, quantity uint16) (results []byte, err error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().ReadDiscreteInputs(address, quantity))
-}
-
-// WriteMultipleCoils wraps the underlying implementation
-func (mb *Connection) WriteMultipleCoilsWithSlave(slaveID uint8, address, quantity uint16, value []byte) (results []byte, err error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().WriteMultipleCoils(address, quantity, value))
-}
-
-// ReadWriteMultipleRegisters wraps the underlying implementation
-func (mb *Connection) ReadWriteMultipleRegistersWithSlave(slaveID uint8, readAddress, readQuantity, writeAddress, writeQuantity uint16, value []byte) (results []byte, err error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().ReadWriteMultipleRegisters(readAddress, readQuantity, writeAddress, writeQuantity, value))
-}
-
-// MaskWriteRegister wraps the underlying implementation
-func (mb *Connection) MaskWriteRegisterWithSlave(slaveID uint8, address, andMask, orMask uint16) (results []byte, err error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().MaskWriteRegister(address, andMask, orMask))
-}
-
-// ReadFIFOQueue wraps the underlying implementation
-func (mb *Connection) ReadFIFOQueueWithSlave(slaveID uint8, address uint16) (results []byte, err error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().ReadFIFOQueue(address))
-}
-
-func (mb *Connection) ReadCoils(address, quantity uint16) ([]byte, error) {
-	return mb.ReadCoilsWithSlave(mb.slaveID, address, quantity)
-}
-
-func (mb *Connection) WriteSingleCoil(address, value uint16) ([]byte, error) {
-	return mb.WriteSingleCoilWithSlave(mb.slaveID, address, value)
-}
-
-func (mb *Connection) ReadInputRegisters(address, quantity uint16) ([]byte, error) {
-	return mb.ReadInputRegistersWithSlave(mb.slaveID, address, quantity)
-}
-
-func (mb *Connection) ReadHoldingRegisters(address, quantity uint16) ([]byte, error) {
-	return mb.ReadHoldingRegistersWithSlave(mb.slaveID, address, quantity)
-}
-
-func (mb *Connection) WriteSingleRegister(address, value uint16) ([]byte, error) {
-	return mb.WriteSingleRegisterWithSlave(mb.slaveID, address, value)
-}
-
-func (mb *Connection) WriteMultipleRegisters(address, quantity uint16, value []byte) ([]byte, error) {
-	return mb.WriteMultipleRegistersWithSlave(mb.slaveID, address, quantity, value)
-}
-
-func (mb *Connection) ReadDiscreteInputs(address, quantity uint16) (results []byte, err error) {
-	return mb.ReadDiscreteInputsWithSlave(mb.slaveID, address, quantity)
-}
-
-func (mb *Connection) WriteMultipleCoils(address, quantity uint16, value []byte) (results []byte, err error) {
-	return mb.WriteMultipleCoilsWithSlave(mb.slaveID, address, quantity, value)
-}
-
-func (mb *Connection) ReadWriteMultipleRegisters(readAddress, readQuantity, writeAddress, writeQuantity uint16, value []byte) (results []byte, err error) {
-	return mb.ReadWriteMultipleRegistersWithSlave(mb.slaveID, readAddress, readQuantity, writeAddress, writeQuantity, value)
-}
-
-func (mb *Connection) MaskWriteRegister(address, andMask, orMask uint16) (results []byte, err error) {
-	return mb.MaskWriteRegisterWithSlave(mb.slaveID, address, andMask, orMask)
-}
-
-func (mb *Connection) ReadFIFOQueue(address uint16) (results []byte, err error) {
-	return mb.ReadFIFOQueueWithSlave(mb.slaveID, address)
-}
-
 var (
 	connections = make(map[string]meters.Connection)
 	mu          sync.Mutex
@@ -240,65 +79,69 @@ func registeredConnection(key string, newConn meters.Connection) meters.Connecti
 	return newConn
 }
 
-// ProtocolFromRTU identifies the wire format from the RTU setting
-func ProtocolFromRTU(rtu *bool) Protocol {
-	if rtu != nil && *rtu {
-		return Rtu
+// NewConnection creates physical modbus device from config
+func NewConnection(uri, device, comset string, baudrate int, proto Protocol, slaveID uint8) (*Connection, error) {
+	conn, err := physicalConnection(proto, Settings{
+		URI:      uri,
+		Device:   device,
+		Comset:   comset,
+		Baudrate: baudrate,
+	})
+	if err != nil {
+		return nil, err
 	}
-	return Tcp
+
+	res := &Connection{
+		Connection: conn.Clone(slaveID),
+		logger:     new(logger),
+	}
+
+	return res, nil
 }
 
-// NewConnection creates physical modbus device from config
-func NewConnection(uri, device, comset string, baudrate int, proto Protocol, slaveID uint8) (*Connection, error) {
+func physicalConnection(proto Protocol, cfg Settings) (meters.Connection, error) {
 	var conn meters.Connection
 
-	if device != "" && uri != "" {
-		return nil, errors.New("invalid modbus configuration: can only have either uri or device")
+	if (cfg.Device != "") == (cfg.URI != "") {
+		return nil, errors.New("invalid modbus configuration: must have either uri or device")
 	}
 
-	if device != "" {
-		switch strings.ToUpper(comset) {
+	if cfg.Device != "" {
+		switch strings.ToUpper(cfg.Comset) {
 		case "8N1", "8E1", "8N2":
 		case "80":
-			comset = "8E1"
+			cfg.Comset = "8E1"
 		default:
-			return nil, fmt.Errorf("invalid comset: %s", comset)
+			return nil, fmt.Errorf("invalid comset: %s", cfg.Comset)
 		}
 
-		if baudrate == 0 {
+		if cfg.Baudrate == 0 {
 			return nil, errors.New("invalid modbus configuration: need baudrate and comset")
 		}
 
 		if proto == Ascii {
-			conn = registeredConnection(device, meters.NewASCII(device, baudrate, comset))
+			conn = registeredConnection(cfg.Device, meters.NewASCII(cfg.Device, cfg.Baudrate, cfg.Comset))
 		} else {
-			conn = registeredConnection(device, meters.NewRTU(device, baudrate, comset))
+			conn = registeredConnection(cfg.Device, meters.NewRTU(cfg.Device, cfg.Baudrate, cfg.Comset))
 		}
 	}
 
-	if uri != "" {
-		uri = util.DefaultPort(uri, 502)
+	if cfg.URI != "" {
+		cfg.URI = util.DefaultPort(cfg.URI, 502)
 
 		switch proto {
+		case Udp:
+			conn = registeredConnection(cfg.URI, meters.NewRTUOverUDP(cfg.URI))
 		case Rtu:
-			conn = registeredConnection(uri, meters.NewRTUOverTCP(uri))
+			conn = registeredConnection(cfg.URI, meters.NewRTUOverTCP(cfg.URI))
 		case Ascii:
-			conn = registeredConnection(uri, meters.NewASCIIOverTCP(uri))
+			conn = registeredConnection(cfg.URI, meters.NewASCIIOverTCP(cfg.URI))
 		default:
-			conn = registeredConnection(uri, meters.NewTCP(uri))
+			conn = registeredConnection(cfg.URI, meters.NewTCP(cfg.URI))
 		}
 	}
 
-	if conn == nil {
-		return nil, errors.New("invalid modbus configuration: need either uri or device")
-	}
-
-	slaveConn := &Connection{
-		slaveID: slaveID,
-		conn:    conn,
-	}
-
-	return slaveConn, nil
+	return conn, nil
 }
 
 // NewDevice creates physical modbus device from config
diff --git a/util/modbus/mutex.go b/util/modbus/mutex.go
new file mode 100644
index 00000000..4592bddb
--- /dev/null
+++ b/util/modbus/mutex.go
@@ -0,0 +1,13 @@
+package modbus
+
+import "sync"
+
+var mu2 sync.Mutex
+
+func Lock() {
+	mu2.Lock()
+}
+
+func Unlock() {
+	mu2.Unlock()
+}
diff --git a/util/registry/registry.go b/util/registry/registry.go
new file mode 100644
index 00000000..3394f1b3
--- /dev/null
+++ b/util/registry/registry.go
@@ -0,0 +1,49 @@
+package registry
+
+import (
+	"context"
+	"fmt"
+
+	"golang.org/x/exp/maps"
+)
+
+type (
+	factoryFunc[T any] func(context.Context, map[string]any) (T, error)
+
+	registry[T any] struct {
+		typ  string
+		data map[string]factoryFunc[T]
+	}
+)
+
+func (r registry[T]) Add(name string, factory func(map[string]any) (T, error)) {
+	r.AddCtx(name, func(_ context.Context, cc map[string]any) (T, error) {
+		return factory(cc)
+	})
+}
+
+func (r registry[T]) AddCtx(name string, factory factoryFunc[T]) {
+	if _, exists := r.data[name]; exists {
+		panic(fmt.Sprintf("cannot register duplicate %s type: %s", r.typ, name))
+	}
+	r.data[name] = factory
+}
+
+func (r registry[T]) Get(name string) (factoryFunc[T], error) {
+	factory, exists := r.data[name]
+	if !exists {
+		return nil, fmt.Errorf("invalid %s type: %s", r.typ, name)
+	}
+	return factory, nil
+}
+
+func (r registry[T]) Types() []string {
+	return maps.Keys(r.data)
+}
+
+func New[T any](typ string) registry[T] {
+	return registry[T]{
+		typ:  typ,
+		data: make(map[string]factoryFunc[T]),
+	}
+}
diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml
index 93982c69..79f29d93 100644
--- a/util/templates/defaults.yaml
+++ b/util/templates/defaults.yaml
@@ -435,6 +435,7 @@ modbus:
   interfaces:
     rs485: ["rs485serial", "rs485tcpip"]
     tcpip: ["tcpip"]
+    udp: ["udp"]
   types:
     rs485serial:
       description:
@@ -476,6 +477,18 @@ modbus:
         - reference: true
           name: port
           default: 502
+    udp:
+      description:
+        generic: UDP
+      params:
+        - reference: true
+          referencename: modbusid
+          name: id
+        - reference: true
+          name: host
+        - reference: true
+          name: port
+          default: 502
 
 devicegroups:
   generic:
diff --git a/util/templates/documentation.go b/util/templates/documentation.go
index 004d7a18..c041af52 100644
--- a/util/templates/documentation.go
+++ b/util/templates/documentation.go
@@ -3,6 +3,7 @@ package templates
 import (
 	"bytes"
 	_ "embed"
+	"slices"
 	"strconv"
 	"strings"
 	"text/template"
@@ -77,6 +78,17 @@ func (t *Template) RenderDocumentation(product Product, lang string) ([]byte, er
 		filteredParams = append(filteredParams, param)
 	}
 
+	// all advanced params should be sorted to the end
+	slices.SortStableFunc(filteredParams, func(i, j Param) int {
+		if i.IsAdvanced() && !j.IsAdvanced() {
+			return 1
+		}
+		if !i.IsAdvanced() && j.IsAdvanced() {
+			return -1
+		}
+		return 0
+	})
+
 	data := map[string]interface{}{
 		"Template":               t.Template,
 		"ProductBrand":           product.Brand,
diff --git a/util/templates/modbus.tpl b/util/templates/modbus.tpl
index aea82255..ea374c4c 100644
--- a/util/templates/modbus.tpl
+++ b/util/templates/modbus.tpl
@@ -13,6 +13,11 @@ rtu: true
 # Modbus TCP
 uri: {{ .host }}:{{ .port }}
 rtu: false
+{{- else if or (eq .modbus "udp") .udp }}
+# Modbus UDP
+uri: {{ .host }}:{{ if (ne .port "502") }}{{ .port }}{{ else }}8899{{ end }}
+udp: true
+rtu: true
 {{- else }}
 # configuration error - should not happen
 modbusConnectionTypeNotDefined: {{ .modbus }}
diff --git a/util/templates/types.go b/util/templates/types.go
index 8a598bb2..c95838f5 100644
--- a/util/templates/types.go
+++ b/util/templates/types.go
@@ -17,9 +17,11 @@ const (
 
 	ModbusChoiceRS485    = "rs485"
 	ModbusChoiceTCPIP    = "tcpip"
+	ModbusChoiceUDP      = "udp"
 	ModbusKeyRS485Serial = "rs485serial"
 	ModbusKeyRS485TCPIP  = "rs485tcpip"
 	ModbusKeyTCPIP       = "tcpip"
+	ModbusKeyUDP         = "udp"
 
 	ModbusParamNameId       = "id"
 	ModbusParamNameDevice   = "device"
@@ -37,7 +39,7 @@ const (
 	RenderModeInstance
 )
 
-var ValidModbusChoices = []string{ModbusChoiceRS485, ModbusChoiceTCPIP}
+var ValidModbusChoices = []string{ModbusChoiceRS485, ModbusChoiceTCPIP, ModbusChoiceUDP}
 
 const (
 	CapabilityISO151182      = "iso151182"       // ISO 15118-2 support
@@ -62,7 +64,7 @@ var predefinedTemplateProperties = []string{
 	"type", "template", "name",
 	ModbusParamNameId, ModbusParamNameDevice, ModbusParamNameBaudrate, ModbusParamNameComset,
 	ModbusParamNameURI, ModbusParamNameHost, ModbusParamNamePort, ModbusParamNameRTU,
-	ModbusKeyTCPIP, ModbusKeyRS485Serial, ModbusKeyRS485TCPIP,
+	ModbusKeyTCPIP, ModbusKeyUDP, ModbusKeyRS485Serial, ModbusKeyRS485TCPIP,
 }
 
 // TextLanguage contains language-specific texts
diff --git a/vehicle/config.go b/vehicle/config.go
index ca692e59..2590013d 100644
--- a/vehicle/config.go
+++ b/vehicle/config.go
@@ -1,12 +1,14 @@
 package vehicle
 
 import (
+	"context"
 	"fmt"
 	"strings"
 	"time"
 
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util"
+	reg "github.com/evcc-io/evcc/util/registry"
 )
 
 const (
@@ -14,32 +16,11 @@ const (
 	interval = 15 * time.Minute // refresh interval when charging
 )
 
-type vehicleRegistry map[string]func(map[string]interface{}) (api.Vehicle, error)
+var registry = reg.New[api.Vehicle]("vehicle")
 
-func (r vehicleRegistry) Add(name string, factory func(map[string]interface{}) (api.Vehicle, error)) {
-	if _, exists := r[name]; exists {
-		panic(fmt.Sprintf("cannot register duplicate vehicle type: %s", name))
-	}
-	r[name] = factory
-}
-
-func (r vehicleRegistry) Get(name string) (func(map[string]interface{}) (api.Vehicle, error), error) {
-	factory, exists := r[name]
-	if !exists {
-		return nil, fmt.Errorf("invalid vehicle type: %s", name)
-	}
-	return factory, nil
-}
-
-var registry vehicleRegistry = make(map[string]func(map[string]interface{}) (api.Vehicle, error))
-
-// Types returns the list of vehicle types
+// Types returns the list of types
 func Types() []string {
-	var res []string
-	for typ := range registry {
-		res = append(res, typ)
-	}
-	return res
+	return registry.Types()
 }
 
 // NewFromConfig creates vehicle from configuration
@@ -63,7 +44,7 @@ func NewFromConfig(typ string, other map[string]interface{}) (api.Vehicle, error
 		return nil, err
 	}
 
-	v, err := factory(cc.Other)
+	v, err := factory(context.TODO(), cc.Other)
 	if err != nil {
 		err = fmt.Errorf("cannot create vehicle type '%s': %w", typ, err)
 	}
diff --git a/vehicle/mercedes/api.go b/vehicle/mercedes/api.go
index 1ba5546d..47acfb02 100644
--- a/vehicle/mercedes/api.go
+++ b/vehicle/mercedes/api.go
@@ -97,6 +97,10 @@ func (v *API) Status(vin string) (StatusResponse, error) {
 		res.EvInfo.Battery.ChargingStatus = 3
 	}
 
+	if val, ok := message.Attributes["maxSoc"]; ok && val != nil {
+		res.EvInfo.Battery.SocLimit = int(val.GetIntValue())
+	}
+
 	if val, ok := message.Attributes["selectedChargeProgram"]; ok && val != nil {
 		selectedChargeProgram := val.GetIntValue()
 		res.EvInfo.Battery.SelectedChargeProgram = int(selectedChargeProgram)
@@ -108,10 +112,7 @@ func (v *API) Status(vin string) (StatusResponse, error) {
 				}
 			}
 		}
-	} else {
-		if val, ok := message.Attributes["maxSoc"]; ok && val != nil {
-			res.EvInfo.Battery.SocLimit = int(val.GetIntValue())
-		}
 	}
+
 	return res, err
 }


Test patch:
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index 2c74d474..26fdeef9 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -182,7 +182,7 @@ func TestUpdatePowerZero(t *testing.T) {
 		}
 
 		lp.mode = tc.mode
-		lp.Update(0, false, time.Time{}, false, false, 0, nil, nil) // false,sitePower false,0
+		lp.Update(0, nil, false, false, 0, nil, nil) // false,sitePower false,0
 
 		ctrl.Finish()
 	}
@@ -428,7 +428,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
 	charger.EXPECT().Status().Return(api.StatusC, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
-	lp.Update(500, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(500, nil, false, false, 0, nil, nil)
 	ctrl.Finish()
 
 	t.Log("charging above target - soc deactivates charger")
@@ -437,7 +437,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
 	charger.EXPECT().Status().Return(api.StatusC, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Enable(false).Return(nil)
-	lp.Update(500, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(500, nil, false, false, 0, nil, nil)
 	ctrl.Finish()
 
 	t.Log("deactivated charger changes status to B")
@@ -445,14 +445,14 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
 	vehicle.EXPECT().Soc().Return(95.0, nil)
 	charger.EXPECT().Status().Return(api.StatusB, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
-	lp.Update(-5000, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-500, nil, false, false, 0, nil, nil)
 	ctrl.Finish()
 
-	t.Log("soc has fallen below target - soc update prevented by timer")
+	t.Log("soc has risen below target - soc update prevented by timer")
 	clock.Add(5 * time.Minute)
 	charger.EXPECT().Status().Return(api.StatusB, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
-	lp.Update(-5000, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-500, nil, false, false, 0, nil, nil)
 	ctrl.Finish()
 
 	t.Log("soc has fallen below target - soc update timer expired")
@@ -460,8 +460,9 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
 	vehicle.EXPECT().Soc().Return(85.0, nil)
 	charger.EXPECT().Status().Return(api.StatusB, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
+	charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
 	charger.EXPECT().Enable(true).Return(nil)
-	lp.Update(-5000, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-500, nil, false, false, 0, nil, nil)
 	ctrl.Finish()
 }
 
@@ -496,14 +497,14 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusC, nil)
 	charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
-	lp.Update(500, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(500, nil, false, false, 0, nil, nil)
 
 	t.Log("switch off when disconnected")
 	clock.Add(5 * time.Minute)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusA, nil)
 	charger.EXPECT().Enable(false).Return(nil)
-	lp.Update(-3000, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-300, nil, false, false, 0, nil, nil)
 
 	if mode := lp.GetMode(); mode != api.ModeOff {
 		t.Error("unexpected mode", mode)
@@ -566,14 +567,14 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
 	rater.EXPECT().ChargedEnergy().Return(0.0, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusC, nil)
-	lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-1, nil, false, false, 0, nil, nil)
 
 	t.Log("at 1:00h charging at 5 kWh")
 	clock.Add(time.Hour)
 	rater.EXPECT().ChargedEnergy().Return(5.0, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusC, nil)
-	lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-1, nil, false, false, 0, nil, nil)
 	expectCache("chargedEnergy", 5000.0)
 
 	t.Log("at 1:00h stop charging at 5 kWh")
@@ -581,7 +582,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
 	rater.EXPECT().ChargedEnergy().Return(5.0, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusB, nil)
-	lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-1, nil, false, false, 0, nil, nil)
 	expectCache("chargedEnergy", 5000.0)
 
 	t.Log("at 1:00h restart charging at 5 kWh")
@@ -589,7 +590,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
 	rater.EXPECT().ChargedEnergy().Return(5.0, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusC, nil)
-	lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-1, nil, false, false, 0, nil, nil)
 	expectCache("chargedEnergy", 5000.0)
 
 	t.Log("at 1:30h continue charging at 7.5 kWh")
@@ -597,7 +598,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
 	rater.EXPECT().ChargedEnergy().Return(7.5, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusC, nil)
-	lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-1, nil, false, false, 0, nil, nil)
 	expectCache("chargedEnergy", 7500.0)
 
 	t.Log("at 2:00h stop charging at 10 kWh")
@@ -605,7 +606,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
 	rater.EXPECT().ChargedEnergy().Return(10.0, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusB, nil)
-	lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-1, nil, false, false, 0, nil, nil)
 	expectCache("chargedEnergy", 10000.0)
 
 	ctrl.Finish()
diff --git a/core/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go
index 3ac7320b..5b846796 100644
--- a/core/loadpoint_vehicle_test.go
+++ b/core/loadpoint_vehicle_test.go
@@ -270,7 +270,7 @@ func TestReconnectVehicle(t *testing.T) {
 			// vehicle not updated yet
 			vehicle.MockChargeState.EXPECT().Status().Return(api.StatusA, nil)
 
-			lp.Update(0, false, time.Time{}, false, false, 0, nil, nil)
+			lp.Update(0, nil, false, false, 0, nil, nil)
 			ctrl.Finish()
 
 			// detection started
@@ -284,7 +284,7 @@ func TestReconnectVehicle(t *testing.T) {
 			// vehicle not updated yet
 			vehicle.MockChargeState.EXPECT().Status().Return(api.StatusB, nil)
 
-			lp.Update(0, false, time.Time{}, false, false, 0, nil, nil)
+			lp.Update(0, nil, false, false, 0, nil, nil)
 			ctrl.Finish()
 
 			// vehicle detected
diff --git a/core/site_battery_test.go b/core/site_battery_test.go
deleted file mode 100644
index a39f0833..00000000
--- a/core/site_battery_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package core
-
-import (
-	"testing"
-
-	"github.com/evcc-io/evcc/api"
-	"github.com/evcc-io/evcc/util"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-	"go.uber.org/mock/gomock"
-)
-
-func TestUpdateBatteryMode(t *testing.T) {
-	expBatMode := api.BatteryHold
-
-	ctrl := gomock.NewController(t)
-
-	batCtrl := struct {
-		*api.MockBatteryController
-		*api.MockMeter
-	}{
-		api.NewMockBatteryController(ctrl),
-		api.NewMockMeter(ctrl),
-	}
-	batCtrl.MockBatteryController.EXPECT().SetBatteryMode(expBatMode).Times(1)
-
-	s := &Site{
-		log:           util.NewLogger("foo"),
-		batteryMeters: []api.Meter{batCtrl},
-		batteryMode:   api.BatteryNormal,
-	}
-
-	err := s.applyBatteryMode(expBatMode)
-	require.NoError(t, err)
-	assert.Equal(t, expBatMode, s.GetBatteryMode())
-}
diff --git a/meter/template_test.go b/meter/template_test.go
index 9a4067be..eaf8d134 100644
--- a/meter/template_test.go
+++ b/meter/template_test.go
@@ -13,9 +13,10 @@ var acceptable = []string{
 	"missing token",
 	"mqtt not configured",
 	"not a SunSpec device",
-	"missing credentials", // sockets
-	"power: timeout",      // sockets
-	"missing password",    // Powerwall
+	"connect: connection refused", // sockets
+	"missing credentials",         // sockets
+	"power: timeout",              // sockets
+	"missing password",            // Powerwall
 	"connect: no route to host",
 	"connect: connection refused",
 	"connect: network is unreachable",
diff --git a/server/modbus/proxy_test.go b/server/modbus/proxy_test.go
index 5bd72f7c..84cb382c 100644
--- a/server/modbus/proxy_test.go
+++ b/server/modbus/proxy_test.go
@@ -27,21 +27,21 @@ func TestConcurrentRead(t *testing.T) {
 	require.NoError(t, srv.Start(l))
 	defer func() { _ = srv.Stop() }()
 
-	// client
-	conn, err := modbus.NewConnection(l.Addr().String(), "", "", 0, modbus.Tcp, 1)
-	require.NoError(t, err)
-
 	var wg sync.WaitGroup
 
 	for i := 1; i <= 10; i++ {
 		wg.Add(1)
 
 		go func(id int) {
+			// client
+			conn, err := modbus.NewConnection(l.Addr().String(), "", "", 0, modbus.Tcp, uint8(id))
+			require.NoError(t, err)
+
 			for i := 0; i < 50; i++ {
 				addr := uint16(rand.Int31n(200) + 1)
 				qty := uint16(rand.Int31n(32) + 1)
 
-				b, err := conn.ReadInputRegistersWithSlave(uint8(id), addr, qty)
+				b, err := conn.ReadInputRegisters(addr, qty)
 				require.NoError(t, err)
 
 				if err == nil {
@@ -94,24 +94,24 @@ func TestReadCoils(t *testing.T) {
 		require.NoError(t, err)
 
 		{ // read
-			b, err := conn.ReadCoilsWithSlave(1, 1, 1)
+			b, err := conn.ReadCoils(1, 1)
 			require.NoError(t, err)
 			assert.Equal(t, []byte{0x01}, b)
 
-			b, err = conn.ReadCoilsWithSlave(1, 1, 2)
+			b, err = conn.ReadCoils(1, 2)
 			require.NoError(t, err)
 			assert.Equal(t, []byte{0x03}, b)
 
-			b, err = conn.ReadCoilsWithSlave(1, 1, 9)
+			b, err = conn.ReadCoils(1, 9)
 			require.NoError(t, err)
 			assert.Equal(t, []byte{0xFF, 0x01}, b)
 		}
 		{ // write
-			b, err := conn.WriteSingleCoilWithSlave(1, 1, 0xFF00)
+			b, err := conn.WriteSingleCoil(1, 0xFF00)
 			require.NoError(t, err)
 			assert.Equal(t, []byte{0xFF, 0x00}, b)
 
-			b, err = conn.WriteMultipleCoilsWithSlave(1, 1, 9, []byte{0xFF, 0x01})
+			b, err = conn.WriteMultipleCoils(1, 9, []byte{0xFF, 0x01})
 			require.NoError(t, err)
 			assert.Equal(t, []byte{0x00, 0x09}, b)
 		}
diff --git a/tests/battery-settings.evcc.yaml b/tests/battery-settings.evcc.yaml
new file mode 100755
index 00000000..9dda4dcf
--- /dev/null
+++ b/tests/battery-settings.evcc.yaml
@@ -0,0 +1,71 @@
+interval: 0.1s
+
+site:
+  title: Battery Settings
+  meters:
+    grid: grid
+    pv: pv
+    battery: battery
+
+meters:
+  - name: grid
+    type: custom
+    power:
+      source: const
+      value: 1000
+  - name: pv
+    type: custom
+    power:
+      source: const
+      value: 2000
+  - name: battery
+    type: custom
+    power:
+      source: const
+      value: -1000
+    soc:
+      source: const
+      value: 50
+    capacity: 20
+    batterymode:
+      source: js
+      vm: shared
+      script: |
+        1
+
+loadpoints:
+  - title: Carport
+    charger: charger
+    mode: now
+
+chargers:
+  - name: charger
+    type: custom
+    enable:
+      source: js
+      script:
+    enabled:
+      source: js
+      script: |
+        true
+    status:
+      source: js
+      script: |
+        "C"
+    maxcurrent:
+      source: js
+      script: |
+        16
+
+tariffs:
+  currency: EUR
+  grid:
+    type: fixed
+    price: 0.2 # EUR/kWh
+    zones:
+      - hours: 5-7
+        price: 0.6
+      - hours: 11-14
+        price: 0.05
+      - hours: 17-21
+        price: 0.5
diff --git a/tests/battery-settings.spec.js b/tests/battery-settings.spec.js
new file mode 100644
index 00000000..1b56a83a
--- /dev/null
+++ b/tests/battery-settings.spec.js
@@ -0,0 +1,62 @@
+import { test, expect } from "@playwright/test";
+import { start, stop, baseUrl } from "./evcc";
+
+test.use({ baseURL: baseUrl() });
+
+test.beforeAll(async () => {
+  await start("battery-settings.evcc.yaml", "password.sql");
+});
+test.afterAll(async () => {
+  await stop();
+});
+
+async function enableExperimental(page) {
+  await page.getByTestId("topnavigation-button").click();
+  await page.getByTestId("topnavigation-settings").click();
+  await page.getByLabel("Experimental 🧪").click();
+  await page.getByRole("button", { name: "Close" }).click();
+  await expect(page.locator(".modal-backdrop")).not.toBeVisible();
+}
+
+test.describe("battery settings", async () => {
+  test("open modal", async ({ page }) => {
+    await page.goto("/");
+    await page.getByTestId("topnavigation-button").click();
+    await page.getByTestId("topnavigation-battery").click();
+
+    const modal = page.getByTestId("battery-settings-modal");
+    await expect(modal.getByRole("heading", { name: "Home Battery" })).toBeVisible();
+    await expect(modal.getByRole("link", { name: "Grid charging 🧪" })).not.toBeVisible();
+    await expect(modal).toContainText("Battery level: 50%");
+    await expect(modal).toContainText("10.0 kWh of 20.0 kWh");
+  });
+
+  test("battery usage", async ({ page }) => {
+    await page.goto("/");
+    await page.getByTestId("topnavigation-button").click();
+    await page.getByTestId("topnavigation-battery").click();
+
+    await page.locator("#batterySettingsPriority").selectOption({ label: "50%" });
+    await expect(page.locator("label[for=batterySettingsPriorityMiddle] span")).toHaveText("50%");
+    await expect(page.locator("label[for=batterySettingsPriorityBottom] span")).toHaveText("50%");
+    await page.locator("#batterySettingsBufferTop").selectOption({ label: "80%" });
+    await page.locator("#batterySettingsBufferStart").selectOption({ label: "when above 90%." });
+    await expect(page.locator("label[for=batterySettingsBuffer] span")).toHaveText("80%");
+  });
+
+  test("grid charging", async ({ page }) => {
+    await page.goto("/");
+    await enableExperimental(page);
+    await page.getByTestId("topnavigation-button").click();
+    await page.getByTestId("topnavigation-battery").click();
+
+    await page.getByRole("link", { name: "Grid charging 🧪" }).click();
+    await page.getByLabel("Price limit").selectOption({ label: "≤ 50.0 ct/kWh" });
+    await expect(page.getByTestId("battery-settings-modal")).toContainText("5.0 ct – 50.0 ct");
+    await page.getByRole("button", { name: "Close" }).click();
+    await expect(page.getByTestId("battery-settings-modal")).not.toBeVisible();
+    await page.getByTestId("energyflow").click();
+    await page.getByRole("button", { name: "cheap grid energy (≤ 50.0 ct)" }).click();
+    await expect(page.getByTestId("battery-settings-modal")).toBeVisible();
+  });
+});
diff --git a/util/templates/render_testing.go b/util/templates/render_testing.go
index 5ad7b912..ab6a63b6 100644
--- a/util/templates/render_testing.go
+++ b/util/templates/render_testing.go
@@ -48,6 +48,8 @@ func TestClass(t *testing.T, class Class, instantiate func(t *testing.T, values
 			// we only test one modbus setup
 			if slices.Contains(modbusChoices, ModbusChoiceTCPIP) {
 				values[ModbusKeyTCPIP] = true
+			} else if slices.Contains(modbusChoices, ModbusChoiceUDP) {
+				values[ModbusKeyUDP] = true
 			} else {
 				values[ModbusKeyRS485TCPIP] = true
 			}