iobroker.metermaster 0.9.8 → 0.9.9

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 CHANGED
@@ -56,6 +56,8 @@ Open the Web UI anytime at `http://{ioBroker-IP}:8089/` (no password required fo
56
56
  - **Full history** — every meter keeps a `readings.history` JSON array
57
57
  - **Basic Auth** — optional username/password protection for write endpoints
58
58
  - **Built-in Web UI** — Data, Import, Logs and System tabs in DE/EN
59
+ - **Delete in Web UI** — remove apartments/meters from ioBroker (password confirm)
60
+ - **Collapsible sections** — fold house/apartment blocks in the Data tab
59
61
  - **Charts & CSV** — history charts, monthly consumption and per-meter CSV export
60
62
  - **Backup import** — restore MeterMaster app backups (schema 2.0) via drag & drop
61
63
 
@@ -212,6 +214,8 @@ metermaster.0.
212
214
  | POST | `/api/reading` | Store a single reading |
213
215
  | POST | `/api/readings` | Store batch readings |
214
216
  | POST | `/api/import` | Import app backup |
217
+ | DELETE | `/api/apartment/{house}/{apartment}` | Delete apartment channel tree |
218
+ | DELETE | `/api/meter/{house}/{apartment}/{meter}` | Delete single meter |
215
219
  | GET/POST | `/api/nodes/{MAC}/config` | Get / set ESP32 config |
216
220
  | POST | `/api/nodes/{MAC}/configAck` | Config acknowledgement |
217
221
  | POST | `/api/nodes/{MAC}/cmd` | Immediate command (e.g. LED) |
package/io-package.json CHANGED
@@ -1,8 +1,21 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "metermaster",
4
- "version": "0.9.8",
4
+ "version": "0.9.9",
5
5
  "news": {
6
+ "0.9.9": {
7
+ "en": "Web UI: delete apartment/meter (password confirm); collapsible house/apartment sections",
8
+ "de": "Web-UI: Wohnung/Zähler löschen (Passwort-Bestätigung); Haus/Wohnung einklappbar",
9
+ "ru": "Web UI: delete apartment/meter; collapsible sections",
10
+ "pt": "Web UI: delete apartment/meter; collapsible sections",
11
+ "nl": "Web-UI: appartement/meter verwijderen; inklapbare secties",
12
+ "fr": "UI web : supprimer logement/compteur ; sections repliables",
13
+ "it": "Web UI: elimina appartamento/contatore; sezioni comprimibili",
14
+ "es": "Web UI: eliminar vivienda/contador; secciones plegables",
15
+ "pl": "Web UI: usuwanie mieszkania/licznika; zwijane sekcje",
16
+ "uk": "Web UI: delete apartment/meter; collapsible sections",
17
+ "zh-cn": "Web UI:删除公寓/表计;可折叠分区"
18
+ },
6
19
  "0.9.8": {
7
20
  "en": "Log MeterMaster app connection tests from User-Agent at info level",
8
21
  "de": "Verbindungstests der MeterMaster-App per User-Agent auf Info-Level loggen",
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.8';
7
+ const CURRENT_VERSION = '0.9.9';
8
8
  const GITHUB_REPO = 'MPunktBPunkt/ioBroker.metermaster';
9
9
  const GITHUB_URL = 'https://github.com/MPunktBPunkt/ioBroker.metermaster';
10
10
 
@@ -254,7 +254,7 @@ function startHttpServer() {
254
254
 
255
255
  server = http.createServer((req, res) => {
256
256
  res.setHeader('Access-Control-Allow-Origin', '*');
257
- res.setHeader('Access-Control-Allow-Methods', 'POST, PUT, GET, OPTIONS');
257
+ res.setHeader('Access-Control-Allow-Methods', 'POST, PUT, GET, DELETE, OPTIONS');
258
258
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
259
259
  if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
260
260
 
@@ -333,6 +333,22 @@ function startHttpServer() {
333
333
  }
334
334
  else if (req.method === 'POST' && url === '/api/readings') { readBody(req, b => handleReadings(b, res, clientIp)); }
335
335
  else if (req.method === 'POST' && url === '/api/import') { readBody(req, b => handleImport(b, res, clientIp)); }
336
+ else if (req.method === 'DELETE' && url.startsWith('/api/apartment/')) {
337
+ const parts = url.split('/').filter(Boolean); // api, apartment, house, apt
338
+ if (parts.length === 4) {
339
+ handleDeleteApartment(parts[2], parts[3], res, clientIp);
340
+ } else {
341
+ sendJson(res, 400, { error: 'Expected /api/apartment/{house}/{apartment}' });
342
+ }
343
+ }
344
+ else if (req.method === 'DELETE' && url.startsWith('/api/meter/')) {
345
+ const parts = url.split('/').filter(Boolean); // api, meter, house, apt, meter
346
+ if (parts.length === 5) {
347
+ handleDeleteMeter(parts[2], parts[3], parts[4], res, clientIp);
348
+ } else {
349
+ sendJson(res, 400, { error: 'Expected /api/meter/{house}/{apartment}/{meter}' });
350
+ }
351
+ }
336
352
  else {
337
353
  // Node-Config: POST /api/nodes/{MAC}/config
338
354
  const nodeMatch = url.match(/^\/api\/nodes\/([A-Fa-f0-9]+)\/config$/);
@@ -549,6 +565,62 @@ async function storeReading(data) {
549
565
  return { path: base, history: histResult };
550
566
  }
551
567
 
568
+ // ─── Löschen (Wohnung / Zähler) ───────────────────────────────────────────────
569
+ async function handleDeleteApartment(houseRaw, aptRaw, res, clientIp) {
570
+ const house = sanitize(decodeURIComponent(houseRaw || ''));
571
+ const apt = sanitize(decodeURIComponent(aptRaw || ''));
572
+ if (!house || !apt || house === 'unknown' || apt === 'unknown') {
573
+ sendJson(res, 400, { error: 'Invalid house or apartment' });
574
+ return;
575
+ }
576
+ const relId = `${house}.${apt}`;
577
+ try {
578
+ const existed = await adapter.getObjectAsync(relId).catch(() => null);
579
+ await adapter.delObjectAsync(relId, { recursive: true });
580
+ if (receivedData[house] && receivedData[house][apt]) {
581
+ delete receivedData[house][apt];
582
+ if (!Object.keys(receivedData[house]).length) {
583
+ delete receivedData[house];
584
+ }
585
+ }
586
+ log(LVL.INFO, CAT.DATAPOINT, `Apartment deleted`,
587
+ `${adapter.namespace}.${relId} | IP: ${clientIp}` + (existed ? '' : ' (objects already gone)'));
588
+ sendJson(res, 200, { ok: true, deleted: relId });
589
+ } catch (e) {
590
+ log(LVL.ERROR, CAT.DATAPOINT, `Apartment delete failed`, `${relId}: ${e.message}`);
591
+ sendJson(res, 500, { error: e.message });
592
+ }
593
+ }
594
+
595
+ async function handleDeleteMeter(houseRaw, aptRaw, meterRaw, res, clientIp) {
596
+ const house = sanitize(decodeURIComponent(houseRaw || ''));
597
+ const apt = sanitize(decodeURIComponent(aptRaw || ''));
598
+ const meter = sanitize(decodeURIComponent(meterRaw || ''));
599
+ if (!house || !apt || !meter || house === 'unknown' || apt === 'unknown' || meter === 'unknown') {
600
+ sendJson(res, 400, { error: 'Invalid house, apartment or meter' });
601
+ return;
602
+ }
603
+ const relId = `${house}.${apt}.${meter}`;
604
+ try {
605
+ await adapter.delObjectAsync(relId, { recursive: true });
606
+ if (receivedData[house] && receivedData[house][apt] && receivedData[house][apt][meter]) {
607
+ delete receivedData[house][apt][meter];
608
+ if (!Object.keys(receivedData[house][apt]).length) {
609
+ delete receivedData[house][apt];
610
+ }
611
+ if (receivedData[house] && !Object.keys(receivedData[house]).length) {
612
+ delete receivedData[house];
613
+ }
614
+ }
615
+ log(LVL.INFO, CAT.DATAPOINT, `Meter deleted`,
616
+ `${adapter.namespace}.${relId} | IP: ${clientIp}`);
617
+ sendJson(res, 200, { ok: true, deleted: relId });
618
+ } catch (e) {
619
+ log(LVL.ERROR, CAT.DATAPOINT, `Meter delete failed`, `${relId}: ${e.message}`);
620
+ sendJson(res, 500, { error: e.message });
621
+ }
622
+ }
623
+
552
624
  // ─── Historie ─────────────────────────────────────────────────────────────────
553
625
  async function updateHistory(base, entry) {
554
626
  const stateId = `${base}.readings.history`;
@@ -1136,6 +1208,9 @@ nav {
1136
1208
  margin-bottom: 12px; display: flex; align-items: center; gap: 8px;
1137
1209
  padding-bottom: 8px; border-bottom: 1px solid var(--border);
1138
1210
  }
1211
+ .house-title .collapse-label { flex: 1; cursor: pointer; user-select: none; }
1212
+ .house-body { }
1213
+ .house-block.collapsed .house-body { display: none; }
1139
1214
  .apt-block { margin-bottom: 14px; margin-left: 10px; }
1140
1215
  .apt-title {
1141
1216
  font-size: .88em; font-weight: 600; color: var(--text-dim);
@@ -1144,6 +1219,22 @@ nav {
1144
1219
  border-radius: 0 6px 6px 0;
1145
1220
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
1146
1221
  }
1222
+ .apt-title .collapse-label { flex: 1; cursor: pointer; user-select: none; }
1223
+ .apt-block.collapsed .meters-grid { display: none; }
1224
+ .collapse-toggle {
1225
+ background: transparent; border: none; color: var(--text-dim);
1226
+ cursor: pointer; font-size: .85em; padding: 2px 4px; line-height: 1;
1227
+ min-width: 1.4em;
1228
+ }
1229
+ .collapse-toggle:hover { color: var(--secondary); }
1230
+ .apt-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
1231
+ .apt-delete-btn, .meter-delete-btn {
1232
+ background: transparent; border: 1px solid transparent; color: var(--text-muted);
1233
+ border-radius: 6px; padding: 2px 7px; cursor: pointer; font-size: .85em; line-height: 1.2;
1234
+ }
1235
+ .apt-delete-btn:hover, .meter-delete-btn:hover {
1236
+ color: #EF9A9A; border-color: rgba(244,67,54,.4); background: rgba(244,67,54,.1);
1237
+ }
1147
1238
  .print-scope-btn {
1148
1239
  background: transparent; border: 1px solid var(--border); color: var(--text-dim);
1149
1240
  border-radius: 6px; padding: 2px 8px; cursor: pointer; font-size: .78em;
@@ -1713,6 +1804,25 @@ input.search {
1713
1804
  </div>
1714
1805
  </div>
1715
1806
  </div>
1807
+ <!-- ══ DELETE-CONFIRM-MODAL ═══════════════════════════════════════════════════ -->
1808
+ <div id="delete-overlay" style="display:none;position:fixed;inset:0;background:rgba(15,11,26,.85);z-index:9999;align-items:center;justify-content:center;">
1809
+ <div style="background:var(--bg-surface);border:1px solid rgba(244,67,54,.45);border-radius:16px;padding:28px 32px;min-width:320px;max-width:440px;width:90%;">
1810
+ <div id="delete-title" style="font-size:1.1em;font-weight:700;color:#EF9A9A;margin-bottom:8px;">\uD83D\uDDD1 Delete</div>
1811
+ <div id="delete-msg" style="font-size:.86em;color:var(--text-dim);margin-bottom:14px;line-height:1.5;"></div>
1812
+ <div id="delete-pass-hint" style="font-size:.78em;color:var(--text-muted);margin-bottom:6px;"></div>
1813
+ <div style="margin-bottom:14px;">
1814
+ <label id="delete-user-label" style="font-size:.82em;color:var(--text-dim);display:block;margin-bottom:4px;">Username</label>
1815
+ <input id="delete-user" type="text" value="metermaster" style="width:100%;background:var(--bg-surface2);border:1px solid var(--border-light);color:var(--text);padding:8px 12px;border-radius:8px;font-size:.9em;outline:none;margin-bottom:10px;">
1816
+ <label id="delete-pass-label" style="font-size:.82em;color:var(--text-dim);display:block;margin-bottom:4px;">Password</label>
1817
+ <input id="delete-pass" type="password" style="width:100%;background:var(--bg-surface2);border:1px solid var(--border-light);color:var(--text);padding:8px 12px;border-radius:8px;font-size:.9em;outline:none;" onkeydown="if(event.key===\'Enter\')confirmDeleteAction()">
1818
+ </div>
1819
+ <div id="delete-err" style="font-size:.82em;color:var(--danger);min-height:18px;margin-bottom:10px;"></div>
1820
+ <div style="display:flex;gap:10px;">
1821
+ <button id="delete-confirm-btn" onclick="confirmDeleteAction()" style="flex:1;background:#C62828;color:#fff;border:none;padding:9px;border-radius:9px;cursor:pointer;font-size:.9em;font-weight:600;"></button>
1822
+ <button id="delete-cancel-btn" onclick="hideDeleteModal()" style="background:transparent;border:1px solid var(--border-light);color:var(--text-dim);padding:9px 16px;border-radius:9px;cursor:pointer;font-size:.9em;"></button>
1823
+ </div>
1824
+ </div>
1825
+ </div>
1716
1826
 
1717
1827
  <script>
1718
1828
 
@@ -1755,6 +1865,106 @@ async function authFetch(url, opts) {
1755
1865
  return r;
1756
1866
  }
1757
1867
 
1868
+ // ── Collapse state ──────────────────────────────────────────────────────────
1869
+ function loadCollapsedState() {
1870
+ try { return JSON.parse(localStorage.getItem('mm-collapsed') || '{}') || {}; }
1871
+ catch { return {}; }
1872
+ }
1873
+ function saveCollapsedState(map) {
1874
+ try { localStorage.setItem('mm-collapsed', JSON.stringify(map)); } catch {}
1875
+ }
1876
+ function toggleCollapse(ckey) {
1877
+ if (!ckey) return;
1878
+ const map = loadCollapsedState();
1879
+ map[ckey] = !map[ckey];
1880
+ if (!map[ckey]) delete map[ckey];
1881
+ saveCollapsedState(map);
1882
+ const blocks = document.querySelectorAll('[data-ckey="'+CSS.escape(ckey)+'"]');
1883
+ blocks.forEach(el => {
1884
+ if (el.classList.contains('house-block') || el.classList.contains('apt-block')) {
1885
+ el.classList.toggle('collapsed', !!map[ckey]);
1886
+ }
1887
+ if (el.classList.contains('collapse-toggle')) {
1888
+ el.textContent = map[ckey] ? '\u25B6' : '\u25BC';
1889
+ el.title = t(map[ckey] ? 'collapse_expand' : 'collapse_collapse');
1890
+ }
1891
+ });
1892
+ }
1893
+
1894
+ // ── Delete apartment / meter ────────────────────────────────────────────────
1895
+ let _pendingDelete = null;
1896
+
1897
+ function hideDeleteModal() {
1898
+ document.getElementById('delete-overlay').style.display = 'none';
1899
+ _pendingDelete = null;
1900
+ const err = document.getElementById('delete-err');
1901
+ if (err) err.textContent = '';
1902
+ const pass = document.getElementById('delete-pass');
1903
+ if (pass) pass.value = '';
1904
+ }
1905
+
1906
+ function showDeleteModal(kind, house, apt, meter) {
1907
+ _pendingDelete = { kind, house, apt, meter: meter || '' };
1908
+ document.getElementById('delete-title').textContent =
1909
+ '\uD83D\uDDD1 ' + (kind === 'meter' ? t('delete_meter_title') : t('delete_apt_title'));
1910
+ document.getElementById('delete-msg').textContent = kind === 'meter'
1911
+ ? t('delete_meter_msg', { meter })
1912
+ : t('delete_apt_msg', { apt });
1913
+ document.getElementById('delete-pass-hint').textContent = t('delete_pass_hint');
1914
+ document.getElementById('delete-user-label').textContent = t('login_user');
1915
+ document.getElementById('delete-pass-label').textContent = t('login_pass');
1916
+ document.getElementById('delete-confirm-btn').textContent = t('delete_confirm');
1917
+ document.getElementById('delete-cancel-btn').textContent = t('delete_cancel');
1918
+ document.getElementById('delete-err').textContent = '';
1919
+ const loginUser = document.getElementById('login-user');
1920
+ if (loginUser && loginUser.value) {
1921
+ document.getElementById('delete-user').value = loginUser.value;
1922
+ }
1923
+ document.getElementById('delete-overlay').style.display = 'flex';
1924
+ window.setTimeout(() => document.getElementById('delete-pass').focus(), 50);
1925
+ }
1926
+
1927
+ window.confirmDeleteAction = async function confirmDeleteAction() {
1928
+ if (!_pendingDelete) return;
1929
+ const user = (document.getElementById('delete-user').value || '').trim() || 'metermaster';
1930
+ const pass = document.getElementById('delete-pass').value || '';
1931
+ const { kind, house, apt, meter } = _pendingDelete;
1932
+ const url = kind === 'meter'
1933
+ ? '/api/meter/' + encodeURIComponent(house) + '/' + encodeURIComponent(apt) + '/' + encodeURIComponent(meter)
1934
+ : '/api/apartment/' + encodeURIComponent(house) + '/' + encodeURIComponent(apt);
1935
+ const btn = document.getElementById('delete-confirm-btn');
1936
+ btn.disabled = true;
1937
+ document.getElementById('delete-err').textContent = '';
1938
+ try {
1939
+ let r;
1940
+ if (pass) {
1941
+ setAuth(user, pass);
1942
+ r = await authFetch(url, { method: 'DELETE' });
1943
+ if (!r) { btn.disabled = false; return; }
1944
+ } else {
1945
+ r = await fetch(url, { method: 'DELETE' });
1946
+ if (r.status === 401 || r.status === 403) {
1947
+ document.getElementById('delete-err').textContent = t('delete_pass_hint');
1948
+ btn.disabled = false;
1949
+ return;
1950
+ }
1951
+ }
1952
+ const d = await r.json().catch(() => ({}));
1953
+ if (!r.ok || !d.ok) {
1954
+ document.getElementById('delete-err').textContent =
1955
+ t('delete_failed') + ': ' + (d.error || ('HTTP ' + r.status));
1956
+ btn.disabled = false;
1957
+ return;
1958
+ }
1959
+ hideDeleteModal();
1960
+ await fetchData();
1961
+ } catch (e) {
1962
+ document.getElementById('delete-err').textContent = t('delete_failed') + ': ' + e.message;
1963
+ } finally {
1964
+ btn.disabled = false;
1965
+ }
1966
+ };
1967
+
1758
1968
  const TYPE_ICONS = {
1759
1969
  Electricity:'\u26A1', Gas:'\uD83D\uDD25', Water:'\uD83D\uDCA7', HotWater:'\uD83C\uDF21',
1760
1970
  TotalWater:'\uD83D\uDCA7', ColdWater:'\u2744', Heat:'\uD83C\uDFE0', HeatMeter:'\uD83C\uDFE0',
@@ -1809,11 +2019,24 @@ const I18N = {
1809
2019
  sys_tip:'Tipp: Alle drei Befehle nacheinander ausf\u00FChren \u2014 warten bis jeder abgeschlossen ist.',
1810
2020
  copy:'Kopieren',
1811
2021
  login_title:'Anmelden',
1812
- login_desc:'Zugangsdaten f\u00FCr schreibende Aktionen (Z\u00E4hler zuweisen, Import).',
2022
+ login_desc:'Zugangsdaten f\u00FCr schreibende Aktionen (Z\u00E4hler zuweisen, Import, L\u00F6schen).',
1813
2023
  login_user:'Benutzername', login_pass:'Passwort', login_submit:'Anmelden',
1814
2024
  login_missing_user:'Benutzername fehlt', login_wrong:'Falsche Zugangsdaten',
1815
2025
  login_conn_err:'Verbindungsfehler', login_expired:'Sitzung abgelaufen \u2013 bitte neu anmelden',
1816
- invalid_json:'Ung\u00FCltige JSON-Datei', network_err:'Netzwerkfehler'
2026
+ invalid_json:'Ung\u00FCltige JSON-Datei', network_err:'Netzwerkfehler',
2027
+ delete_apt_title:'Wohnung l\u00F6schen',
2028
+ delete_apt_msg:'Wohnung \u201E{apt}\u201C inkl. aller Z\u00E4hler und Ablesungen in ioBroker unwiderruflich l\u00F6schen?',
2029
+ delete_meter_title:'Z\u00E4hler l\u00F6schen',
2030
+ delete_meter_msg:'Z\u00E4hler \u201E{meter}\u201C inkl. Verlauf in ioBroker unwiderruflich l\u00F6schen?',
2031
+ delete_pass_hint:'Passwort aus den Adapter-Einstellungen eingeben.',
2032
+ delete_confirm:'Endg\u00FCltig l\u00F6schen',
2033
+ delete_cancel:'Abbrechen',
2034
+ delete_ok:'Gel\u00F6scht.',
2035
+ delete_failed:'L\u00F6schen fehlgeschlagen',
2036
+ delete_apt_title_attr:'Wohnung in ioBroker l\u00F6schen',
2037
+ delete_meter_title_attr:'Z\u00E4hler in ioBroker l\u00F6schen',
2038
+ collapse_expand:'Aufklappen',
2039
+ collapse_collapse:'Einklappen'
1817
2040
  },
1818
2041
  en: {
1819
2042
  readings:'Readings', nodes:'Nodes', uptime:'Uptime', live:'Live', disconnected:'Disconnected',
@@ -1862,11 +2085,24 @@ const I18N = {
1862
2085
  sys_tip:'Tip: Run all three commands in sequence \u2014 wait for each to finish.',
1863
2086
  copy:'Copy',
1864
2087
  login_title:'Sign in',
1865
- login_desc:'Credentials for write actions (assign meter, import).',
2088
+ login_desc:'Credentials for write actions (assign meter, import, delete).',
1866
2089
  login_user:'Username', login_pass:'Password', login_submit:'Sign in',
1867
2090
  login_missing_user:'Username required', login_wrong:'Invalid credentials',
1868
2091
  login_conn_err:'Connection error', login_expired:'Session expired \u2013 please sign in again',
1869
- invalid_json:'Invalid JSON file', network_err:'Network error'
2092
+ invalid_json:'Invalid JSON file', network_err:'Network error',
2093
+ delete_apt_title:'Delete apartment',
2094
+ delete_apt_msg:'Permanently delete apartment "{apt}" including all meters and readings in ioBroker?',
2095
+ delete_meter_title:'Delete meter',
2096
+ delete_meter_msg:'Permanently delete meter "{meter}" including history in ioBroker?',
2097
+ delete_pass_hint:'Enter the password from the adapter settings.',
2098
+ delete_confirm:'Delete permanently',
2099
+ delete_cancel:'Cancel',
2100
+ delete_ok:'Deleted.',
2101
+ delete_failed:'Delete failed',
2102
+ delete_apt_title_attr:'Delete apartment in ioBroker',
2103
+ delete_meter_title_attr:'Delete meter in ioBroker',
2104
+ collapse_expand:'Expand',
2105
+ collapse_collapse:'Collapse'
1870
2106
  }
1871
2107
  };
1872
2108
 
@@ -2407,12 +2643,29 @@ async function fetchData() {
2407
2643
  }
2408
2644
  let html = '';
2409
2645
  let idx = 0;
2646
+ const collapsed = loadCollapsedState();
2410
2647
  for (const [house, apts] of Object.entries(d.data)) {
2411
- html += '<div class="house-block"><div class="house-title"><span>\uD83C\uDFE0 '+esc(house)+'</span>'+
2412
- '<button type="button" class="print-scope-btn" data-print-house="'+esc(house)+'" title="'+esc(t('print_house'))+'">\uD83D\uDDA8</button></div>';
2648
+ const houseKey = 'h:' + house;
2649
+ const houseCollapsed = !!collapsed[houseKey];
2650
+ html += '<div class="house-block'+(houseCollapsed ? ' collapsed' : '')+'" data-ckey="'+esc(houseKey)+'">'+
2651
+ '<div class="house-title">'+
2652
+ '<button type="button" class="collapse-toggle" data-ckey="'+esc(houseKey)+'" title="'+esc(t(houseCollapsed ? 'collapse_expand' : 'collapse_collapse'))+'">'+(houseCollapsed ? '\u25B6' : '\u25BC')+'</button>'+
2653
+ '<span class="collapse-label" data-ckey="'+esc(houseKey)+'">\uD83C\uDFE0 '+esc(house)+'</span>'+
2654
+ '<button type="button" class="print-scope-btn" data-print-house="'+esc(house)+'" title="'+esc(t('print_house'))+'">\uD83D\uDDA8</button>'+
2655
+ '</div><div class="house-body">';
2413
2656
  for (const [apt, meters] of Object.entries(apts)) {
2414
- html += '<div class="apt-block"><div class="apt-title"><span>\uD83C\uDFD8 '+esc(apt)+'</span>'+
2415
- '<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">';
2657
+ const aptKey = 'a:' + house + '/' + apt;
2658
+ const aptCollapsed = !!collapsed[aptKey];
2659
+ const meterCount = Object.keys(meters).length;
2660
+ html += '<div class="apt-block'+(aptCollapsed ? ' collapsed' : '')+'" data-ckey="'+esc(aptKey)+'">'+
2661
+ '<div class="apt-title">'+
2662
+ '<button type="button" class="collapse-toggle" data-ckey="'+esc(aptKey)+'" title="'+esc(t(aptCollapsed ? 'collapse_expand' : 'collapse_collapse'))+'">'+(aptCollapsed ? '\u25B6' : '\u25BC')+'</button>'+
2663
+ '<span class="collapse-label" data-ckey="'+esc(aptKey)+'">\uD83C\uDFD8 '+esc(apt)+' <span style="opacity:.6;font-weight:500">('+meterCount+')</span></span>'+
2664
+ '<span class="apt-actions">'+
2665
+ '<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>'+
2666
+ '<button type="button" class="apt-delete-btn" data-house="'+esc(house)+'" data-apt="'+esc(apt)+'" title="'+esc(t('delete_apt_title_attr'))+'">\uD83D\uDDD1</button>'+
2667
+ '</span>'+
2668
+ '</div><div class="meters-grid">';
2416
2669
  for (const [key, m] of Object.entries(meters)) {
2417
2670
  const cacheIdx = idx++;
2418
2671
  dataCacheList.push({ house, apartment: apt, meter: key, unit: m.unit, typeName: m.typeName, latest: m.latest, latestDate: m.latestDate, history: m.history || [] });
@@ -2445,7 +2698,10 @@ async function fetchData() {
2445
2698
  '<div class="meter-card'+(assignedHere ? ' has-node' : '')+'">'+
2446
2699
  '<div class="mc-head">'+
2447
2700
  '<div class="mc-name">'+icon+' '+esc(key)+'</div>'+
2448
- '<div class="mc-badge">'+esc(m.typeName||'?')+'</div>'+
2701
+ '<div style="display:flex;align-items:center;gap:6px;">'+
2702
+ '<div class="mc-badge">'+esc(m.typeName||'?')+'</div>'+
2703
+ '<button type="button" class="meter-delete-btn" data-house="'+esc(house)+'" data-apt="'+esc(apt)+'" data-meter="'+esc(key)+'" title="'+esc(t('delete_meter_title_attr'))+'">\uD83D\uDDD1</button>'+
2704
+ '</div>'+
2449
2705
  '</div>'+
2450
2706
  '<div class="mc-value-row">'+
2451
2707
  '<span class="mc-value">'+(m.latest !== undefined ? m.latest : '\u2013')+'</span>'+
@@ -2463,7 +2719,7 @@ async function fetchData() {
2463
2719
  }
2464
2720
  html += '</div></div>';
2465
2721
  }
2466
- html += '</div>';
2722
+ html += '</div></div>';
2467
2723
  }
2468
2724
  con.innerHTML = html;
2469
2725
  } catch(e) {
@@ -2871,6 +3127,29 @@ function initTabs() {
2871
3127
  if (btn && btn.dataset.hist) toggleHist(btn.dataset.hist);
2872
3128
  });
2873
3129
 
3130
+ // Collapse house / apartment
3131
+ document.addEventListener('click', e => {
3132
+ const toggle = e.target.closest('.collapse-toggle, .collapse-label');
3133
+ if (!toggle || !toggle.dataset.ckey) return;
3134
+ e.preventDefault();
3135
+ toggleCollapse(toggle.dataset.ckey);
3136
+ });
3137
+
3138
+ // Delete apartment / meter
3139
+ document.addEventListener('click', e => {
3140
+ const aptDel = e.target.closest('.apt-delete-btn');
3141
+ if (aptDel) {
3142
+ e.stopPropagation();
3143
+ showDeleteModal('apartment', aptDel.dataset.house, aptDel.dataset.apt);
3144
+ return;
3145
+ }
3146
+ const meterDel = e.target.closest('.meter-delete-btn');
3147
+ if (meterDel) {
3148
+ e.stopPropagation();
3149
+ showDeleteModal('meter', meterDel.dataset.house, meterDel.dataset.apt, meterDel.dataset.meter);
3150
+ }
3151
+ });
3152
+
2874
3153
  // History value edit
2875
3154
  document.addEventListener('click', e => {
2876
3155
  const editBtn = e.target.closest('.hist-edit-btn');
@@ -2928,7 +3207,7 @@ function initTabs() {
2928
3207
  if (langSel) langSel.addEventListener('change', e => setLang(e.target.value));
2929
3208
 
2930
3209
  document.addEventListener('keydown', e => {
2931
- if (e.key === 'Escape') closeChartModal();
3210
+ if (e.key === 'Escape') { closeChartModal(); hideDeleteModal(); }
2932
3211
  });
2933
3212
 
2934
3213
  // Nodes-Speichern via Event Delegation (kein onclick-Attribut mit Escaping-Problemen)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "iobroker.metermaster",
3
- "version": "0.9.8",
4
- "description": "ioBroker adapter for the MeterMaster Android app \u2013 receives meter readings and manages ESP32 display nodes",
3
+ "version": "0.9.9",
4
+ "description": "ioBroker adapter for the MeterMaster Android app – receives meter readings and manages ESP32 display nodes",
5
5
  "author": {
6
6
  "name": "MPunktBPunkt",
7
7
  "url": "https://github.com/MPunktBPunkt"