iobroker.nexowatt-ui 0.7.0 → 0.7.2

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.
@@ -96,6 +96,8 @@ class HeatingRodControlModule extends BaseModule {
96
96
  this._stateCache = new Map();
97
97
  /** @type {Map<string, {targetStage:number,lastIncreaseMs:number,lastDecreaseMs:number}>} */
98
98
  this._stageCtl = new Map();
99
+ /** @type {{importSinceMs:number, dischargeSinceMs:number}} */
100
+ this._budgetProtect = { importSinceMs: 0, dischargeSinceMs: 0 };
99
101
  }
100
102
 
101
103
  _isEnabled() {
@@ -362,6 +364,15 @@ class HeatingRodControlModule extends BaseModule {
362
364
  await mk('heatingRod.summary.pvAvailableW', 'PV available after thermal (W)', 'number', 'value.power', 'W');
363
365
  await mk('heatingRod.summary.appliedTotalW', 'Applied total (W)', 'number', 'value.power', 'W');
364
366
  await mk('heatingRod.summary.budgetUsedW', 'Budget used (W)', 'number', 'value.power', 'W');
367
+ await mk('heatingRod.summary.budgetGateTotalW', 'Budget gate total (W)', 'number', 'value.power', 'W');
368
+ await mk('heatingRod.summary.budgetGateRemainingW', 'Budget gate remaining after EVCS (W)', 'number', 'value.power', 'W');
369
+ await mk('heatingRod.summary.budgetGatePvW', 'Budget gate PV for heating rod (W)', 'number', 'value.power', 'W');
370
+ await mk('heatingRod.summary.budgetGateEffectiveW', 'Budget gate effective for heating rod (W)', 'number', 'value.power', 'W');
371
+ await mk('heatingRod.summary.budgetGateSource', 'Budget gate source', 'string', 'text');
372
+ await mk('heatingRod.summary.gridImportW', 'Grid import used for heating rod gate (W)', 'number', 'value.power', 'W');
373
+ await mk('heatingRod.summary.gridImportLimitW', 'Allowed grid import in PV auto (W)', 'number', 'value.power', 'W');
374
+ await mk('heatingRod.summary.gridImportExceeded', 'Grid import above heating rod limit', 'boolean', 'indicator');
375
+ await mk('heatingRod.summary.storageDischargeExceeded', 'Storage discharge above heating rod limit', 'boolean', 'indicator');
365
376
  await mk('heatingRod.summary.debugJson', 'Debug JSON', 'string', 'json');
366
377
  await mk('heatingRod.summary.zeroExportActive', 'Zero/minus feed-in logic active', 'boolean', 'indicator');
367
378
  await mk('heatingRod.summary.zeroExportCanProbe', 'Zero/minus feed-in probe allowed', 'boolean', 'indicator');
@@ -380,9 +391,18 @@ class HeatingRodControlModule extends BaseModule {
380
391
  try {
381
392
  const ns = String(this.adapter.namespace || '').trim();
382
393
  if (ns && this.dp) {
394
+ await this.dp.upsert({ key: 'hr.cm.active', objectId: `${ns}.chargingManagement.control.active`, dataType: 'boolean', direction: 'in' });
395
+ await this.dp.upsert({ key: 'hr.cm.budgetW', objectId: `${ns}.chargingManagement.control.budgetW`, dataType: 'number', direction: 'in', unit: 'W' });
396
+ await this.dp.upsert({ key: 'hr.cm.remainingW', objectId: `${ns}.chargingManagement.control.remainingW`, dataType: 'number', direction: 'in', unit: 'W' });
397
+ await this.dp.upsert({ key: 'hr.cm.pvCapRawW', objectId: `${ns}.chargingManagement.control.pvCapRawW`, dataType: 'number', direction: 'in', unit: 'W' });
383
398
  await this.dp.upsert({ key: 'hr.cm.pvCapW', objectId: `${ns}.chargingManagement.control.pvCapEffectiveW`, dataType: 'number', direction: 'in', unit: 'W' });
399
+ await this.dp.upsert({ key: 'hr.cm.pvAvailable', objectId: `${ns}.chargingManagement.control.pvAvailable`, dataType: 'boolean', direction: 'in' });
384
400
  await this.dp.upsert({ key: 'hr.cm.usedW', objectId: `${ns}.chargingManagement.control.usedW`, dataType: 'number', direction: 'in', unit: 'W' });
385
401
  await this.dp.upsert({ key: 'hr.cm.pvSurplusNoEvRawW', objectId: `${ns}.chargingManagement.control.pvSurplusNoEvRawW`, dataType: 'number', direction: 'in', unit: 'W' });
402
+ await this.dp.upsert({ key: 'hr.cm.pvSurplusNoEvAvg5mW', objectId: `${ns}.chargingManagement.control.pvSurplusNoEvAvg5mW`, dataType: 'number', direction: 'in', unit: 'W' });
403
+ await this.dp.upsert({ key: 'hr.cm.gridW', objectId: `${ns}.chargingManagement.control.gridImportW`, dataType: 'number', direction: 'in', unit: 'W' });
404
+ await this.dp.upsert({ key: 'hr.cm.staleMeter', objectId: `${ns}.chargingManagement.control.staleMeter`, dataType: 'boolean', direction: 'in' });
405
+ await this.dp.upsert({ key: 'hr.cm.staleBudget', objectId: `${ns}.chargingManagement.control.staleBudget`, dataType: 'boolean', direction: 'in' });
386
406
  for (let i = 1; i <= 10; i++) {
387
407
  await this.dp.upsert({ key: `hr.user.c${i}.regEnabled`, objectId: `${ns}.heatingRod.user.c${i}.regEnabled`, dataType: 'boolean', direction: 'in' });
388
408
  await this.dp.upsert({ key: `hr.user.c${i}.mode`, objectId: `${ns}.heatingRod.user.c${i}.mode`, dataType: 'string', direction: 'in' });
@@ -490,6 +510,58 @@ class HeatingRodControlModule extends BaseModule {
490
510
  return fallback;
491
511
  }
492
512
 
513
+ _readBooleanAny(keys, staleMs, fallback = null) {
514
+ const list = Array.isArray(keys) ? keys : [keys];
515
+ for (const key of list) {
516
+ if (!key) continue;
517
+ try {
518
+ if (this.dp && this.dp.getEntry && this.dp.getEntry(key)) {
519
+ if (typeof this.dp.isStale === 'function' && this.dp.isStale(key, staleMs)) continue;
520
+ const v = this.dp.getBoolean ? this.dp.getBoolean(key, null) : null;
521
+ if (v !== null && v !== undefined) return !!v;
522
+ }
523
+ } catch (_e) {
524
+ // ignore
525
+ }
526
+ const raw = this._readCacheRaw(key, null);
527
+ if (raw === null || raw === undefined) continue;
528
+ if (typeof raw === 'boolean') return raw;
529
+ if (typeof raw === 'number') return raw !== 0;
530
+ if (typeof raw === 'string') {
531
+ const t = raw.trim().toLowerCase();
532
+ if (['true', '1', 'on', 'yes', 'active', 'enabled'].includes(t)) return true;
533
+ if (['false', '0', 'off', 'no', 'inactive', 'disabled'].includes(t)) return false;
534
+ }
535
+ }
536
+ return fallback;
537
+ }
538
+
539
+ _getBudgetGateCfg() {
540
+ const cfg = this._getCfg();
541
+ const zero = (cfg.zeroExport && typeof cfg.zeroExport === 'object') ? cfg.zeroExport : {};
542
+ const pickNum = (keys, def, minV = 0, maxV = 1e12) => {
543
+ const list = Array.isArray(keys) ? keys : [keys];
544
+ for (const key of list) {
545
+ const raw = (cfg[key] !== undefined && cfg[key] !== null && cfg[key] !== '') ? cfg[key] : zero[key];
546
+ if (raw === null || raw === undefined || raw === '') continue;
547
+ const n = Number(raw);
548
+ if (Number.isFinite(n)) return Math.round(clamp(n, minV, maxV));
549
+ }
550
+ return Math.round(clamp(def, minV, maxV));
551
+ };
552
+
553
+ return {
554
+ useBudgetGates: true,
555
+ maxGridImportW: pickNum(['maxGridImportW', 'gridImportToleranceW', 'pvMaxGridImportW', 'pvImportToleranceW', 'gridImportTripW'], 250, 0, 1000000),
556
+ gridImportHoldSec: pickNum(['gridImportHoldSec', 'gridImportTripSec', 'pvGridImportHoldSec'], 15, 0, 3600),
557
+ hardGridImportW: pickNum(['hardGridImportW', 'pvHardGridImportW'], 3000, 0, 1000000),
558
+ storageDischargeToleranceW: pickNum(['storageDischargeToleranceW', 'pvStorageDischargeToleranceW'], 300, 0, 1000000),
559
+ storageDischargeHoldSec: pickNum(['storageDischargeHoldSec', 'storageDischargeTripSec', 'pvStorageDischargeHoldSec'], 15, 0, 3600),
560
+ hardStorageDischargeW: pickNum(['hardStorageDischargeW', 'pvHardStorageDischargeW'], 1200, 0, 1000000),
561
+ budgetSafetyReserveW: pickNum(['budgetSafetyReserveW', 'pvSafetyReserveW'], 150, 0, 1000000),
562
+ };
563
+ }
564
+
493
565
  _readStorageSnapshot(staleMs) {
494
566
  let chargeW = Math.max(0, num(this._readNumberAny([
495
567
  'storageFarm.totalChargePowerW',
@@ -522,19 +594,38 @@ class HeatingRodControlModule extends BaseModule {
522
594
 
523
595
  _computeBasePvAvailableW(currentHeatingRodW = 0) {
524
596
  const cfg = this._getCfg();
597
+ const gateCfg = this._getBudgetGateCfg();
525
598
  const staleTimeoutSec = clamp(num(cfg.staleTimeoutSec, 15), 1, 3600);
526
599
  const staleMs = Math.max(1, Math.round(staleTimeoutSec * 1000));
600
+ const finite = (v) => (typeof v === 'number' && Number.isFinite(v));
601
+
602
+ const cmActive = this._readBooleanAny(['hr.cm.active', 'chargingManagement.control.active'], staleMs, null);
603
+ const cmStaleMeter = this._readBooleanAny(['hr.cm.staleMeter', 'chargingManagement.control.staleMeter'], staleMs, false);
604
+ const cmStaleBudget = this._readBooleanAny(['hr.cm.staleBudget', 'chargingManagement.control.staleBudget'], staleMs, false);
605
+
606
+ const cmBudgetWRaw = this._readNumberAny(['hr.cm.budgetW', 'chargingManagement.control.budgetW'], staleMs, null);
607
+ const cmRemainingWRaw = this._readNumberAny(['hr.cm.remainingW', 'chargingManagement.control.remainingW'], staleMs, null);
608
+ const cmUsedWRaw = this._readNumberAny(['hr.cm.usedW', 'chargingManagement.control.usedW'], staleMs, null);
609
+ const cmPvCapEffectiveRaw = this._readNumberAny(['hr.cm.pvCapW', 'chargingManagement.control.pvCapEffectiveW'], staleMs, null);
610
+ const cmPvCapRawRaw = this._readNumberAny(['hr.cm.pvCapRawW', 'chargingManagement.control.pvCapRawW'], staleMs, null);
611
+ const cmPvNoEvRaw = this._readNumberAny(['hr.cm.pvSurplusNoEvRawW', 'chargingManagement.control.pvSurplusNoEvRawW'], staleMs, null);
612
+ const cmPvNoEvAvg = this._readNumberAny(['hr.cm.pvSurplusNoEvAvg5mW', 'chargingManagement.control.pvSurplusNoEvAvg5mW'], staleMs, null);
613
+ const cmPvAvailable = this._readBooleanAny(['hr.cm.pvAvailable', 'chargingManagement.control.pvAvailable'], staleMs, null);
614
+
615
+ const pvCapW = finite(cmPvCapEffectiveRaw) ? Math.max(0, cmPvCapEffectiveRaw) : 0;
616
+ const evcsUsedW = finite(cmUsedWRaw) ? Math.max(0, cmUsedWRaw) : 0;
617
+ const currentW = Math.max(0, num(currentHeatingRodW, 0));
527
618
 
528
- const pvCapWRaw = this._readNumberAny(['hr.cm.pvCapW', 'chargingManagement.control.pvCapEffectiveW'], staleMs, null);
529
- const usedWRaw = this._readNumberAny(['hr.cm.usedW', 'chargingManagement.control.usedW'], staleMs, null);
530
- const pvCapW = (typeof pvCapWRaw === 'number' && Number.isFinite(pvCapWRaw)) ? Math.max(0, pvCapWRaw) : 0;
531
- const evcsUsedW = (typeof usedWRaw === 'number' && Number.isFinite(usedWRaw)) ? Math.max(0, usedWRaw) : 0;
532
-
533
- const gridW = this._readNumberAny(['grid.powerRawW', 'grid.powerW', 'ps.gridPowerW'], staleMs, null);
534
- const gridKnown = (typeof gridW === 'number' && Number.isFinite(gridW));
619
+ const gridW = this._readNumberAny([
620
+ 'hr.cm.gridW',
621
+ 'chargingManagement.control.gridImportW',
622
+ 'grid.powerRawW',
623
+ 'grid.powerW',
624
+ 'ps.gridPowerW'
625
+ ], staleMs, null);
626
+ const gridKnown = finite(gridW);
535
627
  const exportW = gridKnown ? Math.max(0, -gridW) : 0;
536
628
  const importW = gridKnown ? Math.max(0, gridW) : 0;
537
- const currentW = Math.max(0, num(currentHeatingRodW, 0));
538
629
  const storage = this._readStorageSnapshot(staleMs);
539
630
 
540
631
  const storageTargetSocPct = clamp(num(cfg.storageTargetSocPct, 90), 0, 100);
@@ -547,63 +638,168 @@ class HeatingRodControlModule extends BaseModule {
547
638
  ? storageReserveCfgW
548
639
  : 0;
549
640
 
550
- // NVP-Bilanz vor flexiblen Heizstäben:
551
- // Export/Import am Netzpunkt + bereits laufende Heizstableistung + Speicherladung - Speicherentladung.
552
- // Wichtig: Netzbezug muss abgezogen werden. Sonst hält eine bereits laufende Heizstableistung
553
- // nachts ihren eigenen vermeintlichen PV-Überschuss künstlich am Leben.
554
- // Ohne frischen Netzpunktwert darf die aktuelle Heizstableistung NICHT als Überschuss-Fallback
555
- // verwendet werden; sonst schaltet ein laufender Heizstab bei Dunkelheit nie sauber aus.
641
+ // Gate A/T/§14a/Peak: consume the remaining central budget after EVCS.
642
+ // This is intentionally read-only: Heizstab does not change the load management budget engine.
643
+ let totalGateRemainingW = Number.POSITIVE_INFINITY;
644
+ let totalGateBudgetW = Number.POSITIVE_INFINITY;
645
+ let totalGateSource = 'unlimited';
646
+ const cmLooksActive = cmActive === true
647
+ || evcsUsedW > 0
648
+ || (finite(cmBudgetWRaw) && cmBudgetWRaw > 0)
649
+ || (finite(cmRemainingWRaw) && cmRemainingWRaw > 0);
650
+ if (gateCfg.useBudgetGates && cmLooksActive && !cmStaleBudget && finite(cmRemainingWRaw)) {
651
+ totalGateRemainingW = Math.max(0, cmRemainingWRaw);
652
+ totalGateBudgetW = finite(cmBudgetWRaw) ? Math.max(0, cmBudgetWRaw) : totalGateRemainingW + evcsUsedW;
653
+ totalGateSource = 'chargingManagement.remainingW';
654
+ } else {
655
+ try {
656
+ const caps = (this.adapter && this.adapter._emsCaps && typeof this.adapter._emsCaps === 'object') ? this.adapter._emsCaps : null;
657
+ const cap = caps && caps.evcsHighLevel ? num(caps.evcsHighLevel.capW, null) : null;
658
+ if (gateCfg.useBudgetGates && typeof cap === 'number' && Number.isFinite(cap) && cap > 0) {
659
+ totalGateBudgetW = Math.max(0, cap);
660
+ totalGateRemainingW = Math.max(0, cap - evcsUsedW);
661
+ totalGateSource = `ems.core.${String(caps.evcsHighLevel.binding || 'highLevel')}`;
662
+ }
663
+ } catch (_e) {
664
+ // ignore core fallback
665
+ }
666
+ }
667
+
668
+ // Gate B: prefer the same PV surplus gate that EVCS uses when it is active.
669
+ // It is reconstructed without EVCS; therefore add the current Heizstab load back in,
670
+ // otherwise an already running stage would collapse its own PV budget at 0 export.
671
+ let cmPvGateW = null;
672
+ let cmPvGateSource = '';
673
+ if (!cmStaleMeter) {
674
+ const candidates = [];
675
+ if (finite(cmPvCapEffectiveRaw) && cmPvCapEffectiveRaw > 0) candidates.push({ k: 'cm.pvCapEffectiveW', w: cmPvCapEffectiveRaw });
676
+ if (finite(cmPvCapRawRaw) && cmPvCapRawRaw > 0) candidates.push({ k: 'cm.pvCapRawW', w: cmPvCapRawRaw });
677
+ if (finite(cmPvNoEvRaw) && cmPvNoEvRaw > 0) candidates.push({ k: 'cm.pvSurplusNoEvRawW', w: cmPvNoEvRaw });
678
+ if (finite(cmPvNoEvAvg) && cmPvNoEvAvg > 0) candidates.push({ k: 'cm.pvSurplusNoEvAvg5mW', w: cmPvNoEvAvg });
679
+ if (candidates.length) {
680
+ const best = candidates.reduce((a, b) => (b.w > a.w ? b : a), candidates[0]);
681
+ cmPvGateW = Math.max(0, best.w - evcsUsedW + currentW - storageReserveW - gateCfg.budgetSafetyReserveW);
682
+ cmPvGateSource = best.k;
683
+ } else if (cmPvAvailable === false && finite(cmPvCapEffectiveRaw)) {
684
+ cmPvGateW = Math.max(0, currentW - storageReserveW - gateCfg.budgetSafetyReserveW);
685
+ cmPvGateSource = 'cm.pvAvailable.false_hold_only';
686
+ }
687
+ }
688
+
689
+ // Fallback/second truth: NVP balance without Heizstab as flexible load.
690
+ // import above the configured tolerance consumes the budget; small import remains allowed
691
+ // to keep the stages running calmly like PV-only EV charging.
692
+ const importExcessW = gridKnown ? Math.max(0, importW - gateCfg.maxGridImportW) : 0;
556
693
  const nvpSurplusBeforeFlexW = gridKnown
557
- ? Math.max(0, exportW - importW + currentW + storage.chargeW - storage.dischargeW)
694
+ ? Math.max(0, exportW + currentW + storage.chargeW - storage.dischargeW - importExcessW)
558
695
  : 0;
559
- const nvpAvailableW = Math.max(0, nvpSurplusBeforeFlexW - storageReserveW);
560
-
561
- const cmAvailableW = (pvCapW > 0) ? Math.max(0, pvCapW - evcsUsedW - storageReserveW) : 0;
562
- // Wenn ein frischer Netzpunktwert vorhanden ist, ist er für Heizstäbe die härtere Wahrheit.
563
- // Ohne Netzpunktwert wird ausschließlich das Charging-Management-Cap genutzt; wenn auch das
564
- // fehlt, ist die sichere Vorgabe 0 W verfügbar.
565
- const availableW = gridKnown ? nvpAvailableW : cmAvailableW;
566
- const source = gridKnown ? 'nvp-storage-reserve' : (pvCapW > 0 ? 'cm-storage-reserve' : 'no-fresh-nvp');
567
-
568
- // PV-Auto darf keine Netz- oder Speicherenergie nachziehen.
569
- // Bei echtem Netzbezug oder Speicherentladung muss die Regelung zurücknehmen und darf
570
- // nicht neu einschalten. Kleine Toleranzen verhindern Flattern durch Messrauschen um 0 W.
571
- const importToleranceW = Math.max(0, Math.round(num(cfg.gridImportToleranceW ?? cfg.pvImportToleranceW ?? 50)));
572
- const dischargeToleranceW = Math.max(0, Math.round(num(cfg.storageDischargeToleranceW ?? cfg.pvStorageDischargeToleranceW ?? 100)));
573
- const gridImportActive = !!(gridKnown && importW > importToleranceW);
574
- const storageDischargeActive = !!(storage.dischargeW > dischargeToleranceW);
696
+ const nvpAvailableW = Math.max(0, nvpSurplusBeforeFlexW - storageReserveW - gateCfg.budgetSafetyReserveW);
697
+
698
+ let pvBudgetGateW = nvpAvailableW;
699
+ let pvBudgetSource = gridKnown ? 'nvp+ownLoad+storageReserve' : 'no-fresh-nvp';
700
+ if (cmPvGateW !== null && Number.isFinite(cmPvGateW) && cmPvGateW > pvBudgetGateW) {
701
+ pvBudgetGateW = cmPvGateW;
702
+ pvBudgetSource = cmPvGateSource || 'cm.pvGate';
703
+ }
704
+
705
+ const effectiveGateW = Math.max(0, Math.min(
706
+ pvBudgetGateW,
707
+ Number.isFinite(totalGateRemainingW) ? totalGateRemainingW : Number.POSITIVE_INFINITY
708
+ ));
709
+
710
+ const source = `${pvBudgetSource}|${totalGateSource}`;
711
+ const gridImportActive = !!(gridKnown && importW > gateCfg.maxGridImportW);
712
+ const storageDischargeActive = !!(storage.dischargeW > gateCfg.storageDischargeToleranceW);
575
713
  const nonPvEnergyActive = !!(gridImportActive || storageDischargeActive);
576
- const forceOff = availableW <= 50 && (storageDischargeActive || (gridImportActive && currentW > 0) || (!gridKnown && currentW > 0 && pvCapW <= 0));
714
+ const forceOff = effectiveGateW <= 50 && currentW > 0 && (importW > gateCfg.hardGridImportW || storage.dischargeW > gateCfg.hardStorageDischargeW);
577
715
 
578
716
  return {
579
- pvCapW: Math.max(pvCapW, nvpSurplusBeforeFlexW),
717
+ pvCapW: Math.max(pvBudgetGateW, pvCapW, nvpSurplusBeforeFlexW),
580
718
  evcsUsedW,
581
- availableW,
719
+ availableW: effectiveGateW,
582
720
  source,
721
+ gateCfg,
722
+ useBudgetGates: !!gateCfg.useBudgetGates,
723
+ budgetGateTotalW: Number.isFinite(totalGateBudgetW) ? Math.max(0, totalGateBudgetW) : null,
724
+ budgetGateRemainingW: Number.isFinite(totalGateRemainingW) ? Math.max(0, totalGateRemainingW) : null,
725
+ budgetGatePvW: Math.max(0, pvBudgetGateW),
726
+ budgetGateEffectiveW: Math.max(0, effectiveGateW),
727
+ budgetGateSource: source,
728
+ cmActive,
729
+ cmStaleMeter: !!cmStaleMeter,
730
+ cmStaleBudget: !!cmStaleBudget,
731
+ cmPvAvailable,
732
+ cmPvCapEffectiveW: pvCapW,
733
+ cmPvCapRawW: finite(cmPvCapRawRaw) ? Math.max(0, cmPvCapRawRaw) : 0,
734
+ cmPvSurplusNoEvRawW: finite(cmPvNoEvRaw) ? Math.max(0, cmPvNoEvRaw) : 0,
583
735
  gridKnown,
584
736
  gridW: gridKnown ? gridW : null,
585
737
  importW,
586
- importToleranceW,
738
+ importToleranceW: gateCfg.maxGridImportW,
587
739
  gridImportActive,
588
740
  exportW,
589
741
  currentHeatingRodW: currentW,
590
742
  storageChargeW: storage.chargeW,
591
743
  storageDischargeW: storage.dischargeW,
592
- dischargeToleranceW,
744
+ dischargeToleranceW: gateCfg.storageDischargeToleranceW,
593
745
  storageDischargeActive,
594
746
  nonPvEnergyActive,
595
747
  storageSocPct: storage.socPct,
596
748
  storageReserveW,
597
749
  storageTargetSocPct,
598
750
  nvpSurplusBeforeFlexW,
599
- cmAvailableW,
751
+ cmAvailableW: (cmPvGateW !== null && Number.isFinite(cmPvGateW)) ? Math.max(0, cmPvGateW) : 0,
600
752
  nvpAvailableW,
601
753
  forceOff,
602
754
  };
603
755
  }
604
756
 
757
+ _updateBudgetGateProtection(pvBase, now) {
758
+ const cfg = (pvBase && pvBase.gateCfg) ? pvBase.gateCfg : this._getBudgetGateCfg();
759
+ const st = this._budgetProtect || { importSinceMs: 0, dischargeSinceMs: 0 };
760
+ const importActive = !!(pvBase && pvBase.gridKnown && num(pvBase.importW, 0) > cfg.maxGridImportW);
761
+ const dischargeActive = !!(pvBase && num(pvBase.storageDischargeW, 0) > cfg.storageDischargeToleranceW);
762
+ const hardImport = !!(pvBase && pvBase.gridKnown && num(pvBase.importW, 0) > cfg.hardGridImportW);
763
+ const hardDischarge = !!(pvBase && num(pvBase.storageDischargeW, 0) > cfg.hardStorageDischargeW);
764
+ if (importActive) {
765
+ if (!st.importSinceMs) st.importSinceMs = now;
766
+ } else {
767
+ st.importSinceMs = 0;
768
+ }
769
+ if (dischargeActive) {
770
+ if (!st.dischargeSinceMs) st.dischargeSinceMs = now;
771
+ } else {
772
+ st.dischargeSinceMs = 0;
773
+ }
774
+
775
+ const importHoldMs = importActive && st.importSinceMs ? Math.max(0, now - st.importSinceMs) : 0;
776
+ const dischargeHoldMs = dischargeActive && st.dischargeSinceMs ? Math.max(0, now - st.dischargeSinceMs) : 0;
777
+ const hardOff = !!(hardImport || hardDischarge);
778
+ const reduceNow = hardOff
779
+ || (importActive && importHoldMs >= Math.max(0, cfg.gridImportHoldSec * 1000))
780
+ || (dischargeActive && dischargeHoldMs >= Math.max(0, cfg.storageDischargeHoldSec * 1000));
781
+ const reason = hardOff
782
+ ? (hardImport ? 'hard_grid_import' : 'hard_storage_discharge')
783
+ : (reduceNow ? (importActive ? 'grid_import_hold' : 'storage_discharge_hold') : (importActive || dischargeActive ? 'watch' : 'ok'));
784
+
785
+ this._budgetProtect = st;
786
+ return {
787
+ importActive,
788
+ dischargeActive,
789
+ hardImport,
790
+ hardDischarge,
791
+ importHoldMs,
792
+ dischargeHoldMs,
793
+ hardOff,
794
+ reduceNow,
795
+ watchActive: !!((importActive || dischargeActive) && !reduceNow),
796
+ reason,
797
+ };
798
+ }
799
+
605
800
  _getZeroExportCfg() {
606
801
  const cfg = this._getCfg();
802
+ const gateCfg = this._getBudgetGateCfg();
607
803
  const raw = (cfg.zeroExport && typeof cfg.zeroExport === 'object')
608
804
  ? cfg.zeroExport
609
805
  : ((cfg.zeroFeedIn && typeof cfg.zeroFeedIn === 'object') ? cfg.zeroFeedIn : {});
@@ -630,15 +826,19 @@ class HeatingRodControlModule extends BaseModule {
630
826
  minForecastPeakW: n(['minForecastPeakW', 'forecastMinPeakW'], 1000, 0, 1000000),
631
827
  minForecastKwh6h: clamp(num(raw.minForecastKwh6h ?? raw.forecastMinKwh6h, 0.5), 0, 100000),
632
828
  storageFullSocPct: n(['storageFullSocPct', 'storagePrioritySocPct'], 95, 0, 100),
633
- gridImportTripW: n(['gridImportTripW', 'maxGridImportW'], 150, 0, 1000000),
634
- gridImportTripSec: n(['gridImportTripSec', 'gridImportHoldSec'], 5, 0, 3600),
635
- hardGridImportW: n(['hardGridImportW', 'hardImportW'], 500, 0, 1000000),
636
- storageDischargeToleranceW: n(['storageDischargeToleranceW', 'batteryDischargeToleranceW'], 300, 0, 1000000),
637
- storageDischargeTripSec: n(['storageDischargeTripSec', 'batteryDischargeHoldSec'], 8, 0, 3600),
638
- hardStorageDischargeW: n(['hardStorageDischargeW', 'hardBatteryDischargeW'], 800, 0, 1000000),
829
+ gridImportTripW: gateCfg.maxGridImportW,
830
+ gridImportTripSec: gateCfg.gridImportHoldSec,
831
+ hardGridImportW: gateCfg.hardGridImportW,
832
+ storageDischargeToleranceW: gateCfg.storageDischargeToleranceW,
833
+ storageDischargeTripSec: gateCfg.storageDischargeHoldSec,
834
+ hardStorageDischargeW: gateCfg.hardStorageDischargeW,
639
835
  stepUpDelaySec: n(['stepUpDelaySec', 'stepUpWaitSec'], 60, 0, 86400),
640
836
  stepDownDelaySec: n(['stepDownDelaySec', 'stepDownWaitSec'], 5, 0, 86400),
641
837
  cooldownSec: n(['cooldownSec', 'probeCooldownSec'], 60, 0, 86400),
838
+ probeObserveSec: n(['probeObserveSec', 'pvFollowCheckSec', 'pvNachregelCheckSec'], 45, 0, 3600),
839
+ probeMinPvRisePct: n(['probeMinPvRisePct', 'pvRiseMinPct', 'pvAnstiegMinPct'], 20, 0, 1000),
840
+ probeMinPvRiseW: n(['probeMinPvRiseW', 'pvRiseMinW', 'pvAnstiegMinW'], 150, 0, 1000000),
841
+ probeRetrySec: n(['probeRetrySec', 'retryAfterFailedRiseSec', 'pvRiseRetrySec'], 600, 0, 86400),
642
842
  };
643
843
  }
644
844
 
@@ -1016,6 +1216,7 @@ class HeatingRodControlModule extends BaseModule {
1016
1216
  const currentStage = Math.max(0, Math.min(Math.round(Number(st.targetStage ?? observedStage) || 0), d.stageCount));
1017
1217
  let targetStage = Math.max(0, Math.min(Math.round(Number(desiredStage) || 0), d.stageCount));
1018
1218
  let reason = (info && info.reason) ? String(info.reason) : 'zero_export';
1219
+ const pvNowW = Math.max(0, Math.round(num(info && info.pvNowW, 0)));
1019
1220
  let reduceNow = false;
1020
1221
  let hardOff = false;
1021
1222
 
@@ -1049,6 +1250,7 @@ class HeatingRodControlModule extends BaseModule {
1049
1250
  st.zeroCooldownUntilMs = now + Math.max(0, cfg.cooldownSec * 1000);
1050
1251
  st.zeroLastStepDownMs = now;
1051
1252
  st.lastDecreaseMs = now;
1253
+ st.zeroProbe = null;
1052
1254
  st.targetStage = targetStage;
1053
1255
  reason = hardOff ? 'hard_non_pv_reduce' : (importActive ? 'grid_import_reduce' : 'storage_discharge_reduce');
1054
1256
  this._stageCtl.set(d.id, st);
@@ -1073,6 +1275,7 @@ class HeatingRodControlModule extends BaseModule {
1073
1275
  st.zeroCooldownUntilMs = now + Math.max(0, cfg.cooldownSec * 1000);
1074
1276
  st.zeroLastStepDownMs = now;
1075
1277
  st.lastDecreaseMs = now;
1278
+ st.zeroProbe = null;
1076
1279
  st.targetStage = targetStage;
1077
1280
  reason = 'storage_priority_reduce';
1078
1281
  this._stageCtl.set(d.id, st);
@@ -1087,6 +1290,65 @@ class HeatingRodControlModule extends BaseModule {
1087
1290
  };
1088
1291
  }
1089
1292
 
1293
+ const probe = (st.zeroProbe && typeof st.zeroProbe === 'object') ? st.zeroProbe : null;
1294
+ if (probe) {
1295
+ const probeStage = Math.max(0, Math.min(Math.round(Number(probe.stage) || 0), d.stageCount));
1296
+ const probeStillOn = probeStage > 0 && Math.max(currentStage, observedStage, targetStage) >= probeStage;
1297
+ if (!probeStillOn) {
1298
+ st.zeroProbe = null;
1299
+ } else {
1300
+ const observeMs = Math.max(0, Math.round(num(cfg.probeObserveSec, 45) * 1000));
1301
+ const startMs = Math.max(0, Math.round(num(probe.startMs, now)));
1302
+ const elapsedMs = Math.max(0, now - startMs);
1303
+ if (observeMs > 0 && elapsedMs < observeMs) {
1304
+ targetStage = Math.max(targetStage, probeStage);
1305
+ st.targetStage = targetStage;
1306
+ reason = 'probe_observing_pv_rise';
1307
+ this._stageCtl.set(d.id, st);
1308
+ return {
1309
+ targetStage: st.targetStage,
1310
+ reduceNow: false,
1311
+ hardOff: false,
1312
+ reason,
1313
+ importHoldMs,
1314
+ dischargeHoldMs,
1315
+ nextAllowedAt: startMs + observeMs,
1316
+ };
1317
+ }
1318
+
1319
+ const riseW = Math.max(0, pvNowW - Math.max(0, Math.round(num(probe.basePvW, 0))));
1320
+ const addedPowerW = Math.max(0, Math.round(num(probe.addedPowerW, 0)));
1321
+ const needRiseW = Math.max(
1322
+ Math.max(0, Math.round(num(cfg.probeMinPvRiseW, 150))),
1323
+ Math.round(addedPowerW * Math.max(0, num(cfg.probeMinPvRisePct, 20)) / 100)
1324
+ );
1325
+
1326
+ if (riseW + 1 < needRiseW) {
1327
+ const reduceBase = Math.max(currentStage, observedStage, targetStage, probeStage);
1328
+ targetStage = this._previousPhysicalStageBelow(d, reduceBase);
1329
+ st.zeroCooldownUntilMs = now + Math.max(0, cfg.probeRetrySec * 1000);
1330
+ st.zeroLastStepDownMs = now;
1331
+ st.lastDecreaseMs = now;
1332
+ st.zeroProbe = null;
1333
+ st.targetStage = targetStage;
1334
+ reason = `probe_pv_rise_failed_${riseW}of${needRiseW}W`;
1335
+ this._stageCtl.set(d.id, st);
1336
+ return {
1337
+ targetStage,
1338
+ reduceNow: true,
1339
+ hardOff: false,
1340
+ reason,
1341
+ importHoldMs,
1342
+ dischargeHoldMs,
1343
+ nextAllowedAt: st.zeroCooldownUntilMs || 0,
1344
+ };
1345
+ }
1346
+
1347
+ st.zeroProbe = null;
1348
+ reason = `probe_pv_rise_ok_${riseW}of${needRiseW}W`;
1349
+ }
1350
+ }
1351
+
1090
1352
  const cooldownActive = !!(st.zeroCooldownUntilMs && now < st.zeroCooldownUntilMs);
1091
1353
  const canProbe = !!(info && info.active && info.canProbe && !cooldownActive);
1092
1354
 
@@ -1097,8 +1359,17 @@ class HeatingRodControlModule extends BaseModule {
1097
1359
  const stepWaitMs = Math.max(0, cfg.stepUpDelaySec * 1000);
1098
1360
  const mayStep = nextStage > baseStage && (!lastUp || (now - lastUp) >= stepWaitMs);
1099
1361
  if (mayStep) {
1362
+ const basePowerW = this._sumStagePower(d, baseStage);
1363
+ const nextPowerW = this._sumStagePower(d, nextStage);
1100
1364
  targetStage = Math.max(targetStage, nextStage);
1101
1365
  st.zeroLastStepUpMs = now;
1366
+ st.zeroProbe = {
1367
+ stage: nextStage,
1368
+ baseStage,
1369
+ basePvW: pvNowW,
1370
+ addedPowerW: Math.max(0, nextPowerW - basePowerW),
1371
+ startMs: now,
1372
+ };
1102
1373
  reason = 'probe_step_up';
1103
1374
  } else {
1104
1375
  reason = (nextStage <= baseStage) ? 'max_physical_stage' : 'waiting_step_up_delay';
@@ -1262,6 +1533,7 @@ class HeatingRodControlModule extends BaseModule {
1262
1533
  }
1263
1534
 
1264
1535
  const pvBase = this._computeBasePvAvailableW(currentHeatingRodW);
1536
+ const budgetProtection = this._updateBudgetGateProtection(pvBase, now);
1265
1537
  const zeroExportInfo = this._computeZeroExportInfo(pvBase);
1266
1538
  const minPvAutomationW = this._getPvAutomationMinW();
1267
1539
  const staleTimeoutSec = clamp(num(this._getCfg().staleTimeoutSec, 15), 1, 3600);
@@ -1514,6 +1786,13 @@ class HeatingRodControlModule extends BaseModule {
1514
1786
  let desiredStage = this._computeDesiredStage(d, remainingW, observedStage);
1515
1787
  let zeroDecision = null;
1516
1788
 
1789
+ // Wenn der Budget-Gate-Schutz gerade einen Netzbezug/Speicherbezug beobachtet,
1790
+ // nicht weiter hochfahren. Bestehende Stufe darf bis zur konfigurierten Schutzzeit
1791
+ // laufen, damit PV/FEMS sauber nachregeln kann.
1792
+ if (budgetProtection && budgetProtection.watchActive && desiredStage > observedStage) {
1793
+ desiredStage = observedStage;
1794
+ }
1795
+
1517
1796
  // 0-/Minus-Einspeiseanlagen verstecken PV-Überschuss am Netzpunkt, weil der
1518
1797
  // Wechselrichter/FEMS die PV abregelt. In diesem Sondermodus darf PV-Auto vorsichtig
1519
1798
  // eine physische Heizstab-Stufe als Testlast zuschalten, wenn Forecast, PV-Leistung,
@@ -1529,13 +1808,14 @@ class HeatingRodControlModule extends BaseModule {
1529
1808
  // Reiner PV-Betrieb: bei Netzbezug oder Speicherentladung keine Stufe halten
1530
1809
  // oder neu zuschalten. Bei aktivem 0-Einspeise-Sondermodus werden kurze Transienten
1531
1810
  // nicht sofort gekillt, sondern erst nach den konfigurierten Schutzzeiten.
1532
- let forceNonPvDown = !!(pvBase.nonPvEnergyActive);
1533
- if (zeroExportInfo.active) forceNonPvDown = !!(zeroDecision && zeroDecision.reduceNow);
1811
+ let forceNonPvDown = !!(budgetProtection && budgetProtection.reduceNow);
1812
+ if (zeroExportInfo.active) forceNonPvDown = !!(forceNonPvDown || (zeroDecision && zeroDecision.reduceNow));
1534
1813
  if (forceNonPvDown) {
1535
1814
  // Reduce to the next lower *physical* actuator set. This is important for
1536
1815
  // installations that accidentally map several virtual stages to the same KNX/relay
1537
1816
  // datapoint: targetStage 3 -> 2 would otherwise still keep the same actuator ON.
1538
- const lowerPhysicalStage = (zeroDecision && zeroDecision.hardOff)
1817
+ const hardOff = !!((budgetProtection && budgetProtection.hardOff) || (zeroDecision && zeroDecision.hardOff));
1818
+ const lowerPhysicalStage = hardOff
1539
1819
  ? 0
1540
1820
  : this._previousPhysicalStageBelow(d, Math.max(observedStage, desiredStage));
1541
1821
  desiredStage = Math.min(desiredStage, lowerPhysicalStage);
@@ -1562,9 +1842,10 @@ class HeatingRodControlModule extends BaseModule {
1562
1842
  await this._setStateIfChanged(`heatingRod.devices.${d.id}.targetW`, Math.round(targetW));
1563
1843
  await this._setStateIfChanged(`heatingRod.devices.${d.id}.appliedW`, Math.round(usedW));
1564
1844
  const zeroSuffix = zeroDecision && zeroDecision.reason ? `_zero_${String(zeroDecision.reason)}` : '';
1845
+ const gateSuffix = budgetProtection && budgetProtection.reason && budgetProtection.reason !== 'ok' ? `_gate_${String(budgetProtection.reason)}` : '';
1565
1846
  const autoStatus = forceStorageProtectOff
1566
- ? `storage_protect_${String(res.status || '')}${zeroSuffix}`
1567
- : (forceNonPvDown ? `pv_only_protect_${String(res.status || '')}${zeroSuffix}` : `${String(res.status || 'pv_auto')}${zeroSuffix}`);
1847
+ ? `storage_protect_${String(res.status || '')}${zeroSuffix}${gateSuffix}`
1848
+ : (forceNonPvDown ? `pv_only_protect_${String(res.status || '')}${zeroSuffix}${gateSuffix}` : `${String(res.status || 'pv_auto')}${zeroSuffix}${gateSuffix}`);
1568
1849
  await this._setStateIfChanged(`heatingRod.devices.${d.id}.status`, autoStatus);
1569
1850
  await this._setStateIfChanged(`heatingRod.devices.${d.id}.override`, '');
1570
1851
  }
@@ -1582,6 +1863,15 @@ class HeatingRodControlModule extends BaseModule {
1582
1863
  await this._setStateIfChanged('heatingRod.summary.pvAvailableW', Math.round(Math.max(0, num(pvBase.availableW, 0) - thermalUsedW)));
1583
1864
  await this._setStateIfChanged('heatingRod.summary.appliedTotalW', Math.round(appliedTotalW));
1584
1865
  await this._setStateIfChanged('heatingRod.summary.budgetUsedW', Math.round(budgetUsedW));
1866
+ await this._setStateIfChanged('heatingRod.summary.budgetGateTotalW', pvBase.budgetGateTotalW === null ? 0 : Math.round(num(pvBase.budgetGateTotalW, 0)));
1867
+ await this._setStateIfChanged('heatingRod.summary.budgetGateRemainingW', pvBase.budgetGateRemainingW === null ? 0 : Math.round(num(pvBase.budgetGateRemainingW, 0)));
1868
+ await this._setStateIfChanged('heatingRod.summary.budgetGatePvW', Math.round(num(pvBase.budgetGatePvW, 0)));
1869
+ await this._setStateIfChanged('heatingRod.summary.budgetGateEffectiveW', Math.round(num(pvBase.budgetGateEffectiveW, 0)));
1870
+ await this._setStateIfChanged('heatingRod.summary.budgetGateSource', String(pvBase.budgetGateSource || pvBase.source || ''));
1871
+ await this._setStateIfChanged('heatingRod.summary.gridImportW', Math.round(num(pvBase.importW, 0)));
1872
+ await this._setStateIfChanged('heatingRod.summary.gridImportLimitW', Math.round(num(pvBase.importToleranceW, 0)));
1873
+ await this._setStateIfChanged('heatingRod.summary.gridImportExceeded', !!(budgetProtection && budgetProtection.importActive));
1874
+ await this._setStateIfChanged('heatingRod.summary.storageDischargeExceeded', !!(budgetProtection && budgetProtection.dischargeActive));
1585
1875
  await this._setStateIfChanged('heatingRod.summary.zeroExportActive', !!zeroExportInfo.active);
1586
1876
  await this._setStateIfChanged('heatingRod.summary.zeroExportCanProbe', !!zeroExportInfo.canProbe);
1587
1877
  await this._setStateIfChanged('heatingRod.summary.zeroExportReason', String(zeroExportInfo.reason || ''));
@@ -1617,6 +1907,22 @@ class HeatingRodControlModule extends BaseModule {
1617
1907
  availableW: Math.round(num(pvBase.availableW, 0)),
1618
1908
  thermalUsedW: Math.round(thermalUsedW),
1619
1909
  forceOff: !!pvBase.forceOff,
1910
+ budgetGate: {
1911
+ useBudgetGates: !!pvBase.useBudgetGates,
1912
+ totalW: pvBase.budgetGateTotalW,
1913
+ remainingW: pvBase.budgetGateRemainingW,
1914
+ pvW: Math.round(num(pvBase.budgetGatePvW, 0)),
1915
+ effectiveW: Math.round(num(pvBase.budgetGateEffectiveW, 0)),
1916
+ source: pvBase.budgetGateSource,
1917
+ cmActive: pvBase.cmActive,
1918
+ cmStaleMeter: !!pvBase.cmStaleMeter,
1919
+ cmStaleBudget: !!pvBase.cmStaleBudget,
1920
+ cmPvAvailable: pvBase.cmPvAvailable,
1921
+ cmPvCapEffectiveW: Math.round(num(pvBase.cmPvCapEffectiveW, 0)),
1922
+ cmPvCapRawW: Math.round(num(pvBase.cmPvCapRawW, 0)),
1923
+ cmPvSurplusNoEvRawW: Math.round(num(pvBase.cmPvSurplusNoEvRawW, 0)),
1924
+ protection: budgetProtection || null,
1925
+ },
1620
1926
  zeroExport: {
1621
1927
  active: !!zeroExportInfo.active,
1622
1928
  canProbe: !!zeroExportInfo.canProbe,
@@ -1630,7 +1936,7 @@ class HeatingRodControlModule extends BaseModule {
1630
1936
  },
1631
1937
  }));
1632
1938
  await this._setStateIfChanged('heatingRod.summary.lastUpdate', now);
1633
- await this._setStateIfChanged('heatingRod.summary.status', (this._devices && this._devices.length) ? `ok_${pvBase.source}${!pvAutomationAllowedByMin ? '_pv_min_block' : ''}${pvBase.forceOff ? '_storage_protect' : ''}${pvBase.nonPvEnergyActive ? '_pv_only_protect' : ''}${zeroExportInfo.active ? `_zero_${String(zeroExportInfo.reason || 'active')}` : ''}` : 'no_devices');
1939
+ await this._setStateIfChanged('heatingRod.summary.status', (this._devices && this._devices.length) ? `ok_${pvBase.source}${!pvAutomationAllowedByMin ? '_pv_min_block' : ''}${pvBase.forceOff ? '_storage_protect' : ''}${budgetProtection && budgetProtection.reason !== 'ok' ? `_gate_${String(budgetProtection.reason)}` : ''}${zeroExportInfo.active ? `_zero_${String(zeroExportInfo.reason || 'active')}` : ''}` : 'no_devices');
1634
1940
  }
1635
1941
  }
1636
1942
 
@@ -1389,21 +1389,32 @@ if (typeof soc === 'number') {
1389
1389
  errW = nvpRawW - targetImportW;
1390
1390
  }
1391
1391
 
1392
- // Deadband gegen Flattern: erst außerhalb der Bandbreite nachregeln
1393
- let errAdjW = 0;
1394
- if (errW > deadbandW || errW < -deadbandW) {
1395
- errAdjW = errW; // volle Abweichung verwenden (keine systematische Offset-Regelung)
1396
- }
1397
-
1398
- // OpenEMS-Balancing (Vorbild): neuer Sollwert = battIst + (gridIst - gridZiel)
1399
- // Fallback ohne Ist-Batterieleistung: inkrementelle Regelung (Soll = letzter Sollwert + Fehler)
1392
+ // Deadband gegen Flattern: erst außerhalb der Bandbreite nachregeln.
1393
+ // WICHTIG: Innerhalb der Deadband darf der Sollwert nicht mit der Batterie-Istleistung
1394
+ // mitwandern. Viele Speicher/Farm-DPs melden die Istleistung zeitversetzt oder leicht
1395
+ // springend. Wenn wir bei err=0 trotzdem battIst als neuen Sollwert übernehmen,
1396
+ // entstehen sichtbare Sollwertsprünge trotz konstantem Verbrauch.
1397
+ const outsideDeadband = (errW > deadbandW || errW < -deadbandW);
1398
+ const holdInDeadband = !outsideDeadband && curSetW > 0;
1399
+ let errAdjW = outsideDeadband ? errW : 0;
1400
+
1401
+ // OpenEMS-Balancing (Vorbild): neuer Sollwert = battIst + (gridIst - gridZiel).
1402
+ // Innerhalb der Deadband halten wir aber den letzten Sollwert, statt auf die
1403
+ // zeitversetzte Istleistung zu springen.
1404
+ // Fallback ohne Ist-Batterieleistung: inkrementelle Regelung (Soll = letzter Sollwert + Fehler).
1400
1405
  // Rampe/Schrittweite/Anti-PingPong folgen im Dispatcher weiter unten.
1401
- let nextSetW = (typeof battW === 'number') ? (battW + errAdjW) : (curSetW + errAdjW);
1406
+ let nextSetW = holdInDeadband
1407
+ ? curSetW
1408
+ : ((typeof battW === 'number') ? (battW + errAdjW) : (curSetW + errAdjW));
1402
1409
 
1403
1410
  // Safety-Clamp gegen unnötige Export-Spikes:
1404
1411
  // Begrenze grob auf aktuelle Hauslast am NVP: Import (roh) + aktuelle Entladung (falls messbar) + Puffer.
1412
+ // Bei aktiver Regelung zählt der letzte selbst gesetzte Sollwert als plausible Entladebasis,
1413
+ // damit zeitversetzte/0-W-Istwerte die Farm nicht künstlich herunterziehen.
1405
1414
  const importRawNowW = Math.max(0, (typeof nvpRawW === 'number') ? nvpRawW : 0);
1406
- const dischargeNowW = (typeof battW === 'number') ? Math.max(0, battW) : 0;
1415
+ const measuredDischargeNowW = (typeof battW === 'number') ? Math.max(0, battW) : 0;
1416
+ const commandedDischargeNowW = curSetW > 0 ? curSetW : 0;
1417
+ const dischargeNowW = Math.max(measuredDischargeNowW, commandedDischargeNowW);
1407
1418
  const safetyMarginW = 200;
1408
1419
  const maxByDemandW = importRawNowW + dischargeNowW + safetyMarginW;
1409
1420
  if (Number.isFinite(maxByDemandW) && maxByDemandW > 0) {
@@ -1555,22 +1566,32 @@ if (targetW === 0 && selfDischargeEnabled) {
1555
1566
 
1556
1567
  // PI-light: Inkrement-Regelung.
1557
1568
  // Hinweis: Stabilisierung erfolgt über Deadband + Dispatcher (Schrittweite/Rampe/Anti-PingPong).
1558
- let errAdjW = 0;
1559
- if (errW > deadbandW || errW < -deadbandW) {
1560
- errAdjW = errW;
1561
- }
1562
-
1563
- // OpenEMS-Balancing (Vorbild): battIst + (gridIst - gridZiel)
1569
+ // WICHTIG: Innerhalb der Deadband den letzten Sollwert halten. Sonst wandert der
1570
+ // Sollwert mit der zeitversetzten Batterie-/Farm-Istleistung mit und springt trotz
1571
+ // konstantem Hausverbrauch unnötig hoch/runter.
1572
+ const outsideDeadband = (errW > deadbandW || errW < -deadbandW);
1573
+ const holdInDeadband = !outsideDeadband && lastWasSelf && curSetW > 0;
1574
+ let errAdjW = outsideDeadband ? errW : 0;
1575
+
1576
+ // OpenEMS-Balancing (Vorbild): battIst + (gridIst - gridZiel).
1577
+ // Innerhalb der Deadband halten wir den letzten Sollwert statt auf die
1578
+ // zeitversetzte Istleistung zu springen.
1564
1579
  // Fallback: letzter Sollwert + Fehler
1565
- let nextSetW = (typeof battW === 'number') ? (battW + errAdjW) : (curSetW + errAdjW);
1580
+ let nextSetW = holdInDeadband
1581
+ ? curSetW
1582
+ : ((typeof battW === 'number') ? (battW + errAdjW) : (curSetW + errAdjW));
1566
1583
 
1567
1584
  // Safety-Clamp gegen Überschwingen:
1568
1585
  // Wenn battW nicht gemappt ist (oder NVP kurzfristig "alt" ist), kann die inkrementelle Regelung
1569
1586
  // zu großen Sollwerten aufintegrieren. Wir begrenzen deshalb die Entladeleistung grob auf
1570
1587
  // "aktuelle Last" am NVP: Import (roh) + aktuelle Entladung (falls messbar) + kleiner Puffer.
1571
1588
  // Dadurch bleibt die Regelung im Bereich der realen Hauslast und erzeugt keine Export-Spikes.
1589
+ // Bei aktiver Eigenverbrauchsregelung zählt der letzte selbst gesetzte Sollwert als plausible
1590
+ // Entladebasis, damit zeitversetzte/0-W-Istwerte die Farm nicht künstlich herunterziehen.
1572
1591
  const importRawNowW = Math.max(0, (typeof nvpRawW === 'number') ? nvpRawW : 0);
1573
- const dischargeNowW = (typeof battW === 'number') ? Math.max(0, battW) : 0;
1592
+ const measuredDischargeNowW = (typeof battW === 'number') ? Math.max(0, battW) : 0;
1593
+ const commandedDischargeNowW = curSetW > 0 ? curSetW : 0;
1594
+ const dischargeNowW = Math.max(measuredDischargeNowW, commandedDischargeNowW);
1574
1595
  const safetyMarginW = 200; // bewusst konservativ; Feintuning über selfTargetGridW/Deadband/Rampe
1575
1596
  const maxByDemandW = importRawNowW + dischargeNowW + safetyMarginW;
1576
1597
  if (Number.isFinite(maxByDemandW) && maxByDemandW > 0) {
package/io-package.json CHANGED
@@ -1,8 +1,16 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "nexowatt-ui",
4
- "version": "0.7.0",
4
+ "version": "0.7.2",
5
5
  "news": {
6
+ "0.7.2": {
7
+ "en": "Heating rod PV-Auto rebuilt as a read-only budget-gate consumer. It now uses the existing charging/load-management remaining budget and PV gate before switching stages, keeps running through short inverter/FEMS transients, and reduces only after configurable grid-import or battery-discharge hold times. Zero/minus feed-in test loads now verify that PV generation actually rises and retry after a failed PV-rise check. Charging management, storage control and storage-farm distribution were not changed.",
8
+ "de": "Heizstab-PV-Auto als lesender Budget-Gate-Verbraucher neu aufgebaut. Die App nutzt jetzt Restbudget und PV-Gate aus dem bestehenden Lade-/Lastmanagement, bevor Stufen geschaltet werden, bleibt bei kurzen WR-/FEMS-Transienten stabil und reduziert erst nach einstellbarer Netzbezug- oder Speicherentlade-Haltezeit. 0-/Minus-Einspeise-Testlasten prüfen jetzt, ob die PV-Erzeugung wirklich nachregelt, und warten nach fehlendem PV-Anstieg bis zum nächsten Versuch. Lade-/Lastmanagement, Speicherregelung und Speicherfarm-Verteilung wurden nicht geändert."
9
+ },
10
+ "0.7.1": {
11
+ "en": "Storage control stabilized: self-consumption and tariff discharge now hold the last active setpoint inside the NVP target band instead of following delayed/noisy battery or storage-farm actual-power values. The demand safety clamp also treats the last commanded discharge setpoint as plausible discharge power, preventing stale 0 W telemetry from pulling the farm down. No changes to heating rod logic, PV surplus charging, storage-farm distribution, or zero-feed-in control.",
12
+ "de": "Speicherregelung stabilisiert: Eigenverbrauchs- und Tarif-Entladung halten innerhalb des NVP-Zielbands den letzten aktiven Sollwert, statt verzögerten/rauschenden Batterie- oder Speicherfarm-Istleistungen zu folgen. Der Sicherheits-Lastdeckel wertet den letzten geschriebenen Entlade-Sollwert zusätzlich als plausible Entladeleistung, damit stale 0-W-Telemetrie die Farm nicht herunterzieht. Keine Änderungen an Heizstablogik, PV-Überschussladung, Speicherfarm-Verteilung oder 0-Einspeise-Regelung."
13
+ },
6
14
  "0.7.0": {
7
15
  "en": "Stable 0.7.0 baseline from uploaded 0.6.262: version bump and web-cache bump only. The faulty storage/farm/heating-rod control changes from 0.6.267–0.6.270 are not included.",
8
16
  "de": "Stabile 0.7.0-Basis aus der hochgeladenen 0.6.262: nur Versionssprung und Webcache-Bump. Die fehlerhaften Speicher-/Farm-/Heizstab-Regeländerungen aus 0.6.267–0.6.270 sind nicht übernommen."
@@ -1814,8 +1822,12 @@
1814
1822
  }
1815
1823
  },
1816
1824
  "instanceObjects": [],
1817
- "version": "0.7.0",
1825
+ "version": "0.7.1",
1818
1826
  "news": {
1827
+ "0.7.1": {
1828
+ "en": "Storage control stabilized: self-consumption and tariff discharge now hold the last active setpoint inside the NVP target band instead of following delayed/noisy battery or storage-farm actual-power values. The demand safety clamp also treats the last commanded discharge setpoint as plausible discharge power, preventing stale 0 W telemetry from pulling the farm down. No changes to heating rod logic, PV surplus charging, storage-farm distribution, or zero-feed-in control.",
1829
+ "de": "Speicherregelung stabilisiert: Eigenverbrauchs- und Tarif-Entladung halten innerhalb des NVP-Zielbands den letzten aktiven Sollwert, statt verzögerten/rauschenden Batterie- oder Speicherfarm-Istleistungen zu folgen. Der Sicherheits-Lastdeckel wertet den letzten geschriebenen Entlade-Sollwert zusätzlich als plausible Entladeleistung, damit stale 0-W-Telemetrie die Farm nicht herunterzieht. Keine Änderungen an Heizstablogik, PV-Überschussladung, Speicherfarm-Verteilung oder 0-Einspeise-Regelung."
1830
+ },
1819
1831
  "0.7.0": {
1820
1832
  "en": "Stable 0.7.0 baseline from uploaded 0.6.262: version bump and web-cache bump only. The faulty storage/farm/heating-rod control changes from 0.6.267–0.6.270 are not included.",
1821
1833
  "de": "Stabile 0.7.0-Basis aus der hochgeladenen 0.6.262: nur Versionssprung und Webcache-Bump. Die fehlerhaften Speicher-/Farm-/Heizstab-Regeländerungen aus 0.6.267–0.6.270 sind nicht übernommen."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.nexowatt-ui",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Responsive NexoWatt EMS visualization adapter.",
5
5
  "author": "NexoWatt",
6
6
  "license": "Proprietary License (NPL) v1.0",
@@ -49,5 +49,6 @@
49
49
  "README.md",
50
50
  "LICENSE",
51
51
  "ems/**/*"
52
- ]
52
+ ],
53
+ "type": "commonjs"
53
54
  }
package/www/ems-apps.js CHANGED
@@ -1675,6 +1675,37 @@ function _collectFlowPowerDpIsWFromUI() {
1675
1675
  h.storageTargetSocPct = Math.max(0, Math.min(100, Math.round(Number(h.storageTargetSocPct ?? 90) || 90)));
1676
1676
  const hMinPvRaw = Number(h.minPvPowerW ?? h.pvAutoMinPvPowerW ?? h.minCurrentPvW);
1677
1677
  h.minPvPowerW = Math.max(0, Math.round(Number.isFinite(hMinPvRaw) ? hMinPvRaw : 800));
1678
+ h.useBudgetGates = true;
1679
+ const legacyZeroForBudget = (h.zeroExport && typeof h.zeroExport === 'object') ? h.zeroExport : {};
1680
+ const legacyBudgetAliases = {
1681
+ maxGridImportW: ['maxGridImportW', 'gridImportTripW'],
1682
+ gridImportHoldSec: ['gridImportHoldSec', 'gridImportTripSec'],
1683
+ hardGridImportW: ['hardGridImportW'],
1684
+ storageDischargeToleranceW: ['storageDischargeToleranceW'],
1685
+ storageDischargeHoldSec: ['storageDischargeHoldSec', 'storageDischargeTripSec'],
1686
+ hardStorageDischargeW: ['hardStorageDischargeW'],
1687
+ budgetSafetyReserveW: ['budgetSafetyReserveW', 'pvSafetyReserveW'],
1688
+ };
1689
+ const hn = (key, def, min, max) => {
1690
+ let raw = h[key];
1691
+ if (raw === undefined || raw === null || raw === '') {
1692
+ const aliases = legacyBudgetAliases[key] || [];
1693
+ for (const alias of aliases) {
1694
+ const zRaw = legacyZeroForBudget[alias];
1695
+ if (zRaw !== undefined && zRaw !== null && zRaw !== '') { raw = zRaw; break; }
1696
+ }
1697
+ }
1698
+ let n = Number(raw);
1699
+ if (!Number.isFinite(n)) n = def;
1700
+ h[key] = Math.round(Math.max(min, Math.min(max, n)));
1701
+ };
1702
+ hn('maxGridImportW', 250, 0, 1000000);
1703
+ hn('gridImportHoldSec', 15, 0, 3600);
1704
+ hn('hardGridImportW', 3000, 0, 1000000);
1705
+ hn('storageDischargeToleranceW', 300, 0, 1000000);
1706
+ hn('storageDischargeHoldSec', 15, 0, 3600);
1707
+ hn('hardStorageDischargeW', 1200, 0, 1000000);
1708
+ hn('budgetSafetyReserveW', 150, 0, 1000000);
1678
1709
  h.zeroExport = (h.zeroExport && typeof h.zeroExport === 'object') ? h.zeroExport : {};
1679
1710
  const z = h.zeroExport;
1680
1711
  const zn = (key, def, min, max, integer = true) => {
@@ -1702,6 +1733,10 @@ function _collectFlowPowerDpIsWFromUI() {
1702
1733
  zn('stepUpDelaySec', 60, 0, 86400);
1703
1734
  zn('stepDownDelaySec', 5, 0, 86400);
1704
1735
  zn('cooldownSec', 60, 0, 86400);
1736
+ zn('probeObserveSec', 45, 0, 3600);
1737
+ zn('probeMinPvRisePct', 20, 0, 1000);
1738
+ zn('probeMinPvRiseW', 150, 0, 1000000);
1739
+ zn('probeRetrySec', 600, 0, 86400);
1705
1740
 
1706
1741
  const bySlot = new Map();
1707
1742
  for (const raw of h.devices) {
@@ -2074,9 +2109,23 @@ function _collectFlowPowerDpIsWFromUI() {
2074
2109
  grpCoord.body.appendChild(coordHint);
2075
2110
  els.heatingRodDevices.appendChild(grpCoord.wrap);
2076
2111
 
2112
+ const grpGate = _mkCfgGroup('Budget-Gates & Lastmanagement');
2113
+ const gateInfo = document.createElement('div');
2114
+ gateInfo.className = 'nw-config-field-hint';
2115
+ gateInfo.textContent = 'Die Heizstab-App arbeitet als Budget-Verbraucher: sie liest Restbudget und PV-Budget aus den bestehenden Gates des Lade-/Lastmanagements nur mit. Ladepark, Speicher und Lastmanagement werden hier nicht verändert.';
2116
+ grpGate.body.appendChild(gateInfo);
2117
+ grpGate.body.appendChild(_mkCfgField('Max. Netzbezug erlaubt (W)', _mkCfgInput('number', cfg.maxGridImportW, (v) => { cfg.maxGridImportW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Bis zu diesem kurzen Netzbezug darf eine Heizstab-Stufe weiterlaufen, damit Wechselrichter/FEMS sauber nachregeln können. Darüber wird beobachtet und danach reduziert.'));
2118
+ grpGate.body.appendChild(_mkCfgField('Netzbezug-Haltezeit (s)', _mkCfgInput('number', cfg.gridImportHoldSec, (v) => { cfg.gridImportHoldSec = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 1, width: '150px' }), 'Nach dieser Zeit mit zu hohem Netzbezug wird eine physische Heizstab-Stufe reduziert.'));
2119
+ grpGate.body.appendChild(_mkCfgField('Harter Netzbezug AUS (W)', _mkCfgInput('number', cfg.hardGridImportW, (v) => { cfg.hardGridImportW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 50, width: '150px' }), 'Ab diesem Netzbezug wird sofort komplett zurückgenommen.'));
2120
+ grpGate.body.appendChild(_mkCfgField('Speicherentladung erlaubt (W)', _mkCfgInput('number', cfg.storageDischargeToleranceW, (v) => { cfg.storageDischargeToleranceW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Kleine Transienten sind erlaubt; anhaltende Speicherentladung wird nicht verheizt.'));
2121
+ grpGate.body.appendChild(_mkCfgField('Speicherentladung-Haltezeit (s)', _mkCfgInput('number', cfg.storageDischargeHoldSec, (v) => { cfg.storageDischargeHoldSec = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 1, width: '150px' }), 'Nach dieser Zeit mit Speicherentladung über der Toleranz wird eine physische Stufe reduziert.'));
2122
+ grpGate.body.appendChild(_mkCfgField('Harte Speicherentladung AUS (W)', _mkCfgInput('number', cfg.hardStorageDischargeW, (v) => { cfg.hardStorageDischargeW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 50, width: '150px' }), 'Ab dieser Speicherentladung wird sofort komplett zurückgenommen.'));
2123
+ grpGate.body.appendChild(_mkCfgField('Budget-Sicherheitsreserve (W)', _mkCfgInput('number', cfg.budgetSafetyReserveW, (v) => { cfg.budgetSafetyReserveW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Wird vom berechneten Heizstab-PV-Budget abgezogen, damit Speicher, Hauslast und Messrauschen nicht gegeneinander regeln.'));
2124
+ els.heatingRodDevices.appendChild(grpGate.wrap);
2125
+
2077
2126
  const zeroCfg = cfg.zeroExport || {};
2078
- const grpZero = _mkCfgGroup('0-Einspeisung / PV-Abregelung nutzen');
2079
- grpZero.body.appendChild(_mkCfgField('Logik aktiv', _mkCfgToggle(!!zeroCfg.enabled, (v) => { zeroCfg.enabled = !!v; setDirty(); }), 'Nur für 0-/Minus-Einspeiseanlagen: PV-Auto darf vorsichtig Stufe für Stufe Testlast zuschalten, wenn Forecast und Einspeiselimit darauf hindeuten, dass PV abgeregelt wird.'));
2127
+ const grpZero = _mkCfgGroup('Heizstab: 0-Einspeise-Testlast / PV-Nachregelung');
2128
+ grpZero.body.appendChild(_mkCfgField('Logik aktiv', _mkCfgToggle(!!zeroCfg.enabled, (v) => { zeroCfg.enabled = !!v; setDirty(); }), 'Nur Heizstab-PV-Auto: bei 0-/Minus-Einspeiseanlagen darf die App vorsichtig Stufe für Stufe Testlast zuschalten, wenn Budget-Gates, Forecast und Einspeiselimit es freigeben.'));
2080
2129
  grpZero.body.appendChild(_mkCfgField('Erlaubte Einspeisung (W)', _mkCfgInput('number', zeroCfg.feedInLimitW, (v) => { zeroCfg.feedInLimitW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 50, width: '150px' }), 'Bei -1 kW Einspeiselimit bitte 1000 eintragen. Bei echter 0-Einspeisung 0 eintragen.'));
2081
2130
  grpZero.body.appendChild(_mkCfgField('Einspeise-Toleranz (W)', _mkCfgInput('number', zeroCfg.feedInToleranceW, (v) => { zeroCfg.feedInToleranceW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Ab diesem Korridor gilt der Netzpunkt als am Einspeiselimit.'));
2082
2131
  grpZero.body.appendChild(_mkCfgField('Ziel-Einspeisepuffer (W)', _mkCfgInput('number', zeroCfg.targetExportBufferW, (v) => { zeroCfg.targetExportBufferW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Sicherheitsabstand: die Testlast startet erst, wenn am Einspeiselimit noch dieser Puffer plausibel vorhanden ist.'));
@@ -2085,17 +2134,19 @@ function _collectFlowPowerDpIsWFromUI() {
2085
2134
  grpZero.body.appendChild(_mkCfgField('Forecast Peak min. (W)', _mkCfgInput('number', zeroCfg.minForecastPeakW, (v) => { zeroCfg.minForecastPeakW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 50, width: '150px' }), 'Mindestens erwartete PV-Spitze innerhalb des Forecast-Zeitraums.'));
2086
2135
  grpZero.body.appendChild(_mkCfgField('Forecast 6h min. (kWh)', _mkCfgInput('number', zeroCfg.minForecastKwh6h, (v) => { zeroCfg.minForecastKwh6h = Math.max(0, Number(v) || 0); setDirty(); }, { min: 0, step: 0.1, width: '150px' }), 'Alternative Freigabe über erwartete Energie in den nächsten Stunden.'));
2087
2136
  grpZero.body.appendChild(_mkCfgField('Speicher-Vorrang bis SoC (%)', _mkCfgInput('number', zeroCfg.storageFullSocPct, (v) => { zeroCfg.storageFullSocPct = Math.max(0, Math.min(100, Math.round(Number(v) || 0))); setDirty(); }, { min: 0, max: 100, step: 1, width: '150px' }), 'Erst ab diesem SoC darf versteckte/abgeregelte PV vorsichtig in den Heizstab gehen.'));
2088
- grpZero.body.appendChild(_mkCfgField('Netzbezug-Abwurf (W)', _mkCfgInput('number', zeroCfg.gridImportTripW, (v) => { zeroCfg.gridImportTripW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Wenn Netzbezug länger ansteht, wird eine physische Stufe reduziert.'));
2089
- grpZero.body.appendChild(_mkCfgField('Netzbezug-Zeit (s)', _mkCfgInput('number', zeroCfg.gridImportTripSec, (v) => { zeroCfg.gridImportTripSec = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 1, width: '150px' }), 'Schutzzeit für kurze Nachregel-Transienten des Wechselrichters/FEMS.'));
2090
- grpZero.body.appendChild(_mkCfgField('Harter Netzbezug (W)', _mkCfgInput('number', zeroCfg.hardGridImportW, (v) => { zeroCfg.hardGridImportW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Ab diesem Netzbezug wird sofort komplett zurückgenommen.'));
2091
- grpZero.body.appendChild(_mkCfgField('Speicherentladung-Abwurf (W)', _mkCfgInput('number', zeroCfg.storageDischargeToleranceW, (v) => { zeroCfg.storageDischargeToleranceW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Verhindert, dass Batterieenergie verheizt wird.'));
2092
- grpZero.body.appendChild(_mkCfgField('Speicherentladung-Zeit (s)', _mkCfgInput('number', zeroCfg.storageDischargeTripSec, (v) => { zeroCfg.storageDischargeTripSec = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 1, width: '150px' }), 'Erlaubt kurze Speicher-Transienten, reduziert aber bei anhaltender Entladung.'));
2093
- grpZero.body.appendChild(_mkCfgField('Harte Speicherentladung (W)', _mkCfgInput('number', zeroCfg.hardStorageDischargeW, (v) => { zeroCfg.hardStorageDischargeW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Ab dieser Entladung wird sofort komplett zurückgenommen.'));
2137
+ const zeroGateHint = document.createElement('div');
2138
+ zeroGateHint.className = 'nw-config-field-hint';
2139
+ zeroGateHint.textContent = 'Netzbezug- und Speicherentladungsschutz kommen zentral aus „Budget-Gates & Lastmanagement“ oben. Dieser Block steuert nur die Heizstab-Testlast bei 0-/Minus-Einspeisung.';
2140
+ grpZero.body.appendChild(zeroGateHint);
2094
2141
  grpZero.body.appendChild(_mkCfgField('Stufe-hoch Wartezeit (s)', _mkCfgInput('number', zeroCfg.stepUpDelaySec, (v) => { zeroCfg.stepUpDelaySec = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 1, width: '150px' }), 'Langsam hochfahren: nur eine physische Stufe je Wartezeit.'));
2142
+ grpZero.body.appendChild(_mkCfgField('PV-Nachregelprüfung (s)', _mkCfgInput('number', zeroCfg.probeObserveSec, (v) => { zeroCfg.probeObserveSec = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 1, width: '150px' }), 'Zeitfenster nach Zuschalten einer Teststufe. In diesem Fenster muss die PV-Erzeugung sichtbar nachziehen.'));
2143
+ grpZero.body.appendChild(_mkCfgField('PV-Anstieg min. (%)', _mkCfgInput('number', zeroCfg.probeMinPvRisePct, (v) => { zeroCfg.probeMinPvRisePct = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 1, width: '150px' }), 'Mindestanteil der neu zugeschalteten Stufenleistung, der als PV-Anstieg sichtbar sein muss.'));
2144
+ grpZero.body.appendChild(_mkCfgField('PV-Anstieg min. (W)', _mkCfgInput('number', zeroCfg.probeMinPvRiseW, (v) => { zeroCfg.probeMinPvRiseW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Absolute Mindeständerung gegen Messrauschen.'));
2145
+ grpZero.body.appendChild(_mkCfgField('Erneuter Test nach PV-Fehlanstieg (s)', _mkCfgInput('number', zeroCfg.probeRetrySec, (v) => { zeroCfg.probeRetrySec = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Standard 600 s: wenn PV nach einer Teststufe nicht nachzieht, bleibt die App unten und versucht es später erneut.'));
2095
2146
  grpZero.body.appendChild(_mkCfgField('Cooldown nach Abwurf (s)', _mkCfgInput('number', zeroCfg.cooldownSec, (v) => { zeroCfg.cooldownSec = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 1, width: '150px' }), 'Wartezeit nach Netzbezug/Speicherentladung, bevor erneut getestet wird.'));
2096
2147
  const zeroHint = document.createElement('div');
2097
2148
  zeroHint.className = 'nw-config-field-hint';
2098
- zeroHint.textContent = 'Prinzip: Forecast erlaubt nur den Versuch. Danach wird langsam Stufe für Stufe zugeschaltet. Bei Netzbezug oder Speicherentladung wird schnell reduziert. Manuell, Boost und Aus bleiben davon unberührt.';
2149
+ zeroHint.textContent = 'Prinzip: Die Heizstab-App liest die zentralen Budget-Gates, schaltet dann langsam Stufe für Stufe und prüft, ob PV wirklich nachregelt. Bei Netzbezug, Speicherentladung oder fehlendem PV-Anstieg wird reduziert. Manuell, Boost und Aus bleiben davon unberührt.';
2099
2150
  grpZero.body.appendChild(zeroHint);
2100
2151
  els.heatingRodDevices.appendChild(grpZero.wrap);
2101
2152
 
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-v170';
3
+ const CACHE_NAME = 'nexowatt-cache-v172';
4
4
 
5
5
  const OFFLINE_URLS = [
6
6
  './',