iobroker.nexowatt-ui 0.7.13 → 0.7.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2193,6 +2193,27 @@ class ChargingManagementModule extends BaseModule {
2193
2193
  // ignore
2194
2194
  }
2195
2195
 
2196
+ // Gate E – Negativpreis / Netzbezug bevorzugt:
2197
+ // Wenn der dynamische effektive Tarif negativ ist, darf das Lade-/Lastmanagement
2198
+ // Netzladen freigeben und PV-only nur für Auto/Global-Modi aufheben. Harte Limits
2199
+ // (Netzanschluss, Phasen, §14a, Peak-Shaving) bleiben weiter aktiv.
2200
+ let tariffNegativeActive = false;
2201
+ let tariffGridImportPreferred = false;
2202
+ try {
2203
+ const stNeg = await this._getStateCached('tarif.negativpreisAktiv');
2204
+ const stPref = await this._getStateCached('tarif.netzbezugBevorzugt');
2205
+ tariffNegativeActive = stNeg ? !!stNeg.val : false;
2206
+ tariffGridImportPreferred = stPref ? !!stPref.val : tariffNegativeActive;
2207
+ } catch {
2208
+ tariffNegativeActive = false;
2209
+ tariffGridImportPreferred = false;
2210
+ }
2211
+
2212
+ if (tariffGridImportPreferred) {
2213
+ gridChargeAllowedRaw = true;
2214
+ dischargeAllowedRaw = false;
2215
+ }
2216
+
2196
2217
  // Debounce gegen Flattern:
2197
2218
  // - Sperren (false) wirken sofort (Safety-first)
2198
2219
  // - Freigaben (true) erst nach stabiler True-Phase (hold)
@@ -2243,7 +2264,11 @@ class ChargingManagementModule extends BaseModule {
2243
2264
  }
2244
2265
 
2245
2266
  // Global default PV-only behaviour (same as before)
2246
- const pvSurplusOnlyCfg = cfg.pvSurplusOnly === true || mode === 'pvSurplus';
2267
+ const pvSurplusOnlyCfgBase = cfg.pvSurplusOnly === true || mode === 'pvSurplus';
2268
+ // Bei Negativpreis wird Netzbezug wirtschaftlich bevorzugt. Deshalb darf die
2269
+ // globale PV-only-Vorgabe im Automatikpfad temporär aufgehoben werden. Explizite
2270
+ // Wallbox-Modi wie "PV" bleiben User-Wunsch und werden weiter respektiert.
2271
+ const pvSurplusOnlyCfg = tariffGridImportPreferred ? false : pvSurplusOnlyCfgBase;
2247
2272
  const forcePvSurplusOnly = !gridChargeAllowed;
2248
2273
 
2249
2274
  // Determine effective per-wallbox mode (runtime override via VIS)
@@ -2339,7 +2364,8 @@ class ChargingManagementModule extends BaseModule {
2339
2364
 
2340
2365
  let allowGrid = true;
2341
2366
  if (active) {
2342
- if (state === 'teuer') allowGrid = false;
2367
+ if (p < -1e-9) allowGrid = true;
2368
+ else if (state === 'teuer') allowGrid = false;
2343
2369
  else if (state === 'guenstig') allowGrid = !!allowEvcsCheap;
2344
2370
  else allowGrid = true;
2345
2371
  }
@@ -2559,13 +2585,15 @@ class ChargingManagementModule extends BaseModule {
2559
2585
  // (Hard limits like §14a / Grid caps / Phase caps still apply later in the pipeline.)
2560
2586
  eff = 'boost';
2561
2587
  } else if (override === 'pv') {
2562
- eff = 'pv';
2588
+ // Gate E: Bei negativem dynamischem Tarif wird Netzbezug bewusst bevorzugt.
2589
+ // Dann heben wir auch den PV-Modus temporär auf, damit die Ladepunkte
2590
+ // wirtschaftlich Netzstrom abnehmen können. Harte Netz-/Phasen-/§14a-
2591
+ // Grenzen bleiben weiter aktiv.
2592
+ eff = tariffGridImportPreferred ? 'normal' : 'pv';
2563
2593
  } else if (override === 'minpv') {
2564
- // "Min+PV" is an explicit user mode:
2565
- // - always keep the minimum charging power active (even if PV=0)
2566
- // - additional power beyond the technical minimum is still limited by PV surplus
2567
- // Therefore we do NOT force it to pure PV mode, even when tariff policy blocks grid-charging.
2568
- eff = 'minpv';
2594
+ // "Min+PV" hält normalerweise die Mindestladung und begrenzt Mehrleistung auf PV.
2595
+ // Bei Negativpreis wird daraus temporär ein normaler netzfreigegebener Modus.
2596
+ eff = tariffGridImportPreferred ? 'normal' : 'minpv';
2569
2597
  } else {
2570
2598
  // auto: follow global defaults
2571
2599
  eff = (forcePvForW || pvSurplusOnlyCfg) ? 'pv' : 'normal';
@@ -3008,6 +3036,9 @@ if (components.length) {
3008
3036
  engine: true,
3009
3037
  mode,
3010
3038
  pvSurplusOnlyCfg,
3039
+ pvSurplusOnlyCfgBase,
3040
+ tariffNegativeActive,
3041
+ tariffGridImportPreferred,
3011
3042
  forcePvSurplusOnly,
3012
3043
  gridChargeAllowed,
3013
3044
  dischargeAllowed,
@@ -220,6 +220,12 @@ class CoreLimitsModule extends BaseModule {
220
220
  native: {},
221
221
  });
222
222
 
223
+ await this.adapter.setObjectNotExistsAsync('ems.budget.tariff', {
224
+ type: 'channel',
225
+ common: { name: 'Budget Gate E - Tarif / Negativpreis' },
226
+ native: {},
227
+ });
228
+
223
229
  const mk = async (id, name, type, role, unit = undefined, write = false) => {
224
230
  await this.adapter.setObjectNotExistsAsync(id, {
225
231
  type: 'state',
@@ -307,6 +313,24 @@ class CoreLimitsModule extends BaseModule {
307
313
  await mk('ems.budget.forecast.source', 'PV forecast source', 'string', 'text');
308
314
  await mk('ems.budget.forecast.snapshotJson', 'PV forecast gate snapshot (JSON)', 'string', 'json');
309
315
 
316
+ // Gate E - Tarif / Negativpreis. Advisory + permission gate for price-aware control.
317
+ // It does not bypass hard grid/phase/§14a/peak limits; it only tells apps that
318
+ // grid import is economically preferred during negative effective prices.
319
+ await mk('ems.budget.tariff.active', 'Tariff gate active', 'boolean', 'indicator');
320
+ await mk('ems.budget.tariff.state', 'Tariff state', 'string', 'text');
321
+ await mk('ems.budget.tariff.currentPriceEurKwh', 'Current tariff price (€/kWh)', 'number', 'value');
322
+ await mk('ems.budget.tariff.negativeActive', 'Negative price active', 'boolean', 'indicator');
323
+ await mk('ems.budget.tariff.gridImportPreferred', 'Grid import preferred', 'boolean', 'indicator');
324
+ await mk('ems.budget.tariff.storageGridChargeAllowed', 'Storage grid charge allowed by tariff', 'boolean', 'indicator');
325
+ await mk('ems.budget.tariff.evcsGridChargeAllowed', 'EVCS grid charge allowed by tariff', 'boolean', 'indicator');
326
+ await mk('ems.budget.tariff.dischargeAllowed', 'Discharge allowed by tariff', 'boolean', 'indicator');
327
+ await mk('ems.budget.tariff.pvCurtailRecommended', 'PV curtailment recommended by tariff', 'boolean', 'indicator');
328
+ await mk('ems.budget.tariff.negativeMinPriceEurKwh', 'Minimum negative price in horizon (€/kWh)', 'number', 'value');
329
+ await mk('ems.budget.tariff.nextNegativeFrom', 'Next negative price window from (ISO)', 'string', 'text');
330
+ await mk('ems.budget.tariff.nextNegativeTo', 'Next negative price window to (ISO)', 'string', 'text');
331
+ await mk('ems.budget.tariff.status', 'Tariff gate status', 'string', 'text');
332
+ await mk('ems.budget.tariff.snapshotJson', 'Tariff gate snapshot (JSON)', 'string', 'json');
333
+
310
334
  // Per-consumer diagnostics for currently supported app families.
311
335
  for (const key of ['evcs', 'thermal', 'heatingRod', 'generic']) {
312
336
  await this.adapter.setObjectNotExistsAsync(`ems.budget.consumers.${key}`, {
@@ -577,6 +601,25 @@ class CoreLimitsModule extends BaseModule {
577
601
  // provider JSON separately. It does not alter instantaneous PV budget here.
578
602
  const forecastGate = this._makeForecastGate(now);
579
603
 
604
+ // Gate E: tariff/negative-price gate. This is advisory for all apps and
605
+ // permission-like for modules that already consume tariff flags.
606
+ const tSrc = (coreSnapshot && coreSnapshot.tariff && typeof coreSnapshot.tariff === 'object') ? coreSnapshot.tariff : {};
607
+ const tariffGate = {
608
+ active: !!tSrc.active,
609
+ state: String(tSrc.state || ''),
610
+ currentPriceEurKwh: isFiniteNumber(tSrc.currentPriceEurKwh) ? Number(tSrc.currentPriceEurKwh) : null,
611
+ negativeActive: !!tSrc.negativeActive,
612
+ gridImportPreferred: !!tSrc.gridImportPreferred,
613
+ storageGridChargeAllowed: !!tSrc.storageGridChargeAllowed,
614
+ evcsGridChargeAllowed: !!tSrc.evcsGridChargeAllowed,
615
+ dischargeAllowed: tSrc.dischargeAllowed !== false,
616
+ pvCurtailRecommended: !!tSrc.pvCurtailRecommended,
617
+ negativeMinPriceEurKwh: isFiniteNumber(tSrc.negativeMinPriceEurKwh) ? Number(tSrc.negativeMinPriceEurKwh) : null,
618
+ nextNegativeFrom: String(tSrc.nextNegativeFrom || ''),
619
+ nextNegativeTo: String(tSrc.nextNegativeTo || ''),
620
+ status: String(tSrc.status || (tSrc.gridImportPreferred ? 'grid_import_preferred' : (tSrc.active ? 'active' : 'inactive'))),
621
+ };
622
+
580
623
  return {
581
624
  ts: now,
582
625
  active: true,
@@ -614,6 +657,7 @@ class CoreLimitsModule extends BaseModule {
614
657
  dischargeW: roundW(storageDischargeW),
615
658
  },
616
659
  forecast: forecastGate,
660
+ tariff: tariffGate,
617
661
  total: {
618
662
  effectiveW: Number.isFinite(totalBudgetW) ? roundW(totalBudgetW) : null,
619
663
  binding: bindings.join('+'),
@@ -708,6 +752,16 @@ class CoreLimitsModule extends BaseModule {
708
752
  const gridChargeAllowed = await readStateBool(this.adapter, 'tarif.netzLadenErlaubt', true);
709
753
  const dischargeAllowed = await readStateBool(this.adapter, 'tarif.entladenErlaubt', true);
710
754
 
755
+ const tariffActive = await readStateBool(this.adapter, 'tarif.aktiv', false);
756
+ const tariffState = await readStateString(this.adapter, 'tarif.state', '');
757
+ const tariffCurrentPrice = await readStateNumber(this.adapter, 'tarif.preisAktuellEurProKwh', null);
758
+ const tariffNegativeActive = await readStateBool(this.adapter, 'tarif.negativpreisAktiv', false);
759
+ const tariffGridImportPreferred = await readStateBool(this.adapter, 'tarif.netzbezugBevorzugt', tariffNegativeActive);
760
+ const tariffNegativeMinPrice = await readStateNumber(this.adapter, 'tarif.negativPreisMinEurProKwh', null);
761
+ const tariffNextNegativeFrom = await readStateString(this.adapter, 'tarif.naechstesNegativVon', '');
762
+ const tariffNextNegativeTo = await readStateString(this.adapter, 'tarif.naechstesNegativBis', '');
763
+ const tariffStatus = await readStateString(this.adapter, 'tarif.negativpreisStatus', '');
764
+
711
765
  const p14a = (this.adapter && this.adapter._para14a && typeof this.adapter._para14a === 'object') ? this.adapter._para14a : null;
712
766
 
713
767
  let para14aActive = false;
@@ -771,6 +825,18 @@ class CoreLimitsModule extends BaseModule {
771
825
  budgetW: (typeof tariffBudgetW === 'number') ? tariffBudgetW : null,
772
826
  gridChargeAllowed: !!gridChargeAllowed,
773
827
  dischargeAllowed: !!dischargeAllowed,
828
+ active: !!tariffActive,
829
+ state: tariffState || '',
830
+ currentPriceEurKwh: isFiniteNumber(tariffCurrentPrice) ? Number(tariffCurrentPrice) : null,
831
+ negativeActive: !!tariffNegativeActive,
832
+ gridImportPreferred: !!tariffGridImportPreferred,
833
+ storageGridChargeAllowed: !!(tariffGridImportPreferred && gridChargeAllowed),
834
+ evcsGridChargeAllowed: !!(tariffGridImportPreferred && gridChargeAllowed),
835
+ pvCurtailRecommended: !!tariffGridImportPreferred,
836
+ negativeMinPriceEurKwh: isFiniteNumber(tariffNegativeMinPrice) ? Number(tariffNegativeMinPrice) : null,
837
+ nextNegativeFrom: tariffNextNegativeFrom || '',
838
+ nextNegativeTo: tariffNextNegativeTo || '',
839
+ status: tariffStatus || (tariffGridImportPreferred ? 'active_grid_import_preferred' : (tariffNegativeActive ? 'negative_detected' : 'inactive')),
774
840
  },
775
841
  para14a: {
776
842
  active: !!para14aActive,
@@ -790,6 +856,7 @@ class CoreLimitsModule extends BaseModule {
790
856
  this.adapter._emsCaps = snapshot;
791
857
  this.adapter._emsBudget = budgetRuntime;
792
858
  this.adapter._emsForecastGate = budgetSnapshot && budgetSnapshot.gates ? budgetSnapshot.gates.forecast : null;
859
+ this.adapter._emsTariffGate = budgetSnapshot && budgetSnapshot.gates ? budgetSnapshot.gates.tariff : null;
793
860
  } catch {
794
861
  // ignore
795
862
  }
@@ -862,6 +929,22 @@ class CoreLimitsModule extends BaseModule {
862
929
  await this.adapter.setStateAsync('ems.budget.forecast.source', String(fg.source || ''), true);
863
930
  await this.adapter.setStateAsync('ems.budget.forecast.snapshotJson', JSON.stringify(fg), true);
864
931
 
932
+ const tg = (b.gates && b.gates.tariff) ? b.gates.tariff : {};
933
+ await this.adapter.setStateAsync('ems.budget.tariff.active', !!tg.active, true);
934
+ await this.adapter.setStateAsync('ems.budget.tariff.state', String(tg.state || ''), true);
935
+ await this.adapter.setStateAsync('ems.budget.tariff.currentPriceEurKwh', tg.currentPriceEurKwh === null || tg.currentPriceEurKwh === undefined ? null : Number(tg.currentPriceEurKwh), true);
936
+ await this.adapter.setStateAsync('ems.budget.tariff.negativeActive', !!tg.negativeActive, true);
937
+ await this.adapter.setStateAsync('ems.budget.tariff.gridImportPreferred', !!tg.gridImportPreferred, true);
938
+ await this.adapter.setStateAsync('ems.budget.tariff.storageGridChargeAllowed', !!tg.storageGridChargeAllowed, true);
939
+ await this.adapter.setStateAsync('ems.budget.tariff.evcsGridChargeAllowed', !!tg.evcsGridChargeAllowed, true);
940
+ await this.adapter.setStateAsync('ems.budget.tariff.dischargeAllowed', tg.dischargeAllowed !== false, true);
941
+ await this.adapter.setStateAsync('ems.budget.tariff.pvCurtailRecommended', !!tg.pvCurtailRecommended, true);
942
+ await this.adapter.setStateAsync('ems.budget.tariff.negativeMinPriceEurKwh', tg.negativeMinPriceEurKwh === null || tg.negativeMinPriceEurKwh === undefined ? null : Number(tg.negativeMinPriceEurKwh), true);
943
+ await this.adapter.setStateAsync('ems.budget.tariff.nextNegativeFrom', String(tg.nextNegativeFrom || ''), true);
944
+ await this.adapter.setStateAsync('ems.budget.tariff.nextNegativeTo', String(tg.nextNegativeTo || ''), true);
945
+ await this.adapter.setStateAsync('ems.budget.tariff.status', String(tg.status || ''), true);
946
+ await this.adapter.setStateAsync('ems.budget.tariff.snapshotJson', JSON.stringify(tg), true);
947
+
865
948
  for (const key of ['evcs', 'thermal', 'heatingRod']) {
866
949
  const c = b.consumers[key] || {};
867
950
  await this.adapter.setStateAsync(`ems.budget.consumers.${key}.usedW`, roundW(c.usedW), true);
@@ -883,6 +966,12 @@ class CoreLimitsModule extends BaseModule {
883
966
  this.adapter.updateValue('ems.budget.forecast.kwhNext6h', Number.isFinite(Number(b.gates.forecast.kwhNext6h)) ? Number(b.gates.forecast.kwhNext6h) : 0, now);
884
967
  this.adapter.updateValue('ems.budget.forecast.usable', !!b.gates.forecast.usable, now);
885
968
  }
969
+ if (b.gates && b.gates.tariff) {
970
+ this.adapter.updateValue('ems.budget.tariff.negativeActive', !!b.gates.tariff.negativeActive, now);
971
+ this.adapter.updateValue('ems.budget.tariff.gridImportPreferred', !!b.gates.tariff.gridImportPreferred, now);
972
+ this.adapter.updateValue('ems.budget.tariff.currentPriceEurKwh', b.gates.tariff.currentPriceEurKwh, now);
973
+ this.adapter.updateValue('ems.budget.tariff.status', String(b.gates.tariff.status || ''), now);
974
+ }
886
975
  }
887
976
  } catch {
888
977
  // ignore
@@ -58,6 +58,17 @@ class GridConstraintsModule extends BaseModule {
58
58
  return Math.min(Math.max(n, minV), maxV);
59
59
  }
60
60
 
61
+ async _isTariffGridImportPreferred() {
62
+ try {
63
+ const tv = (this.adapter && this.adapter._tarifVis) ? this.adapter._tarifVis : null;
64
+ if (tv && (tv.gridImportPreferred || tv.netzbezugBevorzugt || tv.negativeActive)) return true;
65
+ const st = await this.adapter.getStateAsync('tarif.netzbezugBevorzugt');
66
+ return !!(st && st.val);
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
61
72
  async init() {
62
73
  if (!this._isEnabled()) return;
63
74
 
@@ -263,7 +274,10 @@ class GridConstraintsModule extends BaseModule {
263
274
  const enabled = !!cfg.zeroExportEnabled;
264
275
  const inv = this._normalizeInvList(cfg.pvCurtailInvertersZero);
265
276
 
266
- const biasW = Math.max(0, this._num(cfg.zeroExportBiasW, 80));
277
+ const tariffGridImportPreferred = await this._isTariffGridImportPreferred();
278
+ const baseBiasW = Math.max(0, this._num(cfg.zeroExportBiasW, 80));
279
+ const negativeBiasW = tariffGridImportPreferred ? Math.max(0, this._num(cfg.zeroExportNegativePriceImportBiasW, 1000)) : 0;
280
+ const biasW = tariffGridImportPreferred ? Math.max(baseBiasW, negativeBiasW) : baseBiasW;
267
281
  const deadbandW = Math.max(0, this._num(cfg.zeroExportDeadbandW, 50));
268
282
 
269
283
  await this.adapter.setStateAsync('gridConstraints.zeroExport.enabled', enabled, true);
@@ -356,7 +370,7 @@ class GridConstraintsModule extends BaseModule {
356
370
  applied = applied || (ok1 === true || ok1 === null) || (ok2 === true || ok2 === null) || (ok3 === true || ok3 === null);
357
371
  }
358
372
 
359
- await this.adapter.setStateAsync('gridConstraints.zeroExport.action', fastTrip ? 'group_fast' : 'group', true);
373
+ await this.adapter.setStateAsync('gridConstraints.zeroExport.action', tariffGridImportPreferred ? (fastTrip ? 'tariff_negative_group_fast' : 'tariff_negative_group') : (fastTrip ? 'group_fast' : 'group'), true);
360
374
  await this.adapter.setStateAsync('gridConstraints.pvCurtail.applied', applied, true);
361
375
  await this.adapter.setStateAsync('gridConstraints.pvCurtail.setpointW', Math.round(next), true);
362
376
  await this.adapter.setStateAsync('gridConstraints.pvCurtail.setpointPct', Math.round(pct * 10) / 10, true);
@@ -461,7 +475,10 @@ class GridConstraintsModule extends BaseModule {
461
475
  async _tickZeroExport(nowMs, gridW, cfg, gridStale) {
462
476
  const enabled = !!cfg.zeroExportEnabled;
463
477
 
464
- const biasW = Math.max(0, this._num(cfg.zeroExportBiasW, 80));
478
+ const tariffGridImportPreferred = await this._isTariffGridImportPreferred();
479
+ const baseBiasW = Math.max(0, this._num(cfg.zeroExportBiasW, 80));
480
+ const negativeBiasW = tariffGridImportPreferred ? Math.max(0, this._num(cfg.zeroExportNegativePriceImportBiasW, 1000)) : 0;
481
+ const biasW = tariffGridImportPreferred ? Math.max(baseBiasW, negativeBiasW) : baseBiasW;
465
482
  const deadbandW = Math.max(0, this._num(cfg.zeroExportDeadbandW, 50));
466
483
 
467
484
  await this.adapter.setStateAsync('gridConstraints.zeroExport.enabled', enabled, true);
@@ -542,7 +559,7 @@ class GridConstraintsModule extends BaseModule {
542
559
 
543
560
  const ok = await this.dp.writeNumber('pv.limitW', next, false);
544
561
 
545
- await this.adapter.setStateAsync('gridConstraints.zeroExport.action', fastTrip ? 'pvLimitW_fast' : 'pvLimitW', true);
562
+ await this.adapter.setStateAsync('gridConstraints.zeroExport.action', tariffGridImportPreferred ? (fastTrip ? 'tariff_negative_pvLimitW_fast' : 'tariff_negative_pvLimitW') : (fastTrip ? 'pvLimitW_fast' : 'pvLimitW'), true);
546
563
  await this.adapter.setStateAsync('gridConstraints.pvCurtail.applied', ok === true || ok === null, true);
547
564
  await this.adapter.setStateAsync('gridConstraints.pvCurtail.setpointW', Math.round(next), true);
548
565
  await this.adapter.setStateAsync('gridConstraints.pvCurtail.setpointPct', 0, true);
@@ -579,7 +596,7 @@ class GridConstraintsModule extends BaseModule {
579
596
 
580
597
  const ok = await this.dp.writeNumber('pv.limitPct', next, false);
581
598
 
582
- await this.adapter.setStateAsync('gridConstraints.zeroExport.action', fastTrip ? 'pvLimitPct_fast' : 'pvLimitPct', true);
599
+ await this.adapter.setStateAsync('gridConstraints.zeroExport.action', tariffGridImportPreferred ? (fastTrip ? 'tariff_negative_pvLimitPct_fast' : 'tariff_negative_pvLimitPct') : (fastTrip ? 'pvLimitPct_fast' : 'pvLimitPct'), true);
583
600
  await this.adapter.setStateAsync('gridConstraints.pvCurtail.applied', ok === true || ok === null, true);
584
601
  await this.adapter.setStateAsync('gridConstraints.pvCurtail.setpointW', 0, true);
585
602
  await this.adapter.setStateAsync('gridConstraints.pvCurtail.setpointPct', Math.round(next * 10) / 10, true);
@@ -775,6 +775,16 @@ class HeatingRodControlModule extends BaseModule {
775
775
  pvBudgetSource = 'ems.budget.remainingPvW+ownAutoLoad';
776
776
  pvBudgetFromCentral = true;
777
777
  }
778
+
779
+ const tariffGate = snap.gates && snap.gates.tariff ? snap.gates.tariff : null;
780
+ const tariffImportPreferred = !!(tariffGate && tariffGate.gridImportPreferred);
781
+ if (tariffImportPreferred && Number.isFinite(remTotal) && remTotal >= 0) {
782
+ // Gate E: Bei Negativpreis darf der Heizstab als aktivierter flexibler
783
+ // Verbraucher Netzbudget nutzen. Gate A/A2/Peak bleiben über remTotal aktiv.
784
+ pvBudgetGateW = Math.max(0, remTotal + currentW - gateCfg.budgetSafetyReserveW);
785
+ pvBudgetSource = 'ems.budget.tariffNegative.remainingTotalW+ownAutoLoad';
786
+ pvBudgetFromCentral = true;
787
+ }
778
788
  }
779
789
  } catch (_e) {
780
790
  // legacy NVP/CM fallback remains active
@@ -804,6 +814,7 @@ class HeatingRodControlModule extends BaseModule {
804
814
  budgetGateEffectiveW: Math.max(0, effectiveGateW),
805
815
  budgetGateSource: source,
806
816
  pvBudgetFromCentral: !!pvBudgetFromCentral,
817
+ tariffGridImportPreferred: String(pvBudgetSource || '').includes('tariffNegative'),
807
818
  cmActive,
808
819
  cmStaleMeter: !!cmStaleMeter,
809
820
  cmStaleBudget: !!cmStaleBudget,
@@ -840,7 +851,8 @@ class HeatingRodControlModule extends BaseModule {
840
851
  _updateBudgetGateProtection(pvBase, now) {
841
852
  const cfg = (pvBase && pvBase.gateCfg) ? pvBase.gateCfg : this._getBudgetGateCfg();
842
853
  const st = this._budgetProtect || { importSinceMs: 0, dischargeSinceMs: 0 };
843
- const importActive = !!(pvBase && pvBase.gridKnown && num(pvBase.importW, 0) > cfg.maxGridImportW);
854
+ const tariffImportPreferred = !!(pvBase && pvBase.tariffGridImportPreferred);
855
+ const importActive = !!(!tariffImportPreferred && pvBase && pvBase.gridKnown && num(pvBase.importW, 0) > cfg.maxGridImportW);
844
856
  const dischargeActive = !!(pvBase && num(pvBase.storageDischargeW, 0) > cfg.storageDischargeToleranceW);
845
857
  const hardImport = !!(pvBase && pvBase.gridKnown && num(pvBase.importW, 0) > cfg.hardGridImportW);
846
858
  const hardDischarge = !!(pvBase && num(pvBase.storageDischargeW, 0) > cfg.hardStorageDischargeW);
@@ -2072,7 +2084,7 @@ class HeatingRodControlModule extends BaseModule {
2072
2084
  // Global PV-Auto minimum: this is now only a start/step-up gate.
2073
2085
  // It must not be a hard OFF, because small cloud/PV transients would otherwise
2074
2086
  // kill a stable stage and external KNX/manual switching would feel broken.
2075
- const pvMinBlocksStepUp = !!(pvAutomationActive && !pvAutomationAllowedByMin);
2087
+ const pvMinBlocksStepUp = !!(pvAutomationActive && !pvBase.tariffGridImportPreferred && !pvAutomationAllowedByMin);
2076
2088
 
2077
2089
  if (d.wiredStages < 1) {
2078
2090
  const usedW = (typeof measuredW === 'number' && Number.isFinite(measuredW) && measuredW > 0)
@@ -2179,6 +2191,7 @@ class HeatingRodControlModule extends BaseModule {
2179
2191
  const rt = this.adapter && this.adapter._emsBudget;
2180
2192
  if (rt && typeof rt.reserve === 'function') {
2181
2193
  const used = Math.max(0, Math.round(budgetUsedW || 0));
2194
+ const tariffImportPreferred = !!(pvBase && pvBase.tariffGridImportPreferred);
2182
2195
  rt.reserve({
2183
2196
  key: 'heatingRod',
2184
2197
  app: 'heatingRodControl',
@@ -2186,10 +2199,10 @@ class HeatingRodControlModule extends BaseModule {
2186
2199
  priority: 300,
2187
2200
  requestedW: used,
2188
2201
  reserveW: used,
2189
- pvReserveW: used,
2202
+ pvReserveW: tariffImportPreferred ? 0 : used,
2190
2203
  actualW: Math.max(0, Math.round(currentHeatingRodW || appliedTotalW || used || 0)),
2191
- pvOnly: true,
2192
- mode: 'pvAuto',
2204
+ pvOnly: !tariffImportPreferred,
2205
+ mode: tariffImportPreferred ? 'tariffNegative' : 'pvAuto',
2193
2206
  });
2194
2207
  }
2195
2208
  } catch (_e) {
@@ -2266,6 +2279,7 @@ class HeatingRodControlModule extends BaseModule {
2266
2279
  effectiveW: Math.round(num(pvBase.budgetGateEffectiveW, 0)),
2267
2280
  source: pvBase.budgetGateSource,
2268
2281
  pvBudgetFromCentral: !!pvBase.pvBudgetFromCentral,
2282
+ tariffGridImportPreferred: !!pvBase.tariffGridImportPreferred,
2269
2283
  cmActive: pvBase.cmActive,
2270
2284
  cmStaleMeter: !!pvBase.cmStaleMeter,
2271
2285
  cmStaleBudget: !!pvBase.cmStaleBudget,
@@ -1015,6 +1015,7 @@ if (typeof soc === 'number') {
1015
1015
  if (targetW === 0) {
1016
1016
  const tv = (this.adapter && this.adapter._tarifVis) ? this.adapter._tarifVis : null;
1017
1017
  const tvAktiv = !!(tv && tv.aktiv);
1018
+ const tariffNegativeImportPreferred = !!(tv && (tv.negativeActive || tv.gridImportPreferred || tv.netzbezugBevorzugt));
1018
1019
  tarifState = (tvAktiv && typeof tv.state === 'string') ? tv.state : null;
1019
1020
 
1020
1021
  if (tvAktiv) {
@@ -1065,7 +1066,7 @@ if (typeof soc === 'number') {
1065
1066
  // Nicht gegen Einspeisung "anladen" – PV-Überschuss wird unten behandelt.
1066
1067
  // Hinweis: Dadurch wird bei Einspeisung kein zusätzliches Netzladen erzwungen.
1067
1068
  // (Bewusstes Design: PV-Überschuss-Laden übernimmt dann die Regelung.)
1068
- if (nvpNowW < 0) {
1069
+ if (nvpNowW < 0 && !tariffNegativeImportPreferred) {
1069
1070
  chargeW = 0;
1070
1071
  }
1071
1072
 
@@ -1089,7 +1090,7 @@ if (typeof soc === 'number') {
1089
1090
  let pvDebug = null;
1090
1091
  try {
1091
1092
  const pf = (this.adapter && this.adapter._pvForecast) ? this.adapter._pvForecast : null;
1092
- const pvReserveEnabled = (cfg.tariffPvReserveEnabled !== false); // default: ON
1093
+ const pvReserveEnabled = (cfg.tariffPvReserveEnabled !== false) && !tariffNegativeImportPreferred; // default: ON, bei Negativpreis bewusst aus
1093
1094
  if (pvReserveEnabled && pf && pf.valid && Array.isArray(pf.curve) && pf.curve.length) {
1094
1095
  // Bei sehr alten Forecasts lieber keine PV‑Reserve erzwingen.
1095
1096
  const maxAgeMs = 24 * 3600000;
@@ -1347,6 +1348,8 @@ if (typeof soc === 'number') {
1347
1348
  targetW = -Math.max(0, chargeW);
1348
1349
  if (pvBlockGridCharge) {
1349
1350
  reason = pvBlockReason || 'Tarif: günstig – PV Forecast -> Netzladen gesperrt';
1351
+ } else if (tariffNegativeImportPreferred) {
1352
+ reason = (targetW === 0) ? 'Tarif: Negativpreis – Netzladen nicht möglich' : 'Tarif: Negativpreis – Netzladen bevorzugt';
1350
1353
  } else {
1351
1354
  reason = (targetW === 0) ? 'Tarif: günstig – Netzladen nicht möglich' : 'Tarif: günstig – Netzladen';
1352
1355
  }
@@ -78,6 +78,15 @@ class TarifVisModule extends BaseModule {
78
78
  await mk('tarif.statusText', 'Tarif Status (VIS)', 'string', 'text');
79
79
  await mk('tarif.netFeeEnabled', 'Zeitvariables Netzentgelt aktiv (VIS)', 'boolean', 'indicator');
80
80
  await mk('tarif.netFeeMode', 'Netzentgelt Modus (NT/Standard/HT)', 'string', 'text');
81
+
82
+ // Gate E – Negativpreis / Tarif-Gewinnoptimierung
83
+ await mk('tarif.negativpreisAktiv', 'Negativpreis aktiv', 'boolean', 'indicator');
84
+ await mk('tarif.netzbezugBevorzugt', 'Netzbezug bevorzugt bei Negativpreis', 'boolean', 'indicator');
85
+ await mk('tarif.negativPreisAktuellEurProKwh', 'Negativpreis aktuell (€/kWh)', 'number', 'value');
86
+ await mk('tarif.negativPreisMinEurProKwh', 'Negativpreis Minimum (€/kWh, Horizon)', 'number', 'value');
87
+ await mk('tarif.naechstesNegativVon', 'Nächstes Negativpreis-Fenster ab (ISO)', 'string', 'text');
88
+ await mk('tarif.naechstesNegativBis', 'Nächstes Negativpreis-Fenster bis (ISO)', 'string', 'text');
89
+ await mk('tarif.negativpreisStatus', 'Negativpreis Status', 'string', 'text');
81
90
  // VIS-Settings als Datenpunkte registrieren (nur wenn dp-Registry vorhanden ist)
82
91
  if (this.dp && typeof this.dp.upsert === 'function') {
83
92
  const visInst = this._getVisInstance();
@@ -710,6 +719,17 @@ class TarifVisModule extends BaseModule {
710
719
  let nextCheapToIso = null;
711
720
  let horizonCurve = null;
712
721
 
722
+ // Gate E – Negativpreis: Wenn der effektive dynamische Preis < 0 ist,
723
+ // soll Netzbezug bewusst bevorzugt werden. Today+Tomorrow werden als
724
+ // Planungs-/Diagnosehorizont ausgewertet, der aktuelle Preis bleibt
725
+ // aber die harte Live-Freigabe.
726
+ let negativeActive = false;
727
+ let negativeWindowNow = false;
728
+ let negativeCurrentPrice = null;
729
+ let negativeMinPrice = null;
730
+ let nextNegativeFromIso = null;
731
+ let nextNegativeToIso = null;
732
+
713
733
  if (aktivEff && modusInt === 2) {
714
734
  const rawToday = (this.dp && typeof this.dp.getEntry === 'function' && this.dp.getEntry('tarif.pricesTodayJson'))
715
735
  ? this.dp.getRaw('tarif.pricesTodayJson')
@@ -736,6 +756,50 @@ class TarifVisModule extends BaseModule {
736
756
  }
737
757
  }
738
758
 
759
+ // Negativpreis-Fenster aus Today+Tomorrow-Forecast ermitteln.
760
+ if (aktivEff) {
761
+ const negEps = 1e-9;
762
+
763
+ if (preisAktuellOk && preisAktuell < -negEps) {
764
+ negativeActive = true;
765
+ negativeCurrentPrice = preisAktuell;
766
+ }
767
+
768
+ if (Array.isArray(horizonCurve) && horizonCurve.length > 0) {
769
+ const negAll = horizonCurve
770
+ .filter(x => x && Number.isFinite(x.startMs) && Number.isFinite(x.endMs) && Number.isFinite(x.priceEurKwh))
771
+ .filter(x => x.endMs > nowMs && x.priceEurKwh < -negEps)
772
+ .sort((a, b) => a.startMs - b.startMs);
773
+
774
+ if (negAll.length > 0) {
775
+ negativeMinPrice = Math.min(...negAll.map(x => x.priceEurKwh));
776
+
777
+ const activeSeg = negAll.find(x => x.startMs <= nowMs && x.endMs > nowMs);
778
+ if (activeSeg) {
779
+ negativeActive = true;
780
+ negativeWindowNow = true;
781
+ negativeCurrentPrice = activeSeg.priceEurKwh;
782
+ }
783
+
784
+ const first = negAll[0];
785
+ let winStart = first.startMs;
786
+ let winEnd = first.endMs;
787
+ for (let i = 1; i < negAll.length; i++) {
788
+ const it = negAll[i];
789
+ if (it.startMs <= (winEnd + 1000)) {
790
+ winEnd = Math.max(winEnd, it.endMs);
791
+ } else {
792
+ break;
793
+ }
794
+ }
795
+ nextNegativeFromIso = new Date(winStart).toISOString();
796
+ nextNegativeToIso = new Date(winEnd).toISOString();
797
+ }
798
+ }
799
+ }
800
+
801
+ const gridImportPreferred = !!(aktivEff && negativeActive);
802
+
739
803
  const preisDurchschnittEff = preisDurchschnittOk ? preisDurchschnitt
740
804
  : (Number.isFinite(preisDurchschnittCalc) ? preisDurchschnittCalc : null);
741
805
  const preisDurchschnittEffOk = (typeof preisDurchschnittEff === 'number' && Number.isFinite(preisDurchschnittEff));
@@ -867,10 +931,20 @@ class TarifVisModule extends BaseModule {
867
931
  tarifState = next;
868
932
  }
869
933
 
934
+ // Negativpreis ist ein Sonderfall von "günstig", aber mit stärkerer
935
+ // Wirkung: Netzbezug wird bevorzugt, Speicherentladung wird gesperrt und
936
+ // die Speicher-Netzlade-Zeitfenster/PV-Reserve dürfen nicht blockieren.
937
+ if (gridImportPreferred) {
938
+ tarifState = 'guenstig';
939
+ this._tarifLastState = 'guenstig';
940
+ }
941
+
870
942
  // --- Priorität ---
871
943
  // 1 = Speicher | 2 = Auto | 3 = Ladestation
872
944
  const allowStorageCheap = (prioritaet === 1 || prioritaet === 2);
873
945
  const allowEvcsCheap = (prioritaet === 2 || prioritaet === 3);
946
+ const allowStorageCheapEff = gridImportPreferred ? true : allowStorageCheap;
947
+ const allowEvcsCheapEff = gridImportPreferred ? true : allowEvcsCheap;
874
948
 
875
949
  // Speicher-SoC (optional)
876
950
  //
@@ -956,15 +1030,16 @@ class TarifVisModule extends BaseModule {
956
1030
  // Q1/Q4: 18:00–06:00 | Q2/Q3: 21:00–06:00
957
1031
  // Ausnahme: Wenn zeitvariables Netzentgelt im NT ist, darf geladen werden.
958
1032
  const storageTimeOk = !!storageChargeWindowOk;
959
- const cheapWanted = (tarifState === 'guenstig' && allowStorageCheap);
1033
+ const cheapWanted = ((tarifState === 'guenstig' && allowStorageCheapEff) || gridImportPreferred);
960
1034
  // Im Netzentgelt‑NT gilt das Zeitfenster als "kosten-günstig" genug,
961
1035
  // damit Speicher‑Netzladung überhaupt erlaubt ist (abhängig von Priorität + SoC‑Latch).
962
1036
  // Dadurch funktioniert NT auch dann, wenn der Stromtarif selbst nur neutral/teuer ist.
963
- const cheapOrNtWanted = (cheapWanted || (netFeeIsNt && allowStorageCheap));
1037
+ const cheapOrNtWanted = (cheapWanted || (netFeeIsNt && allowStorageCheapEff));
964
1038
  // Speicher darf laden, wenn (Tarif günstig ODER Netzentgelt‑NT) UND Zeitfenster ok.
965
- // Ausnahme zur Zeitfenster‑Policy: Wenn Netzentgelt im NT ist, darf auch außerhalb
966
- // des Zeitfensters geladen werden.
967
- const chargeAllowed = (cheapOrNtWanted && (storageTimeOk || netFeeIsNt));
1039
+ // Ausnahmen zur Zeitfenster‑Policy:
1040
+ // - Netzentgelt im NT
1041
+ // - Negativpreis: Netzbezug ist wirtschaftlich erwünscht und darf tagsüber laden.
1042
+ const chargeAllowed = (cheapOrNtWanted && (storageTimeOk || netFeeIsNt || gridImportPreferred));
968
1043
 
969
1044
  if (netFeeIsHt) {
970
1045
  // In HT: Speicher soll NICHT durch Tarif entladen/geladen werden → Eigenverbrauch
@@ -1032,10 +1107,12 @@ class TarifVisModule extends BaseModule {
1032
1107
 
1033
1108
  // 1) Basis: dynamischer Tarif (falls aktiv)
1034
1109
  if (aktivEff) {
1035
- if (tarifState === 'teuer') {
1110
+ if (gridImportPreferred) {
1111
+ gridChargeAllowed = true;
1112
+ } else if (tarifState === 'teuer') {
1036
1113
  gridChargeAllowed = false;
1037
1114
  } else if (tarifState === 'guenstig') {
1038
- gridChargeAllowed = allowEvcsCheap ? true : false;
1115
+ gridChargeAllowed = allowEvcsCheapEff ? true : false;
1039
1116
  } else {
1040
1117
  gridChargeAllowed = true;
1041
1118
  }
@@ -1054,6 +1131,13 @@ class TarifVisModule extends BaseModule {
1054
1131
  // Standard (ST): kein Override
1055
1132
  }
1056
1133
 
1134
+ // Negativpreis hat als wirtschaftliches Signal Vorrang vor PV-only-/HT-Sperren:
1135
+ // Netzbezug erlauben, Speicherentladung vermeiden. Harte Netz-/§14a-/Peak-Grenzen
1136
+ // werden später weiterhin durch die Gates begrenzt.
1137
+ if (gridImportPreferred) {
1138
+ gridChargeAllowed = true;
1139
+ }
1140
+
1057
1141
  // Entladen-Freigabe (für Speicher-/Assist-Logik):
1058
1142
  //
1059
1143
  // Ziel (Bugfix + bessere Praxis):
@@ -1075,7 +1159,10 @@ class TarifVisModule extends BaseModule {
1075
1159
  const storageChargingPlanned = Number.isFinite(speicherSollW) && speicherSollW < 0;
1076
1160
  const forceSelfConsumption = !!storageChargeBlockedByTime || netFeeIsHt;
1077
1161
 
1078
- if (forceSelfConsumption) {
1162
+ if (gridImportPreferred) {
1163
+ // Bei Negativpreis soll der Speicher nicht gegen den gewünschten Netzbezug entladen.
1164
+ dischargeAllowed = false;
1165
+ } else if (forceSelfConsumption) {
1079
1166
  // Eigenverbrauch-Modus (tagsüber Policy oder HT): Entladen erlaubt.
1080
1167
  dischargeAllowed = true;
1081
1168
  } else if (netFeeIsNt) {
@@ -1097,7 +1184,7 @@ class TarifVisModule extends BaseModule {
1097
1184
 
1098
1185
  // Ladepark-Limit: Standard = baseW; Reservierung wenn Speicher im Tarif-Fenster lädt
1099
1186
  let limitW = baseW;
1100
- if (((aktivEff && (tarifState === 'guenstig')) || (netFeeEff && netFeeMode === 'NT')) && speicherSollW < 0 && baseW > 0) {
1187
+ if (!gridImportPreferred && ((aktivEff && (tarifState === 'guenstig')) || (netFeeEff && netFeeMode === 'NT')) && speicherSollW < 0 && baseW > 0) {
1101
1188
  const reserveW = Math.max(0, -speicherSollW);
1102
1189
  const storageShare = (prioritaet === 1) ? 1.0 : (prioritaet === 3) ? 0.0 : 0.5;
1103
1190
  limitW = Math.max(0, Math.round(baseW - (reserveW * storageShare)));
@@ -1131,6 +1218,14 @@ class TarifVisModule extends BaseModule {
1131
1218
  await this._setIfChanged('tarif.netFeeEnabled', netFeeEff);
1132
1219
  await this._setIfChanged('tarif.netFeeMode', netFeeMode);
1133
1220
 
1221
+ await this._setIfChanged('tarif.negativpreisAktiv', !!negativeActive);
1222
+ await this._setIfChanged('tarif.netzbezugBevorzugt', !!gridImportPreferred);
1223
+ await this._setIfChanged('tarif.negativPreisAktuellEurProKwh', (negativeActive && typeof negativeCurrentPrice === 'number' && Number.isFinite(negativeCurrentPrice)) ? negativeCurrentPrice : null);
1224
+ await this._setIfChanged('tarif.negativPreisMinEurProKwh', (typeof negativeMinPrice === 'number' && Number.isFinite(negativeMinPrice)) ? negativeMinPrice : null);
1225
+ await this._setIfChanged('tarif.naechstesNegativVon', nextNegativeFromIso || null);
1226
+ await this._setIfChanged('tarif.naechstesNegativBis', nextNegativeToIso || null);
1227
+ await this._setIfChanged('tarif.negativpreisStatus', gridImportPreferred ? 'active_grid_import_preferred' : (nextNegativeFromIso ? 'scheduled' : 'inactive'));
1228
+
1134
1229
 
1135
1230
  // Kurz-Status für die VIS (Live-Ansicht)
1136
1231
  // Ziel: Kunde sieht sofort, ob Tarif gerade Laden/Entladen triggert.
@@ -1170,7 +1265,15 @@ if (aktivEff || netFeeActive) {
1170
1265
  // - Standard (ST): keine Sperre/Erzwingung → dynamischer Tarif wie bisher
1171
1266
  const base = netFeeActive ? `Netzentgelt ${netFeeMode} | ${baseTarif}` : baseTarif;
1172
1267
 
1173
- if (netFeeOverlayUi) {
1268
+ if (gridImportPreferred) {
1269
+ const parts = [];
1270
+ parts.push('Negativpreis aktiv');
1271
+ if (storageCharging) parts.push('Speicher Netzladen');
1272
+ else if (storageFullHold) parts.push('Speicher voll (ruht)');
1273
+ parts.push('EVCS/Verbraucher Netzbezug freigegeben');
1274
+ parts.push('Speicherentladung gesperrt');
1275
+ statusText = `${base}: ${parts.join(' + ')}`;
1276
+ } else if (netFeeOverlayUi) {
1174
1277
  if (netFeeMode === 'NT') {
1175
1278
  const parts = [];
1176
1279
  if (storageCharging) parts.push('Speicher lädt');
@@ -1247,6 +1350,14 @@ await this._setIfChanged('tarif.statusText', statusText);
1247
1350
  preisSchwelleGuensig: (typeof preisSchwelleGuensig === 'number' && Number.isFinite(preisSchwelleGuensig)) ? preisSchwelleGuensig : null,
1248
1351
  nextCheapFromIso: nextCheapFromIso || null,
1249
1352
  nextCheapToIso: nextCheapToIso || null,
1353
+ negativeActive: !!negativeActive,
1354
+ negativeWindowNow: !!negativeWindowNow,
1355
+ gridImportPreferred: !!gridImportPreferred,
1356
+ netzbezugBevorzugt: !!gridImportPreferred,
1357
+ negativeCurrentPrice: (negativeActive && typeof negativeCurrentPrice === 'number' && Number.isFinite(negativeCurrentPrice)) ? negativeCurrentPrice : null,
1358
+ negativeMinPrice: (typeof negativeMinPrice === 'number' && Number.isFinite(negativeMinPrice)) ? negativeMinPrice : null,
1359
+ nextNegativeFromIso: nextNegativeFromIso || null,
1360
+ nextNegativeToIso: nextNegativeToIso || null,
1250
1361
  autoBandEur: (typeof autoBandEur === 'number' && Number.isFinite(autoBandEur)) ? autoBandEur : null,
1251
1362
  horizonHours: (typeof horizonHours === 'number' && Number.isFinite(horizonHours)) ? horizonHours : null,
1252
1363
  preisRef: (preisRef !== null && preisRef !== undefined && Number.isFinite(preisRef)) ? preisRef : null,
@@ -476,14 +476,20 @@ const mk = async (id, name, type, role, unit = undefined) => {
476
476
  if (snap && age <= staleMs) {
477
477
  const pvTotalW = snap.gates && snap.gates.pv ? Number(snap.gates.pv.effectiveW) : 0;
478
478
  const remainingPvW = Number(snap.remainingPvW);
479
+ const tariffGate = snap.gates && snap.gates.tariff ? snap.gates.tariff : null;
480
+ const tariffImportPreferred = !!(tariffGate && tariffGate.gridImportPreferred);
481
+ const remainingTotalW = Number(snap.remainingTotalW);
479
482
  if (Number.isFinite(remainingPvW) && remainingPvW >= 0) {
480
483
  const evcs = snap.consumers && snap.consumers.evcs ? snap.consumers.evcs : null;
481
484
  const evcsUsedW = evcs && Number.isFinite(Number(evcs.reserveW)) ? Math.max(0, Number(evcs.reserveW)) : 0;
485
+ const availableByTariffW = (tariffImportPreferred && Number.isFinite(remainingTotalW) && remainingTotalW >= 0)
486
+ ? Math.max(0, remainingTotalW)
487
+ : null;
482
488
  return {
483
489
  pvCapW: Math.max(0, Number.isFinite(pvTotalW) ? pvTotalW : remainingPvW),
484
490
  evcsUsedW,
485
- availableW: Math.max(0, remainingPvW),
486
- source: 'ems.budget',
491
+ availableW: availableByTariffW !== null ? availableByTariffW : Math.max(0, remainingPvW),
492
+ source: availableByTariffW !== null ? 'ems.budget.tariffNegative' : 'ems.budget',
487
493
  };
488
494
  }
489
495
  }
@@ -902,6 +908,7 @@ const mk = async (id, name, type, role, unit = undefined) => {
902
908
  const rt = this.adapter && this.adapter._emsBudget;
903
909
  if (rt && typeof rt.reserve === 'function') {
904
910
  const used = Math.max(0, Math.round(budgetUsedW || 0));
911
+ const tariffImportPreferred = String(pv.source || '').includes('tariffNegative');
905
912
  rt.reserve({
906
913
  key: 'thermal',
907
914
  app: 'thermalControl',
@@ -909,9 +916,9 @@ const mk = async (id, name, type, role, unit = undefined) => {
909
916
  priority: 200,
910
917
  requestedW: used,
911
918
  reserveW: used,
912
- pvReserveW: used,
913
- pvOnly: true,
914
- mode: 'pvAuto',
919
+ pvReserveW: tariffImportPreferred ? 0 : used,
920
+ pvOnly: !tariffImportPreferred,
921
+ mode: tariffImportPreferred ? 'tariffNegative' : 'pvAuto',
915
922
  });
916
923
  }
917
924
  } catch (_e) {
package/io-package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "nexowatt-ui",
4
- "version": "0.7.13",
4
+ "version": "0.7.14",
5
5
  "news": {
6
+ "0.7.14": {
7
+ "en": "Added Gate E for negative dynamic electricity prices. When the effective tariff/forecast price is below zero, grid import is preferred: tariff flags allow storage grid charging and EVCS grid charging, block tariff-based storage discharge, publish ems.budget.tariff.*, and recommend PV curtailment. Hard grid, phase, §14a and peak-shaving limits remain active. Heating rod and thermal budget followers can use total budget in negative-price windows without consuming PV budget; MultiUse and peak-shaving behavior remains unchanged.",
8
+ "de": "Gate E für negative dynamische Strompreise ergänzt. Wenn der effektive Tarif-/Forecastpreis unter 0 liegt, wird Netzbezug bevorzugt: Tarif-Flags geben Speicher-Netzladen und EVCS-Netzladen frei, sperren tarifbasierte Speicherentladung, veröffentlichen ems.budget.tariff.* und empfehlen PV-Abregelung. Harte Netz-, Phasen-, §14a- und Peakshaving-Grenzen bleiben aktiv. Heizstab- und Thermik-Budgetfolger können in Negativpreisfenstern Gesamtbudget nutzen, ohne PV-Budget zu verbrauchen; MultiUse und Peakshaving bleiben unverändert."
9
+ },
6
10
  "0.7.13": {
7
11
  "en": "Heating rod PV-auto now treats fresh central EMS remaining PV budget as already priority-adjusted, so thermal budget is not subtracted twice. This fixes blocked auto-start and missing stage-up when the central Gates already deducted higher-priority consumers.",
8
12
  "de": "Heizstab-PV-Auto nutzt frisches zentrales EMS-Rest-PV-Budget jetzt als bereits prioritätsbereinigt. Thermik wird dabei nicht mehr doppelt abgezogen. Das behebt blockierten Auto-Start und fehlendes Hochstufen, wenn die zentralen Gates Verbraucher höherer Priorität bereits abgezogen haben."
@@ -1871,8 +1875,12 @@
1871
1875
  }
1872
1876
  },
1873
1877
  "instanceObjects": [],
1874
- "version": "0.7.13",
1878
+ "version": "0.7.14",
1875
1879
  "news": {
1880
+ "0.7.14": {
1881
+ "en": "Gate E - Tariff/negative price added. Negative dynamic tariff prices now prefer grid import, allow storage and EVCS grid charging, block storage discharge from tariff logic, expose ems.budget.tariff.* diagnostics and let PV curtailment/zero-export logic bias toward import while hard safety limits stay active. Thermal and heating-rod budget followers can use total budget in negative-price windows. MultiUse and peak-shaving unchanged. Web cache bumped.",
1882
+ "de": "Gate E - Tarif/Negativpreis ergänzt. Negative dynamische Tarifpreise bevorzugen jetzt Netzbezug, erlauben Speicher- und EVCS-Netzladen, sperren tarifbasierte Speicherentladung, stellen ems.budget.tariff.* als Diagnose bereit und lassen PV-Abregelung/0-Einspeisung in Richtung Import-Bias arbeiten, während harte Schutzgrenzen aktiv bleiben. Thermik- und Heizstab-Budgetfolger können in Negativpreisfenstern Gesamtbudget nutzen. MultiUse und Peakshaving unverändert. Webcache erhöht."
1883
+ },
1876
1884
  "0.7.13": {
1877
1885
  "en": "Heating rod PV-Auto now treats fresh central ems.budget.remainingPvW as already prioritized, so higher-priority thermal reservations are no longer subtracted a second time. This fixes PV-Auto start/step-up blocking where the rod only started after manual stage 1 and then stayed there. Legacy fallback budgets still keep the local thermal deduction. Web cache version bumped.",
1878
1886
  "de": "Heizstab PV-Auto nutzt frisches zentrales ems.budget.remainingPvW jetzt als bereits priorisiertes Restbudget, sodass Thermik-Reservierungen mit höherer Priorität nicht noch einmal lokal abgezogen werden. Das behebt Start-/Hochschaltblockaden, bei denen der Heizstab erst nach manueller Stufe 1 lief und dann dort hängen blieb. Legacy-Fallback-Budgets behalten den lokalen Thermik-Abzug. Webcache erhöht."
package/main.js CHANGED
@@ -10873,6 +10873,21 @@ app.get('/api/smarthome/type-detect', requireInstaller, async (req, res) => {
10873
10873
  emsForecastKwhNext24h: await getOwn('ems.budget.forecast.kwhNext24h'),
10874
10874
  emsForecastStatus: await getOwn('ems.budget.forecast.status'),
10875
10875
  emsForecastSource: await getOwn('ems.budget.forecast.source'),
10876
+
10877
+ // Gate E - Tarif / Negativpreis
10878
+ emsTariffActive: await getOwn('ems.budget.tariff.active'),
10879
+ emsTariffState: await getOwn('ems.budget.tariff.state'),
10880
+ emsTariffCurrentPriceEurKwh: await getOwn('ems.budget.tariff.currentPriceEurKwh'),
10881
+ emsTariffNegativeActive: await getOwn('ems.budget.tariff.negativeActive'),
10882
+ emsTariffGridImportPreferred: await getOwn('ems.budget.tariff.gridImportPreferred'),
10883
+ emsTariffStorageGridChargeAllowed: await getOwn('ems.budget.tariff.storageGridChargeAllowed'),
10884
+ emsTariffEvcsGridChargeAllowed: await getOwn('ems.budget.tariff.evcsGridChargeAllowed'),
10885
+ emsTariffDischargeAllowed: await getOwn('ems.budget.tariff.dischargeAllowed'),
10886
+ emsTariffPvCurtailRecommended: await getOwn('ems.budget.tariff.pvCurtailRecommended'),
10887
+ emsTariffNegativeMinPriceEurKwh: await getOwn('ems.budget.tariff.negativeMinPriceEurKwh'),
10888
+ emsTariffNextNegativeFrom: await getOwn('ems.budget.tariff.nextNegativeFrom'),
10889
+ emsTariffNextNegativeTo: await getOwn('ems.budget.tariff.nextNegativeTo'),
10890
+ emsTariffStatus: await getOwn('ems.budget.tariff.status'),
10876
10891
  };
10877
10892
 
10878
10893
  const summary = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.nexowatt-ui",
3
- "version": "0.7.13",
3
+ "version": "0.7.14",
4
4
  "description": "Responsive NexoWatt EMS visualization adapter.",
5
5
  "author": "NexoWatt",
6
6
  "license": "Proprietary License (NPL) v1.0",
package/www/ems-apps.js CHANGED
@@ -7536,6 +7536,20 @@ function _collectFlowPowerDpIsWFromUI() {
7536
7536
  return n.toFixed(n >= 10 ? 1 : 2) + ' kWh';
7537
7537
  }
7538
7538
 
7539
+ function _fmtEurKwh(v) {
7540
+ const n = Number(v);
7541
+ if (!Number.isFinite(n)) return '—';
7542
+ return n.toFixed(4) + ' €/kWh';
7543
+ }
7544
+
7545
+ function _fmtIsoShort(v) {
7546
+ const s = String(v || '').trim();
7547
+ if (!s) return '—';
7548
+ const d = new Date(s);
7549
+ if (!Number.isFinite(d.getTime())) return s;
7550
+ return d.toLocaleString();
7551
+ }
7552
+
7539
7553
  function _fmtPct(v) {
7540
7554
  const n = Number(v);
7541
7555
  if (!Number.isFinite(n)) return '—';
@@ -7759,6 +7773,23 @@ function _collectFlowPowerDpIsWFromUI() {
7759
7773
  { label: 'Status', value: String(ctrl.emsForecastStatus || '') },
7760
7774
  ], fcKind));
7761
7775
 
7776
+ const tariffNeg = b(ctrl.emsTariffNegativeActive);
7777
+ const tariffPref = b(ctrl.emsTariffGridImportPreferred);
7778
+ const tariffKind = tariffPref ? 'ok' : (b(ctrl.emsTariffActive) ? '' : 'warn');
7779
+ els.chargingBudget.appendChild(mkCard('Gate E – Tarif / Negativpreis', [
7780
+ { label: 'Tarif aktiv', value: _fmtBool(b(ctrl.emsTariffActive), 'JA', 'NEIN') },
7781
+ { label: 'Status', value: String(ctrl.emsTariffStatus || ctrl.emsTariffState || '') },
7782
+ { label: 'Aktueller Preis', value: _fmtEurKwh(n(ctrl.emsTariffCurrentPriceEurKwh)) },
7783
+ { label: 'Negativpreis aktiv', value: _fmtBool(tariffNeg, 'JA', 'NEIN') },
7784
+ { label: 'Netzbezug bevorzugt', value: _fmtBool(tariffPref, 'JA', 'NEIN') },
7785
+ { label: 'Speicher Netzladen', value: _fmtBool(b(ctrl.emsTariffStorageGridChargeAllowed), 'JA', 'NEIN') },
7786
+ { label: 'EVCS Netzladen', value: _fmtBool(b(ctrl.emsTariffEvcsGridChargeAllowed), 'JA', 'NEIN') },
7787
+ { label: 'Speicher Entladen', value: _fmtBool(b(ctrl.emsTariffDischargeAllowed), 'JA', 'NEIN') },
7788
+ { label: 'PV-Abregelung empfohlen', value: _fmtBool(b(ctrl.emsTariffPvCurtailRecommended), 'JA', 'NEIN') },
7789
+ { label: 'Min. negativ im Forecast', value: _fmtEurKwh(n(ctrl.emsTariffNegativeMinPriceEurKwh)) },
7790
+ { label: 'Nächstes Negativfenster', value: `${_fmtIsoShort(ctrl.emsTariffNextNegativeFrom)} → ${_fmtIsoShort(ctrl.emsTariffNextNegativeTo)}` },
7791
+ ], tariffKind));
7792
+
7762
7793
  try {
7763
7794
  const consumers = JSON.parse(String(ctrl.emsBudgetConsumersJson || '[]'));
7764
7795
  if (Array.isArray(consumers) && consumers.length) {
package/www/sw.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Increment cache name on releases so browser updates JS/HTML reliably.
2
2
  // NOTE: Keep this monotonic to force SW updates on hotfixes.
3
- const CACHE_NAME = 'nexowatt-cache-v183';
3
+ const CACHE_NAME = 'nexowatt-cache-v184';
4
4
 
5
5
  const OFFLINE_URLS = [
6
6
  './',