iobroker.utility-monitor 1.4.4 → 1.4.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.utility-monitor",
3
- "version": "1.4.4",
3
+ "version": "1.4.6",
4
4
  "description": "Monitor gas, water, and electricity consumption with cost calculation",
5
5
  "author": {
6
6
  "name": "fischi87",
@@ -39,18 +39,18 @@
39
39
  "@iobroker/adapter-core": "^3.3.2"
40
40
  },
41
41
  "devDependencies": {
42
- "@alcalzone/release-script": "~5.0.0",
43
- "@alcalzone/release-script-plugin-iobroker": "~4.0.0",
44
- "@alcalzone/release-script-plugin-license": "~4.0.0",
45
- "@alcalzone/release-script-plugin-manual-review": "~4.0.0",
46
- "@iobroker/adapter-dev": "~1.5.0",
47
- "@iobroker/dev-server": "~0.8.0",
48
- "@iobroker/eslint-config": "~2.2.0",
49
- "@iobroker/testing": "~5.2.2",
50
- "@tsconfig/node20": "~20.1.8",
51
- "@types/iobroker": "npm:@iobroker/types@~7.1.0",
52
- "@types/node": "~20.19.27",
53
- "typescript": "~5.9.3"
42
+ "@alcalzone/release-script": "^5.0.0",
43
+ "@alcalzone/release-script-plugin-iobroker": "^4.0.0",
44
+ "@alcalzone/release-script-plugin-license": "^4.0.0",
45
+ "@alcalzone/release-script-plugin-manual-review": "^4.0.0",
46
+ "@iobroker/adapter-dev": "^1.5.0",
47
+ "@iobroker/dev-server": "^0.8.0",
48
+ "@iobroker/eslint-config": "^2.2.0",
49
+ "@iobroker/testing": "^5.2.2",
50
+ "@tsconfig/node20": "^20.1.8",
51
+ "@types/iobroker": "npm:@iobroker/types@^7.1.0",
52
+ "@types/node": "^20.19.27",
53
+ "typescript": "^5.9.3"
54
54
  },
55
55
  "main": "main.js",
56
56
  "files": [
package/admin/tab_m.html DELETED
@@ -1,305 +0,0 @@
1
- <!DOCTYPE html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8">
5
- <link rel="stylesheet" type="text/css" href="../../lib/css/materialize.css">
6
- <link rel="stylesheet" type="text/css" href="../../css/adapter.css"/>
7
- <script type="text/javascript" src="../../lib/js/jquery-3.2.1.min.js"></script>
8
- <script type="text/javascript" src="../../socket.io/socket.io.js"></script>
9
- <script type="text/javascript" src="../../js/adapter-settings.js"></script>
10
- <style>
11
- body {
12
- padding: 20px;
13
- background: transparent;
14
- }
15
- .import-container {
16
- max-width: 800px;
17
- margin: 0 auto;
18
- }
19
- .section {
20
- margin-bottom: 30px;
21
- padding: 20px;
22
- border-radius: 4px;
23
- background: var(--color-background-box, #fff);
24
- }
25
- .section h2 {
26
- margin-top: 0;
27
- font-size: 1.5em;
28
- font-weight: 500;
29
- }
30
- .section h3 {
31
- font-size: 1.2em;
32
- font-weight: 500;
33
- margin-bottom: 10px;
34
- }
35
- .help-text {
36
- color: #666;
37
- font-size: 0.9em;
38
- margin-bottom: 15px;
39
- }
40
- .code-block {
41
- background: #f5f5f5;
42
- padding: 10px;
43
- border-radius: 4px;
44
- font-family: monospace;
45
- white-space: pre;
46
- margin: 10px 0;
47
- }
48
- .input-field {
49
- margin-bottom: 20px;
50
- }
51
- .input-field label {
52
- display: block;
53
- margin-bottom: 5px;
54
- font-weight: 500;
55
- }
56
- .input-field select,
57
- .input-field input[type="file"] {
58
- width: 100%;
59
- padding: 8px;
60
- border: 1px solid #ddd;
61
- border-radius: 4px;
62
- }
63
- .btn {
64
- padding: 10px 20px;
65
- border: none;
66
- border-radius: 4px;
67
- cursor: pointer;
68
- font-size: 1em;
69
- font-weight: 500;
70
- }
71
- .btn-primary {
72
- background: #2196F3;
73
- color: white;
74
- }
75
- .btn-primary:hover {
76
- background: #1976D2;
77
- }
78
- .btn-primary:disabled {
79
- background: #ccc;
80
- cursor: not-allowed;
81
- }
82
- .message {
83
- padding: 15px;
84
- border-radius: 4px;
85
- margin: 15px 0;
86
- display: none;
87
- }
88
- .message.success {
89
- background: #4CAF50;
90
- color: white;
91
- }
92
- .message.error {
93
- background: #f44336;
94
- color: white;
95
- }
96
- .message.info {
97
- background: #2196F3;
98
- color: white;
99
- }
100
- .spinner {
101
- display: none;
102
- margin-left: 10px;
103
- }
104
- </style>
105
- </head>
106
- <body>
107
- <div class="import-container">
108
- <div class="section">
109
- <h2>📥 CSV-Daten importieren</h2>
110
- <p class="help-text">
111
- Importiere historische Zählerstände aus einer CSV-Datei.
112
- Die Daten werden unter <strong>history.csv</strong> gespeichert und überschreiben keine bestehenden Daten.
113
- </p>
114
- </div>
115
-
116
- <div class="section">
117
- <h3>CSV-Format</h3>
118
- <p class="help-text">Erwartetes Format:</p>
119
- <div class="code-block">Datum;Zaehlerstand
120
- 01.01.2024;10250.5
121
- 01.02.2024;10285.3
122
- 01.03.2024;10320.8</div>
123
- <ul class="help-text">
124
- <li>Trennzeichen: Semikolon (<code>;</code>)</li>
125
- <li>Dezimaltrennzeichen: Komma (<code>,</code>) oder Punkt (<code>.</code>)</li>
126
- <li>Datumsformat: <code>DD.MM.YYYY</code> oder <code>DD.MM.YY</code></li>
127
- <li>Der Verbrauch wird automatisch aus der Differenz berechnet</li>
128
- </ul>
129
- </div>
130
-
131
- <div class="section">
132
- <h3>Import durchführen</h3>
133
-
134
- <div class="input-field">
135
- <label for="medium">Medium auswählen:</label>
136
- <select id="medium">
137
- <option value="">-- Bitte wählen --</option>
138
- <option value="gas">🔥 Gas</option>
139
- <option value="water">💧 Wasser</option>
140
- <option value="electricity">⚡ Strom</option>
141
- <option value="pv">☀️ PV</option>
142
- </select>
143
- </div>
144
-
145
- <div class="input-field">
146
- <label for="csvFile">CSV-Datei auswählen:</label>
147
- <input type="file" id="csvFile" accept=".csv,.txt" />
148
- <p class="help-text">Wähle eine CSV-Datei von deinem Computer aus (max. 1 MB)</p>
149
- </div>
150
-
151
- <button id="importBtn" class="btn btn-primary" disabled>
152
- 📤 CSV importieren
153
- <span class="spinner">⏳</span>
154
- </button>
155
-
156
- <div id="message" class="message"></div>
157
- </div>
158
-
159
- <div class="section">
160
- <h3>Nach dem Import</h3>
161
- <p class="help-text">Die importierten Daten findest du unter:</p>
162
- <ul class="help-text">
163
- <li><code>{medium}.history.csv.{Jahr}.{Monat}.reading</code> - Zählerstand</li>
164
- <li><code>{medium}.history.csv.{Jahr}.{Monat}.consumption</code> - Verbrauch</li>
165
- <li><code>{medium}.history.csv.{Jahr}.{Monat}.date</code> - Ablesedatum</li>
166
- </ul>
167
- </div>
168
- </div>
169
-
170
- <script>
171
- let socket;
172
- let instance;
173
-
174
- // Initialize socket connection
175
- function initSocket() {
176
- socket = io.connect('/', {
177
- path: '/socket.io',
178
- reconnection: true
179
- });
180
-
181
- socket.on('connect', function() {
182
- console.log('Socket connected');
183
-
184
- // Get instance number from URL
185
- const match = window.location.search.match(/instance=(\d+)/);
186
- instance = match ? parseInt(match[1]) : 0;
187
- console.log('Instance:', instance);
188
- });
189
-
190
- socket.on('disconnect', function() {
191
- console.log('Socket disconnected');
192
- });
193
- }
194
-
195
- // Enable/disable import button based on form validity
196
- function updateImportButton() {
197
- const medium = $('#medium').val();
198
- const file = $('#csvFile')[0].files[0];
199
- $('#importBtn').prop('disabled', !medium || !file);
200
- }
201
-
202
- // Show message
203
- function showMessage(text, type = 'info') {
204
- const $msg = $('#message');
205
- $msg.removeClass('success error info');
206
- $msg.addClass(type);
207
- $msg.text(text);
208
- $msg.show();
209
-
210
- if (type === 'success' || type === 'error') {
211
- setTimeout(() => $msg.fadeOut(), 5000);
212
- }
213
- }
214
-
215
- // Hide message
216
- function hideMessage() {
217
- $('#message').hide();
218
- }
219
-
220
- // Import CSV
221
- function importCSV() {
222
- const medium = $('#medium').val();
223
- const file = $('#csvFile')[0].files[0];
224
-
225
- if (!medium || !file) {
226
- showMessage('Bitte Medium und Datei auswählen!', 'error');
227
- return;
228
- }
229
-
230
- // Check file size (max 1 MB)
231
- if (file.size > 1048576) {
232
- showMessage('Datei ist zu groß! Maximum: 1 MB', 'error');
233
- return;
234
- }
235
-
236
- // Show loading
237
- $('#importBtn').prop('disabled', true);
238
- $('.spinner').show();
239
- showMessage('CSV wird importiert...', 'info');
240
-
241
- // Read file
242
- const reader = new FileReader();
243
- reader.onload = function(e) {
244
- const content = e.target.result;
245
-
246
- console.log('Sending import request:', {
247
- medium: medium,
248
- fileLength: content.length
249
- });
250
-
251
- // Send to adapter
252
- socket.emit('sendTo', 'utility-monitor.' + instance, 'importCSV', {
253
- medium: medium,
254
- file: content
255
- }, function(result) {
256
- console.log('Import result:', result);
257
-
258
- $('#importBtn').prop('disabled', false);
259
- $('.spinner').hide();
260
-
261
- if (result.error) {
262
- showMessage('❌ Fehler: ' + result.error, 'error');
263
- } else if (result.success) {
264
- showMessage(
265
- `✅ Import erfolgreich! ${result.imported} von ${result.total} Einträgen importiert. ` +
266
- `Jahre: ${result.years.join(', ')}`,
267
- 'success'
268
- );
269
-
270
- // Reset form
271
- $('#csvFile').val('');
272
- updateImportButton();
273
- } else {
274
- showMessage('❌ Unbekannter Fehler beim Import', 'error');
275
- }
276
- });
277
- };
278
-
279
- reader.onerror = function() {
280
- $('#importBtn').prop('disabled', false);
281
- $('.spinner').hide();
282
- showMessage('❌ Fehler beim Lesen der Datei', 'error');
283
- };
284
-
285
- reader.readAsText(file);
286
- }
287
-
288
- // Document ready
289
- $(document).ready(function() {
290
- console.log('Import page loaded');
291
-
292
- // Initialize socket
293
- initSocket();
294
-
295
- // Event listeners
296
- $('#medium').on('change', updateImportButton);
297
- $('#csvFile').on('change', updateImportButton);
298
- $('#importBtn').on('click', importCSV);
299
-
300
- // Initial button state
301
- updateImportButton();
302
- });
303
- </script>
304
- </body>
305
- </html>
@@ -1,344 +0,0 @@
1
- /**
2
- * ImportHandler - Handles CSV import of historical meter readings
3
- *
4
- * Features:
5
- * - Parse CSV files with date and meter readings
6
- * - Automatically calculate consumption from reading differences
7
- * - Create states under history.csv.{year}.{month}
8
- * - Support for all utility types (gas, water, electricity, pv)
9
- */
10
-
11
- class ImportManager {
12
- constructor(adapter, calculator) {
13
- this.adapter = adapter;
14
- this.calculator = calculator;
15
-
16
- this.monthNames = [
17
- 'january', 'february', 'march', 'april', 'may', 'june',
18
- 'july', 'august', 'september', 'october', 'november', 'december'
19
- ];
20
- }
21
-
22
- /**
23
- * Main entry point for CSV import
24
- * @param {string} medium - Utility type (gas, water, electricity, pv)
25
- * @param {string} content - CSV file content
26
- * @returns {Promise<object>} Import result
27
- */
28
- async importCSV(medium, content) {
29
- try {
30
- this.adapter.log.info(`Starting CSV import for ${medium}...`);
31
-
32
- // Validate medium
33
- if (!['gas', 'water', 'electricity', 'pv'].includes(medium)) {
34
- throw new Error(`Ungültiges Medium: ${medium}`);
35
- }
36
-
37
- // Parse CSV
38
- const entries = this.parseCSV(content);
39
-
40
- if (entries.length === 0) {
41
- throw new Error('Keine gültigen Einträge in der CSV gefunden');
42
- }
43
-
44
- this.adapter.log.info(`Parsed ${entries.length} entries from CSV`);
45
-
46
- // Group by year and month
47
- const grouped = this.groupByYearMonth(entries);
48
-
49
- // Create states
50
- const importedCount = await this.createStates(medium, grouped);
51
-
52
- this.adapter.log.info(`✅ Import erfolgreich: ${importedCount} Einträge importiert`);
53
-
54
- return {
55
- success: true,
56
- imported: importedCount,
57
- total: entries.length,
58
- years: Object.keys(grouped)
59
- };
60
-
61
- } catch (error) {
62
- this.adapter.log.error(`CSV Import Fehler: ${error.message}`);
63
- throw error;
64
- }
65
- }
66
-
67
- /**
68
- * Parse CSV content
69
- * Expected format:
70
- * Datum;Zaehlerstand
71
- * 01.01.2024;10250.5
72
- * 01.02.2024;10285.3
73
- *
74
- * Also supports comma as decimal separator: 10250,5
75
- */
76
- parseCSV(content) {
77
- const lines = content.trim().split('\n');
78
-
79
- if (lines.length < 2) {
80
- throw new Error('CSV muss mindestens 2 Zeilen haben (Header + Daten)');
81
- }
82
-
83
- // Parse header
84
- const header = lines[0].toLowerCase().split(';').map(h => h.trim());
85
- const dateIndex = header.findIndex(h => h === 'datum' || h === 'date');
86
- const readingIndex = header.findIndex(h =>
87
- h === 'zaehlerstand' || h === 'zählerstand' || h === 'reading' || h === 'stand'
88
- );
89
-
90
- if (dateIndex === -1) {
91
- throw new Error('CSV muss eine "Datum" Spalte enthalten');
92
- }
93
- if (readingIndex === -1) {
94
- throw new Error('CSV muss eine "Zaehlerstand" Spalte enthalten');
95
- }
96
-
97
- const entries = [];
98
- const errors = [];
99
-
100
- for (let i = 1; i < lines.length; i++) {
101
- const lineNum = i + 1;
102
- const line = lines[i].trim();
103
-
104
- if (!line) continue; // Skip empty lines
105
-
106
- const values = line.split(';').map(v => v.trim());
107
-
108
- if (values.length < Math.max(dateIndex, readingIndex) + 1) {
109
- errors.push(`Zeile ${lineNum}: Nicht genügend Spalten`);
110
- continue;
111
- }
112
-
113
- const dateStr = values[dateIndex];
114
- const readingStr = values[readingIndex];
115
-
116
- if (!dateStr || !readingStr) {
117
- errors.push(`Zeile ${lineNum}: Datum oder Zählerstand fehlt`);
118
- continue;
119
- }
120
-
121
- // Parse date (supports DD.MM.YYYY, DD.MM.YY, YYYY-MM-DD)
122
- const date = this.parseDate(dateStr);
123
- if (!date || isNaN(date.getTime())) {
124
- errors.push(`Zeile ${lineNum}: Ungültiges Datum: ${dateStr}`);
125
- continue;
126
- }
127
-
128
- // Parse reading (supports both , and . as decimal separator)
129
- const reading = this.parseNumber(readingStr);
130
- if (isNaN(reading) || reading < 0) {
131
- errors.push(`Zeile ${lineNum}: Ungültiger Zählerstand: ${readingStr}`);
132
- continue;
133
- }
134
-
135
- entries.push({ date, reading, line: lineNum });
136
- }
137
-
138
- // Log errors if any
139
- if (errors.length > 0) {
140
- this.adapter.log.warn(`Import-Warnungen:\n${errors.join('\n')}`);
141
- }
142
-
143
- // Sort by date (oldest first)
144
- entries.sort((a, b) => a.date.getTime() - b.date.getTime());
145
-
146
- return entries;
147
- }
148
-
149
- /**
150
- * Parse date string (supports multiple formats)
151
- */
152
- parseDate(dateStr) {
153
- // Try German format first (DD.MM.YYYY or DD.MM.YY)
154
- const germanMatch = dateStr.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2,4})$/);
155
- if (germanMatch) {
156
- let day = parseInt(germanMatch[1]);
157
- let month = parseInt(germanMatch[2]) - 1; // 0-indexed
158
- let year = parseInt(germanMatch[3]);
159
-
160
- // Handle 2-digit year
161
- if (year < 100) {
162
- year += year < 50 ? 2000 : 1900;
163
- }
164
-
165
- const date = new Date(year, month, day, 12, 0, 0);
166
- return date;
167
- }
168
-
169
- // Try ISO format (YYYY-MM-DD)
170
- const isoMatch = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
171
- if (isoMatch) {
172
- const year = parseInt(isoMatch[1]);
173
- const month = parseInt(isoMatch[2]) - 1;
174
- const day = parseInt(isoMatch[3]);
175
- return new Date(year, month, day, 12, 0, 0);
176
- }
177
-
178
- return null;
179
- }
180
-
181
- /**
182
- * Parse number (supports comma and dot as decimal separator)
183
- */
184
- parseNumber(str) {
185
- // Replace comma with dot
186
- const normalized = str.replace(',', '.');
187
- return parseFloat(normalized);
188
- }
189
-
190
- /**
191
- * Group entries by year and month, calculate consumption
192
- */
193
- groupByYearMonth(entries) {
194
- const grouped = {};
195
- let previousReading = null;
196
-
197
- for (const entry of entries) {
198
- const year = entry.date.getFullYear();
199
- const month = entry.date.getMonth();
200
- const monthName = this.monthNames[month];
201
-
202
- if (!grouped[year]) {
203
- grouped[year] = {};
204
- }
205
-
206
- // Calculate consumption (difference to previous reading)
207
- const consumption = previousReading !== null ?
208
- entry.reading - previousReading : null;
209
-
210
- grouped[year][monthName] = {
211
- date: entry.date,
212
- reading: entry.reading,
213
- consumption: consumption,
214
- sourceLine: entry.line
215
- };
216
-
217
- previousReading = entry.reading;
218
- }
219
-
220
- return grouped;
221
- }
222
-
223
- /**
224
- * Create states for imported data
225
- */
226
- async createStates(medium, grouped) {
227
- let count = 0;
228
-
229
- // Determine unit based on medium
230
- const unit = this.getUnit(medium);
231
-
232
- for (const [year, months] of Object.entries(grouped)) {
233
- for (const [monthName, data] of Object.entries(months)) {
234
- const basePath = `${medium}.history.csv.${year}.${monthName}`;
235
-
236
- // Create reading state
237
- await this.adapter.setObjectNotExistsAsync(`${basePath}.reading`, {
238
- type: 'state',
239
- common: {
240
- name: {
241
- en: `Meter Reading ${monthName} ${year}`,
242
- de: `Zählerstand ${monthName} ${year}`,
243
- ru: `Показание счетчика ${monthName} ${year}`,
244
- pt: `Leitura do Medidor ${monthName} ${year}`,
245
- nl: `Meterstand ${monthName} ${year}`,
246
- fr: `Relevé du Compteur ${monthName} ${year}`,
247
- it: `Lettura Contatore ${monthName} ${year}`,
248
- es: `Lectura del Medidor ${monthName} ${year}`,
249
- pl: `Odczyt Licznika ${monthName} ${year}`,
250
- uk: `Показник лічильника ${monthName} ${year}`,
251
- 'zh-cn': `仪表读数 ${monthName} ${year}`
252
- },
253
- type: 'number',
254
- role: 'value',
255
- read: true,
256
- write: false,
257
- unit: unit
258
- },
259
- native: {}
260
- });
261
- await this.adapter.setStateAsync(`${basePath}.reading`, data.reading, true);
262
-
263
- // Create date state
264
- await this.adapter.setObjectNotExistsAsync(`${basePath}.date`, {
265
- type: 'state',
266
- common: {
267
- name: {
268
- en: `Date ${monthName} ${year}`,
269
- de: `Datum ${monthName} ${year}`,
270
- ru: `Дата ${monthName} ${year}`,
271
- pt: `Data ${monthName} ${year}`,
272
- nl: `Datum ${monthName} ${year}`,
273
- fr: `Date ${monthName} ${year}`,
274
- it: `Data ${monthName} ${year}`,
275
- es: `Fecha ${monthName} ${year}`,
276
- pl: `Data ${monthName} ${year}`,
277
- uk: `Дата ${monthName} ${year}`,
278
- 'zh-cn': `日期 ${monthName} ${year}`
279
- },
280
- type: 'string',
281
- role: 'date',
282
- read: true,
283
- write: false
284
- },
285
- native: {}
286
- });
287
- await this.adapter.setStateAsync(`${basePath}.date`,
288
- this.calculator.formatDateString(data.date), true);
289
-
290
- // Create consumption state (if calculable)
291
- if (data.consumption !== null) {
292
- await this.adapter.setObjectNotExistsAsync(`${basePath}.consumption`, {
293
- type: 'state',
294
- common: {
295
- name: {
296
- en: `Consumption ${monthName} ${year}`,
297
- de: `Verbrauch ${monthName} ${year}`,
298
- ru: `Потребление ${monthName} ${year}`,
299
- pt: `Consumo ${monthName} ${year}`,
300
- nl: `Verbruik ${monthName} ${year}`,
301
- fr: `Consommation ${monthName} ${year}`,
302
- it: `Consumo ${monthName} ${year}`,
303
- es: `Consumo ${monthName} ${year}`,
304
- pl: `Zużycie ${monthName} ${year}`,
305
- uk: `Споживання ${monthName} ${year}`,
306
- 'zh-cn': `消费 ${monthName} ${year}`
307
- },
308
- type: 'number',
309
- role: 'value',
310
- read: true,
311
- write: false,
312
- unit: unit
313
- },
314
- native: {}
315
- });
316
- await this.adapter.setStateAsync(`${basePath}.consumption`,
317
- this.calculator.round(data.consumption, 2), true);
318
- }
319
-
320
- count++;
321
- }
322
- }
323
-
324
- return count;
325
- }
326
-
327
- /**
328
- * Get unit for medium
329
- */
330
- getUnit(medium) {
331
- switch (medium) {
332
- case 'gas':
333
- case 'water':
334
- return 'm³';
335
- case 'electricity':
336
- case 'pv':
337
- return 'kWh';
338
- default:
339
- return '';
340
- }
341
- }
342
- }
343
-
344
- module.exports = ImportManager;