homebridge-viessmann-vicare 2.0.45 β†’ 2.0.46

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
@@ -1033,7 +1033,7 @@ For issues and questions:
1033
1033
 
1034
1034
  ## πŸ“ˆ Changelog
1035
1035
 
1036
- ### [2.0.45] - 2026-03-15
1036
+ ### [2.0.46] - 2026-03-15
1037
1037
  #### Fixed
1038
1038
  - **Extended Heating state: HomeKit OFF while ViCare ON** β€” confirmed via live API: `forcedLastFromSchedule.active=True` is a schedule management artifact (always present), not an Extended Heating indicator. State now reads `comfort.active OR (programs.active === comfortFeatureSuffix)`. Deactivation uses `comfort.setTemperature` as fallback when `deactivate` is not executable (Vitodens).
1039
1039
  - **Extended Heating / comfort program: API-driven feature discovery** β€” removed hardcoded candidate list `['comfort', 'comfortHeating']`. Plugin now discovers the comfort program by scanning actual device features for any enabled `programs.*` that has an `activate` command, excluding known non-comfort programs. Works for Vitodens (`programs.comfort`), Vitocal gen3 (`programs.comfortHeating`), and any future device model without code changes.
@@ -1042,6 +1042,9 @@ For issues and questions:
1042
1042
  - **Device messages written at startup** β€” `setupDeviceAccessories` now calls `writeDeviceMessages` so the file exists immediately on startup, not only after the first update cycle.
1043
1043
  - **Compressor setpoint path: dynamic** β€” `heating.compressors.0.speed.setpoint` was hardcoded. Now derived from resolved `hpPaths.compressorMod` by replacing `.current` with `.setpoint` β€” correct for any device/compressor index.
1044
1044
 
1045
+ ### [2.0.45] - 2026-03-15
1046
+ *(published separately)*
1047
+
1045
1048
  ### [2.0.44] - 2026-03-15
1046
1049
  *(published separately)*
1047
1050
 
package/SETUP-GUIDE.md CHANGED
@@ -1,8 +1,8 @@
1
- # Complete Setup Guide - v2.0.45
1
+ # Complete Setup Guide - v2.0.46
2
2
 
3
3
  ## Overview
4
4
 
5
- This guide will walk you through setting up the Viessmann ViCare plugin v2.0.45 for Homebridge, including all the advanced features like intelligent caching, rate limiting protection, comprehensive configuration options, **complete localization support with custom names**, **CSV history logging**, **HTML diagnostic reports**, **energy system monitoring** (PV, battery, wallbox), and **heating schedule awareness** with visual bands in the HTML report, and **heat pump (WΓ€rmepumpe) support** with automatic device detection.
5
+ This guide will walk you through setting up the Viessmann ViCare plugin v2.0.46 for Homebridge, including all the advanced features like intelligent caching, rate limiting protection, comprehensive configuration options, **complete localization support with custom names**, **CSV history logging**, **HTML diagnostic reports**, **energy system monitoring** (PV, battery, wallbox), and **heating schedule awareness** with visual bands in the HTML report, and **heat pump (WΓ€rmepumpe) support** with automatic device detection.
6
6
 
7
7
  ## Prerequisites
8
8
 
@@ -1157,7 +1157,7 @@ When reporting issues, include:
1157
1157
 
1158
1158
  ```json
1159
1159
  {
1160
- "plugin_version": "2.0.45",
1160
+ "plugin_version": "2.0.46",
1161
1161
  "homebridge_version": "1.8.x",
1162
1162
  "node_version": "18.x.x",
1163
1163
  "heating_system": "Viessmann Model",
@@ -1228,7 +1228,7 @@ sudo systemctl restart homebridge
1228
1228
 
1229
1229
  ## Changelog
1230
1230
 
1231
- ### v2.0.45 (2026-03-15)
1231
+ ### v2.0.46 (2026-03-15)
1232
1232
  **Multi-device robustness + API-driven feature discovery:**
1233
1233
  - **Comfort program discovery: API-driven** β€” scans actual device features instead of hardcoded name list. Supports Vitodens, Vitocal gen3, and future devices automatically.
1234
1234
  - **HC program normalisation: pattern-based** β€” `comfort*`/`normal*`/`reduced*` prefix matching instead of fixed map.
@@ -1236,6 +1236,9 @@ sudo systemctl restart homebridge
1236
1236
  - **Device messages written at startup** β€” available immediately, not only after first update cycle.
1237
1237
  - **Compressor setpoint path: dynamic** β€” derived from resolved `hpPaths.compressorMod`, no hardcoded device index.
1238
1238
 
1239
+ ### v2.0.45 (2026-03-15)
1240
+ *(published separately)*
1241
+
1239
1242
  ### v2.0.44 (2026-03-15)
1240
1243
  *(published separately)*
1241
1244
 
@@ -9,7 +9,7 @@ export declare const PLUGIN_NAME = "homebridge-viessmann-vicare";
9
9
  /**
10
10
  * Plugin version for User-Agent and logging
11
11
  */
12
- export declare const PLUGIN_VERSION = "2.0.45";
12
+ export declare const PLUGIN_VERSION = "2.0.46";
13
13
  /**
14
14
  * Default configuration values
15
15
  */
package/dist/settings.js CHANGED
@@ -12,7 +12,7 @@ exports.PLUGIN_NAME = 'homebridge-viessmann-vicare';
12
12
  /**
13
13
  * Plugin version for User-Agent and logging
14
14
  */
15
- exports.PLUGIN_VERSION = '2.0.45';
15
+ exports.PLUGIN_VERSION = '2.0.46';
16
16
  /**
17
17
  * Default configuration values
18
18
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-viessmann-vicare",
3
- "version": "2.0.45",
3
+ "version": "2.0.46",
4
4
  "description": "Complete Homebridge plugin for Viessmann ViCare heating systems with full control capabilities, advanced rate limiting protection, and intelligent cache management",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -87,9 +87,30 @@ const avgWallboxPwr = avg(energyRows.filter(r => r.wallbox_charging==='true'), '
87
87
  const lb = boilerRows[boilerRows.length-1] || {};
88
88
  const burnerStarts = lb.burner_starts || null;
89
89
  const burnerHours = lb.burner_hours || null;
90
- const sph = (burnerStarts && burnerHours && parseFloat(burnerHours) > 0) ? (parseFloat(burnerStarts)/parseFloat(burnerHours)).toFixed(2) : null;
91
- const effCls = sph ? (parseFloat(sph) < 2 ? 'good' : 'warn') : 'neutral';
92
- const effLabel = sph ? (parseFloat(sph) < 2 ? 'Good' : 'High cycling') : 'N/A';
90
+
91
+ // Use DELTA of burner_starts/hours within the report period β€” not the lifetime ratio.
92
+ // The CSV has a column mapping issue in early rows (starts=hours value) and the
93
+ // 15-min sample rate misses ~97% of real cycles. API counter delta is the only
94
+ // reliable source.
95
+ // Guard: only rows that have BOTH columns (plugin >= v2.0.40).
96
+ const validBurnerRows = boilerRows.filter(r => r.burner_starts && r.burner_hours && parseFloat(r.burner_hours) > 0);
97
+ const firstVBR = validBurnerRows[0] || null;
98
+ const lastVBR = validBurnerRows[validBurnerRows.length-1] || null;
99
+ const deltaStarts = (firstVBR && lastVBR) ? Math.max(0, parseInt(lastVBR.burner_starts) - parseInt(firstVBR.burner_starts)) : null;
100
+ const deltaHours = (firstVBR && lastVBR) ? Math.max(0, parseFloat(lastVBR.burner_hours) - parseFloat(firstVBR.burner_hours)) : null;
101
+
102
+ // Starts/hour from delta β€” precise (firmware counter, not CSV edges)
103
+ const sph = (deltaStarts !== null && deltaHours !== null && deltaHours > 0)
104
+ ? (deltaStarts / deltaHours).toFixed(1)
105
+ : null;
106
+ const avgCycleDurReal = (deltaStarts !== null && deltaHours !== null && deltaStarts > 0)
107
+ ? (deltaHours * 60 / deltaStarts).toFixed(1)
108
+ : null;
109
+ const realCycleCount = deltaStarts;
110
+ const realAvgDur = avgCycleDurReal;
111
+
112
+ const effCls = sph ? (parseFloat(sph) < 3 ? 'good' : parseFloat(sph) < 6 ? 'warn' : 'bad') : 'neutral';
113
+ const effLabel = sph ? (parseFloat(sph) < 3 ? 'Normal' : parseFloat(sph) < 6 ? 'High' : 'Severe') : 'N/A';
93
114
 
94
115
  // --- Flow temperature stats (from hc0 rows) ---
95
116
  const avgFlow = avg(hcRows, 'flow_temp');
@@ -123,6 +144,54 @@ const gasBarDhw = gasDays.map(d => +gasPerDay[d].dhw.toFixed(2));
123
144
  const gasLineTotal = gasDays.map(d => +(gasPerDay[d].heating + gasPerDay[d].dhw).toFixed(2));
124
145
  const hasGasChart = gasDays.length >= 1;
125
146
 
147
+ // --- API Summary data (from viessmann-history-explore-*.json if present) ---
148
+ let apiSummary = null;
149
+ try {
150
+ const exploreFile = require('path').join(HB_PATH, `viessmann-history-explore-${INSTALLATION_ID || 'all'}.json`);
151
+ if (require('fs').existsSync(exploreFile)) {
152
+ const raw = JSON.parse(require('fs').readFileSync(exploreFile, 'utf8'));
153
+ // Find device with heating features (device id = '0' usually)
154
+ const devKey = Object.keys(raw.devices || {}).find(k => {
155
+ const d = raw.devices[k];
156
+ return d.historyFeatures && d.historyFeatures.some(f => f.feature.includes('gas.consumption'));
157
+ });
158
+ if (devKey) {
159
+ const dev = raw.devices[devKey];
160
+ const feat = (name) => dev.historyFeatures.find(f => f.feature === name);
161
+ const val = (name, prop) => { const f = feat(name); return f?.samples?.[prop]?.value ?? null; };
162
+ const unit = (name, prop) => { const f = feat(name); return f?.samples?.[prop]?.unit ?? ''; };
163
+ apiSummary = {
164
+ timestamp: raw.timestamp,
165
+ gasHeatMonth: val('heating.gas.consumption.summary.heating', 'currentMonth'),
166
+ gasHeatYear: val('heating.gas.consumption.summary.heating', 'currentYear'),
167
+ gasHeat7d: val('heating.gas.consumption.summary.heating', 'lastSevenDays'),
168
+ gasDhwMonth: val('heating.gas.consumption.summary.dhw', 'currentMonth'),
169
+ gasDhwYear: val('heating.gas.consumption.summary.dhw', 'currentYear'),
170
+ heatProdHeatMonth:val('heating.heat.production.summary.heating', 'currentMonth'),
171
+ heatProdHeatYear: val('heating.heat.production.summary.heating', 'currentYear'),
172
+ heatProdDhwMonth: val('heating.heat.production.summary.dhw', 'currentMonth'),
173
+ heatProdDhwYear: val('heating.heat.production.summary.dhw', 'currentYear'),
174
+ pwrConsHeatMonth: val('heating.power.consumption.summary.heating','currentMonth'),
175
+ pwrConsHeatYear: val('heating.power.consumption.summary.heating','currentYear'),
176
+ burnerLifeStarts: val('heating.burners.0.statistics', 'starts'),
177
+ burnerLifeHours: val('heating.burners.0.statistics', 'hours'),
178
+ };
179
+ // Thermal efficiency = heat produced / (gas consumed Γ— PCS)
180
+ const GAS_PCS_EFF = 10.55;
181
+ if (apiSummary.heatProdHeatYear && apiSummary.gasHeatYear && apiSummary.gasHeatYear > 0) {
182
+ apiSummary.thermalEffYear = Math.min(110, Math.round(
183
+ (apiSummary.heatProdHeatYear / (apiSummary.gasHeatYear * GAS_PCS_EFF)) * 100
184
+ ));
185
+ }
186
+ if (apiSummary.heatProdHeatMonth && apiSummary.gasHeatMonth && apiSummary.gasHeatMonth > 0) {
187
+ apiSummary.thermalEffMonth = Math.min(110, Math.round(
188
+ (apiSummary.heatProdHeatMonth / (apiSummary.gasHeatMonth * GAS_PCS_EFF)) * 100
189
+ ));
190
+ }
191
+ }
192
+ }
193
+ } catch(e) { /* explore file not present β€” skip */ }
194
+
126
195
  // --- Heating schedule ---
127
196
  const SCHED_FILE = SCHED_FILE_PATH;
128
197
  let heatingSchedule = null;
@@ -244,7 +313,9 @@ const avgHeatDemand = activeBurnerRows.length
244
313
  : null;
245
314
 
246
315
  // --- Burner cycle analysis ---
247
- // Reconstruct ON/OFF edges from boilerRows sorted by time
316
+ // PRIMARY source: API counter delta (burner_starts / burner_hours) β€” captures ALL ignitions.
317
+ // CSV edge detection misses ~97% of cycles because refresh is 15 min but cycles avg 6 min.
318
+ // We keep edge detection only for the histogram (visual distribution of detected cycles).
248
319
  const sortedBoiler = [...boilerRows].sort((a,b) => new Date(a.timestamp) - new Date(b.timestamp));
249
320
  const cycles = [];
250
321
  let cycleStart = null;
@@ -253,15 +324,18 @@ for (let i = 0; i < sortedBoiler.length; i++) {
253
324
  if (on && cycleStart === null) cycleStart = new Date(sortedBoiler[i].timestamp);
254
325
  if (!on && cycleStart !== null) {
255
326
  const durationMin = (new Date(sortedBoiler[i].timestamp) - cycleStart) / 60000;
256
- if (durationMin >= 1) cycles.push(durationMin); // ignore <1 min noise
327
+ if (durationMin >= 1) cycles.push(durationMin);
257
328
  cycleStart = null;
258
329
  }
259
330
  }
331
+ // cycleCount from CSV edges (used only for histogram β€” severely underestimates real count)
260
332
  const cycleCount = cycles.length;
261
333
  const avgCycleDur = cycleCount ? (cycles.reduce((a,b)=>a+b,0)/cycleCount).toFixed(0) : null;
262
334
  const shortestCycle = cycleCount ? Math.min(...cycles).toFixed(0) : null;
263
335
  const shortCycleCls = shortestCycle ? (parseFloat(shortestCycle) < 5 ? 'warn' : 'good') : 'neutral';
264
336
 
337
+ // realCycleCount, realAvgDur, sph already defined above from delta calculation
338
+
265
339
  // ─────────────────────────────────────────────────────────────────────────────
266
340
  // HEATING SYSTEM ASSISTANT
267
341
  // boilerNominalPowerKW: CLI param > env var > default 0 (kW-based cards hidden)
@@ -306,12 +380,16 @@ const peakLoadKW = (heatLossCoeff !== null && avgRoomNum !== null)
306
380
  // Boiler sizing check
307
381
  const boilerOversized = (hasBoilerKW && peakLoadKW !== null && BOILER_KW > parseFloat(peakLoadKW) * 2);
308
382
 
309
- // Cycle diagnostics
310
- const reportHours = DAYS * 24;
311
- const cyclesPerHour = cycleCount && reportHours ? (cycleCount / reportHours).toFixed(2) : null;
312
- const avgCycleDurNum = avgCycleDur ? parseFloat(avgCycleDur) : null;
383
+ // Cycle diagnostics β€” use API delta values (real), not CSV edge counts (severely undersampled)
384
+ const reportHours = DAYS * 24;
385
+ // cyclesPerHour: prefer real API delta; CSV edge fallback only if no delta available
386
+ const cyclesPerHour = sph || (cycleCount && reportHours ? (cycleCount / reportHours).toFixed(2) : null);
387
+ // avgCycleDurNum: prefer real API-derived value
388
+ const avgCycleDurNum = realAvgDur ? parseFloat(realAvgDur) : (avgCycleDur ? parseFloat(avgCycleDur) : null);
313
389
  const shortCycling = avgCycleDurNum !== null && avgCycleDurNum < 5;
314
390
  const excessiveCycling = cyclesPerHour !== null && parseFloat(cyclesPerHour) > 6;
391
+ // Note: with real API data, Vitodens typically shows 6-12 starts/hour in partial load,
392
+ // which is expected behavior. Only flag if combined with short avg duration.
315
393
 
316
394
  // Flow temp heuristic
317
395
  const avgFlowNum = avgFlow ? parseFloat(avgFlow) : null;
@@ -326,8 +404,8 @@ const inefficientOp = avgModNum !== null && avgCycleDurNum !== null && avgModNum
326
404
  const insights = [];
327
405
  if (shortCycling || excessiveCycling)
328
406
  insights.push({ type:'warn', text: shortCycling
329
- ? `Short cycling detected β€” avg cycle ${avgCycleDur} min (ideal > 10 min). Check system pressure, pump speed, or boiler minimum modulation setting.`
330
- : `High cycling rate β€” ${cyclesPerHour} cycles/hour. Consider increasing heating curve or minimum burner runtime.` });
407
+ ? `Short cycling detected β€” avg cycle ${realAvgDur ?? avgCycleDur} min (ideal > 10 min). With ${cyclesPerHour} starts/hour, the boiler is starting too frequently. Check minimum modulation setting (technician), system hydraulic balance, and pump speed.`
408
+ : `High cycling rate β€” ${cyclesPerHour} starts/hour from API counter. Consider raising the heating curve setpoint or requesting minimum burner runtime calibration.` });
331
409
  if (inefficientOp)
332
410
  insights.push({ type:'warn', text: `Boiler running at low modulation (avg ${avgMod}%) with short cycles. Consider lowering the heating curve to reduce cycling.` });
333
411
  if (highFlowTemp)
@@ -357,13 +435,17 @@ if (roomTemps.length >= 10) {
357
435
  // score = cyclesPerHour Γ— (10 / avgCycleDuration) β†’ <1 excellent, 1-3 ok, >3 severe
358
436
  let cyclingScore = null, cyclingSeverity = null, cyclingSeverityCls = 'neutral';
359
437
  if (cyclesPerHour && avgCycleDurNum && avgCycleDurNum > 0) {
360
- cyclingScore = (parseFloat(cyclesPerHour) * (10 / avgCycleDurNum)).toFixed(2);
438
+ // Score uses real API counter data β€” not CSV edge count
439
+ cyclingScore = (parseFloat(cyclesPerHour) * (10 / avgCycleDurNum)).toFixed(1);
361
440
  const sc2 = parseFloat(cyclingScore);
362
- if (sc2 < 1) { cyclingSeverity = 'Excellent'; cyclingSeverityCls = 'good'; }
363
- else if (sc2 < 3) { cyclingSeverity = 'Acceptable'; cyclingSeverityCls = 'warn'; }
364
- else { cyclingSeverity = 'Severe'; cyclingSeverityCls = 'bad'; }
365
- if (sc2 >= 3)
366
- insights.push({ type:'warn', text: `Cycling severity score ${cyclingScore} (severe). Boiler is cycling too frequently and too briefly β€” check minimum burner runtime setting or hydraulic balancing.` });
441
+ if (sc2 < 2) { cyclingSeverity = 'Good'; cyclingSeverityCls = 'good'; }
442
+ else if (sc2 < 5) { cyclingSeverity = 'Moderate'; cyclingSeverityCls = 'warn'; }
443
+ else if (sc2 < 10){ cyclingSeverity = 'High'; cyclingSeverityCls = 'warn'; }
444
+ else { cyclingSeverity = 'Severe'; cyclingSeverityCls = 'bad'; }
445
+ if (sc2 >= 10)
446
+ insights.push({ type:'warn', text: `Cycling severity score ${cyclingScore} β€” severe. The boiler starts ${cyclesPerHour} times/hour with avg cycle duration ${avgCycleDurNum.toFixed(1)} min. Check minimum burner runtime, system pressure, and pump speed. A technician should calibrate minimum modulation.` });
447
+ else if (sc2 >= 5)
448
+ insights.push({ type:'warn', text: `Cycling severity score ${cyclingScore} β€” high. Boiler starts ${cyclesPerHour} times/hour (avg ${avgCycleDurNum.toFixed(1)} min/cycle). Consider increasing heating curve or requesting minimum modulation calibration.` });
367
449
  }
368
450
 
369
451
  // ── Min modulation check ─────────────────────────────────────────────────────
@@ -810,24 +892,33 @@ footer{text-align:center;font-size:10px;color:#bbb;padding:16px}
810
892
 
811
893
  <div class="box">
812
894
  <h2>Boiler β€” Burner</h2>
813
- <div class="grid">
814
- ${sc('Lifetime starts', burnerStarts)}
815
- ${sc('Running hours', burnerHours, 'h')}
816
- ${sc('Starts/hour', sph, '', badge(effCls, effLabel))}
895
+ <!-- KPI row 1: real cycle metrics from API counter -->
896
+ <div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#999;margin-bottom:8px">Cycle performance (from API counters β€” precise)</div>
897
+ <div class="grid" style="margin-bottom:6px">
898
+ ${realCycleCount !== null ? sc('Cycles in period', realCycleCount, '', badge(effCls, effLabel)) : ''}
899
+ ${sph ? sc('Starts/hour', sph, '/h', badge(effCls, parseFloat(sph)<3?'Normal':parseFloat(sph)<6?'High':'Severe')) : ''}
900
+ ${realAvgDur ? sc('Avg cycle duration', realAvgDur, ' min', badge(parseFloat(realAvgDur)<5?'warn':parseFloat(realAvgDur)<10?'warn':'good', parseFloat(realAvgDur)<5?'Short cycling':parseFloat(realAvgDur)<10?'Check':'OK')) : ''}
901
+ ${burnerStarts ? sc('Lifetime starts', burnerStarts) : ''}
902
+ ${burnerHours ? sc('Lifetime hours', burnerHours, 'h') : ''}
817
903
  ${sc('Burner active', burnerOnPct, '% samples')}
818
- ${sc('Avg modulation', avgMod, '%')}
904
+ </div>
905
+ <p class="note" style="margin-bottom:14px">⚑ Cycle count uses <strong>API firmware counters</strong> (burner_starts delta) β€” captures all ignitions regardless of 15-min CSV sample rate. CSV edge detection would miss ~97% of cycles at this cycle frequency.</p>
906
+
907
+ <!-- KPI row 2: modulation + gas -->
908
+ <div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#999;margin-bottom:8px">Modulation & gas (period)</div>
909
+ <div class="grid" style="margin-bottom:6px">
910
+ ${sc('Avg modulation (active)', avgMod, '%', badge(parseFloat(avgMod||0)<15?'warn':'good', parseFloat(avgMod||0)<15?'Low mod':'OK'))}
819
911
  ${sc('Max modulation', maxMod, '%')}
820
912
  ${avgHeatDemand ? sc('Avg heat demand', avgHeatDemand, ' kW') : ''}
821
913
  ${hasGasData ? sc('Gas heating today', gasHeatingToday, ' mΒ³') : ''}
822
914
  ${hasGasData && gasDhwToday ? sc('Gas DHW today', gasDhwToday, ' mΒ³') : ''}
823
915
  ${hasGasData && gasTotalToday ? sc('Gas total today', gasTotalToday, ' mΒ³') : ''}
824
- ${cycleCount > 0 ? sc('Cycles (period)', cycleCount) : ''}
825
- ${avgCycleDur ? sc('Avg cycle', avgCycleDur, ' min') : ''}
826
- ${shortestCycle ? sc('Shortest cycle', shortestCycle, ' min', badge(shortCycleCls, parseFloat(shortestCycle)<5?'⚠ Short':'OK')) : ''}
827
916
  </div>
917
+
828
918
  ${boilerRows.length < 5 ? `<p class="note">Only ${boilerRows.length} samples β€” data will accumulate over time (~1 every 15 min).</p>` : ''}
829
- ${boilerRows.length >= 2 ? `<div class="ch"><canvas id="cMod"></canvas></div><div class="ch" style="margin-top:14px"><canvas id="cBurner"></canvas></div>` : ''}
830
- ${cycleCount >= 3 ? `<div style="margin-top:18px"><div style="font-size:13px;font-weight:700;color:#1a1a2e;margin-bottom:10px">Cycle duration histogram</div><div class="ch"><canvas id="cCycleHist"></canvas></div><p class="note">Distribution of burner ON durations. Short cycles (&lt;5 min) indicate short-cycling.</p></div>` : ''}
919
+ ${boilerRows.length >= 2 ? `<div class="ch"><canvas id="cMod"></canvas></div><div class="ch" style="margin-top:14px"><canvas id="cBurner"></canvas></div>
920
+ <p class="note">⚠️ Burner ON/OFF bars show only cycles visible within 15-min sampling interval. Actual cycle count (${realCycleCount ?? '?'}) is ${realCycleCount && cycleCount ? Math.round(realCycleCount/Math.max(cycleCount,1)) : '~100'}Γ— higher β€” see API counter KPIs above.</p>` : ''}
921
+ ${cycleCount >= 3 ? `<div style="margin-top:18px"><div style="font-size:13px;font-weight:700;color:#1a1a2e;margin-bottom:10px">Cycle duration histogram (sampled)</div><div class="ch"><canvas id="cCycleHist"></canvas></div><p class="note">Distribution of burner ON durations visible in CSV samples. Actual cycle duration from API counters: avg ${realAvgDur ?? '?'} min. Histogram shows only ~${cycleCount} of ${realCycleCount ?? '?'} real cycles.</p></div>` : ''}
831
922
  ${hasGasChart ? `<div style="margin-top:18px"><div style="font-size:13px;font-weight:700;color:#1a1a2e;margin-bottom:10px">Daily gas consumption (mΒ³)</div><div class="ch-tall"><canvas id="cGas"></canvas></div><p class="note">Stacked bars: heating (dark blue) + DHW (teal). Red line: daily total. Today's bar shows current accumulated value.</p></div>` : ''}
832
923
  </div>
833
924
 
@@ -836,9 +927,9 @@ footer{text-align:center;font-size:10px;color:#bbb;padding:16px}
836
927
  <div class="grid">
837
928
  ${sc('Avg room temp', avgRoom, 'Β°C')}
838
929
  ${sc('Avg setpoint', avgTarget, 'Β°C')}
839
- ${avgFlow ? sc('Avg flow temp', avgFlow, 'Β°C') : ''}
840
930
  ${maxFlow ? sc('Max flow temp', maxFlow, 'Β°C') : ''}
841
931
  ${condensingPct !== null ? sc('Condensing mode', condensingPct, '% time', badge(condensingCls, condensingLabel)) : ''}
932
+ ${avgFlow ? sc('Avg flow temp', avgFlow, 'Β°C', badge(parseFloat(avgFlow||99)<45?'good':parseFloat(avgFlow||99)<55?'warn':'neutral', parseFloat(avgFlow||99)<45?'Excellent':parseFloat(avgFlow||99)<55?'Condensing':'High')) : ''}
842
933
  ${scheduleToday ? sc('Today\'s schedule', scheduleToday) : ''}
843
934
  </div>
844
935
  ${progDist.length ? `<div style="margin-bottom:16px"><div class="sl" style="margin-bottom:8px">Program distribution</div>
@@ -855,8 +946,9 @@ footer{text-align:center;font-size:10px;color:#bbb;padding:16px}
855
946
  ${peakLoadKW ? sc('Est. peak load', peakLoadKW, ' kW', '<span style=\"font-size:10px;color:#888\">at '+DESIGN_TEMP+'Β°C outdoor</span>') : ''}
856
947
  ${hasBoilerKW ? sc('Boiler nominal', BOILER_KW, ' kW', boilerOversized ? badge('warn','Oversized') : badge('good','OK')) : ''}
857
948
  ${houseEff ? sc('House efficiency', houseEff.label, '', badge(houseEff.cls, heatLossCoeff+' kW/Β°C')) : ''}
858
- ${cyclesPerHour ? sc('Cycles/hour', cyclesPerHour, '', (excessiveCycling ? badge('warn','High') : badge('good','OK'))) : ''}
859
- ${cyclingScore ? sc('Cycling severity', cyclingSeverity, '', badge(cyclingSeverityCls, cyclingScore)) : ''}
949
+ ${cyclesPerHour ? sc('Starts/hour', cyclesPerHour, '/h', badge(effCls, parseFloat(cyclesPerHour)<3?'Normal':parseFloat(cyclesPerHour)<6?'High':'Severe')) : ''}
950
+ ${realAvgDur ? sc('Avg cycle duration', realAvgDur, ' min', badge(parseFloat(realAvgDur)<5?'warn':parseFloat(realAvgDur)<10?'warn':'good', parseFloat(realAvgDur)<5?'Short':parseFloat(realAvgDur)<10?'Check':'OK')) : ''}
951
+ ${cyclingScore ? sc('Cycling score', cyclingSeverity, '', badge(cyclingSeverityCls, cyclingScore)) : ''}
860
952
  ${comfortStddev ? sc('Comfort stability', comfortRating, '', badge(comfortCls, 'Β±'+comfortStddev+'Β°C')) : ''}
861
953
  ${gasEfficiencyPct ? sc('Est. efficiency', gasEfficiencyPct, '%') : ''}
862
954
  ${heatCurveCorr ? sc('Heating curve', heatCurveBehaviour, '', badge(heatCurveCls, 'r='+heatCurveCorr)) : ''}
@@ -886,6 +978,42 @@ footer{text-align:center;font-size:10px;color:#bbb;padding:16px}
886
978
  ${dhwRows.length >= 2 ? `<div class="ch-tall"><canvas id="cDhw"></canvas></div>` : ''}
887
979
  </div>
888
980
 
981
+ ${apiSummary ? `
982
+ <div class="box">
983
+ <h2>πŸ“Š Energy Summary (from Viessmann API)</h2>
984
+ <p class="note" style="margin-bottom:14px">Official aggregated data from Viessmann cloud API. Data snapshot: ${apiSummary.timestamp ? new Date(apiSummary.timestamp).toLocaleString('en-GB') : 'N/A'}. Run <code>viessmann-explore-history.js</code> to refresh.</p>
985
+
986
+ <div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#999;margin-bottom:8px">Gas consumption (mΒ³)</div>
987
+ <div class="grid" style="margin-bottom:16px">
988
+ ${apiSummary.gasHeat7d !== null ? sc('Heating last 7 days', apiSummary.gasHeat7d, ' mΒ³') : ''}
989
+ ${apiSummary.gasHeatMonth !== null ? sc('Heating this month', apiSummary.gasHeatMonth,' mΒ³') : ''}
990
+ ${apiSummary.gasHeatYear !== null ? sc('Heating this year', apiSummary.gasHeatYear, ' mΒ³') : ''}
991
+ ${apiSummary.gasDhwMonth !== null ? sc('DHW this month', apiSummary.gasDhwMonth, ' mΒ³') : ''}
992
+ ${apiSummary.gasDhwYear !== null ? sc('DHW this year', apiSummary.gasDhwYear, ' mΒ³') : ''}
993
+ ${(apiSummary.gasHeatMonth !== null && apiSummary.gasDhwMonth !== null) ? sc('Total this month', +(apiSummary.gasHeatMonth + apiSummary.gasDhwMonth).toFixed(1), ' mΒ³') : ''}
994
+ ${(apiSummary.gasHeatYear !== null && apiSummary.gasDhwYear !== null) ? sc('Total this year', +(apiSummary.gasHeatYear + apiSummary.gasDhwYear).toFixed(1), ' mΒ³') : ''}
995
+ </div>
996
+
997
+ <div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#999;margin-bottom:8px">Heat produced (kWh)</div>
998
+ <div class="grid" style="margin-bottom:16px">
999
+ ${apiSummary.heatProdHeatMonth !== null ? sc('Heating this month', apiSummary.heatProdHeatMonth, ' kWh') : ''}
1000
+ ${apiSummary.heatProdHeatYear !== null ? sc('Heating this year', apiSummary.heatProdHeatYear, ' kWh') : ''}
1001
+ ${apiSummary.heatProdDhwMonth !== null ? sc('DHW this month', apiSummary.heatProdDhwMonth, ' kWh') : ''}
1002
+ ${apiSummary.heatProdDhwYear !== null ? sc('DHW this year', apiSummary.heatProdDhwYear, ' kWh') : ''}
1003
+ ${apiSummary.pwrConsHeatMonth !== null ? sc('Pump power month', apiSummary.pwrConsHeatMonth, ' kWh') : ''}
1004
+ ${apiSummary.pwrConsHeatYear !== null ? sc('Pump power year', apiSummary.pwrConsHeatYear, ' kWh') : ''}
1005
+ </div>
1006
+
1007
+ <div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#999;margin-bottom:8px">Thermal efficiency (heat produced / gas input Γ— PCS 10.55 kWh/mΒ³)</div>
1008
+ <div class="grid">
1009
+ ${apiSummary.thermalEffMonth !== undefined ? sc('Efficiency this month', apiSummary.thermalEffMonth, '%', badge(apiSummary.thermalEffMonth >= 90 ? 'good' : 'warn', apiSummary.thermalEffMonth >= 90 ? 'Condensing βœ“' : 'Check')) : ''}
1010
+ ${apiSummary.thermalEffYear !== undefined ? sc('Efficiency this year', apiSummary.thermalEffYear, '%', badge(apiSummary.thermalEffYear >= 90 ? 'good' : 'warn', apiSummary.thermalEffYear >= 90 ? 'Condensing βœ“' : 'Check')) : ''}
1011
+ ${apiSummary.burnerLifeStarts !== null ? sc('Lifetime starts', apiSummary.burnerLifeStarts) : ''}
1012
+ ${apiSummary.burnerLifeHours !== null ? sc('Lifetime hours', apiSummary.burnerLifeHours, 'h') : ''}
1013
+ </div>
1014
+ <p class="note" style="margin-top:10px">Thermal efficiency >100% is possible for condensing boilers (latent heat recovery). Values >105% may indicate rounding in Viessmann API data.</p>
1015
+ </div>` : ''}
1016
+
889
1017
  ${gasForecast ? `
890
1018
  <div class="box">
891
1019
  <h2>β›½ Gas Forecast</h2>