index.html 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>MPPT Solar Monitor - DM Series</title>
  7. <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
  8. <style>
  9. *{margin:0;padding:0;box-sizing:border-box}
  10. body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
  11. .header{background:linear-gradient(135deg,#1e293b,#334155);padding:16px 24px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #475569}
  12. .header h1{font-size:20px;color:#38bdf8}
  13. .header .status{display:flex;gap:16px;align-items:center;font-size:13px}
  14. .header .status .dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:4px}
  15. .dot-green{background:#22c55e}.dot-red{background:#ef4444}.dot-yellow{background:#eab308}
  16. .tabs{display:flex;background:#1e293b;border-bottom:1px solid #334155}
  17. .tab{padding:12px 24px;cursor:pointer;font-size:14px;color:#94a3b8;border-bottom:2px solid transparent;transition:all .2s}
  18. .tab:hover{color:#e2e8f0}.tab.active{color:#38bdf8;border-bottom-color:#38bdf8}
  19. .page{display:none;padding:20px 24px}.page.active{display:block}
  20. /* Device Cards */
  21. .device-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:16px;margin-bottom:24px}
  22. .device-card{background:#1e293b;border-radius:12px;border:1px solid #334155;overflow:hidden}
  23. .device-card.offline{opacity:.6;border-color:#7f1d1d}
  24. .card-header{padding:12px 16px;background:#334155;display:flex;justify-content:space-between;align-items:center}
  25. .card-header h3{font-size:15px;color:#f1f5f9}
  26. .card-header .badge{padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
  27. .badge-online{background:#065f46;color:#6ee7b7}.badge-offline{background:#7f1d1d;color:#fca5a5}
  28. .card-body{padding:16px}
  29. .data-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
  30. .data-item{display:flex;justify-content:space-between;padding:6px 10px;background:#0f172a;border-radius:6px;font-size:13px}
  31. .data-item .label{color:#94a3b8}.data-item .value{color:#f1f5f9;font-weight:500}
  32. .section-title{font-size:13px;color:#38bdf8;margin:12px 0 8px;padding-bottom:4px;border-bottom:1px solid #334155}
  33. /* History Page */
  34. .history-controls{background:#1e293b;border-radius:12px;padding:16px;margin-bottom:20px;border:1px solid #334155}
  35. .controls-row{display:flex;flex-wrap:wrap;gap:12px;align-items:end}
  36. .control-group{display:flex;flex-direction:column;gap:4px}
  37. .control-group label{font-size:12px;color:#94a3b8}
  38. .control-group input,.control-group select{background:#0f172a;border:1px solid #475569;color:#e2e8f0;padding:8px 12px;border-radius:6px;font-size:13px}
  39. .btn{padding:8px 16px;border:none;border-radius:6px;cursor:pointer;font-size:13px;font-weight:500;transition:all .2s}
  40. .btn-primary{background:#0ea5e9;color:#fff}.btn-primary:hover{background:#0284c7}
  41. .btn-secondary{background:#334155;color:#e2e8f0}.btn-secondary:hover{background:#475569}
  42. .quick-btns{display:flex;gap:6px}
  43. .quick-btns .btn{padding:6px 10px;font-size:12px}
  44. /* Chart */
  45. .chart-container{background:#1e293b;border-radius:12px;padding:16px;margin-bottom:20px;border:1px solid #334155}
  46. .chart-wrapper{position:relative;height:300px}
  47. /* Table */
  48. .table-container{background:#1e293b;border-radius:12px;border:1px solid #334155;overflow:hidden}
  49. table{width:100%;border-collapse:collapse;font-size:13px}
  50. th{background:#334155;color:#94a3b8;padding:10px 12px;text-align:left;font-weight:500;white-space:nowrap}
  51. td{padding:8px 12px;border-top:1px solid #1e293b;white-space:nowrap}
  52. tr:hover td{background:#1e293b}
  53. .fault-normal{color:#22c55e}.fault-warning{color:#ef4444}
  54. /* Pagination */
  55. .pagination{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:#334155}
  56. .pagination .info{font-size:13px;color:#94a3b8}
  57. .pagination .btns{display:flex;gap:8px}
  58. /* Responsive */
  59. @media(max-width:768px){.device-grid{grid-template-columns:1fr}.controls-row{flex-direction:column}.data-grid{grid-template-columns:1fr}}
  60. </style>
  61. </head>
  62. <body>
  63. <div class="header">
  64. <h1>MPPT Solar Monitor - DM Series</h1>
  65. <div class="status">
  66. <span><span class="dot dot-green" id="conn-dot"></span> <span id="conn-status">Connecting...</span></span>
  67. <span>Devices: <strong id="dev-online">0</strong>/<strong id="dev-total">0</strong></span>
  68. <span>Records: <strong id="db-records">0</strong></span>
  69. </div>
  70. </div>
  71. <div class="tabs">
  72. <div class="tab active" onclick="switchPage('realtime')">Real-time Monitor</div>
  73. <div class="tab" onclick="switchPage('history')">History Query</div>
  74. </div>
  75. <!-- Real-time Page -->
  76. <div class="page active" id="page-realtime">
  77. <div class="device-grid" id="device-grid"></div>
  78. </div>
  79. <!-- History Page -->
  80. <div class="page" id="page-history">
  81. <div class="history-controls">
  82. <div class="controls-row">
  83. <div class="control-group">
  84. <label>Device</label>
  85. <select id="hist-device"><option value="0">All Devices</option></select>
  86. </div>
  87. <div class="control-group">
  88. <label>Start Time</label>
  89. <input type="datetime-local" id="hist-start">
  90. </div>
  91. <div class="control-group">
  92. <label>End Time</label>
  93. <input type="datetime-local" id="hist-end">
  94. </div>
  95. <div class="control-group">
  96. <label>Page Size</label>
  97. <select id="hist-size">
  98. <option value="20">20</option><option value="50">50</option>
  99. <option value="100">100</option><option value="200">200</option>
  100. </select>
  101. </div>
  102. <div class="control-group">
  103. <label>Sort</label>
  104. <select id="hist-sort"><option value="desc">Newest First</option><option value="asc">Oldest First</option></select>
  105. </div>
  106. <div class="control-group">
  107. <label>&nbsp;</label>
  108. <button class="btn btn-primary" onclick="loadHistory(1)">Query</button>
  109. </div>
  110. <div class="control-group">
  111. <label>Quick Range</label>
  112. <div class="quick-btns">
  113. <button class="btn btn-secondary" onclick="setQuickRange(1)">1h</button>
  114. <button class="btn btn-secondary" onclick="setQuickRange(24)">24h</button>
  115. <button class="btn btn-secondary" onclick="setQuickRange(168)">7d</button>
  116. <button class="btn btn-secondary" onclick="setQuickRange(720)">30d</button>
  117. </div>
  118. </div>
  119. </div>
  120. </div>
  121. <div class="chart-container">
  122. <div class="chart-wrapper"><canvas id="history-chart"></canvas></div>
  123. </div>
  124. <div class="table-container">
  125. <table><thead><tr>
  126. <th>Time</th><th>Dev</th><th>Batt V</th><th>SOC</th><th>PV V</th><th>PV A</th>
  127. <th>Power</th><th>Temp</th><th>Fault</th><th>Mode</th><th>Batt Type</th><th>Energy</th>
  128. </tr></thead><tbody id="history-tbody"></tbody></table>
  129. <div class="pagination">
  130. <div class="info" id="hist-info">No data</div>
  131. <div class="btns">
  132. <button class="btn btn-secondary" onclick="loadHistory(histPage-1)" id="btn-prev" disabled>Prev</button>
  133. <button class="btn btn-secondary" onclick="loadHistory(histPage+1)" id="btn-next" disabled>Next</button>
  134. </div>
  135. </div>
  136. </div>
  137. </div>
  138. <script>
  139. let histPage = 1, histTotalPages = 0, historyChart = null;
  140. const faultDescs = {20:'Normal',30:'PV Overvoltage',31:'PV Overcurrent',32:'Battery Low',33:'Battery Overvoltage',38:'Temp High',50:'Temp Sensor Fault',88:'Not Decrypted'};
  141. function switchPage(name) {
  142. document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
  143. document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
  144. document.getElementById('page-' + name).classList.add('active');
  145. document.querySelectorAll('.tab')[name === 'realtime' ? 0 : 1].classList.add('active');
  146. }
  147. function fmtTime(ts) {
  148. if (!ts) return '-';
  149. return new Date(ts * 1000).toLocaleString('zh-CN', {hour12: false});
  150. }
  151. function renderDevices(data) {
  152. const grid = document.getElementById('device-grid');
  153. let onlineCount = 0;
  154. let html = '';
  155. data.devices.forEach(dev => {
  156. if (dev.online) onlineCount++;
  157. const inp = dev.input_registers || {};
  158. const out = dev.output_registers || {};
  159. html += `<div class="device-card ${dev.online ? '' : 'offline'}">
  160. <div class="card-header">
  161. <h3>Device #${dev.device_addr} ${dev.online ? '' : '(OFFLINE)'}</h3>
  162. <span class="badge ${dev.online ? 'badge-online' : 'badge-offline'}">${dev.online ? 'ONLINE' : 'OFFLINE'}</span>
  163. </div>
  164. <div class="card-body">
  165. <div class="section-title">Real-time Data (Input Registers)</div>
  166. <div class="data-grid">
  167. <div class="data-item"><span class="label">Battery Voltage</span><span class="value">${(inp.battery_voltage||0).toFixed(1)} V</span></div>
  168. <div class="data-item"><span class="label">Battery SOC</span><span class="value">${inp.battery_soc||0} %</span></div>
  169. <div class="data-item"><span class="label">PV Voltage</span><span class="value">${(inp.pv_voltage||0).toFixed(1)} V</span></div>
  170. <div class="data-item"><span class="label">PV Current</span><span class="value">${(inp.pv_charge_current||0).toFixed(1)} A</span></div>
  171. <div class="data-item"><span class="label">PV Power</span><span class="value">${(inp.pv_input_power||0).toFixed(1)} W</span></div>
  172. <div class="data-item"><span class="label">Temperature</span><span class="value">${inp.machine_temp >= 0 ? inp.machine_temp + ' °C' : 'Fault'}</span></div>
  173. <div class="data-item"><span class="label">Fault</span><span class="value ${inp.fault_code===20?'fault-normal':'fault-warning'}">${inp.fault_desc||'Unknown'}</span></div>
  174. <div class="data-item"><span class="label">Charge State</span><span class="value">${inp.charge_state_desc||'-'}</span></div>
  175. <div class="data-item"><span class="label">Cumulative Energy</span><span class="value">${(inp.cumulative_energy||0).toFixed(1)} kWh</span></div>
  176. <div class="data-item"><span class="label">Software Ver</span><span class="value">v${inp.software_version||0}</span></div>
  177. </div>
  178. <div class="section-title">Configuration (Output Registers)</div>
  179. <div class="data-grid">
  180. <div class="data-item"><span class="label">Battery Type</span><span class="value">${out.battery_type_desc||'-'}</span></div>
  181. <div class="data-item"><span class="label">Battery Level</span><span class="value">${out.battery_level_desc||'-'}</span></div>
  182. <div class="data-item"><span class="label">Charge Mode</span><span class="value">${out.charge_mode_desc||'-'}</span></div>
  183. <div class="data-item"><span class="label">Current Limit</span><span class="value">${out.pv_charge_current_limit||0} %</span></div>
  184. <div class="data-item"><span class="label">Full Voltage</span><span class="value">${(out.batt_full_voltage||0).toFixed(1)} V</span></div>
  185. <div class="data-item"><span class="label">Boost Voltage</span><span class="value">${(out.batt_boost_voltage||0).toFixed(1)} V</span></div>
  186. <div class="data-item"><span class="label">Over-discharge V</span><span class="value">${(out.batt_over_discharge_v||0).toFixed(1)} V</span></div>
  187. <div class="data-item"><span class="label">Recovery V</span><span class="value">${(out.batt_recovery_voltage||0).toFixed(1)} V</span></div>
  188. <div class="data-item"><span class="label">485 Address</span><span class="value">${out.device_485_addr||'-'}</span></div>
  189. <div class="data-item"><span class="label">Device Type</span><span class="value">${inp.device_type||'-'}</span></div>
  190. </div>
  191. <div style="margin-top:8px;font-size:11px;color:#64748b">Last update: ${fmtTime(dev.last_update)}</div>
  192. </div>
  193. </div>`;
  194. });
  195. grid.innerHTML = html;
  196. document.getElementById('dev-online').textContent = onlineCount;
  197. document.getElementById('dev-total').textContent = data.device_count;
  198. }
  199. async function fetchRealtime() {
  200. try {
  201. const res = await fetch('/api/data');
  202. const data = await res.json();
  203. renderDevices(data);
  204. document.getElementById('conn-dot').className = 'dot dot-green';
  205. document.getElementById('conn-status').textContent = 'Connected';
  206. // Update device selector in history page
  207. const sel = document.getElementById('hist-device');
  208. if (sel.options.length <= 1) {
  209. data.devices.forEach(d => {
  210. const opt = document.createElement('option');
  211. opt.value = d.device_addr;
  212. opt.textContent = 'Device #' + d.device_addr;
  213. sel.appendChild(opt);
  214. });
  215. }
  216. } catch(e) {
  217. document.getElementById('conn-dot').className = 'dot dot-red';
  218. document.getElementById('conn-status').textContent = 'Disconnected';
  219. }
  220. }
  221. async function fetchStatus() {
  222. try {
  223. const res = await fetch('/api/status');
  224. const data = await res.json();
  225. document.getElementById('db-records').textContent = data.db_records || 0;
  226. } catch(e) {}
  227. }
  228. function setQuickRange(hours) {
  229. const now = new Date();
  230. const start = new Date(now.getTime() - hours * 3600000);
  231. document.getElementById('hist-start').value = start.toISOString().slice(0, 16);
  232. document.getElementById('hist-end').value = now.toISOString().slice(0, 16);
  233. loadHistory(1);
  234. }
  235. async function loadHistory(page) {
  236. if (page < 1) return;
  237. if (histTotalPages > 0 && page > histTotalPages) return;
  238. histPage = page;
  239. const params = new URLSearchParams();
  240. const addr = document.getElementById('hist-device').value;
  241. const start = document.getElementById('hist-start').value;
  242. const end = document.getElementById('hist-end').value;
  243. const size = document.getElementById('hist-size').value;
  244. const sort = document.getElementById('hist-sort').value;
  245. if (addr !== '0') params.set('device_addr', addr);
  246. if (start) params.set('start_time', start.replace('T', ' '));
  247. if (end) params.set('end_time', end.replace('T', ' '));
  248. params.set('page', page);
  249. params.set('page_size', size);
  250. params.set('sort', sort);
  251. try {
  252. const res = await fetch('/api/history?' + params.toString());
  253. const data = await res.json();
  254. histTotalPages = data.total_pages || 0;
  255. // Update table
  256. const tbody = document.getElementById('history-tbody');
  257. tbody.innerHTML = '';
  258. (data.records || []).forEach(r => {
  259. const faultClass = r.fault_code === 20 ? 'fault-normal' : 'fault-warning';
  260. tbody.innerHTML += `<tr>
  261. <td>${r.timestamp}</td><td>#${r.device_addr}</td>
  262. <td>${r.battery_voltage?.toFixed(1)} V</td><td>${r.battery_soc} %</td>
  263. <td>${r.pv_voltage?.toFixed(1)} V</td><td>${r.pv_charge_current?.toFixed(1)} A</td>
  264. <td>${r.pv_input_power?.toFixed(1)} W</td><td>${r.machine_temp >= 0 ? r.machine_temp + '°C' : 'Fault'}</td>
  265. <td class="${faultClass}">${r.fault_desc}</td>
  266. <td>${r.charge_mode_desc || '-'}</td>
  267. <td>${r.battery_type_desc || '-'}</td>
  268. <td>${r.cumulative_energy?.toFixed(1)} kWh</td>
  269. </tr>`;
  270. });
  271. // Update pagination
  272. document.getElementById('hist-info').textContent =
  273. `Page ${data.page}/${data.total_pages} | Total ${data.total} records`;
  274. document.getElementById('btn-prev').disabled = page <= 1;
  275. document.getElementById('btn-next').disabled = page >= histTotalPages;
  276. // Update chart
  277. updateChart(data.records || []);
  278. } catch(e) {
  279. console.error('History fetch failed:', e);
  280. }
  281. }
  282. function updateChart(records) {
  283. const ctx = document.getElementById('history-chart').getContext('2d');
  284. const labels = records.map(r => r.timestamp?.slice(5) || '').reverse();
  285. const battV = records.map(r => r.battery_voltage).reverse();
  286. const soc = records.map(r => r.battery_soc).reverse();
  287. const power = records.map(r => r.pv_input_power).reverse();
  288. const temp = records.map(r => r.machine_temp).reverse();
  289. if (historyChart) historyChart.destroy();
  290. historyChart = new Chart(ctx, {
  291. type: 'line',
  292. data: {
  293. labels,
  294. datasets: [
  295. {label:'Battery (V)',data:battV,borderColor:'#3b82f6',backgroundColor:'rgba(59,130,246,.1)',yAxisID:'y',tension:.3,pointRadius:1},
  296. {label:'SOC (%)',data:soc,borderColor:'#22c55e',backgroundColor:'rgba(34,197,94,.1)',yAxisID:'y1',tension:.3,pointRadius:1},
  297. {label:'PV Power (W)',data:power,borderColor:'#f59e0b',backgroundColor:'rgba(245,158,11,.1)',yAxisID:'y',tension:.3,pointRadius:1},
  298. {label:'Temp (°C)',data:temp,borderColor:'#ef4444',backgroundColor:'rgba(239,68,68,.1)',yAxisID:'y',tension:.3,pointRadius:1}
  299. ]
  300. },
  301. options: {
  302. responsive:true,maintainAspectRatio:false,
  303. interaction:{mode:'index',intersect:false},
  304. plugins:{legend:{labels:{color:'#94a3b8',font:{size:11}}}},
  305. scales:{
  306. x:{ticks:{color:'#64748b',maxTicksLimit:12,font:{size:10}},grid:{color:'#1e293b'}},
  307. y:{type:'linear',position:'left',ticks:{color:'#64748b',font:{size:10}},grid:{color:'#1e293b'},title:{display:true,text:'V / W / °C',color:'#64748b'}},
  308. y1:{type:'linear',position:'right',min:0,max:100,ticks:{color:'#64748b',font:{size:10}},grid:{drawOnChartArea:false},title:{display:true,text:'SOC %',color:'#64748b'}}
  309. }
  310. }
  311. });
  312. }
  313. // Init
  314. fetchRealtime();
  315. fetchStatus();
  316. setInterval(fetchRealtime, 2000);
  317. setInterval(fetchStatus, 10000);
  318. // Set default time range
  319. const now = new Date();
  320. const start = new Date(now.getTime() - 24*3600000);
  321. document.getElementById('hist-end').value = now.toISOString().slice(0,16);
  322. document.getElementById('hist-start').value = start.toISOString().slice(0,16);
  323. </script>
  324. </body>
  325. </html>