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.
- package/README.md +140 -116
- package/io-package.json +28 -2
- package/main.js +326 -27
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,49 +1,104 @@
|
|
|
1
|
-
|
|
1
|
+

|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# ioBroker.metermaster
|
|
4
4
|
|
|
5
|
-
[](https://www.npmjs.com/package/iobroker.metermaster)
|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
[](https://www.npmjs.com/package/iobroker.metermaster)
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
[](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
|
+
[](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**
|
|
16
|
-
- **Automatic
|
|
17
|
-
- **Correct timestamps**
|
|
18
|
-
- **
|
|
19
|
-
- **Basic Auth**
|
|
20
|
-
- **Web UI**
|
|
21
|
-
- **Charts & CSV**
|
|
22
|
-
- **
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
##
|
|
66
|
+
## MeterMaster Android app
|
|
30
67
|
|
|
31
|
-
The
|
|
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
|
-
| **
|
|
36
|
-
| **
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
|  |  |
|
|
88
|
+
|
|
89
|
+
| Import — app backup via drag & drop | Logs — live filter & export |
|
|
90
|
+
|---|---|
|
|
91
|
+
|  |  |
|
|
92
|
+
|
|
93
|
+
| System — statistics & version check | Nodes — optional ESP32 status |
|
|
94
|
+
|---|---|
|
|
95
|
+
|  |  |
|
|
41
96
|
|
|
42
97
|
---
|
|
43
98
|
|
|
44
99
|
## Installation
|
|
45
100
|
|
|
46
|
-
Install
|
|
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
|
-
|
|
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
|
|
116
|
+
More notes: [INSTALLATION.md](INSTALLATION.md)
|
|
62
117
|
|
|
63
118
|
---
|
|
64
119
|
|
|
65
120
|
## Instance configuration
|
|
66
121
|
|
|
67
|
-
|
|
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
|
|
73
|
-
| Password | – | Basic
|
|
74
|
-
| Verbose logging | enabled | Show DEBUG entries in log viewer |
|
|
75
|
-
| Log buffer | `500` |
|
|
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
|
-
[](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
|
|
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
|
|
103
|
-
| Password | as
|
|
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
|
-
|
|
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** |
|
|
120
|
-
| **
|
|
121
|
-
| **
|
|
122
|
-
| **
|
|
123
|
-
| **
|
|
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
|
-
|
|
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
|
|
165
|
-
├──
|
|
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
|
|
200
|
+
| GET | `/api/stats` | Statistics |
|
|
184
201
|
| GET | `/api/data` | All cached readings |
|
|
185
|
-
| GET | `/api/logs` | Log buffer (
|
|
186
|
-
| GET | `/api/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
|
|
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
|
|
199
|
-
| POST | `/api/nodes/{MAC}/
|
|
200
|
-
| POST | `/api/nodes/{MAC}/
|
|
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":
|
|
212
|
-
"apartment":
|
|
213
|
-
"meter":
|
|
214
|
-
"value":
|
|
215
|
-
"unit":
|
|
216
|
-
"typeName":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
318
|
+
MIT License
|
|
296
319
|
|
|
297
|
-
|
|
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
|
+
"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.
|
|
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.
|
|
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
|
-
|
|
39
|
-
if (
|
|
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')
|
|
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
|
|
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}.
|
|
508
|
-
await adapter.setStateAsync(`${base}.
|
|
509
|
-
await adapter.setStateAsync(`${base}.
|
|
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
|
-
//
|
|
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
|
-
|
|
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, {
|
|
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',
|
|
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',
|
|
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
|
|
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)+'</
|
|
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)+'</
|
|
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"
|
|
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
|
-
"description": "ioBroker adapter for the MeterMaster Android app
|
|
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.
|
|
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": {
|