iobroker.utility-monitor 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -8
- package/io-package.json +31 -53
- package/lib/billingManager.js +333 -49
- package/lib/calculator.js +27 -13
- package/lib/multiMeterManager.js +192 -2
- package/lib/state/history.js +95 -0
- package/lib/state/meter.js +605 -0
- package/lib/state/roles.js +16 -0
- package/lib/state/totals.js +136 -0
- package/lib/state/utility.js +650 -0
- package/lib/stateManager.js +8 -2046
- package/lib/utils/helpers.js +56 -0
- package/lib/utils/stateCache.js +147 -0
- package/main.js +2 -6
- package/package.json +1 -1
package/lib/calculator.js
CHANGED
|
@@ -1,23 +1,34 @@
|
|
|
1
1
|
const helpers = require('./utils/helpers');
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* @file Calculator module for utility consumption and cost calculations.
|
|
5
|
+
* Contains functions for gas conversion, cost calculation, and tariff handling.
|
|
6
|
+
* @module calculator
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Converts gas volume from cubic meters (m³) to kilowatt-hours (kWh).
|
|
11
|
+
* The conversion uses the standard German gas billing formula:
|
|
12
|
+
* kWh = m³ × Brennwert (calorific value) × Z-Zahl (state number)
|
|
13
|
+
* Brennwert: Energy content per cubic meter, typically 9.5-11.5 kWh/m³.
|
|
14
|
+
* Z-Zahl: Correction factor for temperature and pressure differences.
|
|
6
15
|
*
|
|
7
|
-
* @param {number} m3 -
|
|
8
|
-
* @param {number} brennwert - Calorific value
|
|
9
|
-
* @param {number} zZahl -
|
|
10
|
-
* @returns {number} Energy in kWh
|
|
16
|
+
* @param {number} m3 - Gas volume in cubic meters (must be >= 0)
|
|
17
|
+
* @param {number} brennwert - Calorific value in kWh/m³ (must be > 0)
|
|
18
|
+
* @param {number} zZahl - State number (must be > 0 and <= 1)
|
|
19
|
+
* @returns {number} Energy consumption in kWh
|
|
20
|
+
* @throws {RangeError} If parameters are outside valid ranges
|
|
11
21
|
*/
|
|
12
22
|
function convertGasM3ToKWh(m3, brennwert = 11.5, zZahl = 0.95) {
|
|
13
|
-
// Hier number zu string
|
|
14
23
|
const cleanM3 = helpers.ensureNumber(m3);
|
|
15
24
|
const cleanBrennwert = helpers.ensureNumber(brennwert);
|
|
16
25
|
const cleanZZahl = helpers.ensureNumber(zZahl);
|
|
17
26
|
|
|
18
|
-
//
|
|
27
|
+
// Validate parameters
|
|
19
28
|
if (cleanM3 < 0 || cleanBrennwert <= 0 || cleanZZahl <= 0 || cleanZZahl > 1) {
|
|
20
|
-
throw new RangeError(
|
|
29
|
+
throw new RangeError(
|
|
30
|
+
'Invalid parameters for gas conversion: m3 must be >= 0, brennwert must be > 0, zZahl must be > 0 and <= 1',
|
|
31
|
+
);
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
return cleanM3 * cleanBrennwert * cleanZZahl;
|
|
@@ -53,11 +64,14 @@ function calculateCost(consumption, price) {
|
|
|
53
64
|
}
|
|
54
65
|
|
|
55
66
|
/**
|
|
56
|
-
* Checks if the current time
|
|
67
|
+
* Checks if the current time falls within the High Tariff (HT) period.
|
|
68
|
+
* German electricity providers often offer dual-tariff rates:
|
|
69
|
+
* HT (Haupttarif): Higher rate during peak hours (typically 6:00-22:00)
|
|
70
|
+
* NT (Nebentarif): Lower rate during off-peak hours (typically 22:00-6:00)
|
|
57
71
|
*
|
|
58
|
-
* @param {object} config - Adapter configuration
|
|
59
|
-
* @param {string} type - Utility type: 'gas' or '
|
|
60
|
-
* @returns {boolean} True if current time is HT, false
|
|
72
|
+
* @param {object} config - Adapter configuration object with HT/NT settings
|
|
73
|
+
* @param {string} type - Utility type identifier: 'gas', 'strom', 'wasser', or 'pv'
|
|
74
|
+
* @returns {boolean} True if current time is within HT period, false for NT
|
|
61
75
|
*/
|
|
62
76
|
function isHTTime(config, type) {
|
|
63
77
|
if (!config || !type) {
|
package/lib/multiMeterManager.js
CHANGED
|
@@ -227,7 +227,7 @@ class MultiMeterManager {
|
|
|
227
227
|
const timestampRoles = ['lastDayStart', 'lastWeekStart', 'lastMonthStart', 'lastYearStart'];
|
|
228
228
|
|
|
229
229
|
for (const role of timestampRoles) {
|
|
230
|
-
const statePath = `${basePath}.statistics.${role}`;
|
|
230
|
+
const statePath = `${basePath}.statistics.timestamps.${role}`;
|
|
231
231
|
const state = await this.adapter.getStateAsync(statePath);
|
|
232
232
|
|
|
233
233
|
if (role === 'lastYearStart') {
|
|
@@ -307,9 +307,148 @@ class MultiMeterManager {
|
|
|
307
307
|
// Initial cost calculation
|
|
308
308
|
await this.updateCosts(type, meterName, config);
|
|
309
309
|
|
|
310
|
+
// Reconstruct weekly consumption from daily values if needed
|
|
311
|
+
await this.reconstructPeriodConsumption(type, meterName, basePath);
|
|
312
|
+
|
|
310
313
|
this.adapter.log.debug(`Meter initialization completed for ${type}.${meterName}`);
|
|
311
314
|
}
|
|
312
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Reconstructs weekly and monthly consumption values after adapter restart
|
|
318
|
+
* This fixes data loss when adapter was offline and missed delta accumulation
|
|
319
|
+
*
|
|
320
|
+
* @param {string} type - Utility type
|
|
321
|
+
* @param {string} meterName - Meter name
|
|
322
|
+
* @param {string} basePath - State base path
|
|
323
|
+
*/
|
|
324
|
+
async reconstructPeriodConsumption(type, meterName, basePath) {
|
|
325
|
+
const now = Date.now();
|
|
326
|
+
|
|
327
|
+
// Get period start timestamps
|
|
328
|
+
const lastWeekStartState = await this.adapter.getStateAsync(`${basePath}.statistics.timestamps.lastWeekStart`);
|
|
329
|
+
const lastDayStartState = await this.adapter.getStateAsync(`${basePath}.statistics.timestamps.lastDayStart`);
|
|
330
|
+
|
|
331
|
+
if (!lastWeekStartState?.val || !lastDayStartState?.val) {
|
|
332
|
+
this.adapter.log.debug(`[${basePath}] No period timestamps found, skipping reconstruction`);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const lastWeekStart = lastWeekStartState.val;
|
|
337
|
+
const lastDayStart = lastDayStartState.val;
|
|
338
|
+
|
|
339
|
+
// Calculate days since week start
|
|
340
|
+
const daysSinceWeekStart = (now - lastWeekStart) / (24 * 60 * 60 * 1000);
|
|
341
|
+
|
|
342
|
+
// Only reconstruct if we're within a valid week (0-7 days)
|
|
343
|
+
if (daysSinceWeekStart < 0 || daysSinceWeekStart > 7) {
|
|
344
|
+
this.adapter.log.debug(
|
|
345
|
+
`[${basePath}] Week period out of range (${daysSinceWeekStart.toFixed(1)} days), skipping reconstruction`,
|
|
346
|
+
);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Get current consumption values
|
|
351
|
+
const weeklyState = await this.adapter.getStateAsync(`${basePath}.consumption.weekly`);
|
|
352
|
+
const dailyState = await this.adapter.getStateAsync(`${basePath}.consumption.daily`);
|
|
353
|
+
const lastDayState = await this.adapter.getStateAsync(`${basePath}.statistics.consumption.lastDay`);
|
|
354
|
+
|
|
355
|
+
const currentWeekly = weeklyState?.val || 0;
|
|
356
|
+
const currentDaily = dailyState?.val || 0;
|
|
357
|
+
const lastDay = lastDayState?.val || 0;
|
|
358
|
+
|
|
359
|
+
// Calculate expected weekly based on lastDay values accumulated since lastWeekStart
|
|
360
|
+
// Simple approach: If daily counter was reset today and we have lastDay,
|
|
361
|
+
// weekly should be at least lastDay + currentDaily
|
|
362
|
+
const daysSinceDayReset = (now - lastDayStart) / (24 * 60 * 60 * 1000);
|
|
363
|
+
|
|
364
|
+
// If daily was reset (daysSinceDayReset < 1) and weekly seems too low
|
|
365
|
+
if (daysSinceDayReset < 1 && currentWeekly < lastDay + currentDaily) {
|
|
366
|
+
// Weekly might have missed the lastDay value
|
|
367
|
+
// This can happen if adapter restarted after daily reset
|
|
368
|
+
const reconstructedWeekly = calculator.roundToDecimals(currentWeekly + lastDay, 2);
|
|
369
|
+
|
|
370
|
+
if (reconstructedWeekly > currentWeekly) {
|
|
371
|
+
this.adapter.log.info(
|
|
372
|
+
`[${basePath}] Reconstructing weekly: ${currentWeekly} -> ${reconstructedWeekly} (added lastDay: ${lastDay})`,
|
|
373
|
+
);
|
|
374
|
+
await this.adapter.setStateAsync(`${basePath}.consumption.weekly`, reconstructedWeekly, true);
|
|
375
|
+
|
|
376
|
+
// Also reconstruct gas volume if applicable
|
|
377
|
+
if (type === 'gas') {
|
|
378
|
+
const weeklyVolumeState = await this.adapter.getStateAsync(`${basePath}.consumption.weeklyVolume`);
|
|
379
|
+
const lastDayVolumeState = await this.adapter.getStateAsync(
|
|
380
|
+
`${basePath}.statistics.consumption.lastDayVolume`,
|
|
381
|
+
);
|
|
382
|
+
const currentWeeklyVolume = weeklyVolumeState?.val || 0;
|
|
383
|
+
const lastDayVolume = lastDayVolumeState?.val || 0;
|
|
384
|
+
|
|
385
|
+
if (lastDayVolume > 0) {
|
|
386
|
+
const reconstructedWeeklyVolume = calculator.roundToDecimals(
|
|
387
|
+
currentWeeklyVolume + lastDayVolume,
|
|
388
|
+
4,
|
|
389
|
+
);
|
|
390
|
+
await this.adapter.setStateAsync(
|
|
391
|
+
`${basePath}.consumption.weeklyVolume`,
|
|
392
|
+
reconstructedWeeklyVolume,
|
|
393
|
+
true,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Similar logic for monthly reconstruction
|
|
401
|
+
const lastMonthStartState = await this.adapter.getStateAsync(
|
|
402
|
+
`${basePath}.statistics.timestamps.lastMonthStart`,
|
|
403
|
+
);
|
|
404
|
+
if (lastMonthStartState?.val) {
|
|
405
|
+
const lastMonthStart = lastMonthStartState.val;
|
|
406
|
+
const daysSinceMonthStart = (now - lastMonthStart) / (24 * 60 * 60 * 1000);
|
|
407
|
+
|
|
408
|
+
// Only if within valid month range (0-31 days)
|
|
409
|
+
if (daysSinceMonthStart >= 0 && daysSinceMonthStart <= 31) {
|
|
410
|
+
const monthlyState = await this.adapter.getStateAsync(`${basePath}.consumption.monthly`);
|
|
411
|
+
const currentMonthly = monthlyState?.val || 0;
|
|
412
|
+
|
|
413
|
+
// If daily was reset and monthly seems to be missing the lastDay
|
|
414
|
+
if (daysSinceDayReset < 1 && currentMonthly < lastDay + currentDaily) {
|
|
415
|
+
const reconstructedMonthly = calculator.roundToDecimals(currentMonthly + lastDay, 2);
|
|
416
|
+
|
|
417
|
+
if (reconstructedMonthly > currentMonthly) {
|
|
418
|
+
this.adapter.log.info(
|
|
419
|
+
`[${basePath}] Reconstructing monthly: ${currentMonthly} -> ${reconstructedMonthly} (added lastDay: ${lastDay})`,
|
|
420
|
+
);
|
|
421
|
+
await this.adapter.setStateAsync(`${basePath}.consumption.monthly`, reconstructedMonthly, true);
|
|
422
|
+
|
|
423
|
+
// Also reconstruct gas volume if applicable
|
|
424
|
+
if (type === 'gas') {
|
|
425
|
+
const monthlyVolumeState = await this.adapter.getStateAsync(
|
|
426
|
+
`${basePath}.consumption.monthlyVolume`,
|
|
427
|
+
);
|
|
428
|
+
const lastDayVolumeState = await this.adapter.getStateAsync(
|
|
429
|
+
`${basePath}.statistics.consumption.lastDayVolume`,
|
|
430
|
+
);
|
|
431
|
+
const currentMonthlyVolume = monthlyVolumeState?.val || 0;
|
|
432
|
+
const lastDayVolume = lastDayVolumeState?.val || 0;
|
|
433
|
+
|
|
434
|
+
if (lastDayVolume > 0) {
|
|
435
|
+
const reconstructedMonthlyVolume = calculator.roundToDecimals(
|
|
436
|
+
currentMonthlyVolume + lastDayVolume,
|
|
437
|
+
4,
|
|
438
|
+
);
|
|
439
|
+
await this.adapter.setStateAsync(
|
|
440
|
+
`${basePath}.consumption.monthlyVolume`,
|
|
441
|
+
reconstructedMonthlyVolume,
|
|
442
|
+
true,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
313
452
|
/**
|
|
314
453
|
* Handles sensor value updates
|
|
315
454
|
*
|
|
@@ -463,6 +602,10 @@ class MultiMeterManager {
|
|
|
463
602
|
if (recoveredValue > 0 && Math.abs(consumption - recoveredValue) < 100) {
|
|
464
603
|
this.adapter.log.info(`[${basePath}] Recovered persistent baseline: ${recoveredValue}`);
|
|
465
604
|
this.lastSensorValues[sensorDP] = recoveredValue;
|
|
605
|
+
|
|
606
|
+
// Validate period consumption values against spike threshold
|
|
607
|
+
// This catches cases where old consumption values are unrealistically high
|
|
608
|
+
await this._validatePeriodConsumption(type, basePath, now);
|
|
466
609
|
} else {
|
|
467
610
|
if (recoveredValue > 0) {
|
|
468
611
|
this.adapter.log.warn(
|
|
@@ -479,6 +622,9 @@ class MultiMeterManager {
|
|
|
479
622
|
await this.adapter.setStateAsync(`${basePath}.info.meterReadingVolume`, consumptionM3 || 0, true);
|
|
480
623
|
}
|
|
481
624
|
|
|
625
|
+
// On baseline reset, validate and potentially reset period consumption values
|
|
626
|
+
await this._validatePeriodConsumption(type, basePath, now);
|
|
627
|
+
|
|
482
628
|
if (config.initialReading > 0) {
|
|
483
629
|
await this.calculateAbsoluteYearly(type, meterName, config, consumption, consumptionM3 || 0, now);
|
|
484
630
|
await this.updateCosts(type, meterName, config);
|
|
@@ -489,6 +635,50 @@ class MultiMeterManager {
|
|
|
489
635
|
await this.adapter.setStateAsync(`${basePath}.consumption.lastUpdate`, now, true);
|
|
490
636
|
}
|
|
491
637
|
|
|
638
|
+
/**
|
|
639
|
+
* Validates period consumption values and resets them if they exceed the spike threshold.
|
|
640
|
+
* This prevents unrealistic values after adapter restart or database inconsistencies.
|
|
641
|
+
*
|
|
642
|
+
* @param {string} type - Utility type
|
|
643
|
+
* @param {string} basePath - State base path
|
|
644
|
+
* @param {number} now - Current timestamp
|
|
645
|
+
*/
|
|
646
|
+
async _validatePeriodConsumption(type, basePath, now) {
|
|
647
|
+
const spikeThreshold = this.adapter.config.sensorSpikeThreshold || DEFAULT_SPIKE_THRESHOLD;
|
|
648
|
+
|
|
649
|
+
// Check and reset period consumption values that exceed the spike threshold
|
|
650
|
+
const periods = ['daily', 'weekly', 'monthly'];
|
|
651
|
+
for (const period of periods) {
|
|
652
|
+
const state = await this.adapter.getStateAsync(`${basePath}.consumption.${period}`);
|
|
653
|
+
const value = state?.val || 0;
|
|
654
|
+
|
|
655
|
+
// Get period start timestamp to calculate expected max consumption
|
|
656
|
+
const periodKey =
|
|
657
|
+
period === 'daily' ? 'Day' : period === 'weekly' ? 'Week' : period === 'monthly' ? 'Month' : 'Year';
|
|
658
|
+
const periodStartState = await this.adapter.getStateAsync(
|
|
659
|
+
`${basePath}.statistics.timestamps.last${periodKey}Start`,
|
|
660
|
+
);
|
|
661
|
+
const periodStart = periodStartState?.val || now;
|
|
662
|
+
const daysSincePeriodStart = (now - periodStart) / (24 * 60 * 60 * 1000);
|
|
663
|
+
|
|
664
|
+
// Calculate reasonable max: spike threshold per day * days in period
|
|
665
|
+
// Add buffer of 2x for safety
|
|
666
|
+
const maxReasonableConsumption = spikeThreshold * Math.max(1, daysSincePeriodStart) * 2;
|
|
667
|
+
|
|
668
|
+
if (value > maxReasonableConsumption) {
|
|
669
|
+
this.adapter.log.warn(
|
|
670
|
+
`[${basePath}] Resetting ${period} consumption: ${value} exceeds reasonable max of ${maxReasonableConsumption.toFixed(0)} (${daysSincePeriodStart.toFixed(1)} days * ${spikeThreshold} threshold * 2)`,
|
|
671
|
+
);
|
|
672
|
+
await this.adapter.setStateAsync(`${basePath}.consumption.${period}`, 0, true);
|
|
673
|
+
|
|
674
|
+
// Also reset volume states for gas
|
|
675
|
+
if (type === 'gas') {
|
|
676
|
+
await this.adapter.setStateAsync(`${basePath}.consumption.${period}Volume`, 0, true);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
492
682
|
/**
|
|
493
683
|
* Handles meter reset or replacement condition
|
|
494
684
|
*
|
|
@@ -798,7 +988,7 @@ class MultiMeterManager {
|
|
|
798
988
|
* @returns {Promise<number>} Months since start (at least 1)
|
|
799
989
|
*/
|
|
800
990
|
async _calculateMonthsSinceYearStart(basePath) {
|
|
801
|
-
const yearStartState = await this.adapter.getStateAsync(`${basePath}.statistics.lastYearStart`);
|
|
991
|
+
const yearStartState = await this.adapter.getStateAsync(`${basePath}.statistics.timestamps.lastYearStart`);
|
|
802
992
|
let monthsSinceYearStart = 1;
|
|
803
993
|
|
|
804
994
|
if (yearStartState && yearStartState.val) {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const STATE_ROLES = require('./roles');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates history structure for a specific year
|
|
7
|
+
*
|
|
8
|
+
* @param {object} adapter - The adapter instance
|
|
9
|
+
* @param {string} type - 'gas', 'water', 'electricity', 'pv'
|
|
10
|
+
* @param {string} meterName - Meter name
|
|
11
|
+
* @param {number|string} year - Year (YYYY)
|
|
12
|
+
* @returns {Promise<void>}
|
|
13
|
+
*/
|
|
14
|
+
async function createHistoryStructure(adapter, type, meterName, year) {
|
|
15
|
+
const basePath = `${type}.${meterName}.history.${year}`;
|
|
16
|
+
|
|
17
|
+
await adapter.setObjectNotExistsAsync(`${type}.${meterName}.history`, {
|
|
18
|
+
type: 'channel',
|
|
19
|
+
common: { name: 'Historie' },
|
|
20
|
+
native: {},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
await adapter.setObjectNotExistsAsync(basePath, {
|
|
24
|
+
type: 'channel',
|
|
25
|
+
common: { name: `Jahr ${year}` },
|
|
26
|
+
native: { year },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
let consumptionUnit = 'kWh';
|
|
30
|
+
if (type === 'water') {
|
|
31
|
+
consumptionUnit = 'm³';
|
|
32
|
+
} else if (type === 'gas') {
|
|
33
|
+
consumptionUnit = 'kWh';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await adapter.setObjectNotExistsAsync(`${basePath}.consumption`, {
|
|
37
|
+
type: 'state',
|
|
38
|
+
common: {
|
|
39
|
+
name: `Jahresverbrauch ${year} (${consumptionUnit})`,
|
|
40
|
+
type: 'number',
|
|
41
|
+
role: STATE_ROLES.consumption,
|
|
42
|
+
read: true,
|
|
43
|
+
write: false,
|
|
44
|
+
unit: consumptionUnit,
|
|
45
|
+
def: 0,
|
|
46
|
+
},
|
|
47
|
+
native: {},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (type === 'gas') {
|
|
51
|
+
await adapter.setObjectNotExistsAsync(`${basePath}.volume`, {
|
|
52
|
+
type: 'state',
|
|
53
|
+
common: {
|
|
54
|
+
name: `Jahresverbrauch ${year} (m³)`,
|
|
55
|
+
type: 'number',
|
|
56
|
+
role: STATE_ROLES.consumption,
|
|
57
|
+
read: true,
|
|
58
|
+
write: false,
|
|
59
|
+
unit: 'm³',
|
|
60
|
+
def: 0,
|
|
61
|
+
},
|
|
62
|
+
native: {},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await adapter.setObjectNotExistsAsync(`${basePath}.costs`, {
|
|
67
|
+
type: 'state',
|
|
68
|
+
common: {
|
|
69
|
+
name: `Jahreskosten ${year} (€)`,
|
|
70
|
+
type: 'number',
|
|
71
|
+
role: STATE_ROLES.cost,
|
|
72
|
+
read: true,
|
|
73
|
+
write: false,
|
|
74
|
+
unit: '€',
|
|
75
|
+
def: 0,
|
|
76
|
+
},
|
|
77
|
+
native: {},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await adapter.setObjectNotExistsAsync(`${basePath}.balance`, {
|
|
81
|
+
type: 'state',
|
|
82
|
+
common: {
|
|
83
|
+
name: `Bilanz ${year} (€)`,
|
|
84
|
+
type: 'number',
|
|
85
|
+
role: STATE_ROLES.cost,
|
|
86
|
+
read: true,
|
|
87
|
+
write: false,
|
|
88
|
+
unit: '€',
|
|
89
|
+
def: 0,
|
|
90
|
+
},
|
|
91
|
+
native: {},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = createHistoryStructure;
|