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/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 = currentMinutes >= onMinutes && currentMinutes < offMinutes;
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
- if (this.percentTimer) {
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 (mode === 0) {
436
- await this.switchConsumerWithDelay(v, false);
437
- } else if (mode === 1) {
438
- 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);
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 = tempState?.val !== undefined ? Number(tempState.val) : null;
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 (!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
+ ) {
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
- `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.`,
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 = tempState?.val !== undefined ? Number(tempState.val) : null;
707
- 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
+ ) {
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 (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
+ ) {
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
- const batteryConsumers = this.consumerList.filter(c => c.ruletype === 'battery');
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: true,
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: true,
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: true,
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 (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
+ ) {
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
- 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;
891
1385
  let powerToSet = 0;
892
1386
  let modeToSet = 0;
893
1387
 
894
- if (surplus <= 0) {
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(surplus, v.performance || 1000);
1396
+ powerToSet = Math.min(availablePower, v.performance || 1000);
902
1397
  modeToSet = 2;
903
1398
  }
904
1399
 
905
- 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
+
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: true,
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: true,
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 = 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
+ }
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;