iobroker.smartloadmanager 0.1.2 → 0.4.1

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.
package/main.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const utils = require('@iobroker/adapter-core');
4
+ const { evaluateForecast, minimumStateTimeSatisfied, parseForecast } = require('./lib/forecast');
4
5
 
5
6
  class ZeroFeedIn extends utils.Adapter {
6
7
  constructor(options) {
@@ -21,11 +22,17 @@ class ZeroFeedIn extends utils.Adapter {
21
22
  // 🔮 Forecast (PV forecast integration)
22
23
  this.forecastEnabled = false;
23
24
  this.forecastJsonDatapoint = null;
25
+ this.forecastNotificationTimes = new Map();
26
+ this.forecastLogSignature = '';
24
27
  this.forecast = {
25
28
  power: 0,
26
29
  surplus: 0,
27
30
  minutes: 0,
28
31
  lastUpdate: 0,
32
+ // eslint-disable-next-line jsdoc/check-tag-names
33
+ points: /** @type {Array<{timestamp: number, power: number, surplus: number}>} */ ([]),
34
+ // eslint-disable-next-line jsdoc/check-tag-names
35
+ unit: /** @type {'W' | 'kW' | null} */ (null),
29
36
  };
30
37
 
31
38
  this.on('ready', this.onReady.bind(this));
@@ -81,6 +88,20 @@ class ZeroFeedIn extends utils.Adapter {
81
88
  // 🔮 Forecast config (PV forecast integration)
82
89
  this.forecastEnabled = !!this.config.enableForecast;
83
90
  this.forecastJsonDatapoint = this.config.forecastJsonDatapoint || null;
91
+ this.log.debug(
92
+ `[Forecast] ${this.forecastEnabled ? 'Enabled' : 'Disabled'}; source: ${this.forecastJsonDatapoint || 'not configured'}; decision notifications: ${this.config.notifyForecast ? 'enabled' : 'disabled'}.`,
93
+ );
94
+ if (this.forecastEnabled && !this.forecastJsonDatapoint) {
95
+ this.log.debug('[Forecast] No JSON data point configured; forecast control cannot run.');
96
+ }
97
+ if (this.config.notifyForecast && !this.forecastEnabled) {
98
+ this.log.debug(
99
+ '[Forecast] Decision notifications do not enable forecast control. Enable forecast control and select a JSON data point first.',
100
+ );
101
+ }
102
+ if (this.forecastEnabled && this.forecastJsonDatapoint) {
103
+ this.forecast.unit = await this.detectForecastPowerUnit();
104
+ }
84
105
 
85
106
  this.consumerList = Array.isArray(this.config.consumer)
86
107
  ? this.config.consumer.filter(
@@ -100,9 +121,22 @@ class ZeroFeedIn extends utils.Adapter {
100
121
  v.enabled = state.val;
101
122
  this.log.debug(`"${v.name}": Active status taken from object tree (${state.val})`);
102
123
  }
124
+ const switchingState = v.datapoint
125
+ ? await this.getForeignStateAsync(v.datapoint)
126
+ : v.numericDatapoint
127
+ ? await this.getForeignStateAsync(v.numericDatapoint)
128
+ : null;
129
+ v.lastStateChange = switchingState?.lc || 0;
130
+ v.lastForecastBridgeAt = null;
103
131
  }
104
132
 
105
133
  this.log.info(`Loaded consumers: ${this.consumerList.map(v => v.name).join(', ')}`);
134
+ if (this.forecastEnabled) {
135
+ const forecastConsumers = this.consumerList.filter(v => v.enabled && v.useForecast);
136
+ this.log.debug(
137
+ `[Forecast] Enabled consumers using forecast: ${forecastConsumers.map(v => v.name).join(', ') || 'none; activate useForecast for the desired consumers'}.`,
138
+ );
139
+ }
106
140
 
107
141
  await this.checkAndCreateConsumerObjects();
108
142
  await this.createConsumerStates();
@@ -126,6 +160,7 @@ class ZeroFeedIn extends utils.Adapter {
126
160
  }
127
161
 
128
162
  await this.checkConsumers();
163
+ this.ruleTimer = this.safeSetInterval(() => this.checkConsumers(), 15000);
129
164
  } catch (error) {
130
165
  this.log.error(`Error in onReady: ${error.message}`);
131
166
  // additionally send alert
@@ -488,6 +523,19 @@ class ZeroFeedIn extends utils.Adapter {
488
523
  // =====================================================================
489
524
  // ========================= Forecast JSON parse =======================
490
525
  // =====================================================================
526
+ async clearForecast(reason) {
527
+ this.forecastLogSignature = '';
528
+ this.forecast.points = [];
529
+ this.forecast.power = 0;
530
+ this.forecast.surplus = 0;
531
+ this.forecast.minutes = 0;
532
+ this.forecast.lastUpdate = 0;
533
+ for (const field of ['power', 'surplus', 'minutes', 'lastUpdate']) {
534
+ await this.setStateAsync(`${this.namespace}.forecast.${field}`, { val: 0, ack: true });
535
+ }
536
+ this.log.debug(`[Forecast] ${reason}; forecast control is unavailable until valid data arrives.`);
537
+ }
538
+
491
539
  async processPvforecastJson() {
492
540
  try {
493
541
  if (!this.forecastEnabled || !this.forecastJsonDatapoint) {
@@ -496,46 +544,30 @@ class ZeroFeedIn extends utils.Adapter {
496
544
 
497
545
  const state = await this.getForeignStateAsync(this.forecastJsonDatapoint);
498
546
  if (!state || !state.val) {
547
+ await this.clearForecast('JSON data point is missing or empty');
499
548
  return;
500
549
  }
501
550
 
502
- let data;
503
551
  try {
504
- data = typeof state.val === 'string' ? JSON.parse(state.val) : state.val;
505
- } catch {
506
- this.log.warn('[Forecast] Invalid JSON in pvforecast datapoint');
507
- return;
508
- }
509
-
510
- if (!Array.isArray(data) || data.length === 0) {
552
+ this.forecast.points = parseForecast(String(state.val), {
553
+ unit: this.forecast.unit,
554
+ baseload: this.config.baseload || 0,
555
+ });
556
+ } catch (error) {
557
+ await this.clearForecast(`Invalid pvforecast JSON: ${error.message}`);
511
558
  return;
512
559
  }
513
560
 
514
- const now = Date.now();
515
- let best = null;
516
-
517
- for (const entry of data) {
518
- if (!entry || typeof entry.t !== 'number' || typeof entry.y !== 'number') {
519
- continue;
520
- }
521
- if (entry.t < now) {
522
- continue;
523
- }
524
- best = entry;
525
- break;
526
- }
527
-
561
+ const best = this.forecast.points[0];
528
562
  if (!best) {
563
+ await this.clearForecast('JSON contains no valid future forecast points');
529
564
  return;
530
565
  }
531
-
532
- // pvforecast liefert kW → wir rechnen auf W
533
- const power = Math.round(best.y * 1000);
534
- const minutes = Math.max(0, Math.round((best.t - now) / 60000));
535
- const surplus = power - (this.config.baseload || 0);
566
+ const power = best.power;
567
+ const minutes = Math.max(0, Math.round((best.timestamp - Date.now()) / 60000));
536
568
 
537
569
  this.forecast.power = power;
538
- this.forecast.surplus = surplus > 0 ? surplus : 0;
570
+ this.forecast.surplus = best.surplus;
539
571
  this.forecast.minutes = minutes;
540
572
  this.forecast.lastUpdate = Date.now();
541
573
 
@@ -550,12 +582,39 @@ class ZeroFeedIn extends utils.Adapter {
550
582
  ack: true,
551
583
  });
552
584
 
553
- this.log.debug(
554
- `[Forecast] JSON parsed → power=${power}W, surplus=${this.forecast.surplus}W, minutes=${minutes}`,
555
- );
585
+ const signature = `${this.forecast.points.length}:${best.timestamp}:${power}:${best.surplus}`;
586
+ if (signature !== this.forecastLogSignature) {
587
+ this.log.debug(
588
+ `[Forecast] ${this.forecast.points.length} values parsed (${this.forecast.unit || 'auto'}) - next: ${power}W in ${minutes}min`,
589
+ );
590
+ this.forecastLogSignature = signature;
591
+ }
592
+ } catch (error) {
593
+ this.log.debug(`[Forecast] Error processing pvforecast JSON: ${error.message}`);
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Detect the power unit used by pvforecast.
599
+ *
600
+ * @returns {Promise<'W'|'kW'|null>} Detected unit or null for value-based detection.
601
+ */
602
+ async detectForecastPowerUnit() {
603
+ const configured = this.config.forecastPowerUnit;
604
+ if (configured === 'W' || configured === 'kW') {
605
+ return configured;
606
+ }
607
+ try {
608
+ const summaryPowerId = (this.forecastJsonDatapoint || '').replace(/\.JSONData$/, '.power.now');
609
+ const object = await this.getForeignObjectAsync(summaryPowerId);
610
+ const unit = object?.common?.unit;
611
+ if (unit === 'W' || unit === 'kW') {
612
+ return unit;
613
+ }
556
614
  } catch (error) {
557
- this.log.error(`[Forecast] Error processing pvforecast JSON: ${error.message}`);
615
+ this.log.debug(`[Forecast] Unit detection failed: ${error.message}`);
558
616
  }
617
+ return null;
559
618
  }
560
619
 
561
620
  // =====================================================================
@@ -569,32 +628,57 @@ class ZeroFeedIn extends utils.Adapter {
569
628
  return this.forecast?.minutes || 0;
570
629
  }
571
630
 
572
- isForecastAllowingSwitchOn(v) {
631
+ getForecastDecision(v, requiredPower) {
632
+ const maxStartMinutes =
633
+ this.config.forecastMaxStartMinutes === undefined ? 30 : Number(this.config.forecastMaxStartMinutes);
634
+ return evaluateForecast(this.forecast.points, {
635
+ requiredPower,
636
+ safetyFactor: (Number(this.config.forecastSafetyPercent) || 80) / 100,
637
+ minDurationMinutes: Number(v.forecastMinMinutes) || Number(this.config.forecastDefaultMinMinutes) || 30,
638
+ maxStartMinutes,
639
+ });
640
+ }
641
+
642
+ isForecastAllowingSwitchOn(v, currentSurplus) {
573
643
  if (!this.forecastEnabled || !v.useForecast) {
574
644
  return false;
575
645
  }
576
- const minMinutes = v.forecastMinMinutes || 0;
577
- if (this.forecast.minutes > minMinutes) {
646
+ const requiredPower = Number(v.performance) || 0;
647
+ const minimumCurrentPercent =
648
+ this.config.forecastMinCurrentPercent === undefined ? 50 : Number(this.config.forecastMinCurrentPercent);
649
+ const minimumCurrent = requiredPower * (minimumCurrentPercent / 100);
650
+ if (currentSurplus < minimumCurrent) {
578
651
  return false;
579
652
  }
580
- if (this.forecast.surplus >= v.performance) {
581
- return true;
653
+ const minimumOffMinutes = v.minimumOffMinutes === undefined ? 5 : Number(v.minimumOffMinutes);
654
+ if (!minimumStateTimeSatisfied(v.lastStateChange, minimumOffMinutes)) {
655
+ return false;
582
656
  }
583
- return false;
657
+ return this.getForecastDecision(v, requiredPower).available;
584
658
  }
585
659
 
586
- isForecastDelayingSwitchOff(v, _currentSurplus) {
660
+ isForecastDelayingSwitchOff(v, currentSurplus, requiredPower) {
587
661
  if (!this.forecastEnabled || !v.useForecast) {
588
662
  return false;
589
663
  }
590
- const minMinutes = v.forecastMinMinutes || 0;
591
- if (this.forecast.minutes > minMinutes) {
664
+ const bridgeMinutes =
665
+ this.config.forecastBridgeMinutes === undefined ? 5 : Number(this.config.forecastBridgeMinutes);
666
+ const maxGridImport = Math.max(
667
+ 0,
668
+ this.config.forecastMaxGridImport === undefined ? 100 : Number(this.config.forecastMaxGridImport),
669
+ );
670
+ if (currentSurplus < -maxGridImport) {
592
671
  return false;
593
672
  }
594
- if (this.forecast.surplus >= v.switchOnPoint) {
595
- return true;
673
+ const decision = this.getForecastDecision(
674
+ v,
675
+ Number(requiredPower) || Number(v.switchOnPoint) || Number(v.performance) || 0,
676
+ );
677
+ if (!decision.available || decision.startMinutes === null || decision.startMinutes > bridgeMinutes) {
678
+ return false;
596
679
  }
597
- return false;
680
+ v.lastForecastBridgeAt ||= Date.now();
681
+ return Date.now() - v.lastForecastBridgeAt <= bridgeMinutes * 60000;
598
682
  }
599
683
 
600
684
  async setForecastState(v, idx, active, reason, power, minutes) {
@@ -609,17 +693,29 @@ class ZeroFeedIn extends utils.Adapter {
609
693
  ack: true,
610
694
  });
611
695
  } catch (err) {
612
- this.log.warn(`[Forecast] Failed to set forecastStatus: ${err.message}`);
696
+ this.log.debug(`[Forecast] Failed to set forecastStatus: ${err.message}`);
613
697
  }
614
698
  }
615
699
 
616
700
  async sendForecastNotification(type, message) {
617
701
  try {
618
702
  if (this.config.notifyForecast) {
703
+ const now = Date.now();
704
+ for (const [key, sentAt] of this.forecastNotificationTimes) {
705
+ if (now - sentAt >= 5 * 60000) {
706
+ this.forecastNotificationTimes.delete(key);
707
+ }
708
+ }
709
+ const key = `${type}:${message}`;
710
+ if (this.forecastNotificationTimes.has(key)) {
711
+ return;
712
+ }
713
+ this.log.debug(`[Forecast] ${message}`);
619
714
  await this.sendNotification('smartloadmanager', 'notify', message);
715
+ this.forecastNotificationTimes.set(key, now);
620
716
  }
621
717
  } catch (err) {
622
- this.log.warn(`[Forecast] Failed to send forecast notification: ${err.message}`);
718
+ this.log.debug(`[Forecast] Failed to send forecast notification: ${err.message}`);
623
719
  }
624
720
  }
625
721
 
@@ -636,7 +732,11 @@ class ZeroFeedIn extends utils.Adapter {
636
732
  const onMinutes = this.parseTimeToMinutes(onTime);
637
733
  const offMinutes = this.parseTimeToMinutes(offTime);
638
734
 
639
- const within = currentMinutes >= onMinutes && currentMinutes < offMinutes;
735
+ const within =
736
+ onMinutes === offMinutes ||
737
+ (onMinutes < offMinutes
738
+ ? currentMinutes >= onMinutes && currentMinutes < offMinutes
739
+ : currentMinutes >= onMinutes || currentMinutes < offMinutes);
640
740
 
641
741
  if (name) {
642
742
  this.log.debug(
@@ -673,40 +773,7 @@ class ZeroFeedIn extends utils.Adapter {
673
773
  this.log.debug(`Feed-in value updated: ${state.val} (ack=${state.ack})`);
674
774
  await this.checkConsumers();
675
775
 
676
- if (this.percentTimer) {
677
- clearTimeout(this.percentTimer);
678
- }
679
- this.percentTimer = this.safeSetTimeout(
680
- async () => {
681
- for (const v of this.consumerList.filter(v => v.ruletype === 'percent')) {
682
- await this.controlPercentConsumer(v);
683
- }
684
- this.percentTimer = null;
685
- },
686
- (this.config.delaySecondsProzent || this.DEFAULT_TIMEOUT / 1000) * 1000,
687
- );
688
-
689
- if (this.batteryTimer) {
690
- clearTimeout(this.batteryTimer);
691
- }
692
- this.batteryTimer = this.safeSetTimeout(
693
- async () => {
694
- if (!this.feedInDatapoint) {
695
- return;
696
- }
697
- const feedInState = await this.getForeignStateAsync(this.feedInDatapoint);
698
- let feedIn = Number(feedInState?.val) || 0;
699
- if (this.config.feedinNegativ) {
700
- feedIn = -feedIn;
701
- }
702
-
703
- for (const v of this.consumerList.filter(v => v.ruletype === 'battery')) {
704
- await this.controlBattery(v, feedIn);
705
- }
706
- this.batteryTimer = null;
707
- },
708
- (this.config.batteryDelaySeconds || this.DEFAULT_TIMEOUT / 1000) * 1000,
709
- );
776
+ this.scheduleRegulation();
710
777
  } else {
711
778
  this.log.debug(`Feed-in value change ignored (ack=${state.ack}) - waiting for confirmed value`);
712
779
  }
@@ -724,6 +791,16 @@ class ZeroFeedIn extends utils.Adapter {
724
791
  const v = this.consumerList[index];
725
792
  if (v) {
726
793
  v.enabled = state.val;
794
+ if (!v.enabled && v.timer) {
795
+ clearTimeout(v.timer);
796
+ v.timer = null;
797
+ v.timerEnd = null;
798
+ v.pendingAction = null;
799
+ if (v.timerTick) {
800
+ clearInterval(v.timerTick);
801
+ v.timerTick = null;
802
+ }
803
+ }
727
804
  this.log.info(`Consumer "${v.name}" became ${state.val ? 'activated' : 'deactivated'}.`);
728
805
  await this.setStateAsync(id, { val: state.val, ack: true });
729
806
  }
@@ -737,10 +814,29 @@ class ZeroFeedIn extends utils.Adapter {
737
814
  const mode = state.val;
738
815
  const v = this.consumerList[index];
739
816
 
740
- if (mode === 0) {
741
- await this.switchConsumerWithDelay(v, false);
742
- } else if (mode === 1) {
743
- await this.switchConsumerWithDelay(v, true);
817
+ if (!v || ![0, 1, 2].includes(mode)) {
818
+ return;
819
+ }
820
+ await this.setStateAsync(id, { val: mode, ack: true });
821
+ if (v.timer) {
822
+ clearTimeout(v.timer);
823
+ v.timer = null;
824
+ v.timerEnd = null;
825
+ v.pendingAction = null;
826
+ }
827
+ if (mode === 2) {
828
+ await this.checkConsumers();
829
+ } else if (v.ruletype === 'percent') {
830
+ await this.setForeignStateAsync(v.datapoint, { val: mode === 1 ? 100 : 0, ack: false });
831
+ } else if (v.ruletype === 'battery') {
832
+ if (v.batterySetpoint) {
833
+ await this.setForeignStateAsync(v.batterySetpoint, {
834
+ val: mode === 1 ? v.performance || 1000 : 0,
835
+ ack: false,
836
+ });
837
+ }
838
+ } else {
839
+ await this.switchConsumerWithDelay(v, mode === 1, 0);
744
840
  }
745
841
  }
746
842
 
@@ -833,13 +929,18 @@ class ZeroFeedIn extends utils.Adapter {
833
929
  }
834
930
 
835
931
  this.log.debug(`"${v.name}" Status: isOn=${isOn}`);
932
+ const minimumOffSatisfied = minimumStateTimeSatisfied(
933
+ v.lastStateChange,
934
+ v.minimumOffMinutes === undefined ? 5 : Number(v.minimumOffMinutes),
935
+ );
836
936
 
837
937
  // Zusatz-Checks heating
838
938
  if (v.ruletype === 'heating' && withinWindow && !isOn) {
839
939
  const tempState = v.temperatureDatapoint
840
940
  ? await this.getForeignStateAsync(v.temperatureDatapoint)
841
941
  : null;
842
- const currentTemp = tempState?.val !== undefined ? Number(tempState.val) : null;
942
+ const currentTemp =
943
+ tempState?.val !== undefined && tempState.val !== null ? Number(tempState.val) : null;
843
944
 
844
945
  if (currentTemp !== null && v.maxTemperature !== undefined && currentTemp >= v.maxTemperature) {
845
946
  this.log.warn(
@@ -889,7 +990,12 @@ class ZeroFeedIn extends utils.Adapter {
889
990
  }
890
991
 
891
992
  if (withinWindow) {
892
- if (!gridUsage && v.performance <= feedIn - baseload && !isOn) {
993
+ if (
994
+ !gridUsage &&
995
+ Math.max(v.performance, Number(v.switchOnPoint) || 0) <= feedIn - baseload &&
996
+ !isOn &&
997
+ minimumOffSatisfied
998
+ ) {
893
999
  this.log.debug(
894
1000
  `Switch-on possible: surplus ${
895
1001
  feedIn - baseload
@@ -899,7 +1005,7 @@ class ZeroFeedIn extends utils.Adapter {
899
1005
  feedIn -= v.performance;
900
1006
  this.log.debug(`Remaining surplus after switch-on: ${feedIn - baseload} W`);
901
1007
  await this.setForecastState(v, idx, false, '', null, null);
902
- } else if (!gridUsage && !isOn && this.isForecastAllowingSwitchOn(v)) {
1008
+ } else if (!gridUsage && !isOn && this.isForecastAllowingSwitchOn(v, feedIn - baseload)) {
903
1009
  const forecastPower = this.getForecastPower(v);
904
1010
  const minutes = this.getForecastMinutes(v);
905
1011
  this.log.debug(
@@ -916,7 +1022,9 @@ class ZeroFeedIn extends utils.Adapter {
916
1022
  this.log.debug(`Not switched on: grid usage active.`);
917
1023
  } else if (!isOn) {
918
1024
  this.log.debug(
919
- `Not switched on: surplus too low (${feedIn - baseload} W < ${v.performance} W).`,
1025
+ minimumOffSatisfied
1026
+ ? `Not switched on: surplus too low (${feedIn - baseload} W < ${v.performance} W).`
1027
+ : `Not switched on: minimum off time (${v.minimumOffMinutes === undefined ? 5 : Number(v.minimumOffMinutes)} min) is active.`,
920
1028
  );
921
1029
  } else {
922
1030
  this.log.debug(`"${v.name}" is already switched on – no action needed.`);
@@ -1025,8 +1133,12 @@ class ZeroFeedIn extends utils.Adapter {
1025
1133
  const tempState = v.temperatureDatapoint
1026
1134
  ? await this.getForeignStateAsync(v.temperatureDatapoint)
1027
1135
  : null;
1028
- const currentTemp = tempState?.val !== undefined ? Number(tempState.val) : null;
1029
- if (currentTemp !== null && currentTemp >= v.maxTemperature) {
1136
+ const currentTemp =
1137
+ tempState?.val !== undefined && tempState.val !== null ? Number(tempState.val) : null;
1138
+ if (
1139
+ (v.temperatureDatapoint && (currentTemp === null || !Number.isFinite(currentTemp))) ||
1140
+ (currentTemp !== null && currentTemp >= v.maxTemperature)
1141
+ ) {
1030
1142
  this.log.warn(
1031
1143
  `"${v.name}" switched off due to maxTemperature reached: ${currentTemp}°C >= ${v.maxTemperature}°C`,
1032
1144
  );
@@ -1037,7 +1149,14 @@ class ZeroFeedIn extends utils.Adapter {
1037
1149
 
1038
1150
  // Timer-Abbruch bei wegfallender Bedingung (Ausschalten)
1039
1151
  const surplus = feedIn - baseload;
1040
- if (v.timer && v.timerEnd && v.pendingAction === false && withinWindow && surplus >= v.switchOffPoint) {
1152
+ if (
1153
+ v.timer &&
1154
+ v.timerEnd &&
1155
+ v.pendingAction === false &&
1156
+ withinWindow &&
1157
+ !gridUsage &&
1158
+ surplus >= v.switchOffPoint
1159
+ ) {
1041
1160
  clearTimeout(v.timer);
1042
1161
  v.timer = null;
1043
1162
  v.timerEnd = null;
@@ -1053,8 +1172,27 @@ class ZeroFeedIn extends utils.Adapter {
1053
1172
  // - Netzbezug aktiv ODER
1054
1173
  // - Überschuss < switchOffPoint
1055
1174
  const shouldSwitchOff = !withinWindow || gridUsage || surplus < v.switchOffPoint;
1175
+ if (!shouldSwitchOff) {
1176
+ v.lastForecastBridgeAt = null;
1177
+ }
1056
1178
 
1057
1179
  if (!v.alwaysOffAtTime && shouldSwitchOff) {
1180
+ const maxGridImport = Math.max(
1181
+ 0,
1182
+ this.config.forecastMaxGridImport === undefined
1183
+ ? 100
1184
+ : Number(this.config.forecastMaxGridImport),
1185
+ );
1186
+ const minimumOnMinutes = v.minimumOnMinutes === undefined ? 10 : Number(v.minimumOnMinutes);
1187
+ const hardGridImport = surplus < -maxGridImport;
1188
+ if (
1189
+ withinWindow &&
1190
+ !hardGridImport &&
1191
+ !minimumStateTimeSatisfied(v.lastStateChange, minimumOnMinutes)
1192
+ ) {
1193
+ this.log.debug(`"${v.name}" remains on: minimum run time (${minimumOnMinutes} min) is active.`);
1194
+ continue;
1195
+ }
1058
1196
  // 🔮 Forecast delay switch-off (PV forecast integration)
1059
1197
  if (this.isForecastDelayingSwitchOff(v, surplus)) {
1060
1198
  const forecastPower = this.getForecastPower(v);
@@ -1084,14 +1222,7 @@ class ZeroFeedIn extends utils.Adapter {
1084
1222
  }
1085
1223
 
1086
1224
  // --- Batterie unverändert ---
1087
- const batteryConsumers = this.consumerList.filter(c => c.ruletype === 'battery');
1088
- for (const v of batteryConsumers) {
1089
- if (!v.enabled) {
1090
- this.log.debug(`"${v.name}" is deactivated – battery control skipped.`);
1091
- continue;
1092
- }
1093
- await this.controlBattery(v, feedIn);
1094
- }
1225
+ this.scheduleRegulation();
1095
1226
 
1096
1227
  const runningTimers = this.consumerList
1097
1228
  .filter(c => c.timer && c.timerEnd)
@@ -1119,8 +1250,55 @@ class ZeroFeedIn extends utils.Adapter {
1119
1250
  // =====================================================================
1120
1251
  // ========================= controlPercentConsumer ====================
1121
1252
  // =====================================================================
1253
+ scheduleRegulation() {
1254
+ if (!this.percentTimer && this.consumerList.some(v => v.ruletype === 'percent')) {
1255
+ this.percentTimer = this.safeSetTimeout(
1256
+ async () => {
1257
+ for (const v of this.consumerList.filter(v => v.ruletype === 'percent')) {
1258
+ await this.controlPercentConsumer(v);
1259
+ }
1260
+ this.percentTimer = null;
1261
+ },
1262
+ Math.max(0, Number(this.config.delaySecondsProzent) || 0) * 1000,
1263
+ );
1264
+ }
1265
+
1266
+ if (!this.batteryTimer && this.consumerList.some(v => v.ruletype === 'battery')) {
1267
+ this.batteryTimer = this.safeSetTimeout(
1268
+ async () => {
1269
+ if (!this.feedInDatapoint) {
1270
+ return;
1271
+ }
1272
+ const feedInState = await this.getForeignStateAsync(this.feedInDatapoint);
1273
+ let feedIn = Number(feedInState?.val) || 0;
1274
+ if (this.config.feedinNegativ) {
1275
+ feedIn = -feedIn;
1276
+ }
1277
+
1278
+ for (const v of this.consumerList.filter(v => v.ruletype === 'battery')) {
1279
+ await this.controlBattery(v, feedIn);
1280
+ }
1281
+ this.batteryTimer = null;
1282
+ },
1283
+ Math.max(0, Number(this.config.batteryDelaySeconds) || 0) * 1000,
1284
+ );
1285
+ }
1286
+ }
1287
+
1288
+ async isAutomaticConsumer(v) {
1289
+ if (!v.enabled) {
1290
+ return false;
1291
+ }
1292
+ const idx = this.consumerList.indexOf(v);
1293
+ const mode = await this.getStateAsync(`${this.namespace}.consumer.${idx}_${this.name2id(v.name)}.controlMode`);
1294
+ return mode?.val === 2;
1295
+ }
1296
+
1122
1297
  async controlPercentConsumer(v) {
1123
1298
  try {
1299
+ if (!(await this.isAutomaticConsumer(v))) {
1300
+ return;
1301
+ }
1124
1302
  const withinWindow = this.timeWithinWindow(v.switchOnTime, v.switchOffTime);
1125
1303
  if (!withinWindow) {
1126
1304
  this.log.debug(`[Percent] ${v.name} outside time window, set to 0%`);
@@ -1137,25 +1315,36 @@ class ZeroFeedIn extends utils.Adapter {
1137
1315
 
1138
1316
  const feedInValue = Number(feedInState?.val) || 0;
1139
1317
 
1140
- let surplus = this.config.feedinNegativ
1141
- ? feedInValue < 0
1142
- ? -feedInValue
1143
- : 0
1144
- : feedInValue > 0
1145
- ? feedInValue
1146
- : 0;
1318
+ let surplus = this.config.feedinNegativ ? -feedInValue : feedInValue;
1147
1319
 
1148
1320
  surplus -= this.config.baseload || 0;
1321
+ const currentSurplus = surplus;
1149
1322
  if (surplus < 0) {
1150
1323
  surplus = 0;
1151
1324
  }
1152
1325
 
1153
1326
  const maxPerformance = v.maxPerformance || v.performance || 1000;
1154
- let newPercent = Math.round((surplus / maxPerformance) * 100);
1155
- newPercent = Math.min(100, Math.max(newPercent, v.minPercentStart || 0));
1156
-
1157
1327
  const state = await this.getForeignStateAsync(v.datapoint);
1158
1328
  const currentPercent = Number(state?.val) || 0;
1329
+ const currentPower = (currentPercent / 100) * maxPerformance;
1330
+ let newPercent = Math.min(
1331
+ 100,
1332
+ Math.max(0, Math.round(((currentPower + currentSurplus) / maxPerformance) * 100)),
1333
+ );
1334
+ if (newPercent < (Number(v.minPercentStart) || 0)) {
1335
+ newPercent = 0;
1336
+ }
1337
+ if (newPercent >= currentPercent) {
1338
+ v.lastForecastBridgeAt = null;
1339
+ }
1340
+ if (newPercent < currentPercent && this.isForecastDelayingSwitchOff(v, currentSurplus, currentPower)) {
1341
+ newPercent = currentPercent;
1342
+ this.log.debug(`[Forecast] ${v.name}: holding ${currentPercent}% during a short forecast-backed dip`);
1343
+ await this.sendForecastNotification(
1344
+ 'percent',
1345
+ `${v.name}: holding ${currentPercent}% during a forecast-backed dip.`,
1346
+ );
1347
+ }
1159
1348
 
1160
1349
  if (newPercent !== currentPercent) {
1161
1350
  await this.setForeignStateAsync(v.datapoint, newPercent);
@@ -1186,6 +1375,9 @@ class ZeroFeedIn extends utils.Adapter {
1186
1375
  // =====================================================================
1187
1376
  async controlBattery(v, feedIn) {
1188
1377
  try {
1378
+ if (!(await this.isAutomaticConsumer(v))) {
1379
+ return;
1380
+ }
1189
1381
  this.log.debug(`[Battery] Checking for ${v.name}`);
1190
1382
 
1191
1383
  const withinWindow = this.timeWithinWindow(v.switchOnTime, v.switchOffTime);
@@ -1194,13 +1386,13 @@ class ZeroFeedIn extends utils.Adapter {
1194
1386
  if (v.batterySetpoint) {
1195
1387
  await this.setForeignStateAsync(v.batterySetpoint, {
1196
1388
  val: 0,
1197
- ack: true,
1389
+ ack: false,
1198
1390
  });
1199
1391
  }
1200
1392
  if (this.batteryControlModeDatapoint) {
1201
1393
  await this.setForeignStateAsync(this.batteryControlModeDatapoint, {
1202
1394
  val: 0,
1203
- ack: true,
1395
+ ack: false,
1204
1396
  });
1205
1397
  }
1206
1398
  return;
@@ -1211,7 +1403,7 @@ class ZeroFeedIn extends utils.Adapter {
1211
1403
  if (this.batteryControlModeDatapoint) {
1212
1404
  await this.setForeignStateAsync(this.batteryControlModeDatapoint, {
1213
1405
  val: 0,
1214
- ack: true,
1406
+ ack: false,
1215
1407
  });
1216
1408
  }
1217
1409
  return;
@@ -1219,34 +1411,64 @@ class ZeroFeedIn extends utils.Adapter {
1219
1411
 
1220
1412
  let soc = null;
1221
1413
  let targetSoc = null;
1414
+ let invalidSoc = false;
1222
1415
  if (v.batterySOC && v.batteryTargetSOC) {
1223
1416
  const socState = await this.getForeignStateAsync(v.batterySOC);
1224
1417
  const targetSocState = await this.getForeignStateAsync(v.batteryTargetSOC);
1225
1418
  soc = Number(socState?.val);
1226
1419
  targetSoc = Number(targetSocState?.val);
1227
- if (isNaN(soc) || isNaN(targetSoc)) {
1420
+ if (
1421
+ !socState ||
1422
+ socState.val === null ||
1423
+ !targetSocState ||
1424
+ targetSocState.val === null ||
1425
+ !Number.isFinite(soc) ||
1426
+ !Number.isFinite(targetSoc)
1427
+ ) {
1228
1428
  this.log.warn(`[Battery] ${v.name}: invalid SOC or targetSOC`);
1229
1429
  soc = null;
1230
1430
  targetSoc = null;
1431
+ invalidSoc = true;
1231
1432
  }
1232
1433
  }
1233
1434
 
1234
- const surplus = (this.config.feedinNegativ ? -feedIn : feedIn) - (this.config.baseload || 0);
1435
+ // Callers provide feed-in normalized to positive export.
1436
+ const surplus = feedIn - (this.config.baseload || 0);
1437
+ const oldPower = Number((await this.getForeignStateAsync(v.batterySetpoint))?.val) || 0;
1235
1438
  let powerToSet = 0;
1236
1439
  let modeToSet = 0;
1237
1440
 
1238
- if (surplus <= 0) {
1441
+ const availablePower = Math.max(0, oldPower + surplus);
1442
+ if (availablePower <= 0 || invalidSoc) {
1239
1443
  powerToSet = 0;
1240
1444
  modeToSet = 0;
1241
1445
  } else if (soc !== null && targetSoc !== null && soc >= targetSoc) {
1242
1446
  powerToSet = 0;
1243
1447
  modeToSet = 1;
1244
1448
  } else {
1245
- powerToSet = Math.min(surplus, v.performance || 1000);
1449
+ powerToSet = Math.min(availablePower, v.performance || 1000);
1246
1450
  modeToSet = 2;
1247
1451
  }
1248
1452
 
1249
- const oldPower = Number((await this.getForeignStateAsync(v.batterySetpoint))?.val) || 0;
1453
+ if (powerToSet >= oldPower) {
1454
+ v.lastForecastBridgeAt = null;
1455
+ }
1456
+
1457
+ if (
1458
+ powerToSet < oldPower &&
1459
+ !invalidSoc &&
1460
+ (soc === null || targetSoc === null || soc < targetSoc) &&
1461
+ this.isForecastDelayingSwitchOff(v, surplus, oldPower)
1462
+ ) {
1463
+ powerToSet = Math.min(oldPower, v.performance || oldPower);
1464
+ modeToSet = 2;
1465
+ this.log.debug(`[Forecast] ${v.name}: holding ${powerToSet}W charging power during a short dip`);
1466
+ await this.sendForecastNotification(
1467
+ 'battery',
1468
+ `${v.name}: holding ${powerToSet}W charging power during a forecast-backed dip.`,
1469
+ );
1470
+ }
1471
+
1250
1472
  const oldMode = this.batteryControlModeDatapoint
1251
1473
  ? Number((await this.getForeignStateAsync(this.batteryControlModeDatapoint))?.val) || 0
1252
1474
  : -1;
@@ -1254,7 +1476,7 @@ class ZeroFeedIn extends utils.Adapter {
1254
1476
  if (powerToSet !== oldPower) {
1255
1477
  await this.setForeignStateAsync(v.batterySetpoint, {
1256
1478
  val: powerToSet,
1257
- ack: true,
1479
+ ack: false,
1258
1480
  });
1259
1481
  this.log.info(`Battery ${v.name}: charging power set to ${powerToSet}W`);
1260
1482
  if (this.config.notifyBattery) {
@@ -1271,7 +1493,7 @@ class ZeroFeedIn extends utils.Adapter {
1271
1493
  if (this.batteryControlModeDatapoint && modeToSet !== oldMode) {
1272
1494
  await this.setForeignStateAsync(this.batteryControlModeDatapoint, {
1273
1495
  val: modeToSet,
1274
- ack: true,
1496
+ ack: false,
1275
1497
  });
1276
1498
  this.log.debug(`[Battery] ${v.name} mode changed from ${oldMode} to ${modeToSet}`);
1277
1499
  if (this.config.notifyBattery) {
@@ -1307,11 +1529,6 @@ class ZeroFeedIn extends utils.Adapter {
1307
1529
  val: targetState,
1308
1530
  ack: false,
1309
1531
  });
1310
- // Danach sofort bestätigen (falls keine Rückmeldung vom Gerät kommt)
1311
- await this.setForeignStateAsync(consumer.datapoint, {
1312
- val: targetState,
1313
- ack: true,
1314
- });
1315
1532
  }
1316
1533
 
1317
1534
  if (consumer.numericDatapoint) {
@@ -1320,13 +1537,12 @@ class ZeroFeedIn extends utils.Adapter {
1320
1537
  val: numVal,
1321
1538
  ack: false,
1322
1539
  });
1323
- await this.setForeignStateAsync(consumer.numericDatapoint, {
1324
- val: numVal,
1325
- ack: true,
1326
- });
1327
1540
  this.log.debug(`Numeric datapoint for "${consumer.name}" set to ${numVal}`);
1328
1541
  }
1329
1542
 
1543
+ consumer.lastStateChange = Date.now();
1544
+ consumer.lastForecastBridgeAt = null;
1545
+
1330
1546
  const msg = `${consumer.name} wurde ${targetState ? 'eingeschaltet' : 'ausgeschaltet'}`;
1331
1547
 
1332
1548
  if (consumer.ruletype === 'heating') {
@@ -1398,12 +1614,57 @@ class ZeroFeedIn extends utils.Adapter {
1398
1614
  };
1399
1615
 
1400
1616
  const doSwitch = async () => {
1617
+ if (!consumer.enabled) {
1618
+ consumer.timer = null;
1619
+ consumer.timerEnd = null;
1620
+ consumer.pendingAction = null;
1621
+ await stopTimerTicker();
1622
+ return;
1623
+ }
1624
+ if (delay > 0 && turnOn) {
1625
+ const mode = await this.getStateAsync(`${this.namespace}.${channelId}.controlMode`);
1626
+ if (mode?.val === 2) {
1627
+ const meter = this.feedInDatapoint ? await this.getForeignStateAsync(this.feedInDatapoint) : null;
1628
+ const rawPower = Number(meter?.val);
1629
+ const surplus = (this.config.feedinNegativ ? -rawPower : rawPower) - (this.config.baseload || 0);
1630
+ const withinWindow = this.timeWithinWindow(consumer.switchOnTime, consumer.switchOffTime);
1631
+ const required = Math.max(Number(consumer.performance) || 0, Number(consumer.switchOnPoint) || 0);
1632
+ if (
1633
+ !meter ||
1634
+ meter.val === null ||
1635
+ !Number.isFinite(surplus) ||
1636
+ !withinWindow ||
1637
+ (surplus < required && !this.isForecastAllowingSwitchOn(consumer, surplus))
1638
+ ) {
1639
+ consumer.timer = null;
1640
+ consumer.timerEnd = null;
1641
+ consumer.pendingAction = null;
1642
+ await stopTimerTicker();
1643
+ return;
1644
+ }
1645
+ } else if (mode?.val !== 1) {
1646
+ consumer.timer = null;
1647
+ consumer.timerEnd = null;
1648
+ consumer.pendingAction = null;
1649
+ await stopTimerTicker();
1650
+ return;
1651
+ }
1652
+ }
1401
1653
  // nur bei heating: zusätzliche Sicherheitschecks
1402
1654
  if (consumer.ruletype === 'heating' && turnOn) {
1403
1655
  const tempState = consumer.temperatureDatapoint
1404
1656
  ? await this.getForeignStateAsync(consumer.temperatureDatapoint)
1405
1657
  : null;
1406
- const currentTemp = tempState?.val !== undefined ? Number(tempState.val) : null;
1658
+ const currentTemp =
1659
+ tempState?.val !== undefined && tempState.val !== null ? Number(tempState.val) : null;
1660
+
1661
+ if (consumer.temperatureDatapoint && (currentTemp === null || !Number.isFinite(currentTemp))) {
1662
+ consumer.timer = null;
1663
+ consumer.timerEnd = null;
1664
+ consumer.pendingAction = null;
1665
+ await stopTimerTicker();
1666
+ return;
1667
+ }
1407
1668
 
1408
1669
  if (
1409
1670
  currentTemp !== null &&
@@ -1674,6 +1935,10 @@ class ZeroFeedIn extends utils.Adapter {
1674
1935
  // =====================================================================
1675
1936
  async onUnload(callback) {
1676
1937
  try {
1938
+ if (this.ruleTimer) {
1939
+ clearInterval(this.ruleTimer);
1940
+ this.ruleTimer = null;
1941
+ }
1677
1942
  if (this.percentTimer) {
1678
1943
  clearTimeout(this.percentTimer);
1679
1944
  }
@@ -1681,6 +1946,10 @@ class ZeroFeedIn extends utils.Adapter {
1681
1946
  clearTimeout(this.batteryTimer);
1682
1947
  }
1683
1948
  for (const v of this.consumerList) {
1949
+ if (v.timerTick) {
1950
+ clearInterval(v.timerTick);
1951
+ v.timerTick = null;
1952
+ }
1684
1953
  if (v.timer) {
1685
1954
  clearTimeout(v.timer);
1686
1955
  v.timer = null;