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/README.md +19 -0
- package/admin/i18n/de.json +130 -0
- package/admin/i18n/en.json +130 -0
- package/admin/i18n/es.json +130 -0
- package/admin/i18n/fr.json +130 -0
- package/admin/i18n/it.json +130 -0
- package/admin/i18n/nl.json +130 -0
- package/admin/i18n/pl.json +130 -0
- package/admin/i18n/pt.json +130 -0
- package/admin/i18n/ru.json +130 -0
- package/admin/i18n/uk.json +130 -0
- package/admin/i18n/zh-cn.json +130 -0
- package/admin/jsonConfig.json +196 -5
- package/docs/de/README.md +283 -0
- package/docs/de/release-0.2.0-pruefung.md +74 -0
- package/docs/en/README.md +268 -0
- package/io-package.json +94 -25
- package/lib/forecast.js +123 -0
- package/main.js +337 -125
- package/package.json +18 -15
- package/admin/i18n/de/translations.json +0 -124
- package/admin/i18n/en/translations.json +0 -116
- package/admin/i18n/es/translations.json +0 -124
- package/admin/i18n/fr/translations.json +0 -124
- package/admin/i18n/it/translations.json +0 -124
- package/admin/i18n/nl/translations.json +0 -124
- package/admin/i18n/pl/translations.json +0 -124
- package/admin/i18n/pt/translations.json +0 -124
- package/admin/i18n/ru/translations.json +0 -124
- package/admin/i18n/uk/translations.json +0 -124
- package/admin/i18n/zh-cn/translations.json +0 -124
- package/lib/adapter-config.d.ts +0 -20
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
|
577
|
-
|
|
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
|
-
|
|
581
|
-
|
|
616
|
+
const minimumOffMinutes = v.minimumOffMinutes === undefined ? 5 : Number(v.minimumOffMinutes);
|
|
617
|
+
if (!minimumStateTimeSatisfied(v.lastStateChange, minimumOffMinutes)) {
|
|
618
|
+
return false;
|
|
582
619
|
}
|
|
583
|
-
return
|
|
620
|
+
return this.getForecastDecision(v, requiredPower).available;
|
|
584
621
|
}
|
|
585
622
|
|
|
586
|
-
isForecastDelayingSwitchOff(v,
|
|
623
|
+
isForecastDelayingSwitchOff(v, currentSurplus, requiredPower) {
|
|
587
624
|
if (!this.forecastEnabled || !v.useForecast) {
|
|
588
625
|
return false;
|
|
589
626
|
}
|
|
590
|
-
const
|
|
591
|
-
|
|
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
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
741
|
-
|
|
742
|
-
}
|
|
743
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
1029
|
-
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1396
|
+
powerToSet = Math.min(availablePower, v.performance || 1000);
|
|
1246
1397
|
modeToSet = 2;
|
|
1247
1398
|
}
|
|
1248
1399
|
|
|
1249
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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;
|