iobroker.smartloadmanager 0.1.2 → 0.2.0

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) {
@@ -26,6 +27,10 @@ class ZeroFeedIn extends utils.Adapter {
26
27
  surplus: 0,
27
28
  minutes: 0,
28
29
  lastUpdate: 0,
30
+ // eslint-disable-next-line jsdoc/check-tag-names
31
+ points: /** @type {Array<{timestamp: number, power: number, surplus: number}>} */ ([]),
32
+ // eslint-disable-next-line jsdoc/check-tag-names
33
+ unit: /** @type {'W' | 'kW' | null} */ (null),
29
34
  };
30
35
 
31
36
  this.on('ready', this.onReady.bind(this));
@@ -81,6 +86,9 @@ class ZeroFeedIn extends utils.Adapter {
81
86
  // 🔮 Forecast config (PV forecast integration)
82
87
  this.forecastEnabled = !!this.config.enableForecast;
83
88
  this.forecastJsonDatapoint = this.config.forecastJsonDatapoint || null;
89
+ if (this.forecastEnabled && this.forecastJsonDatapoint) {
90
+ this.forecast.unit = await this.detectForecastPowerUnit();
91
+ }
84
92
 
85
93
  this.consumerList = Array.isArray(this.config.consumer)
86
94
  ? this.config.consumer.filter(
@@ -100,6 +108,13 @@ class ZeroFeedIn extends utils.Adapter {
100
108
  v.enabled = state.val;
101
109
  this.log.debug(`"${v.name}": Active status taken from object tree (${state.val})`);
102
110
  }
111
+ const switchingState = v.datapoint
112
+ ? await this.getForeignStateAsync(v.datapoint)
113
+ : v.numericDatapoint
114
+ ? await this.getForeignStateAsync(v.numericDatapoint)
115
+ : null;
116
+ v.lastStateChange = switchingState?.lc || 0;
117
+ v.lastForecastBridgeAt = null;
103
118
  }
104
119
 
105
120
  this.log.info(`Loaded consumers: ${this.consumerList.map(v => v.name).join(', ')}`);
@@ -126,6 +141,7 @@ class ZeroFeedIn extends utils.Adapter {
126
141
  }
127
142
 
128
143
  await this.checkConsumers();
144
+ this.ruleTimer = this.safeSetInterval(() => this.checkConsumers(), 15000);
129
145
  } catch (error) {
130
146
  this.log.error(`Error in onReady: ${error.message}`);
131
147
  // additionally send alert
@@ -499,43 +515,26 @@ class ZeroFeedIn extends utils.Adapter {
499
515
  return;
500
516
  }
501
517
 
502
- let data;
503
518
  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) {
519
+ this.forecast.points = parseForecast(String(state.val), {
520
+ unit: this.forecast.unit,
521
+ baseload: this.config.baseload || 0,
522
+ });
523
+ } catch (error) {
524
+ this.forecast.points = [];
525
+ this.log.warn(`[Forecast] Invalid pvforecast JSON: ${error.message}`);
511
526
  return;
512
527
  }
513
528
 
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
-
529
+ const best = this.forecast.points[0];
528
530
  if (!best) {
529
531
  return;
530
532
  }
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);
533
+ const power = best.power;
534
+ const minutes = Math.max(0, Math.round((best.timestamp - Date.now()) / 60000));
536
535
 
537
536
  this.forecast.power = power;
538
- this.forecast.surplus = surplus > 0 ? surplus : 0;
537
+ this.forecast.surplus = best.surplus;
539
538
  this.forecast.minutes = minutes;
540
539
  this.forecast.lastUpdate = Date.now();
541
540
 
@@ -551,13 +550,36 @@ class ZeroFeedIn extends utils.Adapter {
551
550
  });
552
551
 
553
552
  this.log.debug(
554
- `[Forecast] JSON parsed → power=${power}W, surplus=${this.forecast.surplus}W, minutes=${minutes}`,
553
+ `[Forecast] ${this.forecast.points.length} values parsed (${this.forecast.unit || 'auto'}) - next: ${power}W in ${minutes}min`,
555
554
  );
556
555
  } catch (error) {
557
556
  this.log.error(`[Forecast] Error processing pvforecast JSON: ${error.message}`);
558
557
  }
559
558
  }
560
559
 
560
+ /**
561
+ * Detect the power unit used by pvforecast.
562
+ *
563
+ * @returns {Promise<'W'|'kW'|null>} Detected unit or null for value-based detection.
564
+ */
565
+ async detectForecastPowerUnit() {
566
+ const configured = this.config.forecastPowerUnit;
567
+ if (configured === 'W' || configured === 'kW') {
568
+ return configured;
569
+ }
570
+ try {
571
+ const summaryPowerId = (this.forecastJsonDatapoint || '').replace(/\.JSONData$/, '.power.now');
572
+ const object = await this.getForeignObjectAsync(summaryPowerId);
573
+ const unit = object?.common?.unit;
574
+ if (unit === 'W' || unit === 'kW') {
575
+ return unit;
576
+ }
577
+ } catch (error) {
578
+ this.log.debug(`[Forecast] Unit detection failed: ${error.message}`);
579
+ }
580
+ return null;
581
+ }
582
+
561
583
  // =====================================================================
562
584
  // ========================= Forecast helpers ==========================
563
585
  // =====================================================================
@@ -569,32 +591,57 @@ class ZeroFeedIn extends utils.Adapter {
569
591
  return this.forecast?.minutes || 0;
570
592
  }
571
593
 
572
- isForecastAllowingSwitchOn(v) {
594
+ getForecastDecision(v, requiredPower) {
595
+ const maxStartMinutes =
596
+ this.config.forecastMaxStartMinutes === undefined ? 30 : Number(this.config.forecastMaxStartMinutes);
597
+ return evaluateForecast(this.forecast.points, {
598
+ requiredPower,
599
+ safetyFactor: (Number(this.config.forecastSafetyPercent) || 80) / 100,
600
+ minDurationMinutes: Number(v.forecastMinMinutes) || Number(this.config.forecastDefaultMinMinutes) || 30,
601
+ maxStartMinutes,
602
+ });
603
+ }
604
+
605
+ isForecastAllowingSwitchOn(v, currentSurplus) {
573
606
  if (!this.forecastEnabled || !v.useForecast) {
574
607
  return false;
575
608
  }
576
- const minMinutes = v.forecastMinMinutes || 0;
577
- if (this.forecast.minutes > minMinutes) {
609
+ const requiredPower = Number(v.performance) || 0;
610
+ const minimumCurrentPercent =
611
+ this.config.forecastMinCurrentPercent === undefined ? 50 : Number(this.config.forecastMinCurrentPercent);
612
+ const minimumCurrent = requiredPower * (minimumCurrentPercent / 100);
613
+ if (currentSurplus < minimumCurrent) {
578
614
  return false;
579
615
  }
580
- if (this.forecast.surplus >= v.performance) {
581
- return true;
616
+ const minimumOffMinutes = v.minimumOffMinutes === undefined ? 5 : Number(v.minimumOffMinutes);
617
+ if (!minimumStateTimeSatisfied(v.lastStateChange, minimumOffMinutes)) {
618
+ return false;
582
619
  }
583
- return false;
620
+ return this.getForecastDecision(v, requiredPower).available;
584
621
  }
585
622
 
586
- isForecastDelayingSwitchOff(v, _currentSurplus) {
623
+ isForecastDelayingSwitchOff(v, currentSurplus, requiredPower) {
587
624
  if (!this.forecastEnabled || !v.useForecast) {
588
625
  return false;
589
626
  }
590
- const minMinutes = v.forecastMinMinutes || 0;
591
- if (this.forecast.minutes > minMinutes) {
627
+ const bridgeMinutes =
628
+ this.config.forecastBridgeMinutes === undefined ? 5 : Number(this.config.forecastBridgeMinutes);
629
+ const maxGridImport = Math.max(
630
+ 0,
631
+ this.config.forecastMaxGridImport === undefined ? 100 : Number(this.config.forecastMaxGridImport),
632
+ );
633
+ if (currentSurplus < -maxGridImport) {
592
634
  return false;
593
635
  }
594
- if (this.forecast.surplus >= v.switchOnPoint) {
595
- return true;
636
+ const decision = this.getForecastDecision(
637
+ v,
638
+ Number(requiredPower) || Number(v.switchOnPoint) || Number(v.performance) || 0,
639
+ );
640
+ if (!decision.available || decision.startMinutes === null || decision.startMinutes > bridgeMinutes) {
641
+ return false;
596
642
  }
597
- return false;
643
+ v.lastForecastBridgeAt ||= Date.now();
644
+ return Date.now() - v.lastForecastBridgeAt <= bridgeMinutes * 60000;
598
645
  }
599
646
 
600
647
  async setForecastState(v, idx, active, reason, power, minutes) {
@@ -636,7 +683,11 @@ class ZeroFeedIn extends utils.Adapter {
636
683
  const onMinutes = this.parseTimeToMinutes(onTime);
637
684
  const offMinutes = this.parseTimeToMinutes(offTime);
638
685
 
639
- const within = currentMinutes >= onMinutes && currentMinutes < offMinutes;
686
+ const within =
687
+ onMinutes === offMinutes ||
688
+ (onMinutes < offMinutes
689
+ ? currentMinutes >= onMinutes && currentMinutes < offMinutes
690
+ : currentMinutes >= onMinutes || currentMinutes < offMinutes);
640
691
 
641
692
  if (name) {
642
693
  this.log.debug(
@@ -673,40 +724,7 @@ class ZeroFeedIn extends utils.Adapter {
673
724
  this.log.debug(`Feed-in value updated: ${state.val} (ack=${state.ack})`);
674
725
  await this.checkConsumers();
675
726
 
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
- );
727
+ this.scheduleRegulation();
710
728
  } else {
711
729
  this.log.debug(`Feed-in value change ignored (ack=${state.ack}) - waiting for confirmed value`);
712
730
  }
@@ -724,6 +742,16 @@ class ZeroFeedIn extends utils.Adapter {
724
742
  const v = this.consumerList[index];
725
743
  if (v) {
726
744
  v.enabled = state.val;
745
+ if (!v.enabled && v.timer) {
746
+ clearTimeout(v.timer);
747
+ v.timer = null;
748
+ v.timerEnd = null;
749
+ v.pendingAction = null;
750
+ if (v.timerTick) {
751
+ clearInterval(v.timerTick);
752
+ v.timerTick = null;
753
+ }
754
+ }
727
755
  this.log.info(`Consumer "${v.name}" became ${state.val ? 'activated' : 'deactivated'}.`);
728
756
  await this.setStateAsync(id, { val: state.val, ack: true });
729
757
  }
@@ -737,10 +765,29 @@ class ZeroFeedIn extends utils.Adapter {
737
765
  const mode = state.val;
738
766
  const v = this.consumerList[index];
739
767
 
740
- if (mode === 0) {
741
- await this.switchConsumerWithDelay(v, false);
742
- } else if (mode === 1) {
743
- await this.switchConsumerWithDelay(v, true);
768
+ if (!v || ![0, 1, 2].includes(mode)) {
769
+ return;
770
+ }
771
+ await this.setStateAsync(id, { val: mode, ack: true });
772
+ if (v.timer) {
773
+ clearTimeout(v.timer);
774
+ v.timer = null;
775
+ v.timerEnd = null;
776
+ v.pendingAction = null;
777
+ }
778
+ if (mode === 2) {
779
+ await this.checkConsumers();
780
+ } else if (v.ruletype === 'percent') {
781
+ await this.setForeignStateAsync(v.datapoint, { val: mode === 1 ? 100 : 0, ack: false });
782
+ } else if (v.ruletype === 'battery') {
783
+ if (v.batterySetpoint) {
784
+ await this.setForeignStateAsync(v.batterySetpoint, {
785
+ val: mode === 1 ? v.performance || 1000 : 0,
786
+ ack: false,
787
+ });
788
+ }
789
+ } else {
790
+ await this.switchConsumerWithDelay(v, mode === 1, 0);
744
791
  }
745
792
  }
746
793
 
@@ -833,13 +880,18 @@ class ZeroFeedIn extends utils.Adapter {
833
880
  }
834
881
 
835
882
  this.log.debug(`"${v.name}" Status: isOn=${isOn}`);
883
+ const minimumOffSatisfied = minimumStateTimeSatisfied(
884
+ v.lastStateChange,
885
+ v.minimumOffMinutes === undefined ? 5 : Number(v.minimumOffMinutes),
886
+ );
836
887
 
837
888
  // Zusatz-Checks heating
838
889
  if (v.ruletype === 'heating' && withinWindow && !isOn) {
839
890
  const tempState = v.temperatureDatapoint
840
891
  ? await this.getForeignStateAsync(v.temperatureDatapoint)
841
892
  : null;
842
- const currentTemp = tempState?.val !== undefined ? Number(tempState.val) : null;
893
+ const currentTemp =
894
+ tempState?.val !== undefined && tempState.val !== null ? Number(tempState.val) : null;
843
895
 
844
896
  if (currentTemp !== null && v.maxTemperature !== undefined && currentTemp >= v.maxTemperature) {
845
897
  this.log.warn(
@@ -889,7 +941,12 @@ class ZeroFeedIn extends utils.Adapter {
889
941
  }
890
942
 
891
943
  if (withinWindow) {
892
- if (!gridUsage && v.performance <= feedIn - baseload && !isOn) {
944
+ if (
945
+ !gridUsage &&
946
+ Math.max(v.performance, Number(v.switchOnPoint) || 0) <= feedIn - baseload &&
947
+ !isOn &&
948
+ minimumOffSatisfied
949
+ ) {
893
950
  this.log.debug(
894
951
  `Switch-on possible: surplus ${
895
952
  feedIn - baseload
@@ -899,7 +956,7 @@ class ZeroFeedIn extends utils.Adapter {
899
956
  feedIn -= v.performance;
900
957
  this.log.debug(`Remaining surplus after switch-on: ${feedIn - baseload} W`);
901
958
  await this.setForecastState(v, idx, false, '', null, null);
902
- } else if (!gridUsage && !isOn && this.isForecastAllowingSwitchOn(v)) {
959
+ } else if (!gridUsage && !isOn && this.isForecastAllowingSwitchOn(v, feedIn - baseload)) {
903
960
  const forecastPower = this.getForecastPower(v);
904
961
  const minutes = this.getForecastMinutes(v);
905
962
  this.log.debug(
@@ -916,7 +973,9 @@ class ZeroFeedIn extends utils.Adapter {
916
973
  this.log.debug(`Not switched on: grid usage active.`);
917
974
  } else if (!isOn) {
918
975
  this.log.debug(
919
- `Not switched on: surplus too low (${feedIn - baseload} W < ${v.performance} W).`,
976
+ minimumOffSatisfied
977
+ ? `Not switched on: surplus too low (${feedIn - baseload} W < ${v.performance} W).`
978
+ : `Not switched on: minimum off time (${v.minimumOffMinutes === undefined ? 5 : Number(v.minimumOffMinutes)} min) is active.`,
920
979
  );
921
980
  } else {
922
981
  this.log.debug(`"${v.name}" is already switched on – no action needed.`);
@@ -1025,8 +1084,12 @@ class ZeroFeedIn extends utils.Adapter {
1025
1084
  const tempState = v.temperatureDatapoint
1026
1085
  ? await this.getForeignStateAsync(v.temperatureDatapoint)
1027
1086
  : null;
1028
- const currentTemp = tempState?.val !== undefined ? Number(tempState.val) : null;
1029
- if (currentTemp !== null && currentTemp >= v.maxTemperature) {
1087
+ const currentTemp =
1088
+ tempState?.val !== undefined && tempState.val !== null ? Number(tempState.val) : null;
1089
+ if (
1090
+ (v.temperatureDatapoint && (currentTemp === null || !Number.isFinite(currentTemp))) ||
1091
+ (currentTemp !== null && currentTemp >= v.maxTemperature)
1092
+ ) {
1030
1093
  this.log.warn(
1031
1094
  `"${v.name}" switched off due to maxTemperature reached: ${currentTemp}°C >= ${v.maxTemperature}°C`,
1032
1095
  );
@@ -1037,7 +1100,14 @@ class ZeroFeedIn extends utils.Adapter {
1037
1100
 
1038
1101
  // Timer-Abbruch bei wegfallender Bedingung (Ausschalten)
1039
1102
  const surplus = feedIn - baseload;
1040
- if (v.timer && v.timerEnd && v.pendingAction === false && withinWindow && surplus >= v.switchOffPoint) {
1103
+ if (
1104
+ v.timer &&
1105
+ v.timerEnd &&
1106
+ v.pendingAction === false &&
1107
+ withinWindow &&
1108
+ !gridUsage &&
1109
+ surplus >= v.switchOffPoint
1110
+ ) {
1041
1111
  clearTimeout(v.timer);
1042
1112
  v.timer = null;
1043
1113
  v.timerEnd = null;
@@ -1053,8 +1123,27 @@ class ZeroFeedIn extends utils.Adapter {
1053
1123
  // - Netzbezug aktiv ODER
1054
1124
  // - Überschuss < switchOffPoint
1055
1125
  const shouldSwitchOff = !withinWindow || gridUsage || surplus < v.switchOffPoint;
1126
+ if (!shouldSwitchOff) {
1127
+ v.lastForecastBridgeAt = null;
1128
+ }
1056
1129
 
1057
1130
  if (!v.alwaysOffAtTime && shouldSwitchOff) {
1131
+ const maxGridImport = Math.max(
1132
+ 0,
1133
+ this.config.forecastMaxGridImport === undefined
1134
+ ? 100
1135
+ : Number(this.config.forecastMaxGridImport),
1136
+ );
1137
+ const minimumOnMinutes = v.minimumOnMinutes === undefined ? 10 : Number(v.minimumOnMinutes);
1138
+ const hardGridImport = surplus < -maxGridImport;
1139
+ if (
1140
+ withinWindow &&
1141
+ !hardGridImport &&
1142
+ !minimumStateTimeSatisfied(v.lastStateChange, minimumOnMinutes)
1143
+ ) {
1144
+ this.log.debug(`"${v.name}" remains on: minimum run time (${minimumOnMinutes} min) is active.`);
1145
+ continue;
1146
+ }
1058
1147
  // 🔮 Forecast delay switch-off (PV forecast integration)
1059
1148
  if (this.isForecastDelayingSwitchOff(v, surplus)) {
1060
1149
  const forecastPower = this.getForecastPower(v);
@@ -1084,14 +1173,7 @@ class ZeroFeedIn extends utils.Adapter {
1084
1173
  }
1085
1174
 
1086
1175
  // --- 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
- }
1176
+ this.scheduleRegulation();
1095
1177
 
1096
1178
  const runningTimers = this.consumerList
1097
1179
  .filter(c => c.timer && c.timerEnd)
@@ -1119,8 +1201,55 @@ class ZeroFeedIn extends utils.Adapter {
1119
1201
  // =====================================================================
1120
1202
  // ========================= controlPercentConsumer ====================
1121
1203
  // =====================================================================
1204
+ scheduleRegulation() {
1205
+ if (!this.percentTimer && this.consumerList.some(v => v.ruletype === 'percent')) {
1206
+ this.percentTimer = this.safeSetTimeout(
1207
+ async () => {
1208
+ for (const v of this.consumerList.filter(v => v.ruletype === 'percent')) {
1209
+ await this.controlPercentConsumer(v);
1210
+ }
1211
+ this.percentTimer = null;
1212
+ },
1213
+ Math.max(0, Number(this.config.delaySecondsProzent) || 0) * 1000,
1214
+ );
1215
+ }
1216
+
1217
+ if (!this.batteryTimer && this.consumerList.some(v => v.ruletype === 'battery')) {
1218
+ this.batteryTimer = this.safeSetTimeout(
1219
+ async () => {
1220
+ if (!this.feedInDatapoint) {
1221
+ return;
1222
+ }
1223
+ const feedInState = await this.getForeignStateAsync(this.feedInDatapoint);
1224
+ let feedIn = Number(feedInState?.val) || 0;
1225
+ if (this.config.feedinNegativ) {
1226
+ feedIn = -feedIn;
1227
+ }
1228
+
1229
+ for (const v of this.consumerList.filter(v => v.ruletype === 'battery')) {
1230
+ await this.controlBattery(v, feedIn);
1231
+ }
1232
+ this.batteryTimer = null;
1233
+ },
1234
+ Math.max(0, Number(this.config.batteryDelaySeconds) || 0) * 1000,
1235
+ );
1236
+ }
1237
+ }
1238
+
1239
+ async isAutomaticConsumer(v) {
1240
+ if (!v.enabled) {
1241
+ return false;
1242
+ }
1243
+ const idx = this.consumerList.indexOf(v);
1244
+ const mode = await this.getStateAsync(`${this.namespace}.consumer.${idx}_${this.name2id(v.name)}.controlMode`);
1245
+ return mode?.val === 2;
1246
+ }
1247
+
1122
1248
  async controlPercentConsumer(v) {
1123
1249
  try {
1250
+ if (!(await this.isAutomaticConsumer(v))) {
1251
+ return;
1252
+ }
1124
1253
  const withinWindow = this.timeWithinWindow(v.switchOnTime, v.switchOffTime);
1125
1254
  if (!withinWindow) {
1126
1255
  this.log.debug(`[Percent] ${v.name} outside time window, set to 0%`);
@@ -1137,25 +1266,32 @@ class ZeroFeedIn extends utils.Adapter {
1137
1266
 
1138
1267
  const feedInValue = Number(feedInState?.val) || 0;
1139
1268
 
1140
- let surplus = this.config.feedinNegativ
1141
- ? feedInValue < 0
1142
- ? -feedInValue
1143
- : 0
1144
- : feedInValue > 0
1145
- ? feedInValue
1146
- : 0;
1269
+ let surplus = this.config.feedinNegativ ? -feedInValue : feedInValue;
1147
1270
 
1148
1271
  surplus -= this.config.baseload || 0;
1272
+ const currentSurplus = surplus;
1149
1273
  if (surplus < 0) {
1150
1274
  surplus = 0;
1151
1275
  }
1152
1276
 
1153
1277
  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
1278
  const state = await this.getForeignStateAsync(v.datapoint);
1158
1279
  const currentPercent = Number(state?.val) || 0;
1280
+ const currentPower = (currentPercent / 100) * maxPerformance;
1281
+ let newPercent = Math.min(
1282
+ 100,
1283
+ Math.max(0, Math.round(((currentPower + currentSurplus) / maxPerformance) * 100)),
1284
+ );
1285
+ if (newPercent < (Number(v.minPercentStart) || 0)) {
1286
+ newPercent = 0;
1287
+ }
1288
+ if (newPercent >= currentPercent) {
1289
+ v.lastForecastBridgeAt = null;
1290
+ }
1291
+ if (newPercent < currentPercent && this.isForecastDelayingSwitchOff(v, currentSurplus, currentPower)) {
1292
+ newPercent = currentPercent;
1293
+ this.log.debug(`[Forecast] ${v.name}: holding ${currentPercent}% during a short forecast-backed dip`);
1294
+ }
1159
1295
 
1160
1296
  if (newPercent !== currentPercent) {
1161
1297
  await this.setForeignStateAsync(v.datapoint, newPercent);
@@ -1186,6 +1322,9 @@ class ZeroFeedIn extends utils.Adapter {
1186
1322
  // =====================================================================
1187
1323
  async controlBattery(v, feedIn) {
1188
1324
  try {
1325
+ if (!(await this.isAutomaticConsumer(v))) {
1326
+ return;
1327
+ }
1189
1328
  this.log.debug(`[Battery] Checking for ${v.name}`);
1190
1329
 
1191
1330
  const withinWindow = this.timeWithinWindow(v.switchOnTime, v.switchOffTime);
@@ -1194,13 +1333,13 @@ class ZeroFeedIn extends utils.Adapter {
1194
1333
  if (v.batterySetpoint) {
1195
1334
  await this.setForeignStateAsync(v.batterySetpoint, {
1196
1335
  val: 0,
1197
- ack: true,
1336
+ ack: false,
1198
1337
  });
1199
1338
  }
1200
1339
  if (this.batteryControlModeDatapoint) {
1201
1340
  await this.setForeignStateAsync(this.batteryControlModeDatapoint, {
1202
1341
  val: 0,
1203
- ack: true,
1342
+ ack: false,
1204
1343
  });
1205
1344
  }
1206
1345
  return;
@@ -1211,7 +1350,7 @@ class ZeroFeedIn extends utils.Adapter {
1211
1350
  if (this.batteryControlModeDatapoint) {
1212
1351
  await this.setForeignStateAsync(this.batteryControlModeDatapoint, {
1213
1352
  val: 0,
1214
- ack: true,
1353
+ ack: false,
1215
1354
  });
1216
1355
  }
1217
1356
  return;
@@ -1219,34 +1358,60 @@ class ZeroFeedIn extends utils.Adapter {
1219
1358
 
1220
1359
  let soc = null;
1221
1360
  let targetSoc = null;
1361
+ let invalidSoc = false;
1222
1362
  if (v.batterySOC && v.batteryTargetSOC) {
1223
1363
  const socState = await this.getForeignStateAsync(v.batterySOC);
1224
1364
  const targetSocState = await this.getForeignStateAsync(v.batteryTargetSOC);
1225
1365
  soc = Number(socState?.val);
1226
1366
  targetSoc = Number(targetSocState?.val);
1227
- if (isNaN(soc) || isNaN(targetSoc)) {
1367
+ if (
1368
+ !socState ||
1369
+ socState.val === null ||
1370
+ !targetSocState ||
1371
+ targetSocState.val === null ||
1372
+ !Number.isFinite(soc) ||
1373
+ !Number.isFinite(targetSoc)
1374
+ ) {
1228
1375
  this.log.warn(`[Battery] ${v.name}: invalid SOC or targetSOC`);
1229
1376
  soc = null;
1230
1377
  targetSoc = null;
1378
+ invalidSoc = true;
1231
1379
  }
1232
1380
  }
1233
1381
 
1234
- const surplus = (this.config.feedinNegativ ? -feedIn : feedIn) - (this.config.baseload || 0);
1382
+ // Callers provide feed-in normalized to positive export.
1383
+ const surplus = feedIn - (this.config.baseload || 0);
1384
+ const oldPower = Number((await this.getForeignStateAsync(v.batterySetpoint))?.val) || 0;
1235
1385
  let powerToSet = 0;
1236
1386
  let modeToSet = 0;
1237
1387
 
1238
- if (surplus <= 0) {
1388
+ const availablePower = Math.max(0, oldPower + surplus);
1389
+ if (availablePower <= 0 || invalidSoc) {
1239
1390
  powerToSet = 0;
1240
1391
  modeToSet = 0;
1241
1392
  } else if (soc !== null && targetSoc !== null && soc >= targetSoc) {
1242
1393
  powerToSet = 0;
1243
1394
  modeToSet = 1;
1244
1395
  } else {
1245
- powerToSet = Math.min(surplus, v.performance || 1000);
1396
+ powerToSet = Math.min(availablePower, v.performance || 1000);
1246
1397
  modeToSet = 2;
1247
1398
  }
1248
1399
 
1249
- const oldPower = Number((await this.getForeignStateAsync(v.batterySetpoint))?.val) || 0;
1400
+ if (powerToSet >= oldPower) {
1401
+ v.lastForecastBridgeAt = null;
1402
+ }
1403
+
1404
+ if (
1405
+ powerToSet < oldPower &&
1406
+ !invalidSoc &&
1407
+ (soc === null || targetSoc === null || soc < targetSoc) &&
1408
+ this.isForecastDelayingSwitchOff(v, surplus, oldPower)
1409
+ ) {
1410
+ powerToSet = Math.min(oldPower, v.performance || oldPower);
1411
+ modeToSet = 2;
1412
+ this.log.debug(`[Forecast] ${v.name}: holding ${powerToSet}W charging power during a short dip`);
1413
+ }
1414
+
1250
1415
  const oldMode = this.batteryControlModeDatapoint
1251
1416
  ? Number((await this.getForeignStateAsync(this.batteryControlModeDatapoint))?.val) || 0
1252
1417
  : -1;
@@ -1254,7 +1419,7 @@ class ZeroFeedIn extends utils.Adapter {
1254
1419
  if (powerToSet !== oldPower) {
1255
1420
  await this.setForeignStateAsync(v.batterySetpoint, {
1256
1421
  val: powerToSet,
1257
- ack: true,
1422
+ ack: false,
1258
1423
  });
1259
1424
  this.log.info(`Battery ${v.name}: charging power set to ${powerToSet}W`);
1260
1425
  if (this.config.notifyBattery) {
@@ -1271,7 +1436,7 @@ class ZeroFeedIn extends utils.Adapter {
1271
1436
  if (this.batteryControlModeDatapoint && modeToSet !== oldMode) {
1272
1437
  await this.setForeignStateAsync(this.batteryControlModeDatapoint, {
1273
1438
  val: modeToSet,
1274
- ack: true,
1439
+ ack: false,
1275
1440
  });
1276
1441
  this.log.debug(`[Battery] ${v.name} mode changed from ${oldMode} to ${modeToSet}`);
1277
1442
  if (this.config.notifyBattery) {
@@ -1307,11 +1472,6 @@ class ZeroFeedIn extends utils.Adapter {
1307
1472
  val: targetState,
1308
1473
  ack: false,
1309
1474
  });
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
1475
  }
1316
1476
 
1317
1477
  if (consumer.numericDatapoint) {
@@ -1320,13 +1480,12 @@ class ZeroFeedIn extends utils.Adapter {
1320
1480
  val: numVal,
1321
1481
  ack: false,
1322
1482
  });
1323
- await this.setForeignStateAsync(consumer.numericDatapoint, {
1324
- val: numVal,
1325
- ack: true,
1326
- });
1327
1483
  this.log.debug(`Numeric datapoint for "${consumer.name}" set to ${numVal}`);
1328
1484
  }
1329
1485
 
1486
+ consumer.lastStateChange = Date.now();
1487
+ consumer.lastForecastBridgeAt = null;
1488
+
1330
1489
  const msg = `${consumer.name} wurde ${targetState ? 'eingeschaltet' : 'ausgeschaltet'}`;
1331
1490
 
1332
1491
  if (consumer.ruletype === 'heating') {
@@ -1398,12 +1557,57 @@ class ZeroFeedIn extends utils.Adapter {
1398
1557
  };
1399
1558
 
1400
1559
  const doSwitch = async () => {
1560
+ if (!consumer.enabled) {
1561
+ consumer.timer = null;
1562
+ consumer.timerEnd = null;
1563
+ consumer.pendingAction = null;
1564
+ await stopTimerTicker();
1565
+ return;
1566
+ }
1567
+ if (delay > 0 && turnOn) {
1568
+ const mode = await this.getStateAsync(`${this.namespace}.${channelId}.controlMode`);
1569
+ if (mode?.val === 2) {
1570
+ const meter = this.feedInDatapoint ? await this.getForeignStateAsync(this.feedInDatapoint) : null;
1571
+ const rawPower = Number(meter?.val);
1572
+ const surplus = (this.config.feedinNegativ ? -rawPower : rawPower) - (this.config.baseload || 0);
1573
+ const withinWindow = this.timeWithinWindow(consumer.switchOnTime, consumer.switchOffTime);
1574
+ const required = Math.max(Number(consumer.performance) || 0, Number(consumer.switchOnPoint) || 0);
1575
+ if (
1576
+ !meter ||
1577
+ meter.val === null ||
1578
+ !Number.isFinite(surplus) ||
1579
+ !withinWindow ||
1580
+ (surplus < required && !this.isForecastAllowingSwitchOn(consumer, surplus))
1581
+ ) {
1582
+ consumer.timer = null;
1583
+ consumer.timerEnd = null;
1584
+ consumer.pendingAction = null;
1585
+ await stopTimerTicker();
1586
+ return;
1587
+ }
1588
+ } else if (mode?.val !== 1) {
1589
+ consumer.timer = null;
1590
+ consumer.timerEnd = null;
1591
+ consumer.pendingAction = null;
1592
+ await stopTimerTicker();
1593
+ return;
1594
+ }
1595
+ }
1401
1596
  // nur bei heating: zusätzliche Sicherheitschecks
1402
1597
  if (consumer.ruletype === 'heating' && turnOn) {
1403
1598
  const tempState = consumer.temperatureDatapoint
1404
1599
  ? await this.getForeignStateAsync(consumer.temperatureDatapoint)
1405
1600
  : null;
1406
- const currentTemp = tempState?.val !== undefined ? Number(tempState.val) : null;
1601
+ const currentTemp =
1602
+ tempState?.val !== undefined && tempState.val !== null ? Number(tempState.val) : null;
1603
+
1604
+ if (consumer.temperatureDatapoint && (currentTemp === null || !Number.isFinite(currentTemp))) {
1605
+ consumer.timer = null;
1606
+ consumer.timerEnd = null;
1607
+ consumer.pendingAction = null;
1608
+ await stopTimerTicker();
1609
+ return;
1610
+ }
1407
1611
 
1408
1612
  if (
1409
1613
  currentTemp !== null &&
@@ -1674,6 +1878,10 @@ class ZeroFeedIn extends utils.Adapter {
1674
1878
  // =====================================================================
1675
1879
  async onUnload(callback) {
1676
1880
  try {
1881
+ if (this.ruleTimer) {
1882
+ clearInterval(this.ruleTimer);
1883
+ this.ruleTimer = null;
1884
+ }
1677
1885
  if (this.percentTimer) {
1678
1886
  clearTimeout(this.percentTimer);
1679
1887
  }
@@ -1681,6 +1889,10 @@ class ZeroFeedIn extends utils.Adapter {
1681
1889
  clearTimeout(this.batteryTimer);
1682
1890
  }
1683
1891
  for (const v of this.consumerList) {
1892
+ if (v.timerTick) {
1893
+ clearInterval(v.timerTick);
1894
+ v.timerTick = null;
1895
+ }
1684
1896
  if (v.timer) {
1685
1897
  clearTimeout(v.timer);
1686
1898
  v.timer = null;