iobroker.metermaster 0.9.4 → 0.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -116
- package/io-package.json +41 -2
- package/main.js +406 -30
- 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,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "metermaster",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.7",
|
|
5
5
|
"news": {
|
|
6
|
+
"0.9.7": {
|
|
7
|
+
"en": "Print fix (Blob URL); ESP32 discover proxy (getStates/node-discover); node heartbeat/ack logs moved to debug",
|
|
8
|
+
"de": "Druck-Fix (Blob-URL); ESP32-Discover-Proxy (getStates/node-discover); Node-Heartbeat/Ack-Logs auf Debug-Level",
|
|
9
|
+
"ru": "Print fix; ESP32 discover proxy; node logs debug",
|
|
10
|
+
"pt": "Print fix; ESP32 discover proxy; node logs debug",
|
|
11
|
+
"nl": "Print fix; ESP32 discover proxy; node logs debug",
|
|
12
|
+
"fr": "Print fix; ESP32 discover proxy; node logs debug",
|
|
13
|
+
"it": "Print fix; ESP32 discover proxy; node logs debug",
|
|
14
|
+
"es": "Print fix; ESP32 discover proxy; node logs debug",
|
|
15
|
+
"pl": "Print fix; ESP32 discover proxy; node logs debug",
|
|
16
|
+
"uk": "Print fix; ESP32 discover proxy; node logs debug",
|
|
17
|
+
"zh-cn": "打印修复;ESP32 discover 代理;节点日志改为 debug"
|
|
18
|
+
},
|
|
19
|
+
"0.9.6": {
|
|
20
|
+
"en": "Assign display nodes via chips on meter cards in the Data tab",
|
|
21
|
+
"de": "Anzeige-Nodes per Chips auf den Zähler-Kacheln im Daten-Tab zuweisen",
|
|
22
|
+
"ru": "Assign nodes via chips on meter cards",
|
|
23
|
+
"pt": "Assign nodes via chips on meter cards",
|
|
24
|
+
"nl": "Nodes toewijzen via chips op meterkarten",
|
|
25
|
+
"fr": "Assigner des nodes via des chips sur les cartes compteur",
|
|
26
|
+
"it": "Assegna node tramite chip sulle card contatore",
|
|
27
|
+
"es": "Asignar nodes mediante chips en las tarjetas de contador",
|
|
28
|
+
"pl": "Przypisywanie node przez chipy na kartach liczników",
|
|
29
|
+
"uk": "Призначення node через чіпи на картках лічильників",
|
|
30
|
+
"zh-cn": "在数据页计量表卡片上通过芯片分配显示节点"
|
|
31
|
+
},
|
|
32
|
+
"0.9.5": {
|
|
33
|
+
"en": "Correct history on re-sync; edit values in Web UI; print chart and apartment/house latest readings",
|
|
34
|
+
"de": "Historie bei erneutem Sync korrigieren; Werte in der Web-UI bearbeiten; Chart sowie Wohnungs-/Haus-Stände drucken",
|
|
35
|
+
"ru": "Correction of history; Web UI edit; print chart and readings",
|
|
36
|
+
"pt": "Correct history; Web UI edit; print",
|
|
37
|
+
"nl": "History corrigeren; Web UI edit; printen",
|
|
38
|
+
"fr": "Corriger l'historique; édition Web UI; impression",
|
|
39
|
+
"it": "Correggi cronologia; modifica Web UI; stampa",
|
|
40
|
+
"es": "Corregir historial; edición Web UI; imprimir",
|
|
41
|
+
"pl": "Korekta historii; edycja Web UI; druk",
|
|
42
|
+
"uk": "Корекція історії; редагування Web UI; друк",
|
|
43
|
+
"zh-cn": "更正历史记录;Web UI 编辑;打印图表与读数"
|
|
44
|
+
},
|
|
6
45
|
"0.9.4": {
|
|
7
46
|
"en": "Review fixes: English logs/states/API errors, Web UI i18n, config validation, removed /api/update",
|
|
8
47
|
"de": "Review-Fixes: Englische Logs/States/API-Fehler, Web-UI-i18n, Config-Validierung, /api/update entfernt",
|
|
@@ -117,7 +156,7 @@
|
|
|
117
156
|
],
|
|
118
157
|
"globalDependencies": [
|
|
119
158
|
{
|
|
120
|
-
"admin": ">=7.
|
|
159
|
+
"admin": ">=7.8.23"
|
|
121
160
|
}
|
|
122
161
|
]
|
|
123
162
|
},
|
package/main.js
CHANGED
|
@@ -4,7 +4,7 @@ const utils = require('@iobroker/adapter-core');
|
|
|
4
4
|
const http = require('node:http');
|
|
5
5
|
const crypto = require('node:crypto');
|
|
6
6
|
const https = require('node:https');
|
|
7
|
-
const CURRENT_VERSION = '0.9.
|
|
7
|
+
const CURRENT_VERSION = '0.9.7';
|
|
8
8
|
const GITHUB_REPO = 'MPunktBPunkt/ioBroker.metermaster';
|
|
9
9
|
const GITHUB_URL = 'https://github.com/MPunktBPunkt/ioBroker.metermaster';
|
|
10
10
|
|
|
@@ -30,16 +30,21 @@ function cacheReading(house, apt, meter, value, unit, typeName, readingDate, ts)
|
|
|
30
30
|
if (!receivedData[house][apt][meter]) receivedData[house][apt][meter] = { unit, typeName, history: [] };
|
|
31
31
|
|
|
32
32
|
const entry = receivedData[house][apt][meter];
|
|
33
|
-
entry.latest = value;
|
|
34
|
-
entry.latestDate = readingDate;
|
|
35
33
|
entry.unit = unit;
|
|
36
34
|
entry.typeName = typeName;
|
|
37
35
|
|
|
38
|
-
|
|
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 ───────────────────────────────────────────────────────────────
|
|
@@ -137,7 +142,7 @@ adapter.on('stateChange', async (id, state) => {
|
|
|
137
142
|
|
|
138
143
|
if (field === 'lastSeen') {
|
|
139
144
|
const n = nodesCache[mac];
|
|
140
|
-
log(LVL.
|
|
145
|
+
log(LVL.DEBUG, CAT.NODE, `Heartbeat`, `${mac} | IP: ${n.ip || '?'} | v${n.version || '?'} | ${n.name || 'unnamed'}`);
|
|
141
146
|
await ensureNodeStates(mac);
|
|
142
147
|
}
|
|
143
148
|
});
|
|
@@ -249,7 +254,7 @@ function startHttpServer() {
|
|
|
249
254
|
|
|
250
255
|
server = http.createServer((req, res) => {
|
|
251
256
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
252
|
-
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
|
257
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, PUT, GET, OPTIONS');
|
|
253
258
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
254
259
|
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
255
260
|
|
|
@@ -266,6 +271,14 @@ function startHttpServer() {
|
|
|
266
271
|
if (req.method === 'GET' && url === '/api/version') { serveVersion(res); return; }
|
|
267
272
|
if (req.method === 'GET' && url === '/api/nodes') { serveNodesJson(res); return; }
|
|
268
273
|
if (req.method === 'GET' && url === '/api/discover'){ serveDiscoverJson(res); return; }
|
|
274
|
+
if (req.method === 'GET' && url === '/api/node-discover'){ serveNodeDiscoverJson(res); return; }
|
|
275
|
+
if (req.method === 'GET' && (url === '/getStates/metermaster.*' || url === '/getStates/metermaster.0.*.readings.latest')) {
|
|
276
|
+
serveGetStatesLatest(res); return;
|
|
277
|
+
}
|
|
278
|
+
const getStateMatch = req.method === 'GET' ? url.match(/^\/get\/(.+)$/) : null;
|
|
279
|
+
if (getStateMatch) {
|
|
280
|
+
serveGetState(decodeURIComponent(getStateMatch[1]), res); return;
|
|
281
|
+
}
|
|
269
282
|
|
|
270
283
|
// ESP32 node registration (no auth – ESP32 has no credentials)
|
|
271
284
|
if (req.method === 'POST' && url === '/api/register') {
|
|
@@ -315,7 +328,9 @@ function startHttpServer() {
|
|
|
315
328
|
}
|
|
316
329
|
|
|
317
330
|
if (req.method === 'GET' && url === '/api/ping') { handlePing(res, clientIp); }
|
|
318
|
-
else if (req.method === 'POST' && url === '/api/reading')
|
|
331
|
+
else if ((req.method === 'POST' || req.method === 'PUT') && url === '/api/reading') {
|
|
332
|
+
readBody(req, b => handleReading(b, res, clientIp));
|
|
333
|
+
}
|
|
319
334
|
else if (req.method === 'POST' && url === '/api/readings') { readBody(req, b => handleReadings(b, res, clientIp)); }
|
|
320
335
|
else if (req.method === 'POST' && url === '/api/import') { readBody(req, b => handleImport(b, res, clientIp)); }
|
|
321
336
|
else {
|
|
@@ -377,8 +392,8 @@ async function handleReading(body, res, clientIp) {
|
|
|
377
392
|
log(LVL.INFO, CAT.SYNC, `Reading received`,
|
|
378
393
|
`${data.house}/${data.apartment}/${data.meter} = ${data.value} ${data.unit||''} (${data.readingDate})`);
|
|
379
394
|
try {
|
|
380
|
-
const
|
|
381
|
-
sendJson(res, 200, { ok: true, path });
|
|
395
|
+
const result = await storeReading(data);
|
|
396
|
+
sendJson(res, 200, { ok: true, path: result.path, history: result.history });
|
|
382
397
|
} catch (e) {
|
|
383
398
|
log(LVL.ERROR, CAT.SYNC, `Storage error`, e.message);
|
|
384
399
|
sendJson(res, 500, { error: e.message });
|
|
@@ -504,25 +519,28 @@ async function storeReading(data) {
|
|
|
504
519
|
|
|
505
520
|
if (dpNew) log(LVL.INFO, CAT.DATAPOINT, `Data points created`, `${base}.readings.{latest,latestDate,history} + name/unit/typeName`);
|
|
506
521
|
|
|
507
|
-
await adapter.setStateAsync(`${base}.
|
|
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}`);
|
|
522
|
+
await adapter.setStateAsync(`${base}.name`, { val: data.meter, ts, ack: true });
|
|
523
|
+
await adapter.setStateAsync(`${base}.unit`, { val: data.unit||'', ts, ack: true });
|
|
524
|
+
await adapter.setStateAsync(`${base}.typeName`, { val: data.typeName||'', ts, ack: true });
|
|
514
525
|
|
|
515
526
|
const histResult = await updateHistory(base, { value, unit: data.unit||'', readingDate: data.readingDate, ts });
|
|
516
527
|
if (histResult === 'added') log(LVL.DEBUG, CAT.HISTORY, `History +1`, `${base} @ ${data.readingDate}`);
|
|
528
|
+
if (histResult === 'updated') log(LVL.INFO, CAT.HISTORY, `History updated`, `${base} @ ${data.readingDate} → ${value}`);
|
|
517
529
|
if (histResult === 'duplicate') log(LVL.DEBUG, CAT.HISTORY, `Duplicate`, `${base} @ ${data.readingDate}`);
|
|
518
530
|
|
|
519
|
-
//
|
|
531
|
+
// Cache + latest anhand chronologisch neuestem History-Eintrag
|
|
520
532
|
cacheReading(house, apt, meter, value, data.unit||'', data.typeName||'', data.readingDate, ts);
|
|
533
|
+
const cached = receivedData[house][apt][meter];
|
|
534
|
+
const latestTs = new Date(cached.latestDate).getTime();
|
|
535
|
+
await adapter.setStateAsync(`${base}.readings.latest`, { val: cached.latest, ts: latestTs, ack: true });
|
|
536
|
+
await adapter.setStateAsync(`${base}.readings.latestDate`, { val: cached.latestDate, ts: latestTs, ack: true });
|
|
537
|
+
|
|
538
|
+
log(LVL.DEBUG, CAT.SYNC, `State written`, `metermaster.0.${base} = ${value} ${data.unit||''} | ${data.readingDate} (${histResult})`);
|
|
521
539
|
|
|
522
540
|
readingsReceived++;
|
|
523
541
|
await adapter.setStateAsync('info.lastSync', { val: Date.now(), ack: true });
|
|
524
542
|
await adapter.setStateAsync('info.readingsReceived', { val: readingsReceived, ack: true });
|
|
525
|
-
return base;
|
|
543
|
+
return { path: base, history: histResult };
|
|
526
544
|
}
|
|
527
545
|
|
|
528
546
|
// ─── Historie ─────────────────────────────────────────────────────────────────
|
|
@@ -534,7 +552,16 @@ async function updateHistory(base, entry) {
|
|
|
534
552
|
const ex = await adapter.getStateAsync(stateId);
|
|
535
553
|
if (ex && ex.val) { history = JSON.parse(ex.val); if (!Array.isArray(history)) history = []; }
|
|
536
554
|
} catch { history = []; }
|
|
537
|
-
|
|
555
|
+
|
|
556
|
+
const idx = history.findIndex(h => h.ts === entry.ts);
|
|
557
|
+
if (idx >= 0) {
|
|
558
|
+
if (history[idx].value === entry.value) return 'duplicate';
|
|
559
|
+
history[idx] = entry;
|
|
560
|
+
history.sort((a, b) => a.ts - b.ts);
|
|
561
|
+
await adapter.setStateAsync(stateId, { val: JSON.stringify(history), ts: entry.ts, ack: true });
|
|
562
|
+
return 'updated';
|
|
563
|
+
}
|
|
564
|
+
|
|
538
565
|
history.push(entry);
|
|
539
566
|
history.sort((a, b) => a.ts - b.ts);
|
|
540
567
|
if (keep > 0 && history.length > keep) history = history.slice(history.length - keep);
|
|
@@ -619,7 +646,11 @@ async function migrateStateRoles() {
|
|
|
619
646
|
|
|
620
647
|
// ─── API Endpunkte ────────────────────────────────────────────────────────────
|
|
621
648
|
function serveDataJson(res) {
|
|
622
|
-
sendJson(res, 200, {
|
|
649
|
+
sendJson(res, 200, {
|
|
650
|
+
data: receivedData,
|
|
651
|
+
receivedTotal: readingsReceived,
|
|
652
|
+
namespace: adapter.namespace,
|
|
653
|
+
});
|
|
623
654
|
}
|
|
624
655
|
function serveLogsJson(req, res) {
|
|
625
656
|
const u = new URL(req.url, 'http://localhost');
|
|
@@ -690,6 +721,73 @@ function serveDiscoverJson(res) {
|
|
|
690
721
|
sendJson(res, 200, result);
|
|
691
722
|
}
|
|
692
723
|
|
|
724
|
+
// ─── simple-api-kompatibel: nur readings.latest (für ESP32-Nodes mit kleinem JSON-Puffer) ─
|
|
725
|
+
function serveGetStatesLatest(res) {
|
|
726
|
+
const result = {};
|
|
727
|
+
const ns = adapter.namespace;
|
|
728
|
+
const from = `system.adapter.${adapter.name}.${adapter.instance}`;
|
|
729
|
+
const now = Date.now();
|
|
730
|
+
for (const [house, apts] of Object.entries(receivedData)) {
|
|
731
|
+
for (const [apt, meters] of Object.entries(apts)) {
|
|
732
|
+
for (const [meter, data] of Object.entries(meters)) {
|
|
733
|
+
const ts = data.latestDate ? new Date(data.latestDate).getTime() : now;
|
|
734
|
+
result[`${ns}.${house}.${apt}.${meter}.readings.latest`] = {
|
|
735
|
+
val: data.latest,
|
|
736
|
+
ack: true,
|
|
737
|
+
ts,
|
|
738
|
+
q: 0,
|
|
739
|
+
from,
|
|
740
|
+
lc: ts,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
sendJson(res, 200, result);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function serveGetState(stateId, res) {
|
|
749
|
+
try {
|
|
750
|
+
const state = await adapter.getStateAsync(stateId);
|
|
751
|
+
if (!state || state.val === null || state.val === undefined) {
|
|
752
|
+
sendJson(res, 404, { error: 'State not found' });
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
sendJson(res, 200, {
|
|
756
|
+
val: state.val,
|
|
757
|
+
ack: !!state.ack,
|
|
758
|
+
ts: state.ts,
|
|
759
|
+
q: state.q || 0,
|
|
760
|
+
from: state.from || '',
|
|
761
|
+
lc: state.lc || state.ts,
|
|
762
|
+
});
|
|
763
|
+
} catch (e) {
|
|
764
|
+
sendJson(res, 500, { error: e.message });
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// ─── ESP32-WebUI: kompaktes Discover-Format {"ok":true,"states":[...]} ───────
|
|
769
|
+
function serveNodeDiscoverJson(res) {
|
|
770
|
+
const states = [];
|
|
771
|
+
const ns = adapter.namespace;
|
|
772
|
+
for (const [house, apts] of Object.entries(receivedData)) {
|
|
773
|
+
for (const [apt, meters] of Object.entries(apts)) {
|
|
774
|
+
for (const [meter, data] of Object.entries(meters)) {
|
|
775
|
+
const sid = `${ns}.${house}.${apt}.${meter}.readings.latest`;
|
|
776
|
+
let lbl = house;
|
|
777
|
+
if (apt) lbl += ' \u00B7 ' + apt;
|
|
778
|
+
if (meter) lbl += ' \u00B7 ' + meter;
|
|
779
|
+
states.push({
|
|
780
|
+
id: sid,
|
|
781
|
+
val: data.latest,
|
|
782
|
+
label: lbl,
|
|
783
|
+
unit: data.unit || '',
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
sendJson(res, 200, { ok: true, states });
|
|
789
|
+
}
|
|
790
|
+
|
|
693
791
|
// ─── Node Config schreiben ────────────────────────────────────────────────────
|
|
694
792
|
async function handleNodeConfig(mac, body, res, clientIp) {
|
|
695
793
|
let data;
|
|
@@ -778,7 +876,7 @@ async function handleNodeRegister(body, res, clientIp) {
|
|
|
778
876
|
await adapter.setStateAsync(`nodes.${mac}.lastSeen`, { val: ts, ack: true });
|
|
779
877
|
if (ack) await adapter.setStateAsync(`nodes.${mac}.configAck`, { val: ack, ack: true });
|
|
780
878
|
|
|
781
|
-
log(LVL.
|
|
879
|
+
log(LVL.DEBUG, CAT.NODE, `Heartbeat`, `${mac} | IP: ${ip} | v${version} | ${name}`);
|
|
782
880
|
|
|
783
881
|
// Aktuelle Config zurückgeben (null wenn noch keine gesetzt)
|
|
784
882
|
const config = nodesCache[mac].config || null;
|
|
@@ -802,7 +900,7 @@ async function handleNodeAck(mac, body, res) {
|
|
|
802
900
|
try {
|
|
803
901
|
await ensureNodeStates(mac);
|
|
804
902
|
await adapter.setStateAsync(`nodes.${mac}.configAck`, { val: ack, ack: true });
|
|
805
|
-
log(LVL.
|
|
903
|
+
log(LVL.DEBUG, CAT.NODE, `Config acknowledged`, `${mac} | ack: ${ack}`);
|
|
806
904
|
sendJson(res, 200, { ok: true });
|
|
807
905
|
} catch (e) {
|
|
808
906
|
sendJson(res, 500, { error: e.message });
|
|
@@ -1038,6 +1136,38 @@ nav {
|
|
|
1038
1136
|
margin-bottom: 8px; padding: 4px 10px;
|
|
1039
1137
|
border-left: 3px solid var(--primary-deep); background: var(--bg-surface);
|
|
1040
1138
|
border-radius: 0 6px 6px 0;
|
|
1139
|
+
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
1140
|
+
}
|
|
1141
|
+
.print-scope-btn {
|
|
1142
|
+
background: transparent; border: 1px solid var(--border); color: var(--text-dim);
|
|
1143
|
+
border-radius: 6px; padding: 2px 8px; cursor: pointer; font-size: .78em;
|
|
1144
|
+
white-space: nowrap;
|
|
1145
|
+
}
|
|
1146
|
+
.print-scope-btn:hover { color: var(--secondary); border-color: var(--secondary); }
|
|
1147
|
+
.mc-nodes {
|
|
1148
|
+
margin-top: 10px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
|
|
1149
|
+
}
|
|
1150
|
+
.mc-nodes-label {
|
|
1151
|
+
font-size: .72em; color: var(--text-muted); text-transform: uppercase; letter-spacing: .4px;
|
|
1152
|
+
margin-right: 2px;
|
|
1153
|
+
}
|
|
1154
|
+
.node-chip {
|
|
1155
|
+
border: 1px solid var(--border); background: var(--bg-surface2); color: var(--text-dim);
|
|
1156
|
+
border-radius: 999px; padding: 3px 10px; font-size: .75em; font-weight: 600;
|
|
1157
|
+
cursor: pointer; line-height: 1.3; display: inline-flex; align-items: center; gap: 5px;
|
|
1158
|
+
}
|
|
1159
|
+
.node-chip:hover { border-color: var(--secondary); color: var(--secondary); }
|
|
1160
|
+
.node-chip.active {
|
|
1161
|
+
background: rgba(76,175,80,.18); color: #A5D6A7; border-color: rgba(76,175,80,.45);
|
|
1162
|
+
}
|
|
1163
|
+
.node-chip.offline { opacity: .7; }
|
|
1164
|
+
.node-chip .dot {
|
|
1165
|
+
width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); display: inline-block;
|
|
1166
|
+
}
|
|
1167
|
+
.node-chip.active .dot { background: var(--accent); box-shadow: 0 0 4px var(--accent); }
|
|
1168
|
+
.meter-card.has-node {
|
|
1169
|
+
border-color: rgba(76,175,80,.35);
|
|
1170
|
+
box-shadow: inset 0 0 0 1px rgba(76,175,80,.12);
|
|
1041
1171
|
}
|
|
1042
1172
|
.meters-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); gap: 12px; margin-left: 20px; }
|
|
1043
1173
|
|
|
@@ -1078,6 +1208,11 @@ nav {
|
|
|
1078
1208
|
}
|
|
1079
1209
|
.hist-row:last-child { border: none; }
|
|
1080
1210
|
.hist-val { color: var(--text); font-weight: 600; }
|
|
1211
|
+
.hist-edit-btn {
|
|
1212
|
+
background: transparent; border: 1px solid var(--border); color: var(--text-dim);
|
|
1213
|
+
border-radius: 6px; padding: 2px 8px; cursor: pointer; font-size: .78em;
|
|
1214
|
+
}
|
|
1215
|
+
.hist-edit-btn:hover { color: var(--secondary); border-color: var(--secondary); }
|
|
1081
1216
|
.mc-consume {
|
|
1082
1217
|
font-size: .76em; color: var(--accent); margin-top: 6px;
|
|
1083
1218
|
display: flex; align-items: center; gap: 4px;
|
|
@@ -1544,6 +1679,7 @@ input.search {
|
|
|
1544
1679
|
<div class="chart-wrap-box"><canvas id="chart-line"></canvas></div>
|
|
1545
1680
|
<div class="chart-wrap-box chart-wrap-sm"><canvas id="chart-bar"></canvas></div>
|
|
1546
1681
|
<div class="chart-modal-foot">
|
|
1682
|
+
<button class="ghost" id="chart-print-btn" title="Print">🖨 Print</button>
|
|
1547
1683
|
<button class="ghost" id="chart-csv-btn">⬇ CSV</button>
|
|
1548
1684
|
</div>
|
|
1549
1685
|
</div>
|
|
@@ -1625,7 +1761,13 @@ const I18N = {
|
|
|
1625
1761
|
tab_data:'Daten', tab_nodes:'Nodes', tab_import:'Import', tab_logs:'Logs', tab_system:'System',
|
|
1626
1762
|
no_data:'Noch keine Ablesungen empfangen.<br>Starte einen Sync in der MeterMaster App oder lade ein Backup hoch.',
|
|
1627
1763
|
no_nodes:'Noch keine ESP32 Nodes registriert.<br>Wenn ein MeterMaster Node startet, erscheint er automatisch hier.',
|
|
1628
|
-
history:'Verlauf',
|
|
1764
|
+
history:'Verlauf', history_edit:'Wert bearbeiten', history_edit_prompt:'Neuer Zählerstand:', history_saved:'Gespeichert',
|
|
1765
|
+
since_last:'seit letzter Ablesung', days:'Tage', chart_btn:'Chart', csv_btn:'CSV', print_btn:'Drucken',
|
|
1766
|
+
print_chart:'Chart drucken', print_apartment:'Wohnung drucken', print_house:'Haus drucken',
|
|
1767
|
+
print_latest_title:'Aktuelle Zählerstände', print_meter:'Zähler', print_value:'Stand',
|
|
1768
|
+
print_unit:'Einheit', print_date:'Datum', print_generated:'Erstellt',
|
|
1769
|
+
node_label:'Node', node_assign:'Diesen Zähler auf dem Node anzeigen',
|
|
1770
|
+
node_unassign:'Anzeige auf diesem Node beenden', node_none:'Keine Nodes registriert',
|
|
1629
1771
|
chart_readings:'Z\u00E4hlerstand', chart_monthly:'Monatsverbrauch', chart_period_all:'Alles',
|
|
1630
1772
|
chart_yearly:'Pro Jahr', chart_yearly_proj:'Hochrechnung', chart_per_year:'/Jahr',
|
|
1631
1773
|
chart_yearly_hint:'basierend auf {days} {daysLabel} ({delta} {unit})',
|
|
@@ -1672,7 +1814,13 @@ const I18N = {
|
|
|
1672
1814
|
tab_data:'Data', tab_nodes:'Nodes', tab_import:'Import', tab_logs:'Logs', tab_system:'System',
|
|
1673
1815
|
no_data:'No readings received yet.<br>Start a sync in the MeterMaster app or upload a backup.',
|
|
1674
1816
|
no_nodes:'No ESP32 nodes registered yet.<br>When a MeterMaster node starts and sends its heartbeat, it will appear here automatically.',
|
|
1675
|
-
history:'History',
|
|
1817
|
+
history:'History', history_edit:'Edit value', history_edit_prompt:'New meter reading:', history_saved:'Saved',
|
|
1818
|
+
since_last:'since last reading', days:'days', chart_btn:'Chart', csv_btn:'CSV', print_btn:'Print',
|
|
1819
|
+
print_chart:'Print chart', print_apartment:'Print apartment', print_house:'Print house',
|
|
1820
|
+
print_latest_title:'Latest meter readings', print_meter:'Meter', print_value:'Reading',
|
|
1821
|
+
print_unit:'Unit', print_date:'Date', print_generated:'Generated',
|
|
1822
|
+
node_label:'Node', node_assign:'Show this meter on the node',
|
|
1823
|
+
node_unassign:'Stop showing on this node', node_none:'No nodes registered',
|
|
1676
1824
|
chart_readings:'Meter reading', chart_monthly:'Monthly consumption', chart_period_all:'All',
|
|
1677
1825
|
chart_yearly:'Per year', chart_yearly_proj:'Projected', chart_per_year:'/yr',
|
|
1678
1826
|
chart_yearly_hint:'based on {days} {daysLabel} ({delta} {unit})',
|
|
@@ -1798,6 +1946,11 @@ function applyI18n() {
|
|
|
1798
1946
|
if (crAll) crAll.textContent = t('chart_period_all');
|
|
1799
1947
|
const csvBtn = document.getElementById('chart-csv-btn');
|
|
1800
1948
|
if (csvBtn) csvBtn.textContent = '\u2B07 ' + t('csv_btn');
|
|
1949
|
+
const printBtn = document.getElementById('chart-print-btn');
|
|
1950
|
+
if (printBtn) {
|
|
1951
|
+
printBtn.textContent = '\uD83D\uDDA8 ' + t('print_btn');
|
|
1952
|
+
printBtn.title = t('print_chart');
|
|
1953
|
+
}
|
|
1801
1954
|
const cc = document.getElementById('chart-close');
|
|
1802
1955
|
if (cc) cc.title = t('chart_close');
|
|
1803
1956
|
const yearlyBtn = document.getElementById('chart-yearly-toggle');
|
|
@@ -2070,6 +2223,90 @@ function closeChartModal() {
|
|
|
2070
2223
|
chartCtxIdx = -1;
|
|
2071
2224
|
}
|
|
2072
2225
|
|
|
2226
|
+
function openPrintWindow(title, bodyHtml) {
|
|
2227
|
+
const generated = new Date().toLocaleString(localeTag(), { hour12: false });
|
|
2228
|
+
const html =
|
|
2229
|
+
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>'+esc(title)+'</title>'+
|
|
2230
|
+
'<style>'+
|
|
2231
|
+
'body{font-family:Segoe UI,Helvetica,Arial,sans-serif;color:#111;margin:24px;}'+
|
|
2232
|
+
'h1{font-size:1.25rem;margin:0 0 4px}h2{font-size:1rem;color:#444;font-weight:600;margin:0 0 16px}'+
|
|
2233
|
+
'.meta{font-size:.85rem;color:#666;margin-bottom:18px}'+
|
|
2234
|
+
'table{width:100%;border-collapse:collapse;font-size:.95rem}'+
|
|
2235
|
+
'th,td{border-bottom:1px solid #ddd;padding:8px 6px;text-align:left}'+
|
|
2236
|
+
'th{font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;color:#555}'+
|
|
2237
|
+
'td.num{text-align:right;font-variant-numeric:tabular-nums}'+
|
|
2238
|
+
'img{max-width:100%;height:auto;margin:12px 0;border:1px solid #eee}'+
|
|
2239
|
+
'@media print{body{margin:12mm}button{display:none}}'+
|
|
2240
|
+
'</style></head><body>'+
|
|
2241
|
+
'<h1>'+esc(title)+'</h1>'+
|
|
2242
|
+
'<div class="meta">'+esc(t('print_generated'))+': '+esc(generated)+'</div>'+
|
|
2243
|
+
bodyHtml+
|
|
2244
|
+
'<script>window.onload=function(){window.focus();window.print();}<\\/script>'+
|
|
2245
|
+
'</body></html>';
|
|
2246
|
+
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
|
2247
|
+
const url = URL.createObjectURL(blob);
|
|
2248
|
+
const w = window.open(url, '_blank');
|
|
2249
|
+
if (!w) {
|
|
2250
|
+
URL.revokeObjectURL(url);
|
|
2251
|
+
window.alert(t('error'));
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
window.printChartView = function printChartView() {
|
|
2258
|
+
if (chartCtxIdx < 0) return;
|
|
2259
|
+
const m = dataCacheList[chartCtxIdx];
|
|
2260
|
+
if (!m) return;
|
|
2261
|
+
const line = document.getElementById('chart-line');
|
|
2262
|
+
const bar = document.getElementById('chart-bar');
|
|
2263
|
+
const kpi = document.getElementById('chart-kpi');
|
|
2264
|
+
const kpiY = document.getElementById('chart-kpi-yearly');
|
|
2265
|
+
let html = '<h2>'+esc(m.house + ' \u203A ' + m.apartment)+
|
|
2266
|
+
(m.typeName ? ' \u00B7 ' + esc(m.typeName) : '')+'</h2>';
|
|
2267
|
+
if (kpi && kpi.style.display !== 'none' && kpi.textContent.trim()) {
|
|
2268
|
+
html += '<p>'+esc(kpi.textContent.trim())+'</p>';
|
|
2269
|
+
}
|
|
2270
|
+
if (kpiY && kpiY.style.display !== 'none' && kpiY.textContent.trim()) {
|
|
2271
|
+
html += '<p>'+esc(kpiY.textContent.trim())+'</p>';
|
|
2272
|
+
}
|
|
2273
|
+
if (line) html += '<img alt="'+esc(t('chart_readings'))+'" src="'+line.toDataURL('image/png')+'">';
|
|
2274
|
+
if (bar && bar.offsetParent !== null) {
|
|
2275
|
+
html += '<img alt="'+esc(t('chart_monthly'))+'" src="'+bar.toDataURL('image/png')+'">';
|
|
2276
|
+
}
|
|
2277
|
+
openPrintWindow(m.meter + ' \u2013 ' + t('print_chart'), html);
|
|
2278
|
+
};
|
|
2279
|
+
|
|
2280
|
+
window.printScopeReadings = function printScopeReadings(house, apartment) {
|
|
2281
|
+
const rows = dataCacheList.filter(m =>
|
|
2282
|
+
m.house === house && (!apartment || m.apartment === apartment)
|
|
2283
|
+
);
|
|
2284
|
+
if (!rows.length) return;
|
|
2285
|
+
const title = apartment
|
|
2286
|
+
? (house + ' \u203A ' + apartment)
|
|
2287
|
+
: house;
|
|
2288
|
+
let html = '<h2>'+esc(t('print_latest_title'))+'</h2>';
|
|
2289
|
+
html += '<table><thead><tr>'+
|
|
2290
|
+
(apartment ? '' : '<th>'+esc(t('prev_apts'))+'</th>')+
|
|
2291
|
+
'<th>'+esc(t('print_meter'))+'</th>'+
|
|
2292
|
+
'<th>'+esc(t('print_value'))+'</th>'+
|
|
2293
|
+
'<th>'+esc(t('print_unit'))+'</th>'+
|
|
2294
|
+
'<th>'+esc(t('print_date'))+'</th>'+
|
|
2295
|
+
'</tr></thead><tbody>';
|
|
2296
|
+
for (const m of rows) {
|
|
2297
|
+
const dt = m.latestDate ? fmtDt(new Date(m.latestDate).getTime()) : '\u2013';
|
|
2298
|
+
html += '<tr>'+
|
|
2299
|
+
(apartment ? '' : '<td>'+esc(m.apartment)+'</td>')+
|
|
2300
|
+
'<td>'+esc(m.meter)+(m.typeName ? ' <span style="color:#777">('+esc(m.typeName)+')</span>' : '')+'</td>'+
|
|
2301
|
+
'<td class="num">'+(m.latest !== undefined && m.latest !== null ? esc(String(m.latest)) : '\u2013')+'</td>'+
|
|
2302
|
+
'<td>'+esc(m.unit || '')+'</td>'+
|
|
2303
|
+
'<td>'+esc(dt)+'</td>'+
|
|
2304
|
+
'</tr>';
|
|
2305
|
+
}
|
|
2306
|
+
html += '</tbody></table>';
|
|
2307
|
+
openPrintWindow(title, html);
|
|
2308
|
+
};
|
|
2309
|
+
|
|
2073
2310
|
// \u2500\u2500 Tab-Navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2074
2311
|
window.showTab = function showTab(name) {
|
|
2075
2312
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
@@ -2124,9 +2361,38 @@ async function fetchSysStats() {
|
|
|
2124
2361
|
}
|
|
2125
2362
|
|
|
2126
2363
|
// \u2500\u2500 DATEN-TAB \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2364
|
+
let nodesCacheForData = [];
|
|
2365
|
+
let dataNamespace = '';
|
|
2366
|
+
|
|
2367
|
+
function meterStateId(house, apt, meter) {
|
|
2368
|
+
return dataNamespace ? (dataNamespace + '.' + house + '.' + apt + '.' + meter + '.readings.latest') : '';
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
function nodeSid(n) {
|
|
2372
|
+
try { return JSON.parse(n.config || '{}').sid || ''; } catch { return ''; }
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
function buildNodeChipsHtml(stateId, unit, label) {
|
|
2376
|
+
if (!nodesCacheForData.length) return '';
|
|
2377
|
+
const chips = nodesCacheForData.map(n => {
|
|
2378
|
+
const sid = nodeSid(n);
|
|
2379
|
+
const active = sid && stateId && sid === stateId;
|
|
2380
|
+
const name = n.name || n.mac;
|
|
2381
|
+
const cls = 'node-chip' + (active ? ' active' : '') + (n.online ? '' : ' offline');
|
|
2382
|
+
const title = active ? t('node_unassign') : t('node_assign');
|
|
2383
|
+
return '<button type="button" class="'+cls+'" data-mac="'+esc(n.mac)+'" data-sid="'+esc(stateId)+'" data-unit="'+esc(unit||'')+'" data-label="'+esc(label||'')+'" data-active="'+(active?'1':'0')+'" title="'+esc(title)+'"><span class="dot"></span>'+esc(name)+'</button>';
|
|
2384
|
+
}).join('');
|
|
2385
|
+
return '<div class="mc-nodes"><span class="mc-nodes-label">'+esc(t('node_label'))+'</span>'+chips+'</div>';
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2127
2388
|
async function fetchData() {
|
|
2128
2389
|
try {
|
|
2129
|
-
const d
|
|
2390
|
+
const [d, nodes] = await Promise.all([
|
|
2391
|
+
fetch('/api/data').then(r => r.json()),
|
|
2392
|
+
fetch('/api/nodes').then(r => r.json()).catch(() => [])
|
|
2393
|
+
]);
|
|
2394
|
+
nodesCacheForData = Array.isArray(nodes) ? nodes : [];
|
|
2395
|
+
dataNamespace = d.namespace || '';
|
|
2130
2396
|
const con = document.getElementById('data-container');
|
|
2131
2397
|
dataCacheList = [];
|
|
2132
2398
|
if (!d.data || !Object.keys(d.data).length) {
|
|
@@ -2136,9 +2402,11 @@ async function fetchData() {
|
|
|
2136
2402
|
let html = '';
|
|
2137
2403
|
let idx = 0;
|
|
2138
2404
|
for (const [house, apts] of Object.entries(d.data)) {
|
|
2139
|
-
html += '<div class="house-block"><div class="house-title">\uD83C\uDFE0 '+esc(house)+'</
|
|
2405
|
+
html += '<div class="house-block"><div class="house-title"><span>\uD83C\uDFE0 '+esc(house)+'</span>'+
|
|
2406
|
+
'<button type="button" class="print-scope-btn" data-print-house="'+esc(house)+'" title="'+esc(t('print_house'))+'">\uD83D\uDDA8</button></div>';
|
|
2140
2407
|
for (const [apt, meters] of Object.entries(apts)) {
|
|
2141
|
-
html += '<div class="apt-block"><div class="apt-title">\uD83C\uDFD8 '+esc(apt)+'</
|
|
2408
|
+
html += '<div class="apt-block"><div class="apt-title"><span>\uD83C\uDFD8 '+esc(apt)+'</span>'+
|
|
2409
|
+
'<button type="button" class="print-scope-btn" data-print-house="'+esc(house)+'" data-print-apt="'+esc(apt)+'" title="'+esc(t('print_apartment'))+'">\uD83D\uDDA8</button></div><div class="meters-grid">';
|
|
2142
2410
|
for (const [key, m] of Object.entries(meters)) {
|
|
2143
2411
|
const cacheIdx = idx++;
|
|
2144
2412
|
dataCacheList.push({ house, apartment: apt, meter: key, unit: m.unit, typeName: m.typeName, latest: m.latest, latestDate: m.latestDate, history: m.history || [] });
|
|
@@ -2146,8 +2414,16 @@ async function fetchData() {
|
|
|
2146
2414
|
const histId = 'h-'+CSS.escape(house+apt+key);
|
|
2147
2415
|
const hist = m.history || [];
|
|
2148
2416
|
const delta = calcDelta(hist);
|
|
2417
|
+
const sid = meterStateId(house, apt, key);
|
|
2418
|
+
const assignedHere = nodesCacheForData.some(n => nodeSid(n) === sid);
|
|
2149
2419
|
const rows = hist.slice().reverse().map(h =>
|
|
2150
|
-
'<div class="hist-row"
|
|
2420
|
+
'<div class="hist-row">'+
|
|
2421
|
+
'<span>'+esc(fmtDt(h.ts))+'</span>'+
|
|
2422
|
+
'<span style="display:flex;align-items:center;gap:8px;">'+
|
|
2423
|
+
'<span class="hist-val">'+h.value+' '+esc(m.unit||'')+'</span>'+
|
|
2424
|
+
'<button type="button" class="hist-edit-btn" data-idx="'+cacheIdx+'" data-ts="'+h.ts+'" title="'+esc(t('history_edit'))+'">\u270E</button>'+
|
|
2425
|
+
'</span>'+
|
|
2426
|
+
'</div>'
|
|
2151
2427
|
).join('');
|
|
2152
2428
|
const consumeHtml = delta
|
|
2153
2429
|
? '<div class="mc-consume">\uD83D\uDCCA +'+fmtNum(delta.delta)+' '+esc(m.unit||'')+' '+t('since_last')+' ('+delta.days+' '+t('days')+')</div>'
|
|
@@ -2158,8 +2434,9 @@ async function fetchData() {
|
|
|
2158
2434
|
'<button class="mc-csv-btn" data-idx="'+cacheIdx+'" title="'+esc(t('csv_btn'))+'">\u2B07 '+esc(t('csv_btn'))+'</button>'+
|
|
2159
2435
|
'</div>'
|
|
2160
2436
|
: '';
|
|
2437
|
+
const nodesHtml = buildNodeChipsHtml(sid, m.unit, key);
|
|
2161
2438
|
html +=
|
|
2162
|
-
'<div class="meter-card">'+
|
|
2439
|
+
'<div class="meter-card'+(assignedHere ? ' has-node' : '')+'">'+
|
|
2163
2440
|
'<div class="mc-head">'+
|
|
2164
2441
|
'<div class="mc-name">'+icon+' '+esc(key)+'</div>'+
|
|
2165
2442
|
'<div class="mc-badge">'+esc(m.typeName||'?')+'</div>'+
|
|
@@ -2175,6 +2452,7 @@ async function fetchData() {
|
|
|
2175
2452
|
'<div class="mc-history" id="'+histId+'">'+rows+'</div>'
|
|
2176
2453
|
: '')+
|
|
2177
2454
|
actionsHtml+
|
|
2455
|
+
nodesHtml+
|
|
2178
2456
|
'</div>';
|
|
2179
2457
|
}
|
|
2180
2458
|
html += '</div></div>';
|
|
@@ -2188,10 +2466,80 @@ async function fetchData() {
|
|
|
2188
2466
|
}
|
|
2189
2467
|
}
|
|
2190
2468
|
|
|
2469
|
+
window.toggleNodeAssign = async function toggleNodeAssign(mac, sid, label, unit, currentlyActive) {
|
|
2470
|
+
const nextSid = currentlyActive ? '' : sid;
|
|
2471
|
+
try {
|
|
2472
|
+
const r = await authFetch('/api/nodes/'+encodeURIComponent(mac)+'/config', {
|
|
2473
|
+
method: 'POST',
|
|
2474
|
+
body: JSON.stringify({
|
|
2475
|
+
sid: nextSid,
|
|
2476
|
+
label: nextSid ? (label || '') : '',
|
|
2477
|
+
unit: nextSid ? (unit || '') : ''
|
|
2478
|
+
})
|
|
2479
|
+
});
|
|
2480
|
+
if (!r) return;
|
|
2481
|
+
const d = await r.json();
|
|
2482
|
+
if (!d.ok) {
|
|
2483
|
+
window.alert(t('error') + ': ' + (d.error || r.status));
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2486
|
+
await fetchData();
|
|
2487
|
+
if (typeof fetchNodes === 'function' && document.querySelector('.nav-item.active[data-tab="nodes"]')) {
|
|
2488
|
+
fetchNodes();
|
|
2489
|
+
}
|
|
2490
|
+
} catch (e) {
|
|
2491
|
+
window.alert(t('error') + ': ' + e.message);
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2191
2495
|
function toggleHist(id) {
|
|
2192
2496
|
const el = document.getElementById(id);
|
|
2193
2497
|
if (el) el.classList.toggle('open');
|
|
2194
2498
|
}
|
|
2499
|
+
|
|
2500
|
+
window.editHistoryValue = async function editHistoryValue(cacheIdx, ts) {
|
|
2501
|
+
const m = dataCacheList[cacheIdx];
|
|
2502
|
+
if (!m) return;
|
|
2503
|
+
const entry = (m.history || []).find(h => h.ts === ts);
|
|
2504
|
+
if (!entry) return;
|
|
2505
|
+
const raw = window.prompt(t('history_edit_prompt'), String(entry.value));
|
|
2506
|
+
if (raw === null) return;
|
|
2507
|
+
const normalized = String(raw).trim().replace(',', '.');
|
|
2508
|
+
const value = parseFloat(normalized);
|
|
2509
|
+
if (!Number.isFinite(value)) {
|
|
2510
|
+
window.alert(t('error') + ': value');
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
try {
|
|
2514
|
+
const r = await authFetch('/api/reading', {
|
|
2515
|
+
method: 'POST',
|
|
2516
|
+
body: JSON.stringify({
|
|
2517
|
+
house: m.house,
|
|
2518
|
+
apartment: m.apartment,
|
|
2519
|
+
meter: m.meter,
|
|
2520
|
+
value,
|
|
2521
|
+
unit: m.unit || '',
|
|
2522
|
+
typeName: m.typeName || '',
|
|
2523
|
+
readingDate: entry.readingDate
|
|
2524
|
+
})
|
|
2525
|
+
});
|
|
2526
|
+
if (!r) return;
|
|
2527
|
+
const d = await r.json();
|
|
2528
|
+
if (!d.ok) {
|
|
2529
|
+
window.alert(t('error') + ': ' + (d.error || r.status));
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
entry.value = value;
|
|
2533
|
+
if (m.history && m.history.length) {
|
|
2534
|
+
const newest = m.history.slice().sort((a, b) => a.ts - b.ts).pop();
|
|
2535
|
+
m.latest = newest.value;
|
|
2536
|
+
m.latestDate = newest.readingDate;
|
|
2537
|
+
}
|
|
2538
|
+
await fetchData();
|
|
2539
|
+
} catch (e) {
|
|
2540
|
+
window.alert(t('error') + ': ' + e.message);
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2195
2543
|
// \u2500\u2500 NODES-TAB \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2196
2544
|
let discoverCache = [];
|
|
2197
2545
|
|
|
@@ -2517,6 +2865,28 @@ function initTabs() {
|
|
|
2517
2865
|
if (btn && btn.dataset.hist) toggleHist(btn.dataset.hist);
|
|
2518
2866
|
});
|
|
2519
2867
|
|
|
2868
|
+
// History value edit
|
|
2869
|
+
document.addEventListener('click', e => {
|
|
2870
|
+
const editBtn = e.target.closest('.hist-edit-btn');
|
|
2871
|
+
if (!editBtn) return;
|
|
2872
|
+
const idx = parseInt(editBtn.dataset.idx, 10);
|
|
2873
|
+
const ts = parseInt(editBtn.dataset.ts, 10);
|
|
2874
|
+
editHistoryValue(idx, ts);
|
|
2875
|
+
});
|
|
2876
|
+
|
|
2877
|
+
// Node chips on meter cards: assign / unassign
|
|
2878
|
+
document.addEventListener('click', e => {
|
|
2879
|
+
const chip = e.target.closest('.node-chip');
|
|
2880
|
+
if (!chip) return;
|
|
2881
|
+
toggleNodeAssign(
|
|
2882
|
+
chip.dataset.mac,
|
|
2883
|
+
chip.dataset.sid,
|
|
2884
|
+
chip.dataset.label || '',
|
|
2885
|
+
chip.dataset.unit || '',
|
|
2886
|
+
chip.dataset.active === '1'
|
|
2887
|
+
);
|
|
2888
|
+
});
|
|
2889
|
+
|
|
2520
2890
|
// Chart + CSV + Chart-Modal via Event Delegation
|
|
2521
2891
|
document.addEventListener('click', e => {
|
|
2522
2892
|
const chartBtn = e.target.closest('.mc-chart-btn');
|
|
@@ -2526,6 +2896,12 @@ function initTabs() {
|
|
|
2526
2896
|
if (e.target.closest('.chart-close')) { closeChartModal(); return; }
|
|
2527
2897
|
if (e.target.id === 'chart-overlay') { closeChartModal(); return; }
|
|
2528
2898
|
if (e.target.closest('#chart-csv-btn')) { if (chartCtxIdx >= 0) exportMeterCsv(chartCtxIdx); return; }
|
|
2899
|
+
if (e.target.closest('#chart-print-btn')) { printChartView(); return; }
|
|
2900
|
+
const scopeBtn = e.target.closest('.print-scope-btn');
|
|
2901
|
+
if (scopeBtn) {
|
|
2902
|
+
printScopeReadings(scopeBtn.dataset.printHouse, scopeBtn.dataset.printApt || null);
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2529
2905
|
const rangeBtn = e.target.closest('.chart-range[data-months]');
|
|
2530
2906
|
if (rangeBtn) {
|
|
2531
2907
|
chartRangeMonths = parseInt(rangeBtn.dataset.months, 10) || 0;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iobroker.metermaster",
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"description": "ioBroker adapter for the MeterMaster Android app
|
|
3
|
+
"version": "0.9.7",
|
|
4
|
+
"description": "ioBroker adapter for the MeterMaster Android app \u2013 receives meter readings and manages ESP32 display nodes",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "MPunktBPunkt",
|
|
7
7
|
"url": "https://github.com/MPunktBPunkt"
|
|
@@ -34,12 +34,13 @@
|
|
|
34
34
|
"lint": "eslint ."
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@iobroker/adapter-core": "^3.4.
|
|
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": {
|