iobroker.metermaster 0.9.4 → 0.9.7

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.
Files changed (4) hide show
  1. package/README.md +140 -116
  2. package/io-package.json +41 -2
  3. package/main.js +406 -30
  4. package/package.json +4 -3
package/README.md CHANGED
@@ -1,49 +1,104 @@
1
- # ioBroker MeterMaster Adapter
1
+ ![Logo](admin/metermaster.svg)
2
2
 
3
- [![MeterMaster Banner](https://github.com/MPunktBPunkt/ioBroker.metermaster/raw/main/github-banner.svg)](https://github.com/MPunktBPunkt/ioBroker.metermaster)
3
+ # ioBroker.metermaster
4
4
 
5
- [![Version](https://img.shields.io/badge/version-0.9.4-blue.svg)](https://github.com/MPunktBPunkt/ioBroker.metermaster)
6
- [![License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/MPunktBPunkt/ioBroker.metermaster/blob/main/LICENSE)
7
- [![Node.js](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](https://nodejs.org)
5
+ [![NPM version](https://img.shields.io/npm/v/iobroker.metermaster.svg)](https://www.npmjs.com/package/iobroker.metermaster)
6
+ ![Number of Installations](https://iobroker.live/badges/metermaster-installed.svg)
7
+ ![Current version in stable repository](https://iobroker.live/badges/metermaster-stable.svg)
8
+ [![Downloads](https://img.shields.io/npm/dm/iobroker.metermaster.svg)](https://www.npmjs.com/package/iobroker.metermaster)
8
9
 
9
- Receives meter readings from the **[MeterMaster Android app](https://play.google.com/store/apps/details?id=com.propertymanagement.metermaster)**, stores them as ioBroker data points, and manages **ESP32 display nodes** for showing meter values on OLED displays. Source code: [GitHub](https://github.com/MPunktBPunkt/MeterMaster).
10
+ [![NPM](https://nodei.co/npm/iobroker.metermaster.png?downloads=true)](https://nodei.co/npm/iobroker.metermaster/)
11
+
12
+ **Bring meter readings from your phone into ioBroker — automatically.**
13
+
14
+ MeterMaster is the bridge between the [MeterMaster Android app](https://play.google.com/store/apps/details?id=com.propertymanagement.metermaster) and your smart home. Record electricity, gas, water or heat meters on your smartphone; the adapter stores them as ioBroker states with correct timestamps and full history — ready for scripts, visualizations and billing workflows.
15
+
16
+ No cloud account. Readings stay in your network.
17
+
18
+ [![Get it on Google Play](https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png)](https://play.google.com/store/apps/details?id=com.propertymanagement.metermaster)
19
+
20
+ ---
21
+
22
+ ## Why use this adapter?
23
+
24
+ | Without MeterMaster | With MeterMaster |
25
+ |---|---|
26
+ | Manual entry into ioBroker or spreadsheets | One tap in the app → state updated |
27
+ | Guessed timestamps | State `ts` = real reading date |
28
+ | No history per meter | Full `readings.history` array |
29
+ | Separate tools for charts/CSV | Built-in Web UI with charts and export |
30
+
31
+ Typical users: homeowners, landlords and property managers who already read meters on site and want those values in ioBroker without retyping.
32
+
33
+ ---
34
+
35
+ ## Quick start
36
+
37
+ 1. Install **MeterMaster** from the official ioBroker adapter list and create an instance.
38
+ 2. Note the HTTP port (default `8089`) and set a Basic Auth password.
39
+ 3. Install the [Android app](https://play.google.com/store/apps/details?id=com.propertymanagement.metermaster) → **Settings → ioBroker → MeterMaster adapter**.
40
+ 4. Enter your ioBroker host, port, username and password → **Test connection**.
41
+ 5. Record a reading in the app — it appears under `metermaster.0.…` and in the Web UI.
42
+
43
+ ```
44
+ Android app ──HTTP──► MeterMaster adapter ──► ioBroker states + history + Web UI
45
+ ```
46
+
47
+ Open the Web UI anytime at `http://{ioBroker-IP}:8089/` (no password required for viewing).
10
48
 
11
49
  ---
12
50
 
13
51
  ## Features
14
52
 
15
- - **HTTP receiver** – accepts readings directly from the app
16
- - **Automatic data points** – states are created automatically on first sync
17
- - **Correct timestamps** – state `ts` reflects the actual reading date
18
- - **History** – each meter keeps a full `readings.history` array
19
- - **Basic Auth** – optional username/password protection
20
- - **Web UI** – built-in browser viewer with 5 tabs (Data, Nodes, Import, Logs, System)
21
- - **Charts & CSV** – history charts, monthly consumption, and CSV export per meter
22
- - **DE/EN** – language switch in the Web UI
23
- - **Import** – app backup (schema 2.0) via the Web UI
24
- - **ESP32 node management** – view and configure registered display nodes
25
- - **Remote control** – control meter selection and LED of ESP32 nodes from the Web UI
53
+ - **HTTP receiver** — accepts readings from the MeterMaster Android app (single or batch)
54
+ - **Automatic states** — house / apartment / meter objects are created on first sync
55
+ - **Correct timestamps** — `readings.latest` uses the actual reading date as state `ts`
56
+ - **Full history** — every meter keeps a `readings.history` JSON array
57
+ - **Basic Auth** — optional username/password protection for write endpoints
58
+ - **Built-in Web UI** — Data, Import, Logs and System tabs in DE/EN
59
+ - **Charts & CSV** — history charts, monthly consumption and per-meter CSV export
60
+ - **Backup import** — restore MeterMaster app backups (schema 2.0) via drag & drop
61
+
62
+ Optional: [ESP32 OLED display nodes](#optional-esp32-display-nodes) can show selected meter values on a small display.
26
63
 
27
64
  ---
28
65
 
29
- ## Screenshots
66
+ ## MeterMaster Android app
30
67
 
31
- The built-in Web UI offers five tabs — overview:
68
+ The adapter is the ioBroker side of [MeterMaster](https://play.google.com/store/apps/details?id=com.propertymanagement.metermaster) — a local-first Android app for utility meters.
69
+
70
+ - Manage properties, apartments and meters (electricity, gas, water, heat, custom)
71
+ - Record readings with date/time and optional photos
72
+ - Consumption charts and annual billing / CSV / HTML export
73
+ - Local storage only — no cloud, no account, no tracking
74
+ - Optional integrations: ioBroker (this adapter), MQTT, Google Sheets, InfluxDB
32
75
 
33
76
  | | |
34
77
  |---|---|
35
- | **Data** – meter cards with consumption KPI, history, chart & CSV | ![Data tab](docs/screenshots/webui-daten.png) |
36
- | **Chart modal** – meter reading (linear time axis) & monthly consumption | ![Chart modal](docs/screenshots/webui-chart.png) |
37
- | **Nodes** – ESP32 status, IP, firmware | ![Nodes tab](docs/screenshots/webui-nodes.png) |
38
- | **Import** – app backup via drag & drop | ![Import tab](docs/screenshots/webui-import.png) |
39
- | **Logs** – real-time log with filter & export | ![Logs tab](docs/screenshots/webui-logs.png) |
40
- | **System** – statistics & version check | ![System tab](docs/screenshots/webui-system.png) |
78
+ | **Google Play** | [MeterMaster](https://play.google.com/store/apps/details?id=com.propertymanagement.metermaster) |
79
+ | **Source & docs** | [MPunktBPunkt/MeterMaster](https://github.com/MPunktBPunkt/MeterMaster) |
80
+
81
+ ---
82
+
83
+ ## Screenshots
84
+
85
+ | Data — meter cards, KPI, history, chart & CSV | Chart — linear time axis & monthly consumption |
86
+ |---|---|
87
+ | ![Data tab](docs/screenshots/webui-daten.png) | ![Chart modal](docs/screenshots/webui-chart.png) |
88
+
89
+ | Import — app backup via drag & drop | Logs — live filter & export |
90
+ |---|---|
91
+ | ![Import tab](docs/screenshots/webui-import.png) | ![Logs tab](docs/screenshots/webui-logs.png) |
92
+
93
+ | System — statistics & version check | Nodes — optional ESP32 status |
94
+ |---|---|
95
+ | ![System tab](docs/screenshots/webui-system.png) | ![Nodes tab](docs/screenshots/webui-nodes.png) |
41
96
 
42
97
  ---
43
98
 
44
99
  ## Installation
45
100
 
46
- Install the adapter from the official ioBroker adapter list:
101
+ Install from the official ioBroker adapter list:
47
102
 
48
103
  1. Open **ioBroker Admin** → **Adapters**
49
104
  2. Search for **MeterMaster**
@@ -56,91 +111,58 @@ iobroker add metermaster
56
111
  iobroker start metermaster
57
112
  ```
58
113
 
59
- Open the firewall if needed: `sudo ufw allow 8089/tcp`
114
+ If the app cannot reach the adapter, open the firewall for the configured port, e.g. `sudo ufw allow 8089/tcp`.
60
115
 
61
- More details: [INSTALLATION.md](INSTALLATION.md)
116
+ More notes: [INSTALLATION.md](INSTALLATION.md)
62
117
 
63
118
  ---
64
119
 
65
120
  ## Instance configuration
66
121
 
67
- After installation → ioBroker Admin → **Adapters → MeterMaster** → create instance:
122
+ **ioBroker Admin → Adapters → MeterMaster → instance settings**
68
123
 
69
124
  | Setting | Default | Description |
70
125
  |---|---|---|
71
126
  | HTTP port | `8089` | Port the adapter listens on |
72
- | Username | `metermaster` | Basic auth username |
73
- | Password | – | Basic auth password |
74
- | Verbose logging | enabled | Show DEBUG entries in log viewer |
75
- | Log buffer | `500` | Max. stored log entries |
76
- | Keep history | `0` | 0 = unlimited |
77
-
78
- ---
79
-
80
- ## MeterMaster Android app
81
-
82
- Capture meters and sync with ioBroker:
83
-
84
- | | |
85
- |---|---|
86
- | **Google Play** | [**MeterMaster**](https://play.google.com/store/apps/details?id=com.propertymanagement.metermaster) – install the app, read meters, and send to the adapter |
87
- | **GitHub** | [**MPunktBPunkt/MeterMaster**](https://github.com/MPunktBPunkt/MeterMaster) – source code, APK build, and documentation |
88
-
89
- [![Get it on Google Play](https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png)](https://play.google.com/store/apps/details?id=com.propertymanagement.metermaster)
127
+ | Username | `metermaster` | Basic Auth username |
128
+ | Password | – | Basic Auth password (set a strong one) |
129
+ | Verbose logging | enabled | Show DEBUG entries in the log viewer |
130
+ | Log buffer | `500` | Maximum stored log entries |
131
+ | Keep history | `0` | `0` = unlimited readings per meter |
90
132
 
91
133
  ---
92
134
 
93
- ## Configure the MeterMaster app
135
+ ## Configure the Android app
94
136
 
95
- **Settings → ioBroker → MeterMaster adapter:**
137
+ **Settings → ioBroker → MeterMaster adapter**
96
138
 
97
139
  | Field | Value |
98
140
  |---|---|
99
141
  | Enable ioBroker | on |
100
- | IP / hostname | IP of the ioBroker server |
101
- | Adapter port | `8089` |
102
- | Username | as configured in the adapter |
103
- | Password | as configured in the adapter |
142
+ | IP / hostname | IP address of the ioBroker server |
143
+ | Adapter port | `8089` (or your configured port) |
144
+ | Username | as in the adapter instance |
145
+ | Password | as in the adapter instance |
104
146
 
105
- "Test connection" should return `MeterMaster adapter reachable ✓`.
147
+ Use **Test connection**. Success looks like: `MeterMaster adapter reachable ✓`
106
148
 
107
149
  ---
108
150
 
109
151
  ## Web UI
110
152
 
111
- Accessible without password:
112
-
113
153
  ```
114
154
  http://{ioBroker-IP}:8089/
115
155
  ```
116
156
 
117
157
  | Tab | Content |
118
158
  |---|---|
119
- | **Data** | All received meters grouped by house/apartment, with history, chart modal, and CSV export |
120
- | **Nodes** | Registered ESP32 nodes: status, IP link, firmware, meter dropdown, LED control |
121
- | **Import** | App backup (JSON schema 2.0) via drag & drop |
122
- | **Logs** | Real-time log with filter, auto-scroll, export |
123
- | **System** | Statistics & version check |
159
+ | **Data** | Meters grouped by house/apartment — history, charts, CSV |
160
+ | **Import** | MeterMaster app backup (JSON schema 2.0) via drag & drop |
161
+ | **Logs** | Real-time log with filter, auto-scroll and export |
162
+ | **System** | Statistics and update check |
163
+ | **Nodes** | Optional ESP32 displays (see below) |
124
164
 
125
- Screenshots: see [Screenshots](#screenshots) above.
126
-
127
- ---
128
-
129
- ## ESP32 display node
130
-
131
- The adapter supports the [MeterMaster ESP32 node](https://github.com/MPunktBPunkt/esp32.MeterMaster) as an OLED display companion.
132
-
133
- ### Flow
134
- 1. ESP32 sends heartbeat every 60 s: `POST :8089/api/register`
135
- 2. Adapter creates `metermaster.0.nodes.{MAC}.*` states automatically
136
- 3. ESP32 polls every 15 s: `GET :8089/api/nodes/{MAC}/config`
137
- 4. Adapter returns config and optional immediate commands (cmd)
138
-
139
- ### Nodes tab
140
- - Online/offline badge (green if heartbeat < 120 s)
141
- - IP as clickable link → opens ESP32 Web UI
142
- - Meter dropdown: assign meter → ESP32 picks it up on next poll
143
- - LED buttons: on/off → immediate command via cmd state
165
+ Language switch: DE / EN in the Web UI.
144
166
 
145
167
  ---
146
168
 
@@ -160,14 +182,9 @@ metermaster.0.
160
182
  │ ├── unit string
161
183
  │ └── typeName string
162
184
  │
163
- └── nodes/{MAC}/
164
- ├── ip string ESP32 IP address
165
- ├── name string Device name
166
- ├── version string Firmware version
167
- ├── lastSeen number Timestamp of last heartbeat (ms)
168
- ├── config string JSON config (adapter writes, ESP32 reads)
169
- ├── configAck string Acknowledgement by ESP32
170
- └── cmd string Immediate command (adapter writes, ESP32 reads+clears)
185
+ └── nodes/{MAC}/ (only if ESP32 nodes are used)
186
+ ├── ip, name, version, lastSeen
187
+ ├── config, configAck, cmd
171
188
  ```
172
189
 
173
190
  ---
@@ -180,62 +197,62 @@ metermaster.0.
180
197
  |---|---|---|
181
198
  | GET | `/` | Web UI |
182
199
  | GET | `/api/version` | Version + GitHub check |
183
- | GET | `/api/stats` | Statistics (readings, uptime, nodes) |
200
+ | GET | `/api/stats` | Statistics |
184
201
  | GET | `/api/data` | All cached readings |
185
- | GET | `/api/logs` | Log buffer (with `?level=&category=&text=` filter) |
186
- | GET | `/api/nodes` | All registered ESP32 nodes |
202
+ | GET | `/api/logs` | Log buffer (`?level=&category=&text=`) |
203
+ | GET | `/api/nodes` | Registered ESP32 nodes |
187
204
  | GET | `/api/discover` | Known meter state IDs |
188
- | POST | `/api/register` | ESP32 heartbeat (no auth required) |
205
+ | POST | `/api/register` | ESP32 heartbeat |
189
206
 
190
207
  ### With Basic Auth
191
208
 
192
209
  | Method | Path | Description |
193
210
  |---|---|---|
194
211
  | GET | `/api/ping` | Connection test |
195
- | POST | `/api/reading` | Store single reading |
212
+ | POST | `/api/reading` | Store a single reading |
196
213
  | POST | `/api/readings` | Store batch readings |
197
214
  | POST | `/api/import` | Import app backup |
198
- | GET | `/api/nodes/{MAC}/config` | Get config for ESP32 |
199
- | POST | `/api/nodes/{MAC}/config` | Set config for ESP32 |
200
- | POST | `/api/nodes/{MAC}/configAck` | Receive config acknowledgement |
201
- | POST | `/api/nodes/{MAC}/cmd` | Send immediate command (LED, meter) |
215
+ | GET/POST | `/api/nodes/{MAC}/config` | Get / set ESP32 config |
216
+ | POST | `/api/nodes/{MAC}/configAck` | Config acknowledgement |
217
+ | POST | `/api/nodes/{MAC}/cmd` | Immediate command (e.g. LED) |
202
218
 
203
219
  ### Example: single reading
204
220
 
205
- ```
221
+ ```http
206
222
  POST http://host:8089/api/reading
207
223
  Authorization: Basic base64(user:password)
208
224
  Content-Type: application/json
209
225
 
210
226
  {
211
- "house": "MyHouse",
212
- "apartment": "West",
213
- "meter": "HotWater",
214
- "value": 128.75,
215
- "unit": "m³",
216
- "typeName": "HotWater",
227
+ "house": "MyHouse",
228
+ "apartment": "West",
229
+ "meter": "HotWater",
230
+ "value": 128.75,
231
+ "unit": "m³",
232
+ "typeName": "HotWater",
217
233
  "readingDate": "2024-02-12T09:30:00.000Z"
218
234
  }
219
235
  ```
220
236
 
221
- ### Example: immediate command to ESP32
237
+ ---
222
238
 
223
- ```
224
- POST http://host:8089/api/nodes/C8C9A3CB7B08/cmd
225
- Authorization: Basic base64(user:password)
226
- Content-Type: application/json
239
+ ## Optional: ESP32 display nodes
227
240
 
228
- { "ledOn": true }
229
- ```
241
+ As an **optional add-on**, the adapter can manage [MeterMaster ESP32 nodes](https://github.com/MPunktBPunkt/esp32.MeterMaster) that show selected meter values on a small OLED.
242
+
243
+ - Nodes register via heartbeat (`POST /api/register`) and poll config every 15 s
244
+ - States under `metermaster.0.nodes.{MAC}.*`
245
+ - Web UI **Nodes** tab: online status, IP link, meter selection, LED control, firmware
246
+
247
+ You do **not** need an ESP32 to use the adapter or the Android app.
230
248
 
231
249
  ---
232
250
 
233
251
  ## Update
234
252
 
235
- ### Via Web UI
236
- `http://IP:8089/` → **System** tab → "Check for updates" (shows availability; install via CLI below)
253
+ **Web UI:** `http://IP:8089/` → **System** → Check for updates (install via CLI).
237
254
 
238
- ### Command line
255
+ **Command line:**
239
256
 
240
257
  ```bash
241
258
  iobroker upgrade metermaster
@@ -246,6 +263,10 @@ iobroker restart metermaster.0
246
263
 
247
264
  ## Changelog
248
265
 
266
+
267
+ ### **WORK IN PROGRESS**
268
+ - (ioBroker-Bot) Adapter requires admin >= 7.8.23 now.
269
+
249
270
  ### 0.9.4
250
271
  - All adapter log messages and API JSON error responses in English
251
272
  - State common names and roles corrected (readings channel, date/text/json roles, info.firmware for nodes)
@@ -290,9 +311,12 @@ See [io-package.json](io-package.json) `common.news` for full history. Older ent
290
311
 
291
312
  ---
292
313
 
314
+ [Older changelogs can be found there](CHANGELOG_OLD.md)
315
+
293
316
  ## License
294
317
 
295
- Copyright (c) 2026 MPunktBPunkt
318
+ MIT License
296
319
 
297
- MIT License – see [LICENSE](LICENSE) for the full license text.
320
+ Copyright (c) 2026 MPunktBPunkt
298
321
 
322
+ See [LICENSE](LICENSE) for the full license text.
package/io-package.json CHANGED
@@ -1,8 +1,47 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "metermaster",
4
- "version": "0.9.4",
4
+ "version": "0.9.7",
5
5
  "news": {
6
+ "0.9.7": {
7
+ "en": "Print fix (Blob URL); ESP32 discover proxy (getStates/node-discover); node heartbeat/ack logs moved to debug",
8
+ "de": "Druck-Fix (Blob-URL); ESP32-Discover-Proxy (getStates/node-discover); Node-Heartbeat/Ack-Logs auf Debug-Level",
9
+ "ru": "Print fix; ESP32 discover proxy; node logs debug",
10
+ "pt": "Print fix; ESP32 discover proxy; node logs debug",
11
+ "nl": "Print fix; ESP32 discover proxy; node logs debug",
12
+ "fr": "Print fix; ESP32 discover proxy; node logs debug",
13
+ "it": "Print fix; ESP32 discover proxy; node logs debug",
14
+ "es": "Print fix; ESP32 discover proxy; node logs debug",
15
+ "pl": "Print fix; ESP32 discover proxy; node logs debug",
16
+ "uk": "Print fix; ESP32 discover proxy; node logs debug",
17
+ "zh-cn": "打印修复;ESP32 discover 代理;节点日志改为 debug"
18
+ },
19
+ "0.9.6": {
20
+ "en": "Assign display nodes via chips on meter cards in the Data tab",
21
+ "de": "Anzeige-Nodes per Chips auf den Zähler-Kacheln im Daten-Tab zuweisen",
22
+ "ru": "Assign nodes via chips on meter cards",
23
+ "pt": "Assign nodes via chips on meter cards",
24
+ "nl": "Nodes toewijzen via chips op meterkarten",
25
+ "fr": "Assigner des nodes via des chips sur les cartes compteur",
26
+ "it": "Assegna node tramite chip sulle card contatore",
27
+ "es": "Asignar nodes mediante chips en las tarjetas de contador",
28
+ "pl": "Przypisywanie node przez chipy na kartach liczników",
29
+ "uk": "Призначення node через чіпи на картках лічильників",
30
+ "zh-cn": "在数据页计量表卡片上通过芯片分配显示节点"
31
+ },
32
+ "0.9.5": {
33
+ "en": "Correct history on re-sync; edit values in Web UI; print chart and apartment/house latest readings",
34
+ "de": "Historie bei erneutem Sync korrigieren; Werte in der Web-UI bearbeiten; Chart sowie Wohnungs-/Haus-Stände drucken",
35
+ "ru": "Correction of history; Web UI edit; print chart and readings",
36
+ "pt": "Correct history; Web UI edit; print",
37
+ "nl": "History corrigeren; Web UI edit; printen",
38
+ "fr": "Corriger l'historique; édition Web UI; impression",
39
+ "it": "Correggi cronologia; modifica Web UI; stampa",
40
+ "es": "Corregir historial; edición Web UI; imprimir",
41
+ "pl": "Korekta historii; edycja Web UI; druk",
42
+ "uk": "Корекція історії; редагування Web UI; друк",
43
+ "zh-cn": "更正历史记录;Web UI 编辑;打印图表与读数"
44
+ },
6
45
  "0.9.4": {
7
46
  "en": "Review fixes: English logs/states/API errors, Web UI i18n, config validation, removed /api/update",
8
47
  "de": "Review-Fixes: Englische Logs/States/API-Fehler, Web-UI-i18n, Config-Validierung, /api/update entfernt",
@@ -117,7 +156,7 @@
117
156
  ],
118
157
  "globalDependencies": [
119
158
  {
120
- "admin": ">=7.6.20"
159
+ "admin": ">=7.8.23"
121
160
  }
122
161
  ]
123
162
  },
package/main.js CHANGED
@@ -4,7 +4,7 @@ const utils = require('@iobroker/adapter-core');
4
4
  const http = require('node:http');
5
5
  const crypto = require('node:crypto');
6
6
  const https = require('node:https');
7
- const CURRENT_VERSION = '0.9.4';
7
+ const CURRENT_VERSION = '0.9.7';
8
8
  const GITHUB_REPO = 'MPunktBPunkt/ioBroker.metermaster';
9
9
  const GITHUB_URL = 'https://github.com/MPunktBPunkt/ioBroker.metermaster';
10
10
 
@@ -30,16 +30,21 @@ function cacheReading(house, apt, meter, value, unit, typeName, readingDate, ts)
30
30
  if (!receivedData[house][apt][meter]) receivedData[house][apt][meter] = { unit, typeName, history: [] };
31
31
 
32
32
  const entry = receivedData[house][apt][meter];
33
- entry.latest = value;
34
- entry.latestDate = readingDate;
35
33
  entry.unit = unit;
36
34
  entry.typeName = typeName;
37
35
 
38
- // Nur in Cache-History hinzufügen wenn noch nicht vorhanden
39
- if (!entry.history.some(h => h.ts === ts)) {
36
+ const histIdx = entry.history.findIndex(h => h.ts === ts);
37
+ if (histIdx >= 0) {
38
+ entry.history[histIdx] = { value, readingDate, ts };
39
+ } else {
40
40
  entry.history.push({ value, readingDate, ts });
41
41
  entry.history.sort((a, b) => a.ts - b.ts);
42
42
  }
43
+
44
+ // latest = chronologisch neuester Eintrag (Korrektur alter Werte ändert latest nicht)
45
+ const newest = entry.history[entry.history.length - 1];
46
+ entry.latest = newest.value;
47
+ entry.latestDate = newest.readingDate;
43
48
  }
44
49
 
45
50
  // ─── Log-System ───────────────────────────────────────────────────────────────
@@ -137,7 +142,7 @@ adapter.on('stateChange', async (id, state) => {
137
142
 
138
143
  if (field === 'lastSeen') {
139
144
  const n = nodesCache[mac];
140
- log(LVL.INFO, CAT.NODE, `Heartbeat`, `${mac} | IP: ${n.ip || '?'} | v${n.version || '?'} | ${n.name || 'unnamed'}`);
145
+ log(LVL.DEBUG, CAT.NODE, `Heartbeat`, `${mac} | IP: ${n.ip || '?'} | v${n.version || '?'} | ${n.name || 'unnamed'}`);
141
146
  await ensureNodeStates(mac);
142
147
  }
143
148
  });
@@ -249,7 +254,7 @@ function startHttpServer() {
249
254
 
250
255
  server = http.createServer((req, res) => {
251
256
  res.setHeader('Access-Control-Allow-Origin', '*');
252
- res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
257
+ res.setHeader('Access-Control-Allow-Methods', 'POST, PUT, GET, OPTIONS');
253
258
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
254
259
  if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
255
260
 
@@ -266,6 +271,14 @@ function startHttpServer() {
266
271
  if (req.method === 'GET' && url === '/api/version') { serveVersion(res); return; }
267
272
  if (req.method === 'GET' && url === '/api/nodes') { serveNodesJson(res); return; }
268
273
  if (req.method === 'GET' && url === '/api/discover'){ serveDiscoverJson(res); return; }
274
+ if (req.method === 'GET' && url === '/api/node-discover'){ serveNodeDiscoverJson(res); return; }
275
+ if (req.method === 'GET' && (url === '/getStates/metermaster.*' || url === '/getStates/metermaster.0.*.readings.latest')) {
276
+ serveGetStatesLatest(res); return;
277
+ }
278
+ const getStateMatch = req.method === 'GET' ? url.match(/^\/get\/(.+)$/) : null;
279
+ if (getStateMatch) {
280
+ serveGetState(decodeURIComponent(getStateMatch[1]), res); return;
281
+ }
269
282
 
270
283
  // ESP32 node registration (no auth – ESP32 has no credentials)
271
284
  if (req.method === 'POST' && url === '/api/register') {
@@ -315,7 +328,9 @@ function startHttpServer() {
315
328
  }
316
329
 
317
330
  if (req.method === 'GET' && url === '/api/ping') { handlePing(res, clientIp); }
318
- else if (req.method === 'POST' && url === '/api/reading') { readBody(req, b => handleReading(b, res, clientIp)); }
331
+ else if ((req.method === 'POST' || req.method === 'PUT') && url === '/api/reading') {
332
+ readBody(req, b => handleReading(b, res, clientIp));
333
+ }
319
334
  else if (req.method === 'POST' && url === '/api/readings') { readBody(req, b => handleReadings(b, res, clientIp)); }
320
335
  else if (req.method === 'POST' && url === '/api/import') { readBody(req, b => handleImport(b, res, clientIp)); }
321
336
  else {
@@ -377,8 +392,8 @@ async function handleReading(body, res, clientIp) {
377
392
  log(LVL.INFO, CAT.SYNC, `Reading received`,
378
393
  `${data.house}/${data.apartment}/${data.meter} = ${data.value} ${data.unit||''} (${data.readingDate})`);
379
394
  try {
380
- const path = await storeReading(data);
381
- sendJson(res, 200, { ok: true, path });
395
+ const result = await storeReading(data);
396
+ sendJson(res, 200, { ok: true, path: result.path, history: result.history });
382
397
  } catch (e) {
383
398
  log(LVL.ERROR, CAT.SYNC, `Storage error`, e.message);
384
399
  sendJson(res, 500, { error: e.message });
@@ -504,25 +519,28 @@ async function storeReading(data) {
504
519
 
505
520
  if (dpNew) log(LVL.INFO, CAT.DATAPOINT, `Data points created`, `${base}.readings.{latest,latestDate,history} + name/unit/typeName`);
506
521
 
507
- await adapter.setStateAsync(`${base}.readings.latest`, { val: value, ts, ack: true });
508
- await adapter.setStateAsync(`${base}.readings.latestDate`, { val: data.readingDate, ts, ack: true });
509
- await adapter.setStateAsync(`${base}.name`, { val: data.meter, ts, ack: true });
510
- await adapter.setStateAsync(`${base}.unit`, { val: data.unit||'', ts, ack: true });
511
- await adapter.setStateAsync(`${base}.typeName`, { val: data.typeName||'', ts, ack: true });
512
-
513
- log(LVL.DEBUG, CAT.SYNC, `State written`, `metermaster.0.${base} = ${value} ${data.unit||''} | ${data.readingDate}`);
522
+ await adapter.setStateAsync(`${base}.name`, { val: data.meter, ts, ack: true });
523
+ await adapter.setStateAsync(`${base}.unit`, { val: data.unit||'', ts, ack: true });
524
+ await adapter.setStateAsync(`${base}.typeName`, { val: data.typeName||'', ts, ack: true });
514
525
 
515
526
  const histResult = await updateHistory(base, { value, unit: data.unit||'', readingDate: data.readingDate, ts });
516
527
  if (histResult === 'added') log(LVL.DEBUG, CAT.HISTORY, `History +1`, `${base} @ ${data.readingDate}`);
528
+ if (histResult === 'updated') log(LVL.INFO, CAT.HISTORY, `History updated`, `${base} @ ${data.readingDate} → ${value}`);
517
529
  if (histResult === 'duplicate') log(LVL.DEBUG, CAT.HISTORY, `Duplicate`, `${base} @ ${data.readingDate}`);
518
530
 
519
- // In-Memory-Cache aktualisieren
531
+ // Cache + latest anhand chronologisch neuestem History-Eintrag
520
532
  cacheReading(house, apt, meter, value, data.unit||'', data.typeName||'', data.readingDate, ts);
533
+ const cached = receivedData[house][apt][meter];
534
+ const latestTs = new Date(cached.latestDate).getTime();
535
+ await adapter.setStateAsync(`${base}.readings.latest`, { val: cached.latest, ts: latestTs, ack: true });
536
+ await adapter.setStateAsync(`${base}.readings.latestDate`, { val: cached.latestDate, ts: latestTs, ack: true });
537
+
538
+ log(LVL.DEBUG, CAT.SYNC, `State written`, `metermaster.0.${base} = ${value} ${data.unit||''} | ${data.readingDate} (${histResult})`);
521
539
 
522
540
  readingsReceived++;
523
541
  await adapter.setStateAsync('info.lastSync', { val: Date.now(), ack: true });
524
542
  await adapter.setStateAsync('info.readingsReceived', { val: readingsReceived, ack: true });
525
- return base;
543
+ return { path: base, history: histResult };
526
544
  }
527
545
 
528
546
  // ─── Historie ─────────────────────────────────────────────────────────────────
@@ -534,7 +552,16 @@ async function updateHistory(base, entry) {
534
552
  const ex = await adapter.getStateAsync(stateId);
535
553
  if (ex && ex.val) { history = JSON.parse(ex.val); if (!Array.isArray(history)) history = []; }
536
554
  } catch { history = []; }
537
- if (history.some(h => h.ts === entry.ts)) return 'duplicate';
555
+
556
+ const idx = history.findIndex(h => h.ts === entry.ts);
557
+ if (idx >= 0) {
558
+ if (history[idx].value === entry.value) return 'duplicate';
559
+ history[idx] = entry;
560
+ history.sort((a, b) => a.ts - b.ts);
561
+ await adapter.setStateAsync(stateId, { val: JSON.stringify(history), ts: entry.ts, ack: true });
562
+ return 'updated';
563
+ }
564
+
538
565
  history.push(entry);
539
566
  history.sort((a, b) => a.ts - b.ts);
540
567
  if (keep > 0 && history.length > keep) history = history.slice(history.length - keep);
@@ -619,7 +646,11 @@ async function migrateStateRoles() {
619
646
 
620
647
  // ─── API Endpunkte ────────────────────────────────────────────────────────────
621
648
  function serveDataJson(res) {
622
- sendJson(res, 200, { data: receivedData, receivedTotal: readingsReceived });
649
+ sendJson(res, 200, {
650
+ data: receivedData,
651
+ receivedTotal: readingsReceived,
652
+ namespace: adapter.namespace,
653
+ });
623
654
  }
624
655
  function serveLogsJson(req, res) {
625
656
  const u = new URL(req.url, 'http://localhost');
@@ -690,6 +721,73 @@ function serveDiscoverJson(res) {
690
721
  sendJson(res, 200, result);
691
722
  }
692
723
 
724
+ // ─── simple-api-kompatibel: nur readings.latest (für ESP32-Nodes mit kleinem JSON-Puffer) ─
725
+ function serveGetStatesLatest(res) {
726
+ const result = {};
727
+ const ns = adapter.namespace;
728
+ const from = `system.adapter.${adapter.name}.${adapter.instance}`;
729
+ const now = Date.now();
730
+ for (const [house, apts] of Object.entries(receivedData)) {
731
+ for (const [apt, meters] of Object.entries(apts)) {
732
+ for (const [meter, data] of Object.entries(meters)) {
733
+ const ts = data.latestDate ? new Date(data.latestDate).getTime() : now;
734
+ result[`${ns}.${house}.${apt}.${meter}.readings.latest`] = {
735
+ val: data.latest,
736
+ ack: true,
737
+ ts,
738
+ q: 0,
739
+ from,
740
+ lc: ts,
741
+ };
742
+ }
743
+ }
744
+ }
745
+ sendJson(res, 200, result);
746
+ }
747
+
748
+ async function serveGetState(stateId, res) {
749
+ try {
750
+ const state = await adapter.getStateAsync(stateId);
751
+ if (!state || state.val === null || state.val === undefined) {
752
+ sendJson(res, 404, { error: 'State not found' });
753
+ return;
754
+ }
755
+ sendJson(res, 200, {
756
+ val: state.val,
757
+ ack: !!state.ack,
758
+ ts: state.ts,
759
+ q: state.q || 0,
760
+ from: state.from || '',
761
+ lc: state.lc || state.ts,
762
+ });
763
+ } catch (e) {
764
+ sendJson(res, 500, { error: e.message });
765
+ }
766
+ }
767
+
768
+ // ─── ESP32-WebUI: kompaktes Discover-Format {"ok":true,"states":[...]} ───────
769
+ function serveNodeDiscoverJson(res) {
770
+ const states = [];
771
+ const ns = adapter.namespace;
772
+ for (const [house, apts] of Object.entries(receivedData)) {
773
+ for (const [apt, meters] of Object.entries(apts)) {
774
+ for (const [meter, data] of Object.entries(meters)) {
775
+ const sid = `${ns}.${house}.${apt}.${meter}.readings.latest`;
776
+ let lbl = house;
777
+ if (apt) lbl += ' \u00B7 ' + apt;
778
+ if (meter) lbl += ' \u00B7 ' + meter;
779
+ states.push({
780
+ id: sid,
781
+ val: data.latest,
782
+ label: lbl,
783
+ unit: data.unit || '',
784
+ });
785
+ }
786
+ }
787
+ }
788
+ sendJson(res, 200, { ok: true, states });
789
+ }
790
+
693
791
  // ─── Node Config schreiben ────────────────────────────────────────────────────
694
792
  async function handleNodeConfig(mac, body, res, clientIp) {
695
793
  let data;
@@ -778,7 +876,7 @@ async function handleNodeRegister(body, res, clientIp) {
778
876
  await adapter.setStateAsync(`nodes.${mac}.lastSeen`, { val: ts, ack: true });
779
877
  if (ack) await adapter.setStateAsync(`nodes.${mac}.configAck`, { val: ack, ack: true });
780
878
 
781
- log(LVL.INFO, CAT.NODE, `Heartbeat`, `${mac} | IP: ${ip} | v${version} | ${name}`);
879
+ log(LVL.DEBUG, CAT.NODE, `Heartbeat`, `${mac} | IP: ${ip} | v${version} | ${name}`);
782
880
 
783
881
  // Aktuelle Config zurückgeben (null wenn noch keine gesetzt)
784
882
  const config = nodesCache[mac].config || null;
@@ -802,7 +900,7 @@ async function handleNodeAck(mac, body, res) {
802
900
  try {
803
901
  await ensureNodeStates(mac);
804
902
  await adapter.setStateAsync(`nodes.${mac}.configAck`, { val: ack, ack: true });
805
- log(LVL.INFO, CAT.NODE, `Config acknowledged`, `${mac} | ack: ${ack}`);
903
+ log(LVL.DEBUG, CAT.NODE, `Config acknowledged`, `${mac} | ack: ${ack}`);
806
904
  sendJson(res, 200, { ok: true });
807
905
  } catch (e) {
808
906
  sendJson(res, 500, { error: e.message });
@@ -1038,6 +1136,38 @@ nav {
1038
1136
  margin-bottom: 8px; padding: 4px 10px;
1039
1137
  border-left: 3px solid var(--primary-deep); background: var(--bg-surface);
1040
1138
  border-radius: 0 6px 6px 0;
1139
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
1140
+ }
1141
+ .print-scope-btn {
1142
+ background: transparent; border: 1px solid var(--border); color: var(--text-dim);
1143
+ border-radius: 6px; padding: 2px 8px; cursor: pointer; font-size: .78em;
1144
+ white-space: nowrap;
1145
+ }
1146
+ .print-scope-btn:hover { color: var(--secondary); border-color: var(--secondary); }
1147
+ .mc-nodes {
1148
+ margin-top: 10px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
1149
+ }
1150
+ .mc-nodes-label {
1151
+ font-size: .72em; color: var(--text-muted); text-transform: uppercase; letter-spacing: .4px;
1152
+ margin-right: 2px;
1153
+ }
1154
+ .node-chip {
1155
+ border: 1px solid var(--border); background: var(--bg-surface2); color: var(--text-dim);
1156
+ border-radius: 999px; padding: 3px 10px; font-size: .75em; font-weight: 600;
1157
+ cursor: pointer; line-height: 1.3; display: inline-flex; align-items: center; gap: 5px;
1158
+ }
1159
+ .node-chip:hover { border-color: var(--secondary); color: var(--secondary); }
1160
+ .node-chip.active {
1161
+ background: rgba(76,175,80,.18); color: #A5D6A7; border-color: rgba(76,175,80,.45);
1162
+ }
1163
+ .node-chip.offline { opacity: .7; }
1164
+ .node-chip .dot {
1165
+ width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); display: inline-block;
1166
+ }
1167
+ .node-chip.active .dot { background: var(--accent); box-shadow: 0 0 4px var(--accent); }
1168
+ .meter-card.has-node {
1169
+ border-color: rgba(76,175,80,.35);
1170
+ box-shadow: inset 0 0 0 1px rgba(76,175,80,.12);
1041
1171
  }
1042
1172
  .meters-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); gap: 12px; margin-left: 20px; }
1043
1173
 
@@ -1078,6 +1208,11 @@ nav {
1078
1208
  }
1079
1209
  .hist-row:last-child { border: none; }
1080
1210
  .hist-val { color: var(--text); font-weight: 600; }
1211
+ .hist-edit-btn {
1212
+ background: transparent; border: 1px solid var(--border); color: var(--text-dim);
1213
+ border-radius: 6px; padding: 2px 8px; cursor: pointer; font-size: .78em;
1214
+ }
1215
+ .hist-edit-btn:hover { color: var(--secondary); border-color: var(--secondary); }
1081
1216
  .mc-consume {
1082
1217
  font-size: .76em; color: var(--accent); margin-top: 6px;
1083
1218
  display: flex; align-items: center; gap: 4px;
@@ -1544,6 +1679,7 @@ input.search {
1544
1679
  <div class="chart-wrap-box"><canvas id="chart-line"></canvas></div>
1545
1680
  <div class="chart-wrap-box chart-wrap-sm"><canvas id="chart-bar"></canvas></div>
1546
1681
  <div class="chart-modal-foot">
1682
+ <button class="ghost" id="chart-print-btn" title="Print">🖨 Print</button>
1547
1683
  <button class="ghost" id="chart-csv-btn">⬇ CSV</button>
1548
1684
  </div>
1549
1685
  </div>
@@ -1625,7 +1761,13 @@ const I18N = {
1625
1761
  tab_data:'Daten', tab_nodes:'Nodes', tab_import:'Import', tab_logs:'Logs', tab_system:'System',
1626
1762
  no_data:'Noch keine Ablesungen empfangen.<br>Starte einen Sync in der MeterMaster App oder lade ein Backup hoch.',
1627
1763
  no_nodes:'Noch keine ESP32 Nodes registriert.<br>Wenn ein MeterMaster Node startet, erscheint er automatisch hier.',
1628
- history:'Verlauf', since_last:'seit letzter Ablesung', days:'Tage', chart_btn:'Chart', csv_btn:'CSV',
1764
+ history:'Verlauf', history_edit:'Wert bearbeiten', history_edit_prompt:'Neuer Zählerstand:', history_saved:'Gespeichert',
1765
+ since_last:'seit letzter Ablesung', days:'Tage', chart_btn:'Chart', csv_btn:'CSV', print_btn:'Drucken',
1766
+ print_chart:'Chart drucken', print_apartment:'Wohnung drucken', print_house:'Haus drucken',
1767
+ print_latest_title:'Aktuelle Zählerstände', print_meter:'Zähler', print_value:'Stand',
1768
+ print_unit:'Einheit', print_date:'Datum', print_generated:'Erstellt',
1769
+ node_label:'Node', node_assign:'Diesen Zähler auf dem Node anzeigen',
1770
+ node_unassign:'Anzeige auf diesem Node beenden', node_none:'Keine Nodes registriert',
1629
1771
  chart_readings:'Z\u00E4hlerstand', chart_monthly:'Monatsverbrauch', chart_period_all:'Alles',
1630
1772
  chart_yearly:'Pro Jahr', chart_yearly_proj:'Hochrechnung', chart_per_year:'/Jahr',
1631
1773
  chart_yearly_hint:'basierend auf {days} {daysLabel} ({delta} {unit})',
@@ -1672,7 +1814,13 @@ const I18N = {
1672
1814
  tab_data:'Data', tab_nodes:'Nodes', tab_import:'Import', tab_logs:'Logs', tab_system:'System',
1673
1815
  no_data:'No readings received yet.<br>Start a sync in the MeterMaster app or upload a backup.',
1674
1816
  no_nodes:'No ESP32 nodes registered yet.<br>When a MeterMaster node starts and sends its heartbeat, it will appear here automatically.',
1675
- history:'History', since_last:'since last reading', days:'days', chart_btn:'Chart', csv_btn:'CSV',
1817
+ history:'History', history_edit:'Edit value', history_edit_prompt:'New meter reading:', history_saved:'Saved',
1818
+ since_last:'since last reading', days:'days', chart_btn:'Chart', csv_btn:'CSV', print_btn:'Print',
1819
+ print_chart:'Print chart', print_apartment:'Print apartment', print_house:'Print house',
1820
+ print_latest_title:'Latest meter readings', print_meter:'Meter', print_value:'Reading',
1821
+ print_unit:'Unit', print_date:'Date', print_generated:'Generated',
1822
+ node_label:'Node', node_assign:'Show this meter on the node',
1823
+ node_unassign:'Stop showing on this node', node_none:'No nodes registered',
1676
1824
  chart_readings:'Meter reading', chart_monthly:'Monthly consumption', chart_period_all:'All',
1677
1825
  chart_yearly:'Per year', chart_yearly_proj:'Projected', chart_per_year:'/yr',
1678
1826
  chart_yearly_hint:'based on {days} {daysLabel} ({delta} {unit})',
@@ -1798,6 +1946,11 @@ function applyI18n() {
1798
1946
  if (crAll) crAll.textContent = t('chart_period_all');
1799
1947
  const csvBtn = document.getElementById('chart-csv-btn');
1800
1948
  if (csvBtn) csvBtn.textContent = '\u2B07 ' + t('csv_btn');
1949
+ const printBtn = document.getElementById('chart-print-btn');
1950
+ if (printBtn) {
1951
+ printBtn.textContent = '\uD83D\uDDA8 ' + t('print_btn');
1952
+ printBtn.title = t('print_chart');
1953
+ }
1801
1954
  const cc = document.getElementById('chart-close');
1802
1955
  if (cc) cc.title = t('chart_close');
1803
1956
  const yearlyBtn = document.getElementById('chart-yearly-toggle');
@@ -2070,6 +2223,90 @@ function closeChartModal() {
2070
2223
  chartCtxIdx = -1;
2071
2224
  }
2072
2225
 
2226
+ function openPrintWindow(title, bodyHtml) {
2227
+ const generated = new Date().toLocaleString(localeTag(), { hour12: false });
2228
+ const html =
2229
+ '<!DOCTYPE html><html><head><meta charset="utf-8"><title>'+esc(title)+'</title>'+
2230
+ '<style>'+
2231
+ 'body{font-family:Segoe UI,Helvetica,Arial,sans-serif;color:#111;margin:24px;}'+
2232
+ 'h1{font-size:1.25rem;margin:0 0 4px}h2{font-size:1rem;color:#444;font-weight:600;margin:0 0 16px}'+
2233
+ '.meta{font-size:.85rem;color:#666;margin-bottom:18px}'+
2234
+ 'table{width:100%;border-collapse:collapse;font-size:.95rem}'+
2235
+ 'th,td{border-bottom:1px solid #ddd;padding:8px 6px;text-align:left}'+
2236
+ 'th{font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;color:#555}'+
2237
+ 'td.num{text-align:right;font-variant-numeric:tabular-nums}'+
2238
+ 'img{max-width:100%;height:auto;margin:12px 0;border:1px solid #eee}'+
2239
+ '@media print{body{margin:12mm}button{display:none}}'+
2240
+ '</style></head><body>'+
2241
+ '<h1>'+esc(title)+'</h1>'+
2242
+ '<div class="meta">'+esc(t('print_generated'))+': '+esc(generated)+'</div>'+
2243
+ bodyHtml+
2244
+ '<script>window.onload=function(){window.focus();window.print();}<\\/script>'+
2245
+ '</body></html>';
2246
+ const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
2247
+ const url = URL.createObjectURL(blob);
2248
+ const w = window.open(url, '_blank');
2249
+ if (!w) {
2250
+ URL.revokeObjectURL(url);
2251
+ window.alert(t('error'));
2252
+ return;
2253
+ }
2254
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
2255
+ }
2256
+
2257
+ window.printChartView = function printChartView() {
2258
+ if (chartCtxIdx < 0) return;
2259
+ const m = dataCacheList[chartCtxIdx];
2260
+ if (!m) return;
2261
+ const line = document.getElementById('chart-line');
2262
+ const bar = document.getElementById('chart-bar');
2263
+ const kpi = document.getElementById('chart-kpi');
2264
+ const kpiY = document.getElementById('chart-kpi-yearly');
2265
+ let html = '<h2>'+esc(m.house + ' \u203A ' + m.apartment)+
2266
+ (m.typeName ? ' \u00B7 ' + esc(m.typeName) : '')+'</h2>';
2267
+ if (kpi && kpi.style.display !== 'none' && kpi.textContent.trim()) {
2268
+ html += '<p>'+esc(kpi.textContent.trim())+'</p>';
2269
+ }
2270
+ if (kpiY && kpiY.style.display !== 'none' && kpiY.textContent.trim()) {
2271
+ html += '<p>'+esc(kpiY.textContent.trim())+'</p>';
2272
+ }
2273
+ if (line) html += '<img alt="'+esc(t('chart_readings'))+'" src="'+line.toDataURL('image/png')+'">';
2274
+ if (bar && bar.offsetParent !== null) {
2275
+ html += '<img alt="'+esc(t('chart_monthly'))+'" src="'+bar.toDataURL('image/png')+'">';
2276
+ }
2277
+ openPrintWindow(m.meter + ' \u2013 ' + t('print_chart'), html);
2278
+ };
2279
+
2280
+ window.printScopeReadings = function printScopeReadings(house, apartment) {
2281
+ const rows = dataCacheList.filter(m =>
2282
+ m.house === house && (!apartment || m.apartment === apartment)
2283
+ );
2284
+ if (!rows.length) return;
2285
+ const title = apartment
2286
+ ? (house + ' \u203A ' + apartment)
2287
+ : house;
2288
+ let html = '<h2>'+esc(t('print_latest_title'))+'</h2>';
2289
+ html += '<table><thead><tr>'+
2290
+ (apartment ? '' : '<th>'+esc(t('prev_apts'))+'</th>')+
2291
+ '<th>'+esc(t('print_meter'))+'</th>'+
2292
+ '<th>'+esc(t('print_value'))+'</th>'+
2293
+ '<th>'+esc(t('print_unit'))+'</th>'+
2294
+ '<th>'+esc(t('print_date'))+'</th>'+
2295
+ '</tr></thead><tbody>';
2296
+ for (const m of rows) {
2297
+ const dt = m.latestDate ? fmtDt(new Date(m.latestDate).getTime()) : '\u2013';
2298
+ html += '<tr>'+
2299
+ (apartment ? '' : '<td>'+esc(m.apartment)+'</td>')+
2300
+ '<td>'+esc(m.meter)+(m.typeName ? ' <span style="color:#777">('+esc(m.typeName)+')</span>' : '')+'</td>'+
2301
+ '<td class="num">'+(m.latest !== undefined && m.latest !== null ? esc(String(m.latest)) : '\u2013')+'</td>'+
2302
+ '<td>'+esc(m.unit || '')+'</td>'+
2303
+ '<td>'+esc(dt)+'</td>'+
2304
+ '</tr>';
2305
+ }
2306
+ html += '</tbody></table>';
2307
+ openPrintWindow(title, html);
2308
+ };
2309
+
2073
2310
  // \u2500\u2500 Tab-Navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2074
2311
  window.showTab = function showTab(name) {
2075
2312
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
@@ -2124,9 +2361,38 @@ async function fetchSysStats() {
2124
2361
  }
2125
2362
 
2126
2363
  // \u2500\u2500 DATEN-TAB \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2364
+ let nodesCacheForData = [];
2365
+ let dataNamespace = '';
2366
+
2367
+ function meterStateId(house, apt, meter) {
2368
+ return dataNamespace ? (dataNamespace + '.' + house + '.' + apt + '.' + meter + '.readings.latest') : '';
2369
+ }
2370
+
2371
+ function nodeSid(n) {
2372
+ try { return JSON.parse(n.config || '{}').sid || ''; } catch { return ''; }
2373
+ }
2374
+
2375
+ function buildNodeChipsHtml(stateId, unit, label) {
2376
+ if (!nodesCacheForData.length) return '';
2377
+ const chips = nodesCacheForData.map(n => {
2378
+ const sid = nodeSid(n);
2379
+ const active = sid && stateId && sid === stateId;
2380
+ const name = n.name || n.mac;
2381
+ const cls = 'node-chip' + (active ? ' active' : '') + (n.online ? '' : ' offline');
2382
+ const title = active ? t('node_unassign') : t('node_assign');
2383
+ return '<button type="button" class="'+cls+'" data-mac="'+esc(n.mac)+'" data-sid="'+esc(stateId)+'" data-unit="'+esc(unit||'')+'" data-label="'+esc(label||'')+'" data-active="'+(active?'1':'0')+'" title="'+esc(title)+'"><span class="dot"></span>'+esc(name)+'</button>';
2384
+ }).join('');
2385
+ return '<div class="mc-nodes"><span class="mc-nodes-label">'+esc(t('node_label'))+'</span>'+chips+'</div>';
2386
+ }
2387
+
2127
2388
  async function fetchData() {
2128
2389
  try {
2129
- const d = await fetch('/api/data').then(r => r.json());
2390
+ const [d, nodes] = await Promise.all([
2391
+ fetch('/api/data').then(r => r.json()),
2392
+ fetch('/api/nodes').then(r => r.json()).catch(() => [])
2393
+ ]);
2394
+ nodesCacheForData = Array.isArray(nodes) ? nodes : [];
2395
+ dataNamespace = d.namespace || '';
2130
2396
  const con = document.getElementById('data-container');
2131
2397
  dataCacheList = [];
2132
2398
  if (!d.data || !Object.keys(d.data).length) {
@@ -2136,9 +2402,11 @@ async function fetchData() {
2136
2402
  let html = '';
2137
2403
  let idx = 0;
2138
2404
  for (const [house, apts] of Object.entries(d.data)) {
2139
- html += '<div class="house-block"><div class="house-title">\uD83C\uDFE0 '+esc(house)+'</div>';
2405
+ html += '<div class="house-block"><div class="house-title"><span>\uD83C\uDFE0 '+esc(house)+'</span>'+
2406
+ '<button type="button" class="print-scope-btn" data-print-house="'+esc(house)+'" title="'+esc(t('print_house'))+'">\uD83D\uDDA8</button></div>';
2140
2407
  for (const [apt, meters] of Object.entries(apts)) {
2141
- html += '<div class="apt-block"><div class="apt-title">\uD83C\uDFD8 '+esc(apt)+'</div><div class="meters-grid">';
2408
+ html += '<div class="apt-block"><div class="apt-title"><span>\uD83C\uDFD8 '+esc(apt)+'</span>'+
2409
+ '<button type="button" class="print-scope-btn" data-print-house="'+esc(house)+'" data-print-apt="'+esc(apt)+'" title="'+esc(t('print_apartment'))+'">\uD83D\uDDA8</button></div><div class="meters-grid">';
2142
2410
  for (const [key, m] of Object.entries(meters)) {
2143
2411
  const cacheIdx = idx++;
2144
2412
  dataCacheList.push({ house, apartment: apt, meter: key, unit: m.unit, typeName: m.typeName, latest: m.latest, latestDate: m.latestDate, history: m.history || [] });
@@ -2146,8 +2414,16 @@ async function fetchData() {
2146
2414
  const histId = 'h-'+CSS.escape(house+apt+key);
2147
2415
  const hist = m.history || [];
2148
2416
  const delta = calcDelta(hist);
2417
+ const sid = meterStateId(house, apt, key);
2418
+ const assignedHere = nodesCacheForData.some(n => nodeSid(n) === sid);
2149
2419
  const rows = hist.slice().reverse().map(h =>
2150
- '<div class="hist-row"><span>'+esc(fmtDt(h.ts))+'</span><span class="hist-val">'+h.value+' '+esc(m.unit||'')+'</span></div>'
2420
+ '<div class="hist-row">'+
2421
+ '<span>'+esc(fmtDt(h.ts))+'</span>'+
2422
+ '<span style="display:flex;align-items:center;gap:8px;">'+
2423
+ '<span class="hist-val">'+h.value+' '+esc(m.unit||'')+'</span>'+
2424
+ '<button type="button" class="hist-edit-btn" data-idx="'+cacheIdx+'" data-ts="'+h.ts+'" title="'+esc(t('history_edit'))+'">\u270E</button>'+
2425
+ '</span>'+
2426
+ '</div>'
2151
2427
  ).join('');
2152
2428
  const consumeHtml = delta
2153
2429
  ? '<div class="mc-consume">\uD83D\uDCCA +'+fmtNum(delta.delta)+' '+esc(m.unit||'')+' '+t('since_last')+' ('+delta.days+' '+t('days')+')</div>'
@@ -2158,8 +2434,9 @@ async function fetchData() {
2158
2434
  '<button class="mc-csv-btn" data-idx="'+cacheIdx+'" title="'+esc(t('csv_btn'))+'">\u2B07 '+esc(t('csv_btn'))+'</button>'+
2159
2435
  '</div>'
2160
2436
  : '';
2437
+ const nodesHtml = buildNodeChipsHtml(sid, m.unit, key);
2161
2438
  html +=
2162
- '<div class="meter-card">'+
2439
+ '<div class="meter-card'+(assignedHere ? ' has-node' : '')+'">'+
2163
2440
  '<div class="mc-head">'+
2164
2441
  '<div class="mc-name">'+icon+' '+esc(key)+'</div>'+
2165
2442
  '<div class="mc-badge">'+esc(m.typeName||'?')+'</div>'+
@@ -2175,6 +2452,7 @@ async function fetchData() {
2175
2452
  '<div class="mc-history" id="'+histId+'">'+rows+'</div>'
2176
2453
  : '')+
2177
2454
  actionsHtml+
2455
+ nodesHtml+
2178
2456
  '</div>';
2179
2457
  }
2180
2458
  html += '</div></div>';
@@ -2188,10 +2466,80 @@ async function fetchData() {
2188
2466
  }
2189
2467
  }
2190
2468
 
2469
+ window.toggleNodeAssign = async function toggleNodeAssign(mac, sid, label, unit, currentlyActive) {
2470
+ const nextSid = currentlyActive ? '' : sid;
2471
+ try {
2472
+ const r = await authFetch('/api/nodes/'+encodeURIComponent(mac)+'/config', {
2473
+ method: 'POST',
2474
+ body: JSON.stringify({
2475
+ sid: nextSid,
2476
+ label: nextSid ? (label || '') : '',
2477
+ unit: nextSid ? (unit || '') : ''
2478
+ })
2479
+ });
2480
+ if (!r) return;
2481
+ const d = await r.json();
2482
+ if (!d.ok) {
2483
+ window.alert(t('error') + ': ' + (d.error || r.status));
2484
+ return;
2485
+ }
2486
+ await fetchData();
2487
+ if (typeof fetchNodes === 'function' && document.querySelector('.nav-item.active[data-tab="nodes"]')) {
2488
+ fetchNodes();
2489
+ }
2490
+ } catch (e) {
2491
+ window.alert(t('error') + ': ' + e.message);
2492
+ }
2493
+ }
2494
+
2191
2495
  function toggleHist(id) {
2192
2496
  const el = document.getElementById(id);
2193
2497
  if (el) el.classList.toggle('open');
2194
2498
  }
2499
+
2500
+ window.editHistoryValue = async function editHistoryValue(cacheIdx, ts) {
2501
+ const m = dataCacheList[cacheIdx];
2502
+ if (!m) return;
2503
+ const entry = (m.history || []).find(h => h.ts === ts);
2504
+ if (!entry) return;
2505
+ const raw = window.prompt(t('history_edit_prompt'), String(entry.value));
2506
+ if (raw === null) return;
2507
+ const normalized = String(raw).trim().replace(',', '.');
2508
+ const value = parseFloat(normalized);
2509
+ if (!Number.isFinite(value)) {
2510
+ window.alert(t('error') + ': value');
2511
+ return;
2512
+ }
2513
+ try {
2514
+ const r = await authFetch('/api/reading', {
2515
+ method: 'POST',
2516
+ body: JSON.stringify({
2517
+ house: m.house,
2518
+ apartment: m.apartment,
2519
+ meter: m.meter,
2520
+ value,
2521
+ unit: m.unit || '',
2522
+ typeName: m.typeName || '',
2523
+ readingDate: entry.readingDate
2524
+ })
2525
+ });
2526
+ if (!r) return;
2527
+ const d = await r.json();
2528
+ if (!d.ok) {
2529
+ window.alert(t('error') + ': ' + (d.error || r.status));
2530
+ return;
2531
+ }
2532
+ entry.value = value;
2533
+ if (m.history && m.history.length) {
2534
+ const newest = m.history.slice().sort((a, b) => a.ts - b.ts).pop();
2535
+ m.latest = newest.value;
2536
+ m.latestDate = newest.readingDate;
2537
+ }
2538
+ await fetchData();
2539
+ } catch (e) {
2540
+ window.alert(t('error') + ': ' + e.message);
2541
+ }
2542
+ }
2195
2543
  // \u2500\u2500 NODES-TAB \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2196
2544
  let discoverCache = [];
2197
2545
 
@@ -2517,6 +2865,28 @@ function initTabs() {
2517
2865
  if (btn && btn.dataset.hist) toggleHist(btn.dataset.hist);
2518
2866
  });
2519
2867
 
2868
+ // History value edit
2869
+ document.addEventListener('click', e => {
2870
+ const editBtn = e.target.closest('.hist-edit-btn');
2871
+ if (!editBtn) return;
2872
+ const idx = parseInt(editBtn.dataset.idx, 10);
2873
+ const ts = parseInt(editBtn.dataset.ts, 10);
2874
+ editHistoryValue(idx, ts);
2875
+ });
2876
+
2877
+ // Node chips on meter cards: assign / unassign
2878
+ document.addEventListener('click', e => {
2879
+ const chip = e.target.closest('.node-chip');
2880
+ if (!chip) return;
2881
+ toggleNodeAssign(
2882
+ chip.dataset.mac,
2883
+ chip.dataset.sid,
2884
+ chip.dataset.label || '',
2885
+ chip.dataset.unit || '',
2886
+ chip.dataset.active === '1'
2887
+ );
2888
+ });
2889
+
2520
2890
  // Chart + CSV + Chart-Modal via Event Delegation
2521
2891
  document.addEventListener('click', e => {
2522
2892
  const chartBtn = e.target.closest('.mc-chart-btn');
@@ -2526,6 +2896,12 @@ function initTabs() {
2526
2896
  if (e.target.closest('.chart-close')) { closeChartModal(); return; }
2527
2897
  if (e.target.id === 'chart-overlay') { closeChartModal(); return; }
2528
2898
  if (e.target.closest('#chart-csv-btn')) { if (chartCtxIdx >= 0) exportMeterCsv(chartCtxIdx); return; }
2899
+ if (e.target.closest('#chart-print-btn')) { printChartView(); return; }
2900
+ const scopeBtn = e.target.closest('.print-scope-btn');
2901
+ if (scopeBtn) {
2902
+ printScopeReadings(scopeBtn.dataset.printHouse, scopeBtn.dataset.printApt || null);
2903
+ return;
2904
+ }
2529
2905
  const rangeBtn = e.target.closest('.chart-range[data-months]');
2530
2906
  if (rangeBtn) {
2531
2907
  chartRangeMonths = parseInt(rangeBtn.dataset.months, 10) || 0;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "iobroker.metermaster",
3
- "version": "0.9.4",
4
- "description": "ioBroker adapter for the MeterMaster Android app – receives meter readings and manages ESP32 display nodes",
3
+ "version": "0.9.7",
4
+ "description": "ioBroker adapter for the MeterMaster Android app \u2013 receives meter readings and manages ESP32 display nodes",
5
5
  "author": {
6
6
  "name": "MPunktBPunkt",
7
7
  "url": "https://github.com/MPunktBPunkt"
@@ -34,12 +34,13 @@
34
34
  "lint": "eslint ."
35
35
  },
36
36
  "dependencies": {
37
- "@iobroker/adapter-core": "^3.4.1"
37
+ "@iobroker/adapter-core": "^3.4.3"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@iobroker/adapter-dev": "^1.5.0",
41
41
  "@iobroker/eslint-config": "^2.3.4",
42
42
  "@iobroker/testing": "^5.3.0",
43
+ "@tsconfig/node22": "^22.0.6",
43
44
  "@types/node": "^22.0.0"
44
45
  },
45
46
  "publishConfig": {