| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>MPPT Solar Monitor - DM Series</title>
- <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
- <style>
- *{margin:0;padding:0;box-sizing:border-box}
- body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
- .header{background:linear-gradient(135deg,#1e293b,#334155);padding:16px 24px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #475569}
- .header h1{font-size:20px;color:#38bdf8}
- .header .status{display:flex;gap:16px;align-items:center;font-size:13px}
- .header .status .dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:4px}
- .dot-green{background:#22c55e}.dot-red{background:#ef4444}.dot-yellow{background:#eab308}
- .tabs{display:flex;background:#1e293b;border-bottom:1px solid #334155}
- .tab{padding:12px 24px;cursor:pointer;font-size:14px;color:#94a3b8;border-bottom:2px solid transparent;transition:all .2s}
- .tab:hover{color:#e2e8f0}.tab.active{color:#38bdf8;border-bottom-color:#38bdf8}
- .page{display:none;padding:20px 24px}.page.active{display:block}
- /* Device Cards */
- .device-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:16px;margin-bottom:24px}
- .device-card{background:#1e293b;border-radius:12px;border:1px solid #334155;overflow:hidden}
- .device-card.offline{opacity:.6;border-color:#7f1d1d}
- .card-header{padding:12px 16px;background:#334155;display:flex;justify-content:space-between;align-items:center}
- .card-header h3{font-size:15px;color:#f1f5f9}
- .card-header .badge{padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
- .badge-online{background:#065f46;color:#6ee7b7}.badge-offline{background:#7f1d1d;color:#fca5a5}
- .card-body{padding:16px}
- .data-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
- .data-item{display:flex;justify-content:space-between;padding:6px 10px;background:#0f172a;border-radius:6px;font-size:13px}
- .data-item .label{color:#94a3b8}.data-item .value{color:#f1f5f9;font-weight:500}
- .section-title{font-size:13px;color:#38bdf8;margin:12px 0 8px;padding-bottom:4px;border-bottom:1px solid #334155}
- /* History Page */
- .history-controls{background:#1e293b;border-radius:12px;padding:16px;margin-bottom:20px;border:1px solid #334155}
- .controls-row{display:flex;flex-wrap:wrap;gap:12px;align-items:end}
- .control-group{display:flex;flex-direction:column;gap:4px}
- .control-group label{font-size:12px;color:#94a3b8}
- .control-group input,.control-group select{background:#0f172a;border:1px solid #475569;color:#e2e8f0;padding:8px 12px;border-radius:6px;font-size:13px}
- .btn{padding:8px 16px;border:none;border-radius:6px;cursor:pointer;font-size:13px;font-weight:500;transition:all .2s}
- .btn-primary{background:#0ea5e9;color:#fff}.btn-primary:hover{background:#0284c7}
- .btn-secondary{background:#334155;color:#e2e8f0}.btn-secondary:hover{background:#475569}
- .quick-btns{display:flex;gap:6px}
- .quick-btns .btn{padding:6px 10px;font-size:12px}
- /* Chart */
- .chart-container{background:#1e293b;border-radius:12px;padding:16px;margin-bottom:20px;border:1px solid #334155}
- .chart-wrapper{position:relative;height:300px}
- /* Table */
- .table-container{background:#1e293b;border-radius:12px;border:1px solid #334155;overflow:hidden}
- table{width:100%;border-collapse:collapse;font-size:13px}
- th{background:#334155;color:#94a3b8;padding:10px 12px;text-align:left;font-weight:500;white-space:nowrap}
- td{padding:8px 12px;border-top:1px solid #1e293b;white-space:nowrap}
- tr:hover td{background:#1e293b}
- .fault-normal{color:#22c55e}.fault-warning{color:#ef4444}
- /* Pagination */
- .pagination{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:#334155}
- .pagination .info{font-size:13px;color:#94a3b8}
- .pagination .btns{display:flex;gap:8px}
- /* Responsive */
- @media(max-width:768px){.device-grid{grid-template-columns:1fr}.controls-row{flex-direction:column}.data-grid{grid-template-columns:1fr}}
- </style>
- </head>
- <body>
- <div class="header">
- <h1>MPPT Solar Monitor - DM Series</h1>
- <div class="status">
- <span><span class="dot dot-green" id="conn-dot"></span> <span id="conn-status">Connecting...</span></span>
- <span>Devices: <strong id="dev-online">0</strong>/<strong id="dev-total">0</strong></span>
- <span>Records: <strong id="db-records">0</strong></span>
- </div>
- </div>
- <div class="tabs">
- <div class="tab active" onclick="switchPage('realtime')">Real-time Monitor</div>
- <div class="tab" onclick="switchPage('history')">History Query</div>
- </div>
- <!-- Real-time Page -->
- <div class="page active" id="page-realtime">
- <div class="device-grid" id="device-grid"></div>
- </div>
- <!-- History Page -->
- <div class="page" id="page-history">
- <div class="history-controls">
- <div class="controls-row">
- <div class="control-group">
- <label>Device</label>
- <select id="hist-device"><option value="0">All Devices</option></select>
- </div>
- <div class="control-group">
- <label>Start Time</label>
- <input type="datetime-local" id="hist-start">
- </div>
- <div class="control-group">
- <label>End Time</label>
- <input type="datetime-local" id="hist-end">
- </div>
- <div class="control-group">
- <label>Page Size</label>
- <select id="hist-size">
- <option value="20">20</option><option value="50">50</option>
- <option value="100">100</option><option value="200">200</option>
- </select>
- </div>
- <div class="control-group">
- <label>Sort</label>
- <select id="hist-sort"><option value="desc">Newest First</option><option value="asc">Oldest First</option></select>
- </div>
- <div class="control-group">
- <label> </label>
- <button class="btn btn-primary" onclick="loadHistory(1)">Query</button>
- </div>
- <div class="control-group">
- <label>Quick Range</label>
- <div class="quick-btns">
- <button class="btn btn-secondary" onclick="setQuickRange(1)">1h</button>
- <button class="btn btn-secondary" onclick="setQuickRange(24)">24h</button>
- <button class="btn btn-secondary" onclick="setQuickRange(168)">7d</button>
- <button class="btn btn-secondary" onclick="setQuickRange(720)">30d</button>
- </div>
- </div>
- </div>
- </div>
- <div class="chart-container">
- <div class="chart-wrapper"><canvas id="history-chart"></canvas></div>
- </div>
- <div class="table-container">
- <table><thead><tr>
- <th>Time</th><th>Dev</th><th>Batt V</th><th>SOC</th><th>PV V</th><th>PV A</th>
- <th>Power</th><th>Temp</th><th>Fault</th><th>Mode</th><th>Batt Type</th><th>Energy</th>
- </tr></thead><tbody id="history-tbody"></tbody></table>
- <div class="pagination">
- <div class="info" id="hist-info">No data</div>
- <div class="btns">
- <button class="btn btn-secondary" onclick="loadHistory(histPage-1)" id="btn-prev" disabled>Prev</button>
- <button class="btn btn-secondary" onclick="loadHistory(histPage+1)" id="btn-next" disabled>Next</button>
- </div>
- </div>
- </div>
- </div>
- <script>
- let histPage = 1, histTotalPages = 0, historyChart = null;
- 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'};
- function switchPage(name) {
- document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
- document.getElementById('page-' + name).classList.add('active');
- document.querySelectorAll('.tab')[name === 'realtime' ? 0 : 1].classList.add('active');
- }
- function fmtTime(ts) {
- if (!ts) return '-';
- return new Date(ts * 1000).toLocaleString('zh-CN', {hour12: false});
- }
- function renderDevices(data) {
- const grid = document.getElementById('device-grid');
- let onlineCount = 0;
- let html = '';
- data.devices.forEach(dev => {
- if (dev.online) onlineCount++;
- const inp = dev.input_registers || {};
- const out = dev.output_registers || {};
- html += `<div class="device-card ${dev.online ? '' : 'offline'}">
- <div class="card-header">
- <h3>Device #${dev.device_addr} ${dev.online ? '' : '(OFFLINE)'}</h3>
- <span class="badge ${dev.online ? 'badge-online' : 'badge-offline'}">${dev.online ? 'ONLINE' : 'OFFLINE'}</span>
- </div>
- <div class="card-body">
- <div class="section-title">Real-time Data (Input Registers)</div>
- <div class="data-grid">
- <div class="data-item"><span class="label">Battery Voltage</span><span class="value">${(inp.battery_voltage||0).toFixed(1)} V</span></div>
- <div class="data-item"><span class="label">Battery SOC</span><span class="value">${inp.battery_soc||0} %</span></div>
- <div class="data-item"><span class="label">PV Voltage</span><span class="value">${(inp.pv_voltage||0).toFixed(1)} V</span></div>
- <div class="data-item"><span class="label">PV Current</span><span class="value">${(inp.pv_charge_current||0).toFixed(1)} A</span></div>
- <div class="data-item"><span class="label">PV Power</span><span class="value">${(inp.pv_input_power||0).toFixed(1)} W</span></div>
- <div class="data-item"><span class="label">Temperature</span><span class="value">${inp.machine_temp >= 0 ? inp.machine_temp + ' °C' : 'Fault'}</span></div>
- <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>
- <div class="data-item"><span class="label">Charge State</span><span class="value">${inp.charge_state_desc||'-'}</span></div>
- <div class="data-item"><span class="label">Cumulative Energy</span><span class="value">${(inp.cumulative_energy||0).toFixed(1)} kWh</span></div>
- <div class="data-item"><span class="label">Software Ver</span><span class="value">v${inp.software_version||0}</span></div>
- </div>
- <div class="section-title">Configuration (Output Registers)</div>
- <div class="data-grid">
- <div class="data-item"><span class="label">Battery Type</span><span class="value">${out.battery_type_desc||'-'}</span></div>
- <div class="data-item"><span class="label">Battery Level</span><span class="value">${out.battery_level_desc||'-'}</span></div>
- <div class="data-item"><span class="label">Charge Mode</span><span class="value">${out.charge_mode_desc||'-'}</span></div>
- <div class="data-item"><span class="label">Current Limit</span><span class="value">${out.pv_charge_current_limit||0} %</span></div>
- <div class="data-item"><span class="label">Full Voltage</span><span class="value">${(out.batt_full_voltage||0).toFixed(1)} V</span></div>
- <div class="data-item"><span class="label">Boost Voltage</span><span class="value">${(out.batt_boost_voltage||0).toFixed(1)} V</span></div>
- <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>
- <div class="data-item"><span class="label">Recovery V</span><span class="value">${(out.batt_recovery_voltage||0).toFixed(1)} V</span></div>
- <div class="data-item"><span class="label">485 Address</span><span class="value">${out.device_485_addr||'-'}</span></div>
- <div class="data-item"><span class="label">Device Type</span><span class="value">${inp.device_type||'-'}</span></div>
- </div>
- <div style="margin-top:8px;font-size:11px;color:#64748b">Last update: ${fmtTime(dev.last_update)}</div>
- </div>
- </div>`;
- });
- grid.innerHTML = html;
- document.getElementById('dev-online').textContent = onlineCount;
- document.getElementById('dev-total').textContent = data.device_count;
- }
- async function fetchRealtime() {
- try {
- const res = await fetch('/api/data');
- const data = await res.json();
- renderDevices(data);
- document.getElementById('conn-dot').className = 'dot dot-green';
- document.getElementById('conn-status').textContent = 'Connected';
- // Update device selector in history page
- const sel = document.getElementById('hist-device');
- if (sel.options.length <= 1) {
- data.devices.forEach(d => {
- const opt = document.createElement('option');
- opt.value = d.device_addr;
- opt.textContent = 'Device #' + d.device_addr;
- sel.appendChild(opt);
- });
- }
- } catch(e) {
- document.getElementById('conn-dot').className = 'dot dot-red';
- document.getElementById('conn-status').textContent = 'Disconnected';
- }
- }
- async function fetchStatus() {
- try {
- const res = await fetch('/api/status');
- const data = await res.json();
- document.getElementById('db-records').textContent = data.db_records || 0;
- } catch(e) {}
- }
- function setQuickRange(hours) {
- const now = new Date();
- const start = new Date(now.getTime() - hours * 3600000);
- document.getElementById('hist-start').value = start.toISOString().slice(0, 16);
- document.getElementById('hist-end').value = now.toISOString().slice(0, 16);
- loadHistory(1);
- }
- async function loadHistory(page) {
- if (page < 1) return;
- if (histTotalPages > 0 && page > histTotalPages) return;
- histPage = page;
- const params = new URLSearchParams();
- const addr = document.getElementById('hist-device').value;
- const start = document.getElementById('hist-start').value;
- const end = document.getElementById('hist-end').value;
- const size = document.getElementById('hist-size').value;
- const sort = document.getElementById('hist-sort').value;
- if (addr !== '0') params.set('device_addr', addr);
- if (start) params.set('start_time', start.replace('T', ' '));
- if (end) params.set('end_time', end.replace('T', ' '));
- params.set('page', page);
- params.set('page_size', size);
- params.set('sort', sort);
- try {
- const res = await fetch('/api/history?' + params.toString());
- const data = await res.json();
- histTotalPages = data.total_pages || 0;
- // Update table
- const tbody = document.getElementById('history-tbody');
- tbody.innerHTML = '';
- (data.records || []).forEach(r => {
- const faultClass = r.fault_code === 20 ? 'fault-normal' : 'fault-warning';
- tbody.innerHTML += `<tr>
- <td>${r.timestamp}</td><td>#${r.device_addr}</td>
- <td>${r.battery_voltage?.toFixed(1)} V</td><td>${r.battery_soc} %</td>
- <td>${r.pv_voltage?.toFixed(1)} V</td><td>${r.pv_charge_current?.toFixed(1)} A</td>
- <td>${r.pv_input_power?.toFixed(1)} W</td><td>${r.machine_temp >= 0 ? r.machine_temp + '°C' : 'Fault'}</td>
- <td class="${faultClass}">${r.fault_desc}</td>
- <td>${r.charge_mode_desc || '-'}</td>
- <td>${r.battery_type_desc || '-'}</td>
- <td>${r.cumulative_energy?.toFixed(1)} kWh</td>
- </tr>`;
- });
- // Update pagination
- document.getElementById('hist-info').textContent =
- `Page ${data.page}/${data.total_pages} | Total ${data.total} records`;
- document.getElementById('btn-prev').disabled = page <= 1;
- document.getElementById('btn-next').disabled = page >= histTotalPages;
- // Update chart
- updateChart(data.records || []);
- } catch(e) {
- console.error('History fetch failed:', e);
- }
- }
- function updateChart(records) {
- const ctx = document.getElementById('history-chart').getContext('2d');
- const labels = records.map(r => r.timestamp?.slice(5) || '').reverse();
- const battV = records.map(r => r.battery_voltage).reverse();
- const soc = records.map(r => r.battery_soc).reverse();
- const power = records.map(r => r.pv_input_power).reverse();
- const temp = records.map(r => r.machine_temp).reverse();
- if (historyChart) historyChart.destroy();
- historyChart = new Chart(ctx, {
- type: 'line',
- data: {
- labels,
- datasets: [
- {label:'Battery (V)',data:battV,borderColor:'#3b82f6',backgroundColor:'rgba(59,130,246,.1)',yAxisID:'y',tension:.3,pointRadius:1},
- {label:'SOC (%)',data:soc,borderColor:'#22c55e',backgroundColor:'rgba(34,197,94,.1)',yAxisID:'y1',tension:.3,pointRadius:1},
- {label:'PV Power (W)',data:power,borderColor:'#f59e0b',backgroundColor:'rgba(245,158,11,.1)',yAxisID:'y',tension:.3,pointRadius:1},
- {label:'Temp (°C)',data:temp,borderColor:'#ef4444',backgroundColor:'rgba(239,68,68,.1)',yAxisID:'y',tension:.3,pointRadius:1}
- ]
- },
- options: {
- responsive:true,maintainAspectRatio:false,
- interaction:{mode:'index',intersect:false},
- plugins:{legend:{labels:{color:'#94a3b8',font:{size:11}}}},
- scales:{
- x:{ticks:{color:'#64748b',maxTicksLimit:12,font:{size:10}},grid:{color:'#1e293b'}},
- y:{type:'linear',position:'left',ticks:{color:'#64748b',font:{size:10}},grid:{color:'#1e293b'},title:{display:true,text:'V / W / °C',color:'#64748b'}},
- 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'}}
- }
- }
- });
- }
- // Init
- fetchRealtime();
- fetchStatus();
- setInterval(fetchRealtime, 2000);
- setInterval(fetchStatus, 10000);
- // Set default time range
- const now = new Date();
- const start = new Date(now.getTime() - 24*3600000);
- document.getElementById('hist-end').value = now.toISOString().slice(0,16);
- document.getElementById('hist-start').value = start.toISOString().slice(0,16);
- </script>
- </body>
- </html>
|