Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f5106e3a0 |
@@ -80,6 +80,8 @@ Konfigurierbar pro Gerät im Web UI:
|
|||||||
>
|
>
|
||||||
> Die EMS-Funktion muss zusätzlich in der Kathrein easyOperate-App aktiviert sein.
|
> Die EMS-Funktion muss zusätzlich in der Kathrein easyOperate-App aktiviert sein.
|
||||||
|
|
||||||
|
**Prognosebasierter Zwangslade-Timeout (optional):** Mit Standort (Breitengrad/Längengrad, Einstellungen-Tab) passt sich der Zwangslade-Timeout an die Solarstrahlungs-Prognose (Open-Meteo, kostenlos) an — bei bald zu erwartender Sonne wird länger gewartet (gedeckelt), bei aussichtsloser Prognose eher vom Netz geladen statt sinnlos den vollen Timeout abzuwarten. Ohne Standort unverändertes Verhalten (fester Timeout).
|
||||||
|
|
||||||
**Batterie-Schutz beim PV-Überschussladen:** Bei Anlagen mit Hausbatterie (z. B. Goodwe) wird die aktuelle Batterie-Entladeleistung vom berechneten PV-Überschuss abgezogen — ein Netzexport, der eigentlich aus dem Speicher kommt (z. B. bei bewölktem Wetter), wird so nicht fälschlich als PV-Überschuss fürs Auto verwendet. Betrifft nur PV-Überschussladen, Zwangsladen bleibt unverändert netzbasiert.
|
**Batterie-Schutz beim PV-Überschussladen:** Bei Anlagen mit Hausbatterie (z. B. Goodwe) wird die aktuelle Batterie-Entladeleistung vom berechneten PV-Überschuss abgezogen — ein Netzexport, der eigentlich aus dem Speicher kommt (z. B. bei bewölktem Wetter), wird so nicht fälschlich als PV-Überschuss fürs Auto verwendet. Betrifft nur PV-Überschussladen, Zwangsladen bleibt unverändert netzbasiert.
|
||||||
|
|
||||||
## Netzleistung (grid_power)
|
## Netzleistung (grid_power)
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ Reihenfolge nach Aufwand/Abhängigkeit, nicht nach Wichtigkeit.
|
|||||||
4. **Lastspitzenkappung (Peak Shaving)** — Ladestrom drosseln, wenn Netzbezug (mehrere Autos + Haushalt gleichzeitig) einen Grenzwert überschreitet. Eigenes Sicherheits-Design nötig (was passiert bei Messfehler/Ausfall — nie ungebremst weiterladen).
|
4. **Lastspitzenkappung (Peak Shaving)** — Ladestrom drosseln, wenn Netzbezug (mehrere Autos + Haushalt gleichzeitig) einen Grenzwert überschreitet. Eigenes Sicherheits-Design nötig (was passiert bei Messfehler/Ausfall — nie ungebremst weiterladen).
|
||||||
5. **Prognosebasiertes Laden** — in zwei Teilen, hintereinander gebaut:
|
5. **Prognosebasiertes Laden** — in zwei Teilen, hintereinander gebaut:
|
||||||
- ~~**Teil A: Batteriebewusster PV-Überschuss**~~ — erledigt (v1.11.2): Batterie-Entladeleistung wird vom PV-Überschuss abgezogen, damit PV-Überschussladen nie versehentlich den Hausspeicher anzapft (Zwangsladen bleibt unverändert netzbasiert).
|
- ~~**Teil A: Batteriebewusster PV-Überschuss**~~ — erledigt (v1.11.2): Batterie-Entladeleistung wird vom PV-Überschuss abgezogen, damit PV-Überschussladen nie versehentlich den Hausspeicher anzapft (Zwangsladen bleibt unverändert netzbasiert).
|
||||||
- **Teil B: Wetterprognose + Sonnenstand** — PV-Wetterprognose (z.B. Open-Meteo/Solcast) + Spot-Preis kombinieren, damit Zwangsladen nicht stur nach Timeout entscheidet, sondern z.B. "morgen wird's sonnig, noch etwas warten" berücksichtigt. Größter Umfang, externe API-Anbindung nötig.
|
- ~~**Teil B: Wetterprognose**~~ — erledigt (v1.12.0): Standort (Breitengrad/Längengrad) im Einstellungen-Tab, optional. Open-Meteo-Solarstrahlungsprognose (kostenlos, kein Sonnenstand extra nötig — steckt implizit in der stündlichen Prognose) passt den Zwangslade-Timeout adaptiv an: bald Sonne zu erwarten → länger warten (gedeckelt); aussichtslose Prognose → früher vom Netz laden. Ohne Standort unverändertes Verhalten.
|
||||||
6. **Chart-Feintuning** — läuft nebenbei mit, keine feste Priorität (z.B. Live-Kosten-Ticker statt nur Rückblick).
|
6. **Chart-Feintuning** — läuft nebenbei mit, keine feste Priorität (z.B. Live-Kosten-Ticker statt nur Rückblick).
|
||||||
7. **Auto-Cleanup verwaister MQTT-Geräte** — wenn ein Gerät aus ShineBridge entfernt wird, dessen MQTT-Discovery-Topics aktiv abmelden statt sie als tote HA-Geräte-Leichen zurückzulassen (siehe Fund unten).
|
7. **Auto-Cleanup verwaister MQTT-Geräte** — wenn ein Gerät aus ShineBridge entfernt wird, dessen MQTT-Discovery-Topics aktiv abmelden statt sie als tote HA-Geräte-Leichen zurückzulassen (siehe Fund unten).
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: ShineBridge
|
name: ShineBridge
|
||||||
version: "1.11.2"
|
version: "1.12.0"
|
||||||
slug: shinebridge
|
slug: shinebridge
|
||||||
description: Energiemanagement für Growatt/Goodwe-Wechselrichter — PV-Überschussladen, Wallbox-EMS, Tarifvergleich, Zigbee2MQTT-Überschussgeräte
|
description: Energiemanagement für Growatt/Goodwe-Wechselrichter — PV-Überschussladen, Wallbox-EMS, Tarifvergleich, Zigbee2MQTT-Überschussgeräte
|
||||||
url: https://gitea.bitfire.work/retr0/shinebridge
|
url: https://gitea.bitfire.work/retr0/shinebridge
|
||||||
|
|||||||
@@ -76,14 +76,18 @@ class EmsController:
|
|||||||
return max(6000, min(self.max_charge_ma, ma))
|
return max(6000, min(self.max_charge_ma, ma))
|
||||||
|
|
||||||
def update(self, wallbox_reader, pv_surplus_w: float, charging_state: int,
|
def update(self, wallbox_reader, pv_surplus_w: float, charging_state: int,
|
||||||
wallbox_power_w: float = 0.0) -> str:
|
wallbox_power_w: float = 0.0, timeout_override_h: Optional[float] = None) -> str:
|
||||||
"""
|
"""
|
||||||
Wird vom Poll-Loop aufgerufen.
|
Wird vom Poll-Loop aufgerufen.
|
||||||
pv_surplus_w: positiv = PV exportiert ins Netz (verbleibender Überschuss)
|
pv_surplus_w: positiv = PV exportiert ins Netz (verbleibender Überschuss)
|
||||||
wallbox_power_w: aktuell von der Wallbox gezogene Leistung (aus Meter-Daten)
|
wallbox_power_w: aktuell von der Wallbox gezogene Leistung (aus Meter-Daten)
|
||||||
|
timeout_override_h: falls gesetzt, ersetzt self.pv_timeout_h für diesen Aufruf
|
||||||
|
(Prognose-basierte Anpassung, siehe weather_forecast.adjusted_timeout_h —
|
||||||
|
wird vom Poll-Loop berechnet, EmsController kennt selbst keine Standortdaten)
|
||||||
Gibt eine kurze Status-Beschreibung zurück.
|
Gibt eine kurze Status-Beschreibung zurück.
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
effective_timeout_h = timeout_override_h if timeout_override_h is not None else self.pv_timeout_h
|
||||||
|
|
||||||
# EMS einmalig aktivieren wenn Auto angesteckt
|
# EMS einmalig aktivieren wenn Auto angesteckt
|
||||||
if charging_state in (STATE_CONNECTED, STATE_CHARGING, STATE_PAUSED):
|
if charging_state in (STATE_CONNECTED, STATE_CHARGING, STATE_PAUSED):
|
||||||
@@ -132,7 +136,7 @@ class EmsController:
|
|||||||
|
|
||||||
no_pv_h = (now - self._no_pv_since) / 3600.0
|
no_pv_h = (now - self._no_pv_since) / 3600.0
|
||||||
|
|
||||||
if no_pv_h >= self.pv_timeout_h:
|
if no_pv_h >= effective_timeout_h:
|
||||||
# Zwangsladen bis Zielzeit — Strom bei JEDEM Update erneut setzen (nicht nur beim
|
# Zwangsladen bis Zielzeit — Strom bei JEDEM Update erneut setzen (nicht nur beim
|
||||||
# ersten Eintritt), sonst bleibt ein einzelner fehlgeschlagener/verworfener Write
|
# ersten Eintritt), sonst bleibt ein einzelner fehlgeschlagener/verworfener Write
|
||||||
# (Modbus-Hänger, Fahrzeug-Störung) unbemerkt für den Rest der Nacht stehen.
|
# (Modbus-Hänger, Fahrzeug-Störung) unbemerkt für den Rest der Nacht stehen.
|
||||||
@@ -140,8 +144,9 @@ class EmsController:
|
|||||||
mins_left = (target - datetime.now()).total_seconds() / 60
|
mins_left = (target - datetime.now()).total_seconds() / 60
|
||||||
if not self._forced_charging:
|
if not self._forced_charging:
|
||||||
self._forced_charging = True
|
self._forced_charging = True
|
||||||
log.info("[EMS] Zwangsladen gestartet — %dmin bis %02d:00, max %.0fA",
|
log.info("[EMS] Zwangsladen gestartet — %dmin bis %02d:00, max %.0fA (Timeout %.1fh%s)",
|
||||||
int(mins_left), self.target_hour, self.max_charge_ma / 1000)
|
int(mins_left), self.target_hour, self.max_charge_ma / 1000, effective_timeout_h,
|
||||||
|
" prognose-angepasst" if timeout_override_h is not None else "")
|
||||||
wallbox_reader.set_current(self.max_charge_ma, self.phases)
|
wallbox_reader.set_current(self.max_charge_ma, self.phases)
|
||||||
return f"Zwangsladen ({no_pv_h:.1f}h kein PV, {int(mins_left)}min bis {self.target_hour:02d}:00)"
|
return f"Zwangsladen ({no_pv_h:.1f}h kein PV, {int(mins_left)}min bis {self.target_hour:02d}:00)"
|
||||||
|
|
||||||
|
|||||||
+19
-2
@@ -15,6 +15,7 @@ from modbus_client import ModbusReader
|
|||||||
from goodwe_client import GoodweReader
|
from goodwe_client import GoodweReader
|
||||||
from wallbox_client import WallboxReader
|
from wallbox_client import WallboxReader
|
||||||
from ha_entity_client import HAEntityReader
|
from ha_entity_client import HAEntityReader
|
||||||
|
import weather_forecast
|
||||||
from ems_controller import EmsController
|
from ems_controller import EmsController
|
||||||
from mqtt_publisher import MqttPublisher
|
from mqtt_publisher import MqttPublisher
|
||||||
from surplus_devices import SurplusDeviceController
|
from surplus_devices import SurplusDeviceController
|
||||||
@@ -125,6 +126,8 @@ def _defaults() -> Dict[str, Any]:
|
|||||||
"surplus_devices": [],
|
"surplus_devices": [],
|
||||||
"z2m_base": "zigbee2mqtt",
|
"z2m_base": "zigbee2mqtt",
|
||||||
"config_backup_retention_days": 14,
|
"config_backup_retention_days": 14,
|
||||||
|
"site_lat": None,
|
||||||
|
"site_lon": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _load_json_safe(path: str) -> Optional[Dict]:
|
def _load_json_safe(path: str) -> Optional[Dict]:
|
||||||
@@ -176,6 +179,8 @@ def save_config():
|
|||||||
"surplus_devices": State.surplus_devices_cfg,
|
"surplus_devices": State.surplus_devices_cfg,
|
||||||
"z2m_base": State.z2m_base,
|
"z2m_base": State.z2m_base,
|
||||||
"config_backup_retention_days": State.mqtt_cfg.get("config_backup_retention_days", 14),
|
"config_backup_retention_days": State.mqtt_cfg.get("config_backup_retention_days", 14),
|
||||||
|
"site_lat": State.mqtt_cfg.get("site_lat"),
|
||||||
|
"site_lon": State.mqtt_cfg.get("site_lon"),
|
||||||
}
|
}
|
||||||
# Backup der letzten guten Config anlegen
|
# Backup der letzten guten Config anlegen
|
||||||
if os.path.exists(CONFIG_PATH):
|
if os.path.exists(CONFIG_PATH):
|
||||||
@@ -366,7 +371,10 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
|||||||
# charging_state aus 0x0060 (solo-Read); fehlt bei kein Fahrzeug → 0 (Idle)
|
# charging_state aus 0x0060 (solo-Read); fehlt bei kein Fahrzeug → 0 (Idle)
|
||||||
charging_state = int(values.get("charging_state", 0))
|
charging_state = int(values.get("charging_state", 0))
|
||||||
wallbox_power = values.get("total_power", 0.0)
|
wallbox_power = values.get("total_power", 0.0)
|
||||||
ems_status = ems.update(reader, pv_surplus, charging_state, wallbox_power)
|
site_lat = State.mqtt_cfg.get("site_lat")
|
||||||
|
site_lon = State.mqtt_cfg.get("site_lon")
|
||||||
|
timeout_override = weather_forecast.adjusted_timeout_h(ems.pv_timeout_h, site_lat, site_lon)
|
||||||
|
ems_status = ems.update(reader, pv_surplus, charging_state, wallbox_power, timeout_override)
|
||||||
values["ems_status_code"] = float(charging_state)
|
values["ems_status_code"] = float(charging_state)
|
||||||
log.info("[%s] EMS: %s | PV-Überschuss: %.0fW", inv_id, ems_status, pv_surplus)
|
log.info("[%s] EMS: %s | PV-Überschuss: %.0fW", inv_id, ems_status, pv_surplus)
|
||||||
|
|
||||||
@@ -527,6 +535,15 @@ def api_save_config():
|
|||||||
State.mqtt_cfg[k] = float(data[k])
|
State.mqtt_cfg[k] = float(data[k])
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
for k in ("site_lat", "site_lon"):
|
||||||
|
if k in data:
|
||||||
|
if data[k] in (None, ""):
|
||||||
|
State.mqtt_cfg[k] = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
State.mqtt_cfg[k] = float(data[k])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
for k in ("billing_day", "billing_month", "config_backup_retention_days"):
|
for k in ("billing_day", "billing_month", "config_backup_retention_days"):
|
||||||
if k in data and data[k] is not None:
|
if k in data and data[k] is not None:
|
||||||
try:
|
try:
|
||||||
@@ -1049,7 +1066,7 @@ if __name__ == "__main__":
|
|||||||
"price_import", "price_export", "billing_day", "billing_month",
|
"price_import", "price_export", "billing_day", "billing_month",
|
||||||
"tariff_type", "spot_country", "spot_markup", "spot_chart",
|
"tariff_type", "spot_country", "spot_markup", "spot_chart",
|
||||||
"billing_tracker_enabled", "monthly_rate_eur", "grundpreis_eur_per_month",
|
"billing_tracker_enabled", "monthly_rate_eur", "grundpreis_eur_per_month",
|
||||||
"config_backup_retention_days",
|
"config_backup_retention_days", "site_lat", "site_lon",
|
||||||
) if k in cfg}
|
) if k in cfg}
|
||||||
State.inverters_cfg = cfg.get("inverters", [])
|
State.inverters_cfg = cfg.get("inverters", [])
|
||||||
State.surplus_devices_cfg = cfg.get("surplus_devices", [])
|
State.surplus_devices_cfg = cfg.get("surplus_devices", [])
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Solarstrahlungs-Prognose über Open-Meteo (kostenlos, kein API-Key nötig).
|
||||||
|
|
||||||
|
Wird genutzt, um den Zwangslade-Timeout im EMS-Controller adaptiv zu machen:
|
||||||
|
bei noch zu erwartender Sonne wird länger gewartet, bei aussichtsloser Prognose
|
||||||
|
wird eher vom Netz geladen statt sinnlos den vollen Timeout abzuwarten.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import json
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_CACHE_TTL_S = 3 * 3600 # Prognose alle 3h neu holen, nicht bei jedem Poll
|
||||||
|
_cache: dict = {} # key: (lat, lon) -> {"ts": float, "hourly": [(epoch_s, ghi_w_m2), ...]}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch(lat: float, lon: float) -> Optional[List[Tuple[int, float]]]:
|
||||||
|
url = (
|
||||||
|
"https://api.open-meteo.com/v1/forecast"
|
||||||
|
f"?latitude={lat}&longitude={lon}"
|
||||||
|
"&hourly=shortwave_radiation&forecast_days=2&timezone=auto"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=10) as r:
|
||||||
|
data = json.loads(r.read())
|
||||||
|
times = data["hourly"]["time"]
|
||||||
|
values = data["hourly"]["shortwave_radiation"]
|
||||||
|
# ISO-Zeitstrings (lokale Zeit dank timezone=auto) -> Unix-Timestamp
|
||||||
|
import datetime
|
||||||
|
out = []
|
||||||
|
for t, v in zip(times, values):
|
||||||
|
dt = datetime.datetime.fromisoformat(t)
|
||||||
|
out.append((int(dt.timestamp()), float(v or 0.0)))
|
||||||
|
return out
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError, KeyError, ValueError) as e:
|
||||||
|
log.warning("[Weather] Open-Meteo Abfrage fehlgeschlagen: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_hourly_ghi(lat: float, lon: float) -> Optional[List[Tuple[int, float]]]:
|
||||||
|
"""Liefert [(unix_ts, ghi_w_m2), ...] für die nächsten ~48h, gecacht."""
|
||||||
|
key = (round(lat, 3), round(lon, 3))
|
||||||
|
cached = _cache.get(key)
|
||||||
|
now = time.time()
|
||||||
|
if cached and now - cached["ts"] < _CACHE_TTL_S:
|
||||||
|
return cached["hourly"]
|
||||||
|
|
||||||
|
hourly = _fetch(lat, lon)
|
||||||
|
if hourly is None:
|
||||||
|
return cached["hourly"] if cached else None # alten Stand weiterverwenden statt nichts
|
||||||
|
|
||||||
|
_cache[key] = {"ts": now, "hourly": hourly}
|
||||||
|
return hourly
|
||||||
|
|
||||||
|
|
||||||
|
def adjusted_timeout_h(base_timeout_h: float, lat: Optional[float], lon: Optional[float]) -> float:
|
||||||
|
"""Passt den Zwangslade-Timeout an die Prognose an. Ohne Standort (lat/lon
|
||||||
|
nicht gesetzt) unverändert den Basiswert zurückgeben — Feature ist Opt-in."""
|
||||||
|
if lat is None or lon is None:
|
||||||
|
return base_timeout_h
|
||||||
|
|
||||||
|
hourly = get_hourly_ghi(lat, lon)
|
||||||
|
if not hourly:
|
||||||
|
return base_timeout_h
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
# Strahlung in den naechsten 3h (kurzfristig: lohnt es sich, gleich noch zu warten?)
|
||||||
|
near = [ghi for ts, ghi in hourly if now <= ts < now + 3 * 3600]
|
||||||
|
# Strahlung im Rest des "Tagesfensters" (grob 12h voraus, deckt auch noch
|
||||||
|
# kommende Vormittagssonne ab, falls schon frueh am Morgen geprueft wird)
|
||||||
|
horizon = [ghi for ts, ghi in hourly if now <= ts < now + 12 * 3600]
|
||||||
|
|
||||||
|
SUN_THRESHOLD = 80.0 # W/m² — deutlich mehr als Daemmerung, aber kein voller Sonnentag noetig
|
||||||
|
|
||||||
|
if near and max(near) >= SUN_THRESHOLD:
|
||||||
|
# Bald noch nennenswerte Sonne zu erwarten -> mehr Geduld, gedeckelt
|
||||||
|
return min(base_timeout_h * 1.75, base_timeout_h + 4.0)
|
||||||
|
if horizon and max(horizon) < SUN_THRESHOLD:
|
||||||
|
# Auch im Rest des Fensters keine nennenswerte Sonne zu erwarten ->
|
||||||
|
# nicht sinnlos den vollen Timeout abwarten, aber nie unter 0.5h
|
||||||
|
return max(base_timeout_h * 0.5, 0.5)
|
||||||
|
return base_timeout_h
|
||||||
@@ -233,6 +233,18 @@
|
|||||||
<button class="btn btn-primary" onclick="saveMqtt()">Speichern & Neu starten</button>
|
<button class="btn btn-primary" onclick="saveMqtt()">Speichern & Neu starten</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Standort</h3>
|
||||||
|
<p style="color:var(--text-dim);font-size:.85rem;margin:0 0 .75rem">Für die prognosebasierte Anpassung des Zwangslade-Timeouts (Solarstrahlungs-Vorhersage via Open-Meteo). Optional — leer lassen deaktiviert die Anpassung, Zwangsladen nutzt dann weiter den festen Timeout.</p>
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||||||
|
<div class="field" style="max-width:180px"><label>Breitengrad</label>
|
||||||
|
<input type="number" id="cfg-site-lat" step="0.0001" placeholder="z.B. 52.5200"></div>
|
||||||
|
<div class="field" style="max-width:180px"><label>Längengrad</label>
|
||||||
|
<input type="number" id="cfg-site-lon" step="0.0001" placeholder="z.B. 13.4050"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="saveLocation()">Speichern</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Stromtarif</h3>
|
<h3>Stromtarif</h3>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -1238,6 +1250,20 @@ async function loadSettings() {
|
|||||||
document.getElementById("cfg-monthly-rate").value = cfg.monthly_rate_eur ?? 0;
|
document.getElementById("cfg-monthly-rate").value = cfg.monthly_rate_eur ?? 0;
|
||||||
document.getElementById("cfg-grundpreis").value = cfg.grundpreis_eur_per_month ?? 0;
|
document.getElementById("cfg-grundpreis").value = cfg.grundpreis_eur_per_month ?? 0;
|
||||||
document.getElementById("cfg-backup-retention").value = cfg.config_backup_retention_days ?? 14;
|
document.getElementById("cfg-backup-retention").value = cfg.config_backup_retention_days ?? 14;
|
||||||
|
document.getElementById("cfg-site-lat").value = cfg.site_lat ?? "";
|
||||||
|
document.getElementById("cfg-site-lon").value = cfg.site_lon ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLocation() {
|
||||||
|
const lat = document.getElementById("cfg-site-lat").value;
|
||||||
|
const lon = document.getElementById("cfg-site-lon").value;
|
||||||
|
try {
|
||||||
|
await fetchJSON(api("api/config"), {
|
||||||
|
method: "POST", headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({ site_lat: lat === "" ? null : lat, site_lon: lon === "" ? null : lon }),
|
||||||
|
});
|
||||||
|
showToast("Gespeichert!", "ok");
|
||||||
|
} catch(e) { showToast("Fehler beim Speichern", "err"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePrices() {
|
async function savePrices() {
|
||||||
|
|||||||
Reference in New Issue
Block a user