iobroker.metermaster 0.9.4 → 0.9.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.
Files changed (4) hide show
  1. package/README.md +140 -116
  2. package/io-package.json +28 -2
  3. package/main.js +326 -27
  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,34 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "metermaster",
4
- "version": "0.9.4",
4
+ "version": "0.9.6",
5
5
  "news": {
6
+ "0.9.6": {
7
+ "en": "Assign display nodes via chips on meter cards in the Data tab",
8
+ "de": "Anzeige-Nodes per Chips auf den Zähler-Kacheln im Daten-Tab zuweisen",
9
+ "ru": "Assign nodes via chips on meter cards",
10
+ "pt": "Assign nodes via chips on meter cards",
11
+ "nl": "Nodes toewijzen via chips op meterkarten",
12
+ "fr": "Assigner des nodes via des chips sur les cartes compteur",
13
+ "it": "Assegna node tramite chip sulle card contatore",
14
+ "es": "Asignar nodes mediante chips en las tarjetas de contador",
15
+ "pl": "Przypisywanie node przez chipy na kartach liczników",
16
+ "uk": "Призначення node через чіпи на картках лічильників",
17
+ "zh-cn": "在数据页计量表卡片上通过芯片分配显示节点"
18
+ },
19
+ "0.9.5": {
20
+ "en": "Correct history on re-sync; edit values in Web UI; print chart and apartment/house latest readings",
21
+ "de": "Historie bei erneutem Sync korrigieren; Werte in der Web-UI bearbeiten; Chart sowie Wohnungs-/Haus-Stände drucken",
22
+ "ru": "Correction of history; Web UI edit; print chart and readings",
23
+ "pt": "Correct history; Web UI edit; print",
24
+ "nl": "History corrigeren; Web UI edit; printen",
25
+ "fr": "Corriger l'historique; édition Web UI; impression",
26
+ "it": "Correggi cronologia; modifica Web UI; stampa",
27
+ "es": "Corregir historial; edición Web UI; imprimir",
28
+ "pl": "Korekta historii; edycja Web UI; druk",
29
+ "uk": "Корекція історії; редагування Web UI; друк",
30
+ "zh-cn": "更正历史记录;Web UI 编辑;打印图表与读数"
31
+ },
6
32
  "0.9.4": {
7
33
  "en": "Review fixes: English logs/states/API errors, Web UI i18n, config validation, removed /api/update",
8
34
  "de": "Review-Fixes: Englische Logs/States/API-Fehler, Web-UI-i18n, Config-Validierung, /api/update entfernt",
@@ -117,7 +143,7 @@
117
143
  ],
118
144
  "globalDependencies": [
119
145
  {
120
- "admin": ">=7.6.20"
146
+ "admin": ">=7.8.23"
121
147
  }
122
148
  ]
123
149
  },
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.6';
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 ───────────────────────────────────────────────────────────────
@@ -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
 
@@ -315,7 +320,9 @@ function startHttpServer() {
315
320
  }
316
321
 
317
322
  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)); }
323
+ else if ((req.method === 'POST' || req.method === 'PUT') && url === '/api/reading') {
324
+ readBody(req, b => handleReading(b, res, clientIp));
325
+ }
319
326
  else if (req.method === 'POST' && url === '/api/readings') { readBody(req, b => handleReadings(b, res, clientIp)); }
320
327
  else if (req.method === 'POST' && url === '/api/import') { readBody(req, b => handleImport(b, res, clientIp)); }
321
328
  else {
@@ -377,8 +384,8 @@ async function handleReading(body, res, clientIp) {
377
384
  log(LVL.INFO, CAT.SYNC, `Reading received`,
378
385
  `${data.house}/${data.apartment}/${data.meter} = ${data.value} ${data.unit||''} (${data.readingDate})`);
379
386
  try {
380
- const path = await storeReading(data);
381
- sendJson(res, 200, { ok: true, path });
387
+ const result = await storeReading(data);
388
+ sendJson(res, 200, { ok: true, path: result.path, history: result.history });
382
389
  } catch (e) {
383
390
  log(LVL.ERROR, CAT.SYNC, `Storage error`, e.message);
384
391
  sendJson(res, 500, { error: e.message });
@@ -504,25 +511,28 @@ async function storeReading(data) {
504
511
 
505
512
  if (dpNew) log(LVL.INFO, CAT.DATAPOINT, `Data points created`, `${base}.readings.{latest,latestDate,history} + name/unit/typeName`);
506
513
 
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}`);
514
+ await adapter.setStateAsync(`${base}.name`, { val: data.meter, ts, ack: true });
515
+ await adapter.setStateAsync(`${base}.unit`, { val: data.unit||'', ts, ack: true });
516
+ await adapter.setStateAsync(`${base}.typeName`, { val: data.typeName||'', ts, ack: true });
514
517
 
515
518
  const histResult = await updateHistory(base, { value, unit: data.unit||'', readingDate: data.readingDate, ts });
516
519
  if (histResult === 'added') log(LVL.DEBUG, CAT.HISTORY, `History +1`, `${base} @ ${data.readingDate}`);
520
+ if (histResult === 'updated') log(LVL.INFO, CAT.HISTORY, `History updated`, `${base} @ ${data.readingDate} → ${value}`);
517
521
  if (histResult === 'duplicate') log(LVL.DEBUG, CAT.HISTORY, `Duplicate`, `${base} @ ${data.readingDate}`);
518
522
 
519
- // In-Memory-Cache aktualisieren
523
+ // Cache + latest anhand chronologisch neuestem History-Eintrag
520
524
  cacheReading(house, apt, meter, value, data.unit||'', data.typeName||'', data.readingDate, ts);
525
+ const cached = receivedData[house][apt][meter];
526
+ const latestTs = new Date(cached.latestDate).getTime();
527
+ await adapter.setStateAsync(`${base}.readings.latest`, { val: cached.latest, ts: latestTs, ack: true });
528
+ await adapter.setStateAsync(`${base}.readings.latestDate`, { val: cached.latestDate, ts: latestTs, ack: true });
529
+
530
+ log(LVL.DEBUG, CAT.SYNC, `State written`, `metermaster.0.${base} = ${value} ${data.unit||''} | ${data.readingDate} (${histResult})`);
521
531
 
522
532
  readingsReceived++;
523
533
  await adapter.setStateAsync('info.lastSync', { val: Date.now(), ack: true });
524
534
  await adapter.setStateAsync('info.readingsReceived', { val: readingsReceived, ack: true });
525
- return base;
535
+ return { path: base, history: histResult };
526
536
  }
527
537
 
528
538
  // ─── Historie ─────────────────────────────────────────────────────────────────
@@ -534,7 +544,16 @@ async function updateHistory(base, entry) {
534
544
  const ex = await adapter.getStateAsync(stateId);
535
545
  if (ex && ex.val) { history = JSON.parse(ex.val); if (!Array.isArray(history)) history = []; }
536
546
  } catch { history = []; }
537
- if (history.some(h => h.ts === entry.ts)) return 'duplicate';
547
+
548
+ const idx = history.findIndex(h => h.ts === entry.ts);
549
+ if (idx >= 0) {
550
+ if (history[idx].value === entry.value) return 'duplicate';
551
+ history[idx] = entry;
552
+ history.sort((a, b) => a.ts - b.ts);
553
+ await adapter.setStateAsync(stateId, { val: JSON.stringify(history), ts: entry.ts, ack: true });
554
+ return 'updated';
555
+ }
556
+
538
557
  history.push(entry);
539
558
  history.sort((a, b) => a.ts - b.ts);
540
559
  if (keep > 0 && history.length > keep) history = history.slice(history.length - keep);
@@ -619,7 +638,11 @@ async function migrateStateRoles() {
619
638
 
620
639
  // ─── API Endpunkte ────────────────────────────────────────────────────────────
621
640
  function serveDataJson(res) {
622
- sendJson(res, 200, { data: receivedData, receivedTotal: readingsReceived });
641
+ sendJson(res, 200, {
642
+ data: receivedData,
643
+ receivedTotal: readingsReceived,
644
+ namespace: adapter.namespace,
645
+ });
623
646
  }
624
647
  function serveLogsJson(req, res) {
625
648
  const u = new URL(req.url, 'http://localhost');
@@ -1038,6 +1061,38 @@ nav {
1038
1061
  margin-bottom: 8px; padding: 4px 10px;
1039
1062
  border-left: 3px solid var(--primary-deep); background: var(--bg-surface);
1040
1063
  border-radius: 0 6px 6px 0;
1064
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
1065
+ }
1066
+ .print-scope-btn {
1067
+ background: transparent; border: 1px solid var(--border); color: var(--text-dim);
1068
+ border-radius: 6px; padding: 2px 8px; cursor: pointer; font-size: .78em;
1069
+ white-space: nowrap;
1070
+ }
1071
+ .print-scope-btn:hover { color: var(--secondary); border-color: var(--secondary); }
1072
+ .mc-nodes {
1073
+ margin-top: 10px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
1074
+ }
1075
+ .mc-nodes-label {
1076
+ font-size: .72em; color: var(--text-muted); text-transform: uppercase; letter-spacing: .4px;
1077
+ margin-right: 2px;
1078
+ }
1079
+ .node-chip {
1080
+ border: 1px solid var(--border); background: var(--bg-surface2); color: var(--text-dim);
1081
+ border-radius: 999px; padding: 3px 10px; font-size: .75em; font-weight: 600;
1082
+ cursor: pointer; line-height: 1.3; display: inline-flex; align-items: center; gap: 5px;
1083
+ }
1084
+ .node-chip:hover { border-color: var(--secondary); color: var(--secondary); }
1085
+ .node-chip.active {
1086
+ background: rgba(76,175,80,.18); color: #A5D6A7; border-color: rgba(76,175,80,.45);
1087
+ }
1088
+ .node-chip.offline { opacity: .7; }
1089
+ .node-chip .dot {
1090
+ width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); display: inline-block;
1091
+ }
1092
+ .node-chip.active .dot { background: var(--accent); box-shadow: 0 0 4px var(--accent); }
1093
+ .meter-card.has-node {
1094
+ border-color: rgba(76,175,80,.35);
1095
+ box-shadow: inset 0 0 0 1px rgba(76,175,80,.12);
1041
1096
  }
1042
1097
  .meters-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); gap: 12px; margin-left: 20px; }
1043
1098
 
@@ -1078,6 +1133,11 @@ nav {
1078
1133
  }
1079
1134
  .hist-row:last-child { border: none; }
1080
1135
  .hist-val { color: var(--text); font-weight: 600; }
1136
+ .hist-edit-btn {
1137
+ background: transparent; border: 1px solid var(--border); color: var(--text-dim);
1138
+ border-radius: 6px; padding: 2px 8px; cursor: pointer; font-size: .78em;
1139
+ }
1140
+ .hist-edit-btn:hover { color: var(--secondary); border-color: var(--secondary); }
1081
1141
  .mc-consume {
1082
1142
  font-size: .76em; color: var(--accent); margin-top: 6px;
1083
1143
  display: flex; align-items: center; gap: 4px;
@@ -1544,6 +1604,7 @@ input.search {
1544
1604
  <div class="chart-wrap-box"><canvas id="chart-line"></canvas></div>
1545
1605
  <div class="chart-wrap-box chart-wrap-sm"><canvas id="chart-bar"></canvas></div>
1546
1606
  <div class="chart-modal-foot">
1607
+ <button class="ghost" id="chart-print-btn" title="Print">🖨 Print</button>
1547
1608
  <button class="ghost" id="chart-csv-btn">⬇ CSV</button>
1548
1609
  </div>
1549
1610
  </div>
@@ -1625,7 +1686,13 @@ const I18N = {
1625
1686
  tab_data:'Daten', tab_nodes:'Nodes', tab_import:'Import', tab_logs:'Logs', tab_system:'System',
1626
1687
  no_data:'Noch keine Ablesungen empfangen.<br>Starte einen Sync in der MeterMaster App oder lade ein Backup hoch.',
1627
1688
  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',
1689
+ history:'Verlauf', history_edit:'Wert bearbeiten', history_edit_prompt:'Neuer Zählerstand:', history_saved:'Gespeichert',
1690
+ since_last:'seit letzter Ablesung', days:'Tage', chart_btn:'Chart', csv_btn:'CSV', print_btn:'Drucken',
1691
+ print_chart:'Chart drucken', print_apartment:'Wohnung drucken', print_house:'Haus drucken',
1692
+ print_latest_title:'Aktuelle Zählerstände', print_meter:'Zähler', print_value:'Stand',
1693
+ print_unit:'Einheit', print_date:'Datum', print_generated:'Erstellt',
1694
+ node_label:'Node', node_assign:'Diesen Zähler auf dem Node anzeigen',
1695
+ node_unassign:'Anzeige auf diesem Node beenden', node_none:'Keine Nodes registriert',
1629
1696
  chart_readings:'Z\u00E4hlerstand', chart_monthly:'Monatsverbrauch', chart_period_all:'Alles',
1630
1697
  chart_yearly:'Pro Jahr', chart_yearly_proj:'Hochrechnung', chart_per_year:'/Jahr',
1631
1698
  chart_yearly_hint:'basierend auf {days} {daysLabel} ({delta} {unit})',
@@ -1672,7 +1739,13 @@ const I18N = {
1672
1739
  tab_data:'Data', tab_nodes:'Nodes', tab_import:'Import', tab_logs:'Logs', tab_system:'System',
1673
1740
  no_data:'No readings received yet.<br>Start a sync in the MeterMaster app or upload a backup.',
1674
1741
  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',
1742
+ history:'History', history_edit:'Edit value', history_edit_prompt:'New meter reading:', history_saved:'Saved',
1743
+ since_last:'since last reading', days:'days', chart_btn:'Chart', csv_btn:'CSV', print_btn:'Print',
1744
+ print_chart:'Print chart', print_apartment:'Print apartment', print_house:'Print house',
1745
+ print_latest_title:'Latest meter readings', print_meter:'Meter', print_value:'Reading',
1746
+ print_unit:'Unit', print_date:'Date', print_generated:'Generated',
1747
+ node_label:'Node', node_assign:'Show this meter on the node',
1748
+ node_unassign:'Stop showing on this node', node_none:'No nodes registered',
1676
1749
  chart_readings:'Meter reading', chart_monthly:'Monthly consumption', chart_period_all:'All',
1677
1750
  chart_yearly:'Per year', chart_yearly_proj:'Projected', chart_per_year:'/yr',
1678
1751
  chart_yearly_hint:'based on {days} {daysLabel} ({delta} {unit})',
@@ -1798,6 +1871,11 @@ function applyI18n() {
1798
1871
  if (crAll) crAll.textContent = t('chart_period_all');
1799
1872
  const csvBtn = document.getElementById('chart-csv-btn');
1800
1873
  if (csvBtn) csvBtn.textContent = '\u2B07 ' + t('csv_btn');
1874
+ const printBtn = document.getElementById('chart-print-btn');
1875
+ if (printBtn) {
1876
+ printBtn.textContent = '\uD83D\uDDA8 ' + t('print_btn');
1877
+ printBtn.title = t('print_chart');
1878
+ }
1801
1879
  const cc = document.getElementById('chart-close');
1802
1880
  if (cc) cc.title = t('chart_close');
1803
1881
  const yearlyBtn = document.getElementById('chart-yearly-toggle');
@@ -2070,6 +2148,88 @@ function closeChartModal() {
2070
2148
  chartCtxIdx = -1;
2071
2149
  }
2072
2150
 
2151
+ function openPrintWindow(title, bodyHtml) {
2152
+ const w = window.open('', '_blank', 'noopener,noreferrer');
2153
+ if (!w) {
2154
+ window.alert(t('error'));
2155
+ return;
2156
+ }
2157
+ const generated = new Date().toLocaleString(localeTag(), { hour12: false });
2158
+ w.document.write(
2159
+ '<!DOCTYPE html><html><head><meta charset="utf-8"><title>'+esc(title)+'</title>'+
2160
+ '<style>'+
2161
+ 'body{font-family:Segoe UI,Helvetica,Arial,sans-serif;color:#111;margin:24px;}'+
2162
+ 'h1{font-size:1.25rem;margin:0 0 4px}h2{font-size:1rem;color:#444;font-weight:600;margin:0 0 16px}'+
2163
+ '.meta{font-size:.85rem;color:#666;margin-bottom:18px}'+
2164
+ 'table{width:100%;border-collapse:collapse;font-size:.95rem}'+
2165
+ 'th,td{border-bottom:1px solid #ddd;padding:8px 6px;text-align:left}'+
2166
+ 'th{font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;color:#555}'+
2167
+ 'td.num{text-align:right;font-variant-numeric:tabular-nums}'+
2168
+ 'img{max-width:100%;height:auto;margin:12px 0;border:1px solid #eee}'+
2169
+ '@media print{body{margin:12mm}button{display:none}}'+
2170
+ '</style></head><body>'+
2171
+ '<h1>'+esc(title)+'</h1>'+
2172
+ '<div class="meta">'+esc(t('print_generated'))+': '+esc(generated)+'</div>'+
2173
+ bodyHtml+
2174
+ '<script>window.onload=function(){window.focus();window.print();}<\\/script>'+
2175
+ '</body></html>'
2176
+ );
2177
+ w.document.close();
2178
+ }
2179
+
2180
+ window.printChartView = function printChartView() {
2181
+ if (chartCtxIdx < 0) return;
2182
+ const m = dataCacheList[chartCtxIdx];
2183
+ if (!m) return;
2184
+ const line = document.getElementById('chart-line');
2185
+ const bar = document.getElementById('chart-bar');
2186
+ const kpi = document.getElementById('chart-kpi');
2187
+ const kpiY = document.getElementById('chart-kpi-yearly');
2188
+ let html = '<h2>'+esc(m.house + ' \u203A ' + m.apartment)+
2189
+ (m.typeName ? ' \u00B7 ' + esc(m.typeName) : '')+'</h2>';
2190
+ if (kpi && kpi.style.display !== 'none' && kpi.textContent.trim()) {
2191
+ html += '<p>'+esc(kpi.textContent.trim())+'</p>';
2192
+ }
2193
+ if (kpiY && kpiY.style.display !== 'none' && kpiY.textContent.trim()) {
2194
+ html += '<p>'+esc(kpiY.textContent.trim())+'</p>';
2195
+ }
2196
+ if (line) html += '<img alt="'+esc(t('chart_readings'))+'" src="'+line.toDataURL('image/png')+'">';
2197
+ if (bar && bar.offsetParent !== null) {
2198
+ html += '<img alt="'+esc(t('chart_monthly'))+'" src="'+bar.toDataURL('image/png')+'">';
2199
+ }
2200
+ openPrintWindow(m.meter + ' \u2013 ' + t('print_chart'), html);
2201
+ };
2202
+
2203
+ window.printScopeReadings = function printScopeReadings(house, apartment) {
2204
+ const rows = dataCacheList.filter(m =>
2205
+ m.house === house && (!apartment || m.apartment === apartment)
2206
+ );
2207
+ if (!rows.length) return;
2208
+ const title = apartment
2209
+ ? (house + ' \u203A ' + apartment)
2210
+ : house;
2211
+ let html = '<h2>'+esc(t('print_latest_title'))+'</h2>';
2212
+ html += '<table><thead><tr>'+
2213
+ (apartment ? '' : '<th>'+esc(t('prev_apts'))+'</th>')+
2214
+ '<th>'+esc(t('print_meter'))+'</th>'+
2215
+ '<th>'+esc(t('print_value'))+'</th>'+
2216
+ '<th>'+esc(t('print_unit'))+'</th>'+
2217
+ '<th>'+esc(t('print_date'))+'</th>'+
2218
+ '</tr></thead><tbody>';
2219
+ for (const m of rows) {
2220
+ const dt = m.latestDate ? fmtDt(new Date(m.latestDate).getTime()) : '\u2013';
2221
+ html += '<tr>'+
2222
+ (apartment ? '' : '<td>'+esc(m.apartment)+'</td>')+
2223
+ '<td>'+esc(m.meter)+(m.typeName ? ' <span style="color:#777">('+esc(m.typeName)+')</span>' : '')+'</td>'+
2224
+ '<td class="num">'+(m.latest !== undefined && m.latest !== null ? esc(String(m.latest)) : '\u2013')+'</td>'+
2225
+ '<td>'+esc(m.unit || '')+'</td>'+
2226
+ '<td>'+esc(dt)+'</td>'+
2227
+ '</tr>';
2228
+ }
2229
+ html += '</tbody></table>';
2230
+ openPrintWindow(title, html);
2231
+ };
2232
+
2073
2233
  // \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
2234
  window.showTab = function showTab(name) {
2075
2235
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
@@ -2124,9 +2284,38 @@ async function fetchSysStats() {
2124
2284
  }
2125
2285
 
2126
2286
  // \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
2287
+ let nodesCacheForData = [];
2288
+ let dataNamespace = '';
2289
+
2290
+ function meterStateId(house, apt, meter) {
2291
+ return dataNamespace ? (dataNamespace + '.' + house + '.' + apt + '.' + meter + '.readings.latest') : '';
2292
+ }
2293
+
2294
+ function nodeSid(n) {
2295
+ try { return JSON.parse(n.config || '{}').sid || ''; } catch { return ''; }
2296
+ }
2297
+
2298
+ function buildNodeChipsHtml(stateId, unit, label) {
2299
+ if (!nodesCacheForData.length) return '';
2300
+ const chips = nodesCacheForData.map(n => {
2301
+ const sid = nodeSid(n);
2302
+ const active = sid && stateId && sid === stateId;
2303
+ const name = n.name || n.mac;
2304
+ const cls = 'node-chip' + (active ? ' active' : '') + (n.online ? '' : ' offline');
2305
+ const title = active ? t('node_unassign') : t('node_assign');
2306
+ 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>';
2307
+ }).join('');
2308
+ return '<div class="mc-nodes"><span class="mc-nodes-label">'+esc(t('node_label'))+'</span>'+chips+'</div>';
2309
+ }
2310
+
2127
2311
  async function fetchData() {
2128
2312
  try {
2129
- const d = await fetch('/api/data').then(r => r.json());
2313
+ const [d, nodes] = await Promise.all([
2314
+ fetch('/api/data').then(r => r.json()),
2315
+ fetch('/api/nodes').then(r => r.json()).catch(() => [])
2316
+ ]);
2317
+ nodesCacheForData = Array.isArray(nodes) ? nodes : [];
2318
+ dataNamespace = d.namespace || '';
2130
2319
  const con = document.getElementById('data-container');
2131
2320
  dataCacheList = [];
2132
2321
  if (!d.data || !Object.keys(d.data).length) {
@@ -2136,9 +2325,11 @@ async function fetchData() {
2136
2325
  let html = '';
2137
2326
  let idx = 0;
2138
2327
  for (const [house, apts] of Object.entries(d.data)) {
2139
- html += '<div class="house-block"><div class="house-title">\uD83C\uDFE0 '+esc(house)+'</div>';
2328
+ html += '<div class="house-block"><div class="house-title"><span>\uD83C\uDFE0 '+esc(house)+'</span>'+
2329
+ '<button type="button" class="print-scope-btn" data-print-house="'+esc(house)+'" title="'+esc(t('print_house'))+'">\uD83D\uDDA8</button></div>';
2140
2330
  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">';
2331
+ html += '<div class="apt-block"><div class="apt-title"><span>\uD83C\uDFD8 '+esc(apt)+'</span>'+
2332
+ '<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
2333
  for (const [key, m] of Object.entries(meters)) {
2143
2334
  const cacheIdx = idx++;
2144
2335
  dataCacheList.push({ house, apartment: apt, meter: key, unit: m.unit, typeName: m.typeName, latest: m.latest, latestDate: m.latestDate, history: m.history || [] });
@@ -2146,8 +2337,16 @@ async function fetchData() {
2146
2337
  const histId = 'h-'+CSS.escape(house+apt+key);
2147
2338
  const hist = m.history || [];
2148
2339
  const delta = calcDelta(hist);
2340
+ const sid = meterStateId(house, apt, key);
2341
+ const assignedHere = nodesCacheForData.some(n => nodeSid(n) === sid);
2149
2342
  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>'
2343
+ '<div class="hist-row">'+
2344
+ '<span>'+esc(fmtDt(h.ts))+'</span>'+
2345
+ '<span style="display:flex;align-items:center;gap:8px;">'+
2346
+ '<span class="hist-val">'+h.value+' '+esc(m.unit||'')+'</span>'+
2347
+ '<button type="button" class="hist-edit-btn" data-idx="'+cacheIdx+'" data-ts="'+h.ts+'" title="'+esc(t('history_edit'))+'">\u270E</button>'+
2348
+ '</span>'+
2349
+ '</div>'
2151
2350
  ).join('');
2152
2351
  const consumeHtml = delta
2153
2352
  ? '<div class="mc-consume">\uD83D\uDCCA +'+fmtNum(delta.delta)+' '+esc(m.unit||'')+' '+t('since_last')+' ('+delta.days+' '+t('days')+')</div>'
@@ -2158,8 +2357,9 @@ async function fetchData() {
2158
2357
  '<button class="mc-csv-btn" data-idx="'+cacheIdx+'" title="'+esc(t('csv_btn'))+'">\u2B07 '+esc(t('csv_btn'))+'</button>'+
2159
2358
  '</div>'
2160
2359
  : '';
2360
+ const nodesHtml = buildNodeChipsHtml(sid, m.unit, key);
2161
2361
  html +=
2162
- '<div class="meter-card">'+
2362
+ '<div class="meter-card'+(assignedHere ? ' has-node' : '')+'">'+
2163
2363
  '<div class="mc-head">'+
2164
2364
  '<div class="mc-name">'+icon+' '+esc(key)+'</div>'+
2165
2365
  '<div class="mc-badge">'+esc(m.typeName||'?')+'</div>'+
@@ -2175,6 +2375,7 @@ async function fetchData() {
2175
2375
  '<div class="mc-history" id="'+histId+'">'+rows+'</div>'
2176
2376
  : '')+
2177
2377
  actionsHtml+
2378
+ nodesHtml+
2178
2379
  '</div>';
2179
2380
  }
2180
2381
  html += '</div></div>';
@@ -2188,10 +2389,80 @@ async function fetchData() {
2188
2389
  }
2189
2390
  }
2190
2391
 
2392
+ window.toggleNodeAssign = async function toggleNodeAssign(mac, sid, label, unit, currentlyActive) {
2393
+ const nextSid = currentlyActive ? '' : sid;
2394
+ try {
2395
+ const r = await authFetch('/api/nodes/'+encodeURIComponent(mac)+'/config', {
2396
+ method: 'POST',
2397
+ body: JSON.stringify({
2398
+ sid: nextSid,
2399
+ label: nextSid ? (label || '') : '',
2400
+ unit: nextSid ? (unit || '') : ''
2401
+ })
2402
+ });
2403
+ if (!r) return;
2404
+ const d = await r.json();
2405
+ if (!d.ok) {
2406
+ window.alert(t('error') + ': ' + (d.error || r.status));
2407
+ return;
2408
+ }
2409
+ await fetchData();
2410
+ if (typeof fetchNodes === 'function' && document.querySelector('.nav-item.active[data-tab="nodes"]')) {
2411
+ fetchNodes();
2412
+ }
2413
+ } catch (e) {
2414
+ window.alert(t('error') + ': ' + e.message);
2415
+ }
2416
+ }
2417
+
2191
2418
  function toggleHist(id) {
2192
2419
  const el = document.getElementById(id);
2193
2420
  if (el) el.classList.toggle('open');
2194
2421
  }
2422
+
2423
+ window.editHistoryValue = async function editHistoryValue(cacheIdx, ts) {
2424
+ const m = dataCacheList[cacheIdx];
2425
+ if (!m) return;
2426
+ const entry = (m.history || []).find(h => h.ts === ts);
2427
+ if (!entry) return;
2428
+ const raw = window.prompt(t('history_edit_prompt'), String(entry.value));
2429
+ if (raw === null) return;
2430
+ const normalized = String(raw).trim().replace(',', '.');
2431
+ const value = parseFloat(normalized);
2432
+ if (!Number.isFinite(value)) {
2433
+ window.alert(t('error') + ': value');
2434
+ return;
2435
+ }
2436
+ try {
2437
+ const r = await authFetch('/api/reading', {
2438
+ method: 'POST',
2439
+ body: JSON.stringify({
2440
+ house: m.house,
2441
+ apartment: m.apartment,
2442
+ meter: m.meter,
2443
+ value,
2444
+ unit: m.unit || '',
2445
+ typeName: m.typeName || '',
2446
+ readingDate: entry.readingDate
2447
+ })
2448
+ });
2449
+ if (!r) return;
2450
+ const d = await r.json();
2451
+ if (!d.ok) {
2452
+ window.alert(t('error') + ': ' + (d.error || r.status));
2453
+ return;
2454
+ }
2455
+ entry.value = value;
2456
+ if (m.history && m.history.length) {
2457
+ const newest = m.history.slice().sort((a, b) => a.ts - b.ts).pop();
2458
+ m.latest = newest.value;
2459
+ m.latestDate = newest.readingDate;
2460
+ }
2461
+ await fetchData();
2462
+ } catch (e) {
2463
+ window.alert(t('error') + ': ' + e.message);
2464
+ }
2465
+ }
2195
2466
  // \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
2467
  let discoverCache = [];
2197
2468
 
@@ -2517,6 +2788,28 @@ function initTabs() {
2517
2788
  if (btn && btn.dataset.hist) toggleHist(btn.dataset.hist);
2518
2789
  });
2519
2790
 
2791
+ // History value edit
2792
+ document.addEventListener('click', e => {
2793
+ const editBtn = e.target.closest('.hist-edit-btn');
2794
+ if (!editBtn) return;
2795
+ const idx = parseInt(editBtn.dataset.idx, 10);
2796
+ const ts = parseInt(editBtn.dataset.ts, 10);
2797
+ editHistoryValue(idx, ts);
2798
+ });
2799
+
2800
+ // Node chips on meter cards: assign / unassign
2801
+ document.addEventListener('click', e => {
2802
+ const chip = e.target.closest('.node-chip');
2803
+ if (!chip) return;
2804
+ toggleNodeAssign(
2805
+ chip.dataset.mac,
2806
+ chip.dataset.sid,
2807
+ chip.dataset.label || '',
2808
+ chip.dataset.unit || '',
2809
+ chip.dataset.active === '1'
2810
+ );
2811
+ });
2812
+
2520
2813
  // Chart + CSV + Chart-Modal via Event Delegation
2521
2814
  document.addEventListener('click', e => {
2522
2815
  const chartBtn = e.target.closest('.mc-chart-btn');
@@ -2526,6 +2819,12 @@ function initTabs() {
2526
2819
  if (e.target.closest('.chart-close')) { closeChartModal(); return; }
2527
2820
  if (e.target.id === 'chart-overlay') { closeChartModal(); return; }
2528
2821
  if (e.target.closest('#chart-csv-btn')) { if (chartCtxIdx >= 0) exportMeterCsv(chartCtxIdx); return; }
2822
+ if (e.target.closest('#chart-print-btn')) { printChartView(); return; }
2823
+ const scopeBtn = e.target.closest('.print-scope-btn');
2824
+ if (scopeBtn) {
2825
+ printScopeReadings(scopeBtn.dataset.printHouse, scopeBtn.dataset.printApt || null);
2826
+ return;
2827
+ }
2529
2828
  const rangeBtn = e.target.closest('.chart-range[data-months]');
2530
2829
  if (rangeBtn) {
2531
2830
  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.6",
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": {