Files
2026-06-19 00:00:44 +02:00

444 lines
21 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Battery Stats</title>
<script src="chart.umd.min.js"></script>
<style>
body { font-family: system-ui, sans-serif; background: #0d1117; color: #c9d1d9; padding: 15px; margin: 0; }
h1 { color: #58a6ff; margin: 0 0 10px; font-size: 20px; }
button { background: #238636; color: white; border: none; padding: 5px 12px; margin: 2px; border-radius: 4px; cursor: pointer; font-size: 12px; }
button:hover { background: #2ea043; }
button.active { background: #1f6feb; }
#cmd { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; padding: 5px; width: 50%; font-family: monospace; font-size: 12px; }
.status { color: #8b949e; font-size: 11px; margin: 5px 0; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 10px; }
.panel { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 10px; }
.panel h3 { margin: 0 0 8px; color: #58a6ff; font-size: 12px; }
.panel.full { grid-column: 1 / -1; }
.raw { background: #0d1117; padding: 8px; font-family: monospace; font-size: 10px; white-space: pre-wrap; overflow: auto; max-height: 200px; color: #6e7681; border-radius: 4px; }
.row { display: flex; justify-content: space-between; padding: 3px 0; border-bottom: 1px solid #21262d; font-size: 11px; }
.app { display: flex; gap: 6px; padding: 4px; margin: 2px 0; background: #0d1117; border-radius: 3px; font-size: 11px; align-items: center; }
.rank { min-width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; background: #238636; color: white; border-radius: 50%; font-size: 9px; }
.rank.high { background: #da3633; }
.bar { flex: 1; height: 4px; background: #21262d; border-radius: 2px; }
.bar-fill { height: 100%; background: #58a6ff; border-radius: 2px; }
.bar-fill.high { background: #da3633; }
canvas { max-height: 200px; }
</style>
<base target="_blank">
</head>
<body>
<h1>🔋 Battery Stats</h1>(run on target android: busybox telnetd -p 8023 )
<div>
<button onclick="run('summary')" id="btn-summary">Summary</button>
<button onclick="run('history')" id="btn-history">History</button>
<button onclick="run('apps')" id="btn-apps">Top Apps</button>
<button onclick="run('drain')" id="btn-drain">Drain</button>
<button onclick="run('full')" id="btn-full">Full</button>
<button onclick="run('wake')" id="btn-wake">Wake Locks</button>
<button onclick="runCustom()" id="btn-custom">Custom</button>
<input id="cmd" value="adb shell dumpsys batterystats --history | head -n 200" />
</div>
<div class="status" id="status">Ready</div>
<div class="grid">
<div class="panel">
<h3>📊 Overview</h3>
<div id="overview">...</div>
</div>
<div class="panel">
<h3>⚡ Top Apps</h3>
<div id="top-apps">...</div>
</div>
<div class="panel">
<h3>📈 Distribution</h3>
<canvas id="chart-pie"></canvas>
</div>
<div class="panel">
<h3>⏱️ Timeline</h3>
<canvas id="chart-bar"></canvas>
</div>
<div class="panel full">
<h3>📝 Raw (debug)</h3>
<div class="raw" id="raw">...</div>
</div>
</div>
<script>
const baseUrl = '/card/ext/cgi-bin/exec.sh?script=sh/batterystats/batterystats.sh&cmd=';
let pieChart = null, barChart = null;
const presets = {
summary: 'adb shell dumpsys batterystats --history | head -n 50',
history: 'adb shell dumpsys batterystats --history | head -n 200',
apps: 'adb shell dumpsys batterystats --history | head -n 300',
drain: 'adb shell dumpsys batterystats',
full: 'adb shell dumpsys batterystats',
wake: 'adb shell dumpsys batterystats | grep -i "wake_lock" | head -n 50'
};
function stripAnsi(str) {
return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
}
function parse(text) {
const lines = text.split('\n');
const overview = {};
const apps = [];
const history = [];
for (const line of lines) {
const l = line.trim();
if (!l || l.startsWith('=== ') || l.includes('Exit code:') || l.includes('Current dir:')) continue;
if (l.includes('status=') && l.includes('volt=')) {
const m = l.match(/status=(\w+)/); if (m) overview.status = m[1];
const h = l.match(/health=(\w+)/); if (h) overview.health = h[1];
const t = l.match(/temp=(\d+)/); if (t) overview.temp = (parseInt(t[1])/10).toFixed(1) + '°C';
const v = l.match(/volt=(\d+)/); if (v) overview.voltage = v[1] + 'mV';
const c = l.match(/current=(-?\d+)/); if (c) overview.current = c[1] + 'mA';
const p = l.match(/plug=(\w+)/); if (p) overview.plug = p[1];
}
if (l.includes('RESET:TIME:')) {
const m = l.match(/RESET:TIME:\s*(\S+)/);
if (m) overview.since = m[1];
}
const appMatch = l.match(/(?:fg|top|job)=u0a\d+:"([^"]+)"/);
if (appMatch) {
let name = appMatch[1].split('/')[0];
const found = apps.find(a => a.name === name);
if (found) found.count++;
else apps.push({ name, count: 1 });
}
const timeMatch = l.match(/^(\+?\d+s?\d*ms?)\s+\(\d+\)\s+\d+\s+(.+)/);
if (timeMatch) {
history.push({ time: timeMatch[1], event: timeMatch[2].substring(0, 40) });
}
}
apps.sort((a, b) => b.count - a.count);
const max = Math.max(...apps.map(a => a.count), 1);
apps.forEach((a, i) => {
a.percent = Math.round((a.count / max) * 100);
a.rank = i + 1;
});
return { overview, apps: apps.slice(0, 10), history: history.slice(0, 15) };
}
// Parse "Estimated power use (mAh)" section from regular dumpsys batterystats
// Example lines:
// Estimated power use (mAh):
// Capacity: 4000, Computed drain: 312, actual drain: 280-290
// Screen: 120
// Uid 0: 45.2 ( cpu=40 wake=5.2 )
// Uid u0a221: 12.3 ( cpu=10 wake=2.3 )
function parseDrain(text) {
const lines = text.split('\n');
const overview = {};
const drain = {};
let inSection = false;
// Build uid map from full batterystats output.
// Extracts from multiple sources in order of reliability:
// 1. History lines: fg=u0a356:"com.package", top=u0a70:"com.package", job=u0a221:"com.package"
// 2. Per-UID block headers: "u0a399:" or "10399:" followed by package name on next line
// 3. Direct mapping lines: "U0a 10399: com.package" or "U0 10399: com.package"
const uidMap = {};
let lastUid = null;
for (let i = 0; i < lines.length; i++) {
const l = lines[i].trim();
if (!l) continue;
// === SOURCE 1: History inline events (most reliable) ===
// fg=u0a356:"com.arlosoft.macrodroid"
// top=u0a70:"com.test.mygame"
// job=u0a221:"com.package"
// user=0:"0"
// userfg=0:"0"
const inlineM = l.match(/(?:fg|top|job|user|userfg)=([^:]+):"([^"]+)"/g);
if (inlineM) {
for (const match of inlineM) {
const m = match.match(/=(u?\d+a?\d*):"([^"]+)"/);
if (m && m[2].includes('.')) { // only valid package names with dots
const uid = m[1];
const pkg = m[2];
uidMap[uid] = pkg;
// Also store numeric equivalent for u0a style
const uM = uid.match(/^u(\d+)a(\d+)$/);
if (uM) {
const numeric = String(10000 + parseInt(uM[2]));
uidMap[numeric] = pkg;
}
}
}
}
// Also catch wake_lock lines: +wake_lock=u0a21:"*walarm*:com.package" or +wake_lock=1000:"ActivityManager-Sleep"
// These have the uid before the colon, and the value after is a description, not a package
// But we can still extract the uid->tag mapping for reference
const wlM = l.match(/wake_lock=([^:]+):"([^"]+)"/g);
if (wlM) {
for (const match of wlM) {
const m = match.match(/=([^:]+):"([^"]+)"/);
if (m && m[2].includes('.')) {
const uid = m[1];
const pkg = m[2];
uidMap[uid] = pkg;
const uM = uid.match(/^u(\d+)a(\d+)$/);
if (uM) {
const numeric = String(10000 + parseInt(uM[2]));
uidMap[numeric] = pkg;
}
}
}
}
// === SOURCE 2: Per-UID block headers ===
// "u0a399:" or "10399:" — uid block header, package on next line
const hM = l.match(/^(u\d+a\d+|\d+):$/);
if (hM) {
lastUid = hM[1];
continue;
}
// Package name following a uid block header
if (lastUid && /^[a-z][\w.]+\.[a-zA-Z]/.test(l)) {
const pkg = l.split(/\s/)[0];
uidMap[lastUid] = pkg;
const uM = lastUid.match(/^u(\d+)a(\d+)$/);
if (uM) {
const numeric = String(10000 + parseInt(uM[2]));
uidMap[numeric] = pkg;
}
lastUid = null;
continue;
}
// Reset lastUid on non-package, non-empty, non-special lines
if (lastUid && !l.startsWith('//') && !l.startsWith('*') && !l.startsWith('===') && !l.startsWith('---')) {
lastUid = null;
}
// === SOURCE 3: Direct mapping lines ===
// "U0a 10399: com.package" or "U0 10399: com.package" or "UID 10399: com.package"
const uLineM = l.match(/^U(?:\d+a?\s+)?(\d+):\s+([a-z][\w.]+\.[\w.]+)/i);
if (uLineM) {
uidMap[uLineM[1]] = uLineM[2];
}
}
function resolvePkg(rawUid) {
// Direct lookup
if (uidMap[rawUid]) return uidMap[rawUid];
// u0a399 -> check numeric equivalent (10000 + 399 = 10399)
const uM = rawUid.match(/^u(\d+)a(\d+)$/);
if (uM) {
const numeric = String(10000 + parseInt(uM[2]));
if (uidMap[numeric]) return uidMap[numeric];
}
// Numeric 10399 -> check u0a equivalent
const nM = rawUid.match(/^(\d+)$/);
if (nM) {
const num = parseInt(nM[1]);
if (num >= 10000) {
const u0a = 'u0a' + (num - 10000);
if (uidMap[u0a]) return uidMap[u0a];
}
}
if (rawUid === '0') return 'Kernel/System';
return null;
}
const SKIP = new Set(['Timestamp','Capacity','Computed','Duration','Start','End','Statistics','Since','Reset','Date']);
for (const line of lines) {
const l = line.trim();
if (l.includes('Estimated power use')) { inSection = true; continue; }
if (!inSection) continue;
if (l.match(/^\w.*statistics/i) && !l.includes('Estimated')) break;
// Capacity summary: "Capacity: 4000, Computed drain: 312, actual drain: 280-290"
const capM = l.match(/Capacity:\s*([\d.]+).*Computed drain:\s*([\d.]+)/);
if (capM) {
overview.capacity = capM[1] + ' mAh';
overview.computed = capM[2] + ' mAh';
const actM = l.match(/actual drain:\s*([\S]+)/);
if (actM) overview.actual = actM[1] + ' mAh';
continue;
}
// Per-UID: "Uid u0a399: 12.3 ( cpu=10 wake=2.3 )"
const uidM = l.match(/^Uid\s+([^:]+):\s*([\d.]+)\s*(?:\(([^)]*)\))?/i);
if (uidM) {
const rawUid = uidM[1].trim();
const mah = parseFloat(uidM[2]);
if (mah <= 0) continue;
const detail = uidM[3] || '';
const pkg = resolvePkg(rawUid) || ('uid:' + rawUid);
drain[pkg] = drain[pkg] || { name: pkg, mah: 0, label: '' };
drain[pkg].mah += mah;
const bp = [];
const cpuM2 = detail.match(/cpu=([\d.]+)/); if (cpuM2) bp.push('CPU:' +cpuM2[1]+'mAh');
const wakeM2 = detail.match(/wake=([\d.]+)/); if (wakeM2) bp.push('Wake:' +wakeM2[1]+'mAh');
const wifiM2 = detail.match(/wifi=([\d.]+)/); if (wifiM2) bp.push('WiFi:' +wifiM2[1]+'mAh');
const gpsM2 = detail.match(/gps=([\d.]+)/); if (gpsM2) bp.push('GPS:' +gpsM2[1]+'mAh');
drain[pkg].label = bp.join(' ') || mah.toFixed(1)+' mAh';
continue;
}
// Named component: "Screen: 120.5" / "cpu: 1294" etc. — but not uid lines
const compM = l.match(/^([A-Za-z][^:]{0,30}):\s*([\d.]+)/);
if (compM) {
const name = compM[1].trim();
const mah = parseFloat(compM[2]);
if (!SKIP.has(name) && mah > 0) {
drain[name] = drain[name] || { name, mah: 0, label: '' };
drain[name].mah += mah;
drain[name].label = drain[name].mah.toFixed(1) + ' mAh';
}
}
}
if (!inSection) {
overview.note = '⚠️ "Estimated power use" section not found. Try the Full tab and search for it manually.';
}
const apps = Object.values(drain)
.filter(a => a.mah > 0)
.sort((a, b) => b.mah - a.mah)
.slice(0, 50);
const max = Math.max(...apps.map(a => a.mah), 1);
apps.forEach((a, i) => {
a.percent = Math.round((a.mah / max) * 100);
a.rank = i + 1;
a.total = a.mah;
});
return { overview, apps, history: [] };
}
function render(data, mode) {
const o = data.overview;
const isDrain = mode === 'drain';
document.getElementById('overview').innerHTML = (isDrain ? [
['Capacity', o.capacity || '-'],
['Computed', o.computed || '-'],
['Actual', o.actual || '-'],
['Top App', data.apps[0]?.name || '-']
] : [
['Status', o.status || '-'], ['Health', o.health || '-'], ['Temp', o.temp || '-'],
['Voltage', o.voltage || '-'], ['Current', o.current || '-'], ['Plug', o.plug || '-'],
['Level', o.level || '-'], ['Since', o.since || '-'], ['Top App', data.apps[0]?.name || '-']
]).map(([k, v]) => `<div class="row"><span>${k}</span><span>${v}</span></div>`).join('') +
(o.note ? `<div style="color:#d29922;font-size:10px;margin-top:6px;line-height:1.4">${o.note}</div>` : '');
const appsDiv = document.getElementById('top-apps');
if (mode === 'drain') appsDiv.style.maxHeight = '400px', appsDiv.style.overflowY = 'auto';
else appsDiv.style.maxHeight = '', appsDiv.style.overflowY = '';
if (!data.apps.length) {
appsDiv.innerHTML = '<div style="color:#6e7681;font-size:11px">No app data found</div>';
} else {
appsDiv.innerHTML = data.apps.map(a => {
const rc = a.rank <= 2 ? 'high' : '';
const bc = a.rank <= 2 ? 'high' : '';
const label = mode === 'drain' ? (a.label || a.total) : a.count;
return `<div class="app">
<div class="rank ${rc}">${a.rank}</div>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis">${a.name}</span>
<div class="bar"><div class="bar-fill ${bc}" style="width:${a.percent}%"></div></div>
<span>${label}</span>
</div>`;
}).join('');
}
const ctxPie = document.getElementById('chart-pie').getContext('2d');
const ctxBar = document.getElementById('chart-bar').getContext('2d');
if (pieChart) pieChart.destroy();
if (barChart) barChart.destroy();
if (data.apps.length) {
const colors = ['#da3633','#d29922','#58a6ff','#238636','#8957e5','#1f6feb','#f0883e','#3fb950','#a371f7','#56d364'];
const labels = data.apps.map(a => a.name.split('.').pop().substring(0, 10));
const values = mode === 'drain' ? data.apps.map(a => a.total) : data.apps.map(a => a.count);
pieChart = new Chart(ctxPie, {
type: 'doughnut',
data: { labels, datasets: [{ data: values, backgroundColor: colors, borderWidth: 0 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { color: '#8b949e', font: { size: 9 }, boxWidth: 8 } } } }
});
if (data.history.length) {
barChart = new Chart(ctxBar, {
type: 'bar',
data: { labels: data.history.map(h => h.time), datasets: [{ data: data.history.map(() => 1), backgroundColor: '#58a6ff', borderRadius: 2 }] },
options: { responsive: true, maintainAspectRatio: false, scales: { x: { ticks: { color: '#8b949e', font: { size: 8 } }, grid: { color: '#21262d' } }, y: { display: false } }, plugins: { legend: { display: false } } }
});
} else {
// Drain mode: bar chart of top apps
barChart = new Chart(ctxBar, {
type: 'bar',
data: { labels, datasets: [{ data: values, backgroundColor: colors, borderRadius: 2 }] },
options: { responsive: true, maintainAspectRatio: false, scales: { x: { ticks: { color: '#8b949e', font: { size: 8 } }, grid: { color: '#21262d' } }, y: { display: false } }, plugins: { legend: { display: false } } }
});
}
}
}
async function run(type) {
document.querySelectorAll('button').forEach(b => b.classList.remove('active'));
document.getElementById('btn-' + type).classList.add('active');
const cmd = presets[type];
document.getElementById('cmd').value = cmd;
await fetchData(cmd, type);
}
async function runCustom() {
document.querySelectorAll('button').forEach(b => b.classList.remove('active'));
document.getElementById('btn-custom').classList.add('active');
await fetchData(document.getElementById('cmd').value, 'custom');
}
async function fetchData(cmd, type) {
const raw = document.getElementById('raw');
const status = document.getElementById('status');
raw.textContent = 'Fetching...';
status.textContent = 'Running...';
try {
const url = baseUrl + encodeURIComponent(cmd);
const response = await fetch(url);
const text = await response.text();
const clean = stripAnsi(text);
raw.textContent = clean;
status.textContent = 'Updated: ' + new Date().toLocaleString();
// Use parseDrain for drain tab, original parse for everything else
let parsed;
if (type === 'drain') {
parsed = parseDrain(clean);
} else {
parsed = parse(clean);
}
render(parsed, type);
} catch (err) {
raw.textContent = 'Error: ' + err.message;
status.textContent = 'Failed';
}
}
</script>
</body>
</html>