iobroker.smartloadmanager 0.1.0 → 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/LICENSE +1 -1
- package/README.md +23 -5
- 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 +311 -26
- 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 +114 -11
- package/lib/forecast.js +123 -0
- package/main.js +651 -83
- package/package.json +18 -15
- package/admin/i18n/de/translations.json +0 -110
- package/admin/i18n/en/translations.json +0 -108
- package/admin/i18n/es/translations.json +0 -110
- package/admin/i18n/fr/translations.json +0 -110
- package/admin/i18n/it/translations.json +0 -110
- package/admin/i18n/nl/translations.json +0 -110
- package/admin/i18n/pl/translations.json +0 -110
- package/admin/i18n/pt/translations.json +0 -110
- package/admin/i18n/ru/translations.json +0 -110
- package/admin/i18n/uk/translations.json +0 -110
- package/admin/i18n/zh-cn/translations.json +0 -110
- 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) {
|
|
@@ -18,10 +19,25 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
18
19
|
this.batteryTimer = null;
|
|
19
20
|
this.checkRunning = false;
|
|
20
21
|
|
|
22
|
+
// 🔮 Forecast (PV forecast integration)
|
|
23
|
+
this.forecastEnabled = false;
|
|
24
|
+
this.forecastJsonDatapoint = null;
|
|
25
|
+
this.forecast = {
|
|
26
|
+
power: 0,
|
|
27
|
+
surplus: 0,
|
|
28
|
+
minutes: 0,
|
|
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),
|
|
34
|
+
};
|
|
35
|
+
|
|
21
36
|
this.on('ready', this.onReady.bind(this));
|
|
22
37
|
this.on('stateChange', this.onStateChange.bind(this));
|
|
23
38
|
this.on('unload', this.onUnload.bind(this));
|
|
24
39
|
}
|
|
40
|
+
|
|
25
41
|
name2id(pName) {
|
|
26
42
|
return (pName || '')
|
|
27
43
|
.replace(this.FORBIDDEN_CHARS, '_') // ioBroker verbotene Zeichen
|
|
@@ -31,6 +47,7 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
31
47
|
.replace(/_+/g, '_') // mehrere Unterstriche zusammenfassen
|
|
32
48
|
.replace(/^_+|_+$/g, ''); // führende/trailing _ weg
|
|
33
49
|
}
|
|
50
|
+
|
|
34
51
|
// Wrapper für sichere Timer
|
|
35
52
|
safeSetTimeout(fn, ms, ...args) {
|
|
36
53
|
const safeMs = Math.min(Math.max(ms, 0), this.MAX_TIMEOUT);
|
|
@@ -48,6 +65,9 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
48
65
|
return this.setInterval(fn, safeMs, ...args);
|
|
49
66
|
}
|
|
50
67
|
|
|
68
|
+
// =====================================================================
|
|
69
|
+
// ============================ onReady ================================
|
|
70
|
+
// =====================================================================
|
|
51
71
|
async onReady() {
|
|
52
72
|
try {
|
|
53
73
|
this.log.info(`Adapter started, PID: ${process.pid}`);
|
|
@@ -63,6 +83,13 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
63
83
|
this.batteryControlModeDatapoint = this.config.batteryControlModeDatapoint || null;
|
|
64
84
|
this.log.info(`Configured batteryControlModeDatapoint: ${this.batteryControlModeDatapoint}`);
|
|
65
85
|
|
|
86
|
+
// 🔮 Forecast config (PV forecast integration)
|
|
87
|
+
this.forecastEnabled = !!this.config.enableForecast;
|
|
88
|
+
this.forecastJsonDatapoint = this.config.forecastJsonDatapoint || null;
|
|
89
|
+
if (this.forecastEnabled && this.forecastJsonDatapoint) {
|
|
90
|
+
this.forecast.unit = await this.detectForecastPowerUnit();
|
|
91
|
+
}
|
|
92
|
+
|
|
66
93
|
this.consumerList = Array.isArray(this.config.consumer)
|
|
67
94
|
? this.config.consumer.filter(
|
|
68
95
|
v =>
|
|
@@ -81,12 +108,25 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
81
108
|
v.enabled = state.val;
|
|
82
109
|
this.log.debug(`"${v.name}": Active status taken from object tree (${state.val})`);
|
|
83
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;
|
|
84
118
|
}
|
|
85
119
|
|
|
86
120
|
this.log.info(`Loaded consumers: ${this.consumerList.map(v => v.name).join(', ')}`);
|
|
87
121
|
|
|
88
122
|
await this.checkAndCreateConsumerObjects();
|
|
89
123
|
await this.createConsumerStates();
|
|
124
|
+
|
|
125
|
+
// 🔮 Forecast states (adapter-internal)
|
|
126
|
+
if (this.forecastEnabled) {
|
|
127
|
+
await this.createForecastStates();
|
|
128
|
+
}
|
|
129
|
+
|
|
90
130
|
await this.initializeConsumerStatus();
|
|
91
131
|
await this.subscribeStatesAsync('*');
|
|
92
132
|
await this.subscribeForeignStatesAsync(this.feedInDatapoint);
|
|
@@ -94,7 +134,14 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
94
134
|
await this.subscribeForeignStatesAsync(this.batteryControlModeDatapoint);
|
|
95
135
|
}
|
|
96
136
|
|
|
137
|
+
// 🔮 Forecast subscribe + initial parse
|
|
138
|
+
if (this.forecastEnabled && this.forecastJsonDatapoint) {
|
|
139
|
+
await this.subscribeForeignStatesAsync(this.forecastJsonDatapoint);
|
|
140
|
+
await this.processPvforecastJson();
|
|
141
|
+
}
|
|
142
|
+
|
|
97
143
|
await this.checkConsumers();
|
|
144
|
+
this.ruleTimer = this.safeSetInterval(() => this.checkConsumers(), 15000);
|
|
98
145
|
} catch (error) {
|
|
99
146
|
this.log.error(`Error in onReady: ${error.message}`);
|
|
100
147
|
// additionally send alert
|
|
@@ -102,6 +149,9 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
102
149
|
}
|
|
103
150
|
}
|
|
104
151
|
|
|
152
|
+
// =====================================================================
|
|
153
|
+
// ===================== Consumer object checks ========================
|
|
154
|
+
// =====================================================================
|
|
105
155
|
async checkAndCreateConsumerObjects() {
|
|
106
156
|
for (const v of this.consumerList) {
|
|
107
157
|
if (v.datapoint) {
|
|
@@ -119,6 +169,9 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
119
169
|
}
|
|
120
170
|
}
|
|
121
171
|
|
|
172
|
+
// =====================================================================
|
|
173
|
+
// ========================= createConsumerStates ======================
|
|
174
|
+
// =====================================================================
|
|
122
175
|
async createConsumerStates() {
|
|
123
176
|
// Übergeordnetes Device erstellen - löst "intermediate objects missing" Problem
|
|
124
177
|
await this.setObjectNotExistsAsync('consumer', {
|
|
@@ -250,6 +303,47 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
250
303
|
native: {},
|
|
251
304
|
});
|
|
252
305
|
|
|
306
|
+
// 🔮 Forecast options per consumer
|
|
307
|
+
await this.setObjectNotExistsAsync(`${channelId}.useForecast`, {
|
|
308
|
+
type: 'state',
|
|
309
|
+
common: {
|
|
310
|
+
name: 'Use forecast',
|
|
311
|
+
type: 'boolean',
|
|
312
|
+
role: 'switch',
|
|
313
|
+
read: true,
|
|
314
|
+
write: true,
|
|
315
|
+
def: v.useForecast || false,
|
|
316
|
+
},
|
|
317
|
+
native: {},
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
await this.setObjectNotExistsAsync(`${channelId}.forecastMinMinutes`, {
|
|
321
|
+
type: 'state',
|
|
322
|
+
common: {
|
|
323
|
+
name: 'Forecast min minutes',
|
|
324
|
+
type: 'number',
|
|
325
|
+
role: 'value.interval',
|
|
326
|
+
read: true,
|
|
327
|
+
write: true,
|
|
328
|
+
def: v.forecastMinMinutes || 0,
|
|
329
|
+
unit: 'min',
|
|
330
|
+
},
|
|
331
|
+
native: {},
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
await this.setObjectNotExistsAsync(`${channelId}.forecastStatus`, {
|
|
335
|
+
type: 'state',
|
|
336
|
+
common: {
|
|
337
|
+
name: 'Forecast status',
|
|
338
|
+
type: 'string',
|
|
339
|
+
role: 'text',
|
|
340
|
+
read: true,
|
|
341
|
+
write: false,
|
|
342
|
+
def: '',
|
|
343
|
+
},
|
|
344
|
+
native: {},
|
|
345
|
+
});
|
|
346
|
+
|
|
253
347
|
if (v.ruletype === 'battery') {
|
|
254
348
|
await this.setObjectNotExistsAsync(`${channelId}.batterySetpoint`, {
|
|
255
349
|
type: 'state',
|
|
@@ -328,11 +422,257 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
328
422
|
|
|
329
423
|
await this.setStateAsync(`${channelId}.active`, { val: v.enabled || false, ack: true });
|
|
330
424
|
|
|
425
|
+
// 🔮 Forecast states init per consumer
|
|
426
|
+
await this.setStateAsync(`${channelId}.useForecast`, { val: v.useForecast || false, ack: true });
|
|
427
|
+
await this.setStateAsync(`${channelId}.forecastMinMinutes`, {
|
|
428
|
+
val: v.forecastMinMinutes || 0,
|
|
429
|
+
ack: true,
|
|
430
|
+
});
|
|
431
|
+
await this.setStateAsync(`${channelId}.forecastStatus`, { val: '', ack: true });
|
|
432
|
+
|
|
331
433
|
v.switchOnTime = finalOnTime;
|
|
332
434
|
v.switchOffTime = finalOffTime;
|
|
333
435
|
}
|
|
334
436
|
}
|
|
335
437
|
|
|
438
|
+
// =====================================================================
|
|
439
|
+
// ========================= Forecast states ===========================
|
|
440
|
+
// =====================================================================
|
|
441
|
+
async createForecastStates() {
|
|
442
|
+
await this.setObjectNotExistsAsync('forecast', {
|
|
443
|
+
type: 'channel',
|
|
444
|
+
common: { name: 'Forecast' },
|
|
445
|
+
native: {},
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
await this.setObjectNotExistsAsync('forecast.power', {
|
|
449
|
+
type: 'state',
|
|
450
|
+
common: {
|
|
451
|
+
name: 'Forecast Power',
|
|
452
|
+
type: 'number',
|
|
453
|
+
role: 'value.power',
|
|
454
|
+
read: true,
|
|
455
|
+
write: false,
|
|
456
|
+
unit: 'W',
|
|
457
|
+
def: 0,
|
|
458
|
+
},
|
|
459
|
+
native: {},
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
await this.setObjectNotExistsAsync('forecast.surplus', {
|
|
463
|
+
type: 'state',
|
|
464
|
+
common: {
|
|
465
|
+
name: 'Forecast Surplus',
|
|
466
|
+
type: 'number',
|
|
467
|
+
role: 'value.power',
|
|
468
|
+
read: true,
|
|
469
|
+
write: false,
|
|
470
|
+
unit: 'W',
|
|
471
|
+
def: 0,
|
|
472
|
+
},
|
|
473
|
+
native: {},
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
await this.setObjectNotExistsAsync('forecast.minutes', {
|
|
477
|
+
type: 'state',
|
|
478
|
+
common: {
|
|
479
|
+
name: 'Forecast Minutes',
|
|
480
|
+
type: 'number',
|
|
481
|
+
role: 'value.interval',
|
|
482
|
+
read: true,
|
|
483
|
+
write: false,
|
|
484
|
+
unit: 'min',
|
|
485
|
+
def: 0,
|
|
486
|
+
},
|
|
487
|
+
native: {},
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
await this.setObjectNotExistsAsync('forecast.lastUpdate', {
|
|
491
|
+
type: 'state',
|
|
492
|
+
common: {
|
|
493
|
+
name: 'Forecast Last Update',
|
|
494
|
+
type: 'number',
|
|
495
|
+
role: 'value.time',
|
|
496
|
+
read: true,
|
|
497
|
+
write: false,
|
|
498
|
+
def: 0,
|
|
499
|
+
},
|
|
500
|
+
native: {},
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// =====================================================================
|
|
505
|
+
// ========================= Forecast JSON parse =======================
|
|
506
|
+
// =====================================================================
|
|
507
|
+
async processPvforecastJson() {
|
|
508
|
+
try {
|
|
509
|
+
if (!this.forecastEnabled || !this.forecastJsonDatapoint) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const state = await this.getForeignStateAsync(this.forecastJsonDatapoint);
|
|
514
|
+
if (!state || !state.val) {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
try {
|
|
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}`);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const best = this.forecast.points[0];
|
|
530
|
+
if (!best) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const power = best.power;
|
|
534
|
+
const minutes = Math.max(0, Math.round((best.timestamp - Date.now()) / 60000));
|
|
535
|
+
|
|
536
|
+
this.forecast.power = power;
|
|
537
|
+
this.forecast.surplus = best.surplus;
|
|
538
|
+
this.forecast.minutes = minutes;
|
|
539
|
+
this.forecast.lastUpdate = Date.now();
|
|
540
|
+
|
|
541
|
+
await this.setStateAsync(`${this.namespace}.forecast.power`, { val: power, ack: true });
|
|
542
|
+
await this.setStateAsync(`${this.namespace}.forecast.surplus`, {
|
|
543
|
+
val: this.forecast.surplus,
|
|
544
|
+
ack: true,
|
|
545
|
+
});
|
|
546
|
+
await this.setStateAsync(`${this.namespace}.forecast.minutes`, { val: minutes, ack: true });
|
|
547
|
+
await this.setStateAsync(`${this.namespace}.forecast.lastUpdate`, {
|
|
548
|
+
val: this.forecast.lastUpdate,
|
|
549
|
+
ack: true,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
this.log.debug(
|
|
553
|
+
`[Forecast] ${this.forecast.points.length} values parsed (${this.forecast.unit || 'auto'}) - next: ${power}W in ${minutes}min`,
|
|
554
|
+
);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
this.log.error(`[Forecast] Error processing pvforecast JSON: ${error.message}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
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
|
+
|
|
583
|
+
// =====================================================================
|
|
584
|
+
// ========================= Forecast helpers ==========================
|
|
585
|
+
// =====================================================================
|
|
586
|
+
getForecastPower(_v) {
|
|
587
|
+
return this.forecast?.power || 0;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
getForecastMinutes(_v) {
|
|
591
|
+
return this.forecast?.minutes || 0;
|
|
592
|
+
}
|
|
593
|
+
|
|
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) {
|
|
606
|
+
if (!this.forecastEnabled || !v.useForecast) {
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
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) {
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
const minimumOffMinutes = v.minimumOffMinutes === undefined ? 5 : Number(v.minimumOffMinutes);
|
|
617
|
+
if (!minimumStateTimeSatisfied(v.lastStateChange, minimumOffMinutes)) {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
return this.getForecastDecision(v, requiredPower).available;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
isForecastDelayingSwitchOff(v, currentSurplus, requiredPower) {
|
|
624
|
+
if (!this.forecastEnabled || !v.useForecast) {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
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) {
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
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;
|
|
642
|
+
}
|
|
643
|
+
v.lastForecastBridgeAt ||= Date.now();
|
|
644
|
+
return Date.now() - v.lastForecastBridgeAt <= bridgeMinutes * 60000;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async setForecastState(v, idx, active, reason, power, minutes) {
|
|
648
|
+
try {
|
|
649
|
+
const safeName = this.name2id(v.name);
|
|
650
|
+
const channelId = `consumer.${idx}_${safeName}`;
|
|
651
|
+
const text = active
|
|
652
|
+
? `${reason || 'forecast'} (${power || this.forecast.power || 0}W / ${minutes || this.forecast.minutes || 0}min)`
|
|
653
|
+
: '';
|
|
654
|
+
await this.setStateAsync(`${this.namespace}.${channelId}.forecastStatus`, {
|
|
655
|
+
val: text,
|
|
656
|
+
ack: true,
|
|
657
|
+
});
|
|
658
|
+
} catch (err) {
|
|
659
|
+
this.log.warn(`[Forecast] Failed to set forecastStatus: ${err.message}`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async sendForecastNotification(type, message) {
|
|
664
|
+
try {
|
|
665
|
+
if (this.config.notifyForecast) {
|
|
666
|
+
await this.sendNotification('smartloadmanager', 'notify', message);
|
|
667
|
+
}
|
|
668
|
+
} catch (err) {
|
|
669
|
+
this.log.warn(`[Forecast] Failed to send forecast notification: ${err.message}`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// =====================================================================
|
|
674
|
+
// ========================= Time window helper ========================
|
|
675
|
+
// =====================================================================
|
|
336
676
|
timeWithinWindow(onTime, offTime, name) {
|
|
337
677
|
if (!onTime || !offTime) {
|
|
338
678
|
return true;
|
|
@@ -343,7 +683,11 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
343
683
|
const onMinutes = this.parseTimeToMinutes(onTime);
|
|
344
684
|
const offMinutes = this.parseTimeToMinutes(offTime);
|
|
345
685
|
|
|
346
|
-
const within =
|
|
686
|
+
const within =
|
|
687
|
+
onMinutes === offMinutes ||
|
|
688
|
+
(onMinutes < offMinutes
|
|
689
|
+
? currentMinutes >= onMinutes && currentMinutes < offMinutes
|
|
690
|
+
: currentMinutes >= onMinutes || currentMinutes < offMinutes);
|
|
347
691
|
|
|
348
692
|
if (name) {
|
|
349
693
|
this.log.debug(
|
|
@@ -356,11 +700,23 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
356
700
|
return within;
|
|
357
701
|
}
|
|
358
702
|
|
|
703
|
+
// =====================================================================
|
|
704
|
+
// ========================= onStateChange =============================
|
|
705
|
+
// =====================================================================
|
|
359
706
|
async onStateChange(id, state) {
|
|
360
707
|
if (!state) {
|
|
361
708
|
return;
|
|
362
709
|
}
|
|
363
710
|
|
|
711
|
+
// 🔮 Forecast JSON datapoint changed
|
|
712
|
+
if (this.forecastEnabled && this.forecastJsonDatapoint && id === this.forecastJsonDatapoint) {
|
|
713
|
+
if (state.ack === true) {
|
|
714
|
+
await this.processPvforecastJson();
|
|
715
|
+
await this.checkConsumers();
|
|
716
|
+
}
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
364
720
|
// FeedIn Datapoint - fremder State, nur bei ack=true reagieren
|
|
365
721
|
if (id === this.feedInDatapoint) {
|
|
366
722
|
// Nur verarbeiten wenn der Wert vom anderen Adapter bestätigt wurde
|
|
@@ -368,40 +724,7 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
368
724
|
this.log.debug(`Feed-in value updated: ${state.val} (ack=${state.ack})`);
|
|
369
725
|
await this.checkConsumers();
|
|
370
726
|
|
|
371
|
-
|
|
372
|
-
clearTimeout(this.percentTimer);
|
|
373
|
-
}
|
|
374
|
-
this.percentTimer = this.safeSetTimeout(
|
|
375
|
-
async () => {
|
|
376
|
-
for (const v of this.consumerList.filter(v => v.ruletype === 'percent')) {
|
|
377
|
-
await this.controlPercentConsumer(v);
|
|
378
|
-
}
|
|
379
|
-
this.percentTimer = null;
|
|
380
|
-
},
|
|
381
|
-
(this.config.delaySecondsProzent || this.DEFAULT_TIMEOUT / 1000) * 1000,
|
|
382
|
-
);
|
|
383
|
-
|
|
384
|
-
if (this.batteryTimer) {
|
|
385
|
-
clearTimeout(this.batteryTimer);
|
|
386
|
-
}
|
|
387
|
-
this.batteryTimer = this.safeSetTimeout(
|
|
388
|
-
async () => {
|
|
389
|
-
if (!this.feedInDatapoint) {
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
const feedInState = await this.getForeignStateAsync(this.feedInDatapoint);
|
|
393
|
-
let feedIn = Number(feedInState?.val) || 0;
|
|
394
|
-
if (this.config.feedinNegativ) {
|
|
395
|
-
feedIn = -feedIn;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
for (const v of this.consumerList.filter(v => v.ruletype === 'battery')) {
|
|
399
|
-
await this.controlBattery(v, feedIn);
|
|
400
|
-
}
|
|
401
|
-
this.batteryTimer = null;
|
|
402
|
-
},
|
|
403
|
-
(this.config.batteryDelaySeconds || this.DEFAULT_TIMEOUT / 1000) * 1000,
|
|
404
|
-
);
|
|
727
|
+
this.scheduleRegulation();
|
|
405
728
|
} else {
|
|
406
729
|
this.log.debug(`Feed-in value change ignored (ack=${state.ack}) - waiting for confirmed value`);
|
|
407
730
|
}
|
|
@@ -419,6 +742,16 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
419
742
|
const v = this.consumerList[index];
|
|
420
743
|
if (v) {
|
|
421
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
|
+
}
|
|
422
755
|
this.log.info(`Consumer "${v.name}" became ${state.val ? 'activated' : 'deactivated'}.`);
|
|
423
756
|
await this.setStateAsync(id, { val: state.val, ack: true });
|
|
424
757
|
}
|
|
@@ -432,10 +765,29 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
432
765
|
const mode = state.val;
|
|
433
766
|
const v = this.consumerList[index];
|
|
434
767
|
|
|
435
|
-
if (
|
|
436
|
-
|
|
437
|
-
}
|
|
438
|
-
|
|
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);
|
|
439
791
|
}
|
|
440
792
|
}
|
|
441
793
|
|
|
@@ -450,6 +802,9 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
450
802
|
}
|
|
451
803
|
}
|
|
452
804
|
|
|
805
|
+
// =====================================================================
|
|
806
|
+
// ============================ checkConsumers =========================
|
|
807
|
+
// =====================================================================
|
|
453
808
|
async checkConsumers() {
|
|
454
809
|
if (this.checkRunning) {
|
|
455
810
|
this.checkQueued = true;
|
|
@@ -525,13 +880,18 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
525
880
|
}
|
|
526
881
|
|
|
527
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
|
+
);
|
|
528
887
|
|
|
529
888
|
// Zusatz-Checks heating
|
|
530
889
|
if (v.ruletype === 'heating' && withinWindow && !isOn) {
|
|
531
890
|
const tempState = v.temperatureDatapoint
|
|
532
891
|
? await this.getForeignStateAsync(v.temperatureDatapoint)
|
|
533
892
|
: null;
|
|
534
|
-
const currentTemp =
|
|
893
|
+
const currentTemp =
|
|
894
|
+
tempState?.val !== undefined && tempState.val !== null ? Number(tempState.val) : null;
|
|
535
895
|
|
|
536
896
|
if (currentTemp !== null && v.maxTemperature !== undefined && currentTemp >= v.maxTemperature) {
|
|
537
897
|
this.log.warn(
|
|
@@ -581,7 +941,12 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
581
941
|
}
|
|
582
942
|
|
|
583
943
|
if (withinWindow) {
|
|
584
|
-
if (
|
|
944
|
+
if (
|
|
945
|
+
!gridUsage &&
|
|
946
|
+
Math.max(v.performance, Number(v.switchOnPoint) || 0) <= feedIn - baseload &&
|
|
947
|
+
!isOn &&
|
|
948
|
+
minimumOffSatisfied
|
|
949
|
+
) {
|
|
585
950
|
this.log.debug(
|
|
586
951
|
`Switch-on possible: surplus ${
|
|
587
952
|
feedIn - baseload
|
|
@@ -590,11 +955,27 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
590
955
|
await this.switchConsumerWithDelay(v, true, v.switchOnDelay || 0);
|
|
591
956
|
feedIn -= v.performance;
|
|
592
957
|
this.log.debug(`Remaining surplus after switch-on: ${feedIn - baseload} W`);
|
|
958
|
+
await this.setForecastState(v, idx, false, '', null, null);
|
|
959
|
+
} else if (!gridUsage && !isOn && this.isForecastAllowingSwitchOn(v, feedIn - baseload)) {
|
|
960
|
+
const forecastPower = this.getForecastPower(v);
|
|
961
|
+
const minutes = this.getForecastMinutes(v);
|
|
962
|
+
this.log.debug(
|
|
963
|
+
`[Forecast] Early switch-on allowed for "${v.name}": ${forecastPower} W in ${minutes} min.`,
|
|
964
|
+
);
|
|
965
|
+
await this.setForecastState(v, idx, true, 'forecastSwitchOn', forecastPower, minutes);
|
|
966
|
+
await this.sendForecastNotification(
|
|
967
|
+
v.ruletype === 'percent' ? 'percent' : 'binary',
|
|
968
|
+
`🔮 Forecast: "${v.name}" will be switched on in ${minutes} minutes (${forecastPower} W expected).`,
|
|
969
|
+
);
|
|
970
|
+
await this.switchConsumerWithDelay(v, true, v.switchOnDelay || 0);
|
|
971
|
+
feedIn -= v.performance;
|
|
593
972
|
} else if (gridUsage && !isOn) {
|
|
594
973
|
this.log.debug(`Not switched on: grid usage active.`);
|
|
595
974
|
} else if (!isOn) {
|
|
596
975
|
this.log.debug(
|
|
597
|
-
|
|
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.`,
|
|
598
979
|
);
|
|
599
980
|
} else {
|
|
600
981
|
this.log.debug(`"${v.name}" is already switched on – no action needed.`);
|
|
@@ -703,8 +1084,12 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
703
1084
|
const tempState = v.temperatureDatapoint
|
|
704
1085
|
? await this.getForeignStateAsync(v.temperatureDatapoint)
|
|
705
1086
|
: null;
|
|
706
|
-
const currentTemp =
|
|
707
|
-
|
|
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
|
+
) {
|
|
708
1093
|
this.log.warn(
|
|
709
1094
|
`"${v.name}" switched off due to maxTemperature reached: ${currentTemp}°C >= ${v.maxTemperature}°C`,
|
|
710
1095
|
);
|
|
@@ -715,7 +1100,14 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
715
1100
|
|
|
716
1101
|
// Timer-Abbruch bei wegfallender Bedingung (Ausschalten)
|
|
717
1102
|
const surplus = feedIn - baseload;
|
|
718
|
-
if (
|
|
1103
|
+
if (
|
|
1104
|
+
v.timer &&
|
|
1105
|
+
v.timerEnd &&
|
|
1106
|
+
v.pendingAction === false &&
|
|
1107
|
+
withinWindow &&
|
|
1108
|
+
!gridUsage &&
|
|
1109
|
+
surplus >= v.switchOffPoint
|
|
1110
|
+
) {
|
|
719
1111
|
clearTimeout(v.timer);
|
|
720
1112
|
v.timer = null;
|
|
721
1113
|
v.timerEnd = null;
|
|
@@ -731,8 +1123,42 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
731
1123
|
// - Netzbezug aktiv ODER
|
|
732
1124
|
// - Überschuss < switchOffPoint
|
|
733
1125
|
const shouldSwitchOff = !withinWindow || gridUsage || surplus < v.switchOffPoint;
|
|
1126
|
+
if (!shouldSwitchOff) {
|
|
1127
|
+
v.lastForecastBridgeAt = null;
|
|
1128
|
+
}
|
|
734
1129
|
|
|
735
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
|
+
}
|
|
1147
|
+
// 🔮 Forecast delay switch-off (PV forecast integration)
|
|
1148
|
+
if (this.isForecastDelayingSwitchOff(v, surplus)) {
|
|
1149
|
+
const forecastPower = this.getForecastPower(v);
|
|
1150
|
+
const minutes = this.getForecastMinutes(v);
|
|
1151
|
+
this.log.debug(
|
|
1152
|
+
`[Forecast] Switch-off delayed for "${v.name}": ${forecastPower} W in ${minutes} min.`,
|
|
1153
|
+
);
|
|
1154
|
+
await this.setForecastState(v, idx, true, 'forecastDelayOff', forecastPower, minutes);
|
|
1155
|
+
await this.sendForecastNotification(
|
|
1156
|
+
v.ruletype === 'percent' ? 'percent' : 'binary',
|
|
1157
|
+
`🔮 Forecast: "${v.name}" remains active (${forecastPower} W expected in ${minutes} min).`,
|
|
1158
|
+
);
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
736
1162
|
if (v.timer && v.timerEnd && v.pendingAction === false) {
|
|
737
1163
|
const remaining = Math.max(0, Math.round((v.timerEnd - Date.now()) / 1000));
|
|
738
1164
|
this.log.debug(`Active timer for ${v.name}: ${remaining}s remaining`);
|
|
@@ -741,19 +1167,13 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
741
1167
|
`Switching off: "${v.name}" - surplus ${surplus}W < switchOffPoint ${v.switchOffPoint}W, offDelay ${v.switchOffDelay || 0}s.`,
|
|
742
1168
|
);
|
|
743
1169
|
await this.switchConsumerWithDelay(v, false, v.switchOffDelay || 0);
|
|
1170
|
+
await this.setForecastState(v, idx, false, '', null, null);
|
|
744
1171
|
}
|
|
745
1172
|
}
|
|
746
1173
|
}
|
|
747
1174
|
|
|
748
1175
|
// --- Batterie unverändert ---
|
|
749
|
-
|
|
750
|
-
for (const v of batteryConsumers) {
|
|
751
|
-
if (!v.enabled) {
|
|
752
|
-
this.log.debug(`"${v.name}" is deactivated – battery control skipped.`);
|
|
753
|
-
continue;
|
|
754
|
-
}
|
|
755
|
-
await this.controlBattery(v, feedIn);
|
|
756
|
-
}
|
|
1176
|
+
this.scheduleRegulation();
|
|
757
1177
|
|
|
758
1178
|
const runningTimers = this.consumerList
|
|
759
1179
|
.filter(c => c.timer && c.timerEnd)
|
|
@@ -778,8 +1198,58 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
778
1198
|
}
|
|
779
1199
|
}
|
|
780
1200
|
|
|
1201
|
+
// =====================================================================
|
|
1202
|
+
// ========================= controlPercentConsumer ====================
|
|
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
|
+
|
|
781
1248
|
async controlPercentConsumer(v) {
|
|
782
1249
|
try {
|
|
1250
|
+
if (!(await this.isAutomaticConsumer(v))) {
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
783
1253
|
const withinWindow = this.timeWithinWindow(v.switchOnTime, v.switchOffTime);
|
|
784
1254
|
if (!withinWindow) {
|
|
785
1255
|
this.log.debug(`[Percent] ${v.name} outside time window, set to 0%`);
|
|
@@ -796,25 +1266,32 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
796
1266
|
|
|
797
1267
|
const feedInValue = Number(feedInState?.val) || 0;
|
|
798
1268
|
|
|
799
|
-
let surplus = this.config.feedinNegativ
|
|
800
|
-
? feedInValue < 0
|
|
801
|
-
? -feedInValue
|
|
802
|
-
: 0
|
|
803
|
-
: feedInValue > 0
|
|
804
|
-
? feedInValue
|
|
805
|
-
: 0;
|
|
1269
|
+
let surplus = this.config.feedinNegativ ? -feedInValue : feedInValue;
|
|
806
1270
|
|
|
807
1271
|
surplus -= this.config.baseload || 0;
|
|
1272
|
+
const currentSurplus = surplus;
|
|
808
1273
|
if (surplus < 0) {
|
|
809
1274
|
surplus = 0;
|
|
810
1275
|
}
|
|
811
1276
|
|
|
812
1277
|
const maxPerformance = v.maxPerformance || v.performance || 1000;
|
|
813
|
-
let newPercent = Math.round((surplus / maxPerformance) * 100);
|
|
814
|
-
newPercent = Math.min(100, Math.max(newPercent, v.minPercentStart || 0));
|
|
815
|
-
|
|
816
1278
|
const state = await this.getForeignStateAsync(v.datapoint);
|
|
817
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
|
+
}
|
|
818
1295
|
|
|
819
1296
|
if (newPercent !== currentPercent) {
|
|
820
1297
|
await this.setForeignStateAsync(v.datapoint, newPercent);
|
|
@@ -840,8 +1317,14 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
840
1317
|
}
|
|
841
1318
|
}
|
|
842
1319
|
|
|
1320
|
+
// =====================================================================
|
|
1321
|
+
// =============================== controlBattery ======================
|
|
1322
|
+
// =====================================================================
|
|
843
1323
|
async controlBattery(v, feedIn) {
|
|
844
1324
|
try {
|
|
1325
|
+
if (!(await this.isAutomaticConsumer(v))) {
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
845
1328
|
this.log.debug(`[Battery] Checking for ${v.name}`);
|
|
846
1329
|
|
|
847
1330
|
const withinWindow = this.timeWithinWindow(v.switchOnTime, v.switchOffTime);
|
|
@@ -850,13 +1333,13 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
850
1333
|
if (v.batterySetpoint) {
|
|
851
1334
|
await this.setForeignStateAsync(v.batterySetpoint, {
|
|
852
1335
|
val: 0,
|
|
853
|
-
ack:
|
|
1336
|
+
ack: false,
|
|
854
1337
|
});
|
|
855
1338
|
}
|
|
856
1339
|
if (this.batteryControlModeDatapoint) {
|
|
857
1340
|
await this.setForeignStateAsync(this.batteryControlModeDatapoint, {
|
|
858
1341
|
val: 0,
|
|
859
|
-
ack:
|
|
1342
|
+
ack: false,
|
|
860
1343
|
});
|
|
861
1344
|
}
|
|
862
1345
|
return;
|
|
@@ -867,7 +1350,7 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
867
1350
|
if (this.batteryControlModeDatapoint) {
|
|
868
1351
|
await this.setForeignStateAsync(this.batteryControlModeDatapoint, {
|
|
869
1352
|
val: 0,
|
|
870
|
-
ack:
|
|
1353
|
+
ack: false,
|
|
871
1354
|
});
|
|
872
1355
|
}
|
|
873
1356
|
return;
|
|
@@ -875,34 +1358,60 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
875
1358
|
|
|
876
1359
|
let soc = null;
|
|
877
1360
|
let targetSoc = null;
|
|
1361
|
+
let invalidSoc = false;
|
|
878
1362
|
if (v.batterySOC && v.batteryTargetSOC) {
|
|
879
1363
|
const socState = await this.getForeignStateAsync(v.batterySOC);
|
|
880
1364
|
const targetSocState = await this.getForeignStateAsync(v.batteryTargetSOC);
|
|
881
1365
|
soc = Number(socState?.val);
|
|
882
1366
|
targetSoc = Number(targetSocState?.val);
|
|
883
|
-
if (
|
|
1367
|
+
if (
|
|
1368
|
+
!socState ||
|
|
1369
|
+
socState.val === null ||
|
|
1370
|
+
!targetSocState ||
|
|
1371
|
+
targetSocState.val === null ||
|
|
1372
|
+
!Number.isFinite(soc) ||
|
|
1373
|
+
!Number.isFinite(targetSoc)
|
|
1374
|
+
) {
|
|
884
1375
|
this.log.warn(`[Battery] ${v.name}: invalid SOC or targetSOC`);
|
|
885
1376
|
soc = null;
|
|
886
1377
|
targetSoc = null;
|
|
1378
|
+
invalidSoc = true;
|
|
887
1379
|
}
|
|
888
1380
|
}
|
|
889
1381
|
|
|
890
|
-
|
|
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;
|
|
891
1385
|
let powerToSet = 0;
|
|
892
1386
|
let modeToSet = 0;
|
|
893
1387
|
|
|
894
|
-
|
|
1388
|
+
const availablePower = Math.max(0, oldPower + surplus);
|
|
1389
|
+
if (availablePower <= 0 || invalidSoc) {
|
|
895
1390
|
powerToSet = 0;
|
|
896
1391
|
modeToSet = 0;
|
|
897
1392
|
} else if (soc !== null && targetSoc !== null && soc >= targetSoc) {
|
|
898
1393
|
powerToSet = 0;
|
|
899
1394
|
modeToSet = 1;
|
|
900
1395
|
} else {
|
|
901
|
-
powerToSet = Math.min(
|
|
1396
|
+
powerToSet = Math.min(availablePower, v.performance || 1000);
|
|
902
1397
|
modeToSet = 2;
|
|
903
1398
|
}
|
|
904
1399
|
|
|
905
|
-
|
|
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
|
+
|
|
906
1415
|
const oldMode = this.batteryControlModeDatapoint
|
|
907
1416
|
? Number((await this.getForeignStateAsync(this.batteryControlModeDatapoint))?.val) || 0
|
|
908
1417
|
: -1;
|
|
@@ -910,7 +1419,7 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
910
1419
|
if (powerToSet !== oldPower) {
|
|
911
1420
|
await this.setForeignStateAsync(v.batterySetpoint, {
|
|
912
1421
|
val: powerToSet,
|
|
913
|
-
ack:
|
|
1422
|
+
ack: false,
|
|
914
1423
|
});
|
|
915
1424
|
this.log.info(`Battery ${v.name}: charging power set to ${powerToSet}W`);
|
|
916
1425
|
if (this.config.notifyBattery) {
|
|
@@ -927,7 +1436,7 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
927
1436
|
if (this.batteryControlModeDatapoint && modeToSet !== oldMode) {
|
|
928
1437
|
await this.setForeignStateAsync(this.batteryControlModeDatapoint, {
|
|
929
1438
|
val: modeToSet,
|
|
930
|
-
ack:
|
|
1439
|
+
ack: false,
|
|
931
1440
|
});
|
|
932
1441
|
this.log.debug(`[Battery] ${v.name} mode changed from ${oldMode} to ${modeToSet}`);
|
|
933
1442
|
if (this.config.notifyBattery) {
|
|
@@ -963,11 +1472,6 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
963
1472
|
val: targetState,
|
|
964
1473
|
ack: false,
|
|
965
1474
|
});
|
|
966
|
-
// Danach sofort bestätigen (falls keine Rückmeldung vom Gerät kommt)
|
|
967
|
-
await this.setForeignStateAsync(consumer.datapoint, {
|
|
968
|
-
val: targetState,
|
|
969
|
-
ack: true,
|
|
970
|
-
});
|
|
971
1475
|
}
|
|
972
1476
|
|
|
973
1477
|
if (consumer.numericDatapoint) {
|
|
@@ -976,13 +1480,12 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
976
1480
|
val: numVal,
|
|
977
1481
|
ack: false,
|
|
978
1482
|
});
|
|
979
|
-
await this.setForeignStateAsync(consumer.numericDatapoint, {
|
|
980
|
-
val: numVal,
|
|
981
|
-
ack: true,
|
|
982
|
-
});
|
|
983
1483
|
this.log.debug(`Numeric datapoint for "${consumer.name}" set to ${numVal}`);
|
|
984
1484
|
}
|
|
985
1485
|
|
|
1486
|
+
consumer.lastStateChange = Date.now();
|
|
1487
|
+
consumer.lastForecastBridgeAt = null;
|
|
1488
|
+
|
|
986
1489
|
const msg = `${consumer.name} wurde ${targetState ? 'eingeschaltet' : 'ausgeschaltet'}`;
|
|
987
1490
|
|
|
988
1491
|
if (consumer.ruletype === 'heating') {
|
|
@@ -996,6 +1499,9 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
996
1499
|
}
|
|
997
1500
|
}
|
|
998
1501
|
|
|
1502
|
+
// =====================================================================
|
|
1503
|
+
// ========================= switchConsumerWithDelay ===================
|
|
1504
|
+
// =====================================================================
|
|
999
1505
|
async switchConsumerWithDelay(consumer, turnOn, delaySeconds) {
|
|
1000
1506
|
const delay =
|
|
1001
1507
|
delaySeconds !== undefined
|
|
@@ -1051,12 +1557,57 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
1051
1557
|
};
|
|
1052
1558
|
|
|
1053
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
|
+
}
|
|
1054
1596
|
// nur bei heating: zusätzliche Sicherheitschecks
|
|
1055
1597
|
if (consumer.ruletype === 'heating' && turnOn) {
|
|
1056
1598
|
const tempState = consumer.temperatureDatapoint
|
|
1057
1599
|
? await this.getForeignStateAsync(consumer.temperatureDatapoint)
|
|
1058
1600
|
: null;
|
|
1059
|
-
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
|
+
}
|
|
1060
1611
|
|
|
1061
1612
|
if (
|
|
1062
1613
|
currentTemp !== null &&
|
|
@@ -1160,6 +1711,9 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
1160
1711
|
}
|
|
1161
1712
|
}
|
|
1162
1713
|
|
|
1714
|
+
// =====================================================================
|
|
1715
|
+
// ========================= sendNotification ==========================
|
|
1716
|
+
// =====================================================================
|
|
1163
1717
|
// Zentrale, einheitliche Notification-Funktion
|
|
1164
1718
|
// Aufruf: await this.sendNotification('smartloadmanager', 'info'|'notify'|'alert', 'Nachricht');
|
|
1165
1719
|
async sendNotification(scope, category, message) {
|
|
@@ -1286,6 +1840,9 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
1286
1840
|
}
|
|
1287
1841
|
}
|
|
1288
1842
|
|
|
1843
|
+
// =====================================================================
|
|
1844
|
+
// ========================= initializeConsumerStatus ==================
|
|
1845
|
+
// =====================================================================
|
|
1289
1846
|
async initializeConsumerStatus() {
|
|
1290
1847
|
// Initiale Status-Updates
|
|
1291
1848
|
for (const v of this.consumerList) {
|
|
@@ -1316,8 +1873,15 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
1316
1873
|
return h * 60 + m;
|
|
1317
1874
|
}
|
|
1318
1875
|
|
|
1876
|
+
// =====================================================================
|
|
1877
|
+
// =============================== onUnload ============================
|
|
1878
|
+
// =====================================================================
|
|
1319
1879
|
async onUnload(callback) {
|
|
1320
1880
|
try {
|
|
1881
|
+
if (this.ruleTimer) {
|
|
1882
|
+
clearInterval(this.ruleTimer);
|
|
1883
|
+
this.ruleTimer = null;
|
|
1884
|
+
}
|
|
1321
1885
|
if (this.percentTimer) {
|
|
1322
1886
|
clearTimeout(this.percentTimer);
|
|
1323
1887
|
}
|
|
@@ -1325,6 +1889,10 @@ class ZeroFeedIn extends utils.Adapter {
|
|
|
1325
1889
|
clearTimeout(this.batteryTimer);
|
|
1326
1890
|
}
|
|
1327
1891
|
for (const v of this.consumerList) {
|
|
1892
|
+
if (v.timerTick) {
|
|
1893
|
+
clearInterval(v.timerTick);
|
|
1894
|
+
v.timerTick = null;
|
|
1895
|
+
}
|
|
1328
1896
|
if (v.timer) {
|
|
1329
1897
|
clearTimeout(v.timer);
|
|
1330
1898
|
v.timer = null;
|