perf: 📸 Support viewing real-time images and power information of the device

This commit is contained in:
viarotel 2024-12-27 22:58:46 +08:00
parent d5caaa915c
commit d262adf54d
9 changed files with 261 additions and 19 deletions

View File

@ -0,0 +1,91 @@
import { camelCase } from 'lodash-es'
/**
* Parse ADB battery dump data into a structured object
*
* @param {string} dumpData - Raw battery dump data from ADB
* @returns {Object} Parsed and normalized battery data
*
* // Example usage:
* const dumpData = fs.readFileSync('battery-dump.txt', 'utf8');
* const batteryInfo = parseBatteryDump(dumpData);
* console.log(batteryInfo);
*
*/
export function parseBatteryDump(dumpData) {
// Helper to convert string values to appropriate types
const parseValue = (value) => {
value = value.trim()
// Handle booleans
if (value.toLowerCase() === 'true')
return true
if (value.toLowerCase() === 'false')
return false
// Handle numbers
if (!Number.isNaN(Number(value)) && value !== '') {
return Number(value)
}
return value
}
const result = {
raw: {},
computed: {},
}
// Split into lines and process each line
const lines = dumpData.split('\n').filter(line => line.trim())
lines.forEach((line) => {
if (line.includes('Battery Service state:')) {
return
}
// Parse key-value pairs
const separatorIndex = line.indexOf(':')
if (separatorIndex === -1)
return
const key = line.substring(0, separatorIndex).trim()
const value = line.substring(separatorIndex + 1).trim()
// Skip empty key/values
if (!key || !value)
return
// Convert key to camelCase
const camelKey = camelCase(key)
// Add to appropriate section
result.raw[camelKey] = parseValue(value)
})
// Add computed values
result.computed = {
// Convert temperatures to actual degrees
temperatureCelsius: result.raw.temperature ? result.raw.temperature / 10 : null,
// Battery percentage normalized to 0-100
batteryPercentage: result.raw.level || 0,
// Charging state as string
isCharging: !!(result.raw.usbPowered || result.raw.acPowered
|| result.raw.wirelessPowered || result.raw.dockPowered),
// Voltage in V instead of mV
voltageV: result.raw.voltage ? result.raw.voltage / 1000 : null,
// Power source type
powerSource: result.raw.acPowered
? 'AC'
: result.raw.usbPowered
? 'USB'
: result.raw.wirelessPowered
? 'Wireless'
: result.raw.dockPowered ? 'Dock' : 'Battery',
}
return result
}

View File

@ -9,6 +9,8 @@ import { Adb } from '@devicefarmer/adbkit'
import dayjs from 'dayjs'
import { uniq } from 'lodash-es'
import adbConnectionMonitor from './helpers/adbConnectionMonitor/index.js'
import { streamToBase64 } from '$electron/helpers/index.js'
import { parseBatteryDump } from './helpers/battery/index.js'
const exec = util.promisify(_exec)
@ -124,6 +126,8 @@ const getDeviceIP = async (id) => {
const tcpip = async (id, port = 5555) => client.getDevice(id).tcpip(port)
const screencap = async (deviceId, options = {}) => {
const { returnBase64 = false } = options
let fileStream = null
try {
const device = client.getDevice(deviceId)
@ -138,6 +142,11 @@ const screencap = async (deviceId, options = {}) => {
return false
}
if (returnBase64) {
const base64 = await streamToBase64(fileStream)
return base64
}
const fileName = `Screencap-${dayjs().format('YYYY-MM-DD-HH-mm-ss')}.png`
const savePath = options.savePath || path.resolve('../', fileName)
@ -278,10 +287,24 @@ async function connectCode(password, options = {}) {
pair,
connect,
},
...options
...options,
})
}
async function battery(id) {
try {
const res = await deviceShell(id, 'dumpsys battery')
const value = parseBatteryDump(res)
return value
}
catch (error) {
console.warn(error?.message || error)
return {}
}
}
function init() {
const bin = appStore.get('common.adbPath') || adbPath
@ -312,4 +335,5 @@ export default {
watch,
readdir,
connectCode,
battery,
}

View File

@ -1,4 +1,5 @@
import { join, resolve } from 'node:path'
import { Buffer } from 'node:buffer'
import { contextBridge } from 'electron'
import { cloneDeep } from 'lodash-es'
@ -68,3 +69,19 @@ export function loadPage(win, prefix = '') {
win.loadFile(join(process.env.DIST, prefix, 'index.html'))
}
}
export function streamToBase64(stream) {
return new Promise((resolve, reject) => {
const chunks = []
stream.on('data', (chunk) => {
chunks.push(chunk)
})
stream.on('end', () => {
const buffer = Buffer.concat(chunks)
resolve(buffer.toString('base64'))
})
stream.on('error', (error) => {
reject(error)
})
})
}

View File

@ -33,7 +33,7 @@
"@viarotel-org/unocss-preset-shades": "0.8.2",
"@vitejs/plugin-vue": "5.0.4",
"@vueuse/core": "10.9.0",
"bonjour-service": "^1.3.0",
"bonjour-service": "1.3.0",
"dayjs": "1.11.11",
"electron": "33.0.2",
"electron-builder": "25.1.8",
@ -43,7 +43,7 @@
"electron-log": "5.2.0",
"electron-store": "9.0.0",
"electron-updater": "6.3.9",
"element-plus": "2.9.0",
"element-plus": "2.9.1",
"eslint": "9.13.0",
"fix-path": "4.0.0",
"fs-extra": "11.2.0",
@ -52,17 +52,17 @@
"nanoid": "5.0.7",
"pinia": "2.1.7",
"pinia-plugin-persistedstate": "3.2.1",
"pinyin-pro": "^3.26.0",
"pinyin-pro": "3.26.0",
"postcss": "8.4.38",
"postcss-nested": "6.0.1",
"postcss-scss": "4.0.9",
"qrcode": "^1.5.4",
"rimraf": "^6.0.1",
"simple-git": "^3.27.0",
"qrcode": "1.5.4",
"rimraf": "6.0.1",
"simple-git": "3.27.0",
"unocss": "0.62.3",
"unplugin-auto-import": "0.18.3",
"unplugin-vue-components": "0.27.4",
"unplugin-vue-router": "^0.10.9",
"unplugin-vue-router": "0.10.9",
"vite": "5.1.5",
"vite-plugin-electron": "0.28.8",
"vite-plugin-electron-renderer": "0.14.6",
@ -70,8 +70,8 @@
"vue": "3.4.21",
"vue-command": "35.2.1",
"vue-i18n": "9.13.1",
"vue-router": "^4.5.0",
"vue-screen": "^2.4.0",
"vue-router": "4.5.0",
"vue-screen": "2.4.2",
"which": "4.0.0"
}
}

View File

@ -1,4 +1,6 @@
{
"common.yes": "Yes",
"common.no": "No",
"common.cancel": "Cancel",
"common.confirm": "Confirm",
"common.restart": "Restart",
@ -67,6 +69,11 @@
"device.status.connected": "Connected",
"device.status.offline": "Offline",
"device.status.unauthorized": "Unauthorized",
"device.battery": "Device Battery",
"device.isCharging": "Charging Status",
"device.temperature": "Device Temperature",
"device.powerSource": "Power Source",
"device.voltage": "Device Voltage",
"device.task.name": "Scheduled Task",
"device.task.tips": " Note: Please ensure that your computer stays awake, otherwise scheduled tasks will not be executed properly.",

View File

@ -1,4 +1,6 @@
{
"common.yes": "Да",
"common.no": "Нет",
"common.cancel": "Отмена",
"common.confirm": "Подтвердить",
"common.restart": "Перезапустить",
@ -67,6 +69,11 @@
"device.status.connected": "Подключено",
"device.status.offline": "Не в сети",
"device.status.unauthorized": "Не авторизован",
"device.battery": "Уровень заряда устройства",
"device.isCharging": "Состояние зарядки",
"device.temperature": "Температура устройства",
"device.powerSource": "Источник питания",
"device.voltage": "Напряжение устройства",
"device.task.name": "Запланированная задача",
"device.task.tips": "Примечание: Пожалуйста, убедитесь, что ваш компьютер не переходит в спящий режим, иначе запланированные задачи не будут выполнены правильно.",

View File

@ -1,4 +1,6 @@
{
"common.yes": "是",
"common.no": "否",
"common.cancel": "取消",
"common.confirm": "确认",
"common.restart": "重启",
@ -67,6 +69,11 @@
"device.status.offline": "已离线",
"device.status.unauthorized": "未授权",
"device.status.connected": "已连接",
"device.battery": "设备电量",
"device.isCharging": "充电状态",
"device.temperature": "设备温度",
"device.powerSource": "驱动来源",
"device.voltage": "设备电压",
"device.task.name": "计划任务",
"device.task.tips": " 注意:请确保你的计算机保持唤醒状态,否则计划任务将无法被正常执行。",

View File

@ -1,4 +1,6 @@
{
"common.yes": "是",
"common.no": "否",
"common.cancel": "取消",
"common.confirm": "確認",
"common.restart": "重新啟動",
@ -67,6 +69,11 @@
"device.status.connected": "已連接",
"device.status.offline": "已離線",
"device.status.unauthorized": "未授權",
"device.battery": "設備電量",
"device.isCharging": "充電狀態",
"device.temperature": "設備溫度",
"device.powerSource": "驅動來源",
"device.voltage": "設備電壓",
"device.task.name": "計劃任務",
"device.task.tips": "注意:請確保您的電腦保持唤醒状态,否則計劃任務將無法正常執行。",

View File

@ -2,31 +2,113 @@
<el-popover
ref="popoverRef"
placement="right"
:width="450"
trigger="hover"
:width="500"
trigger="click"
popper-class="!p-0 !overflow-hidden !rounded-xl"
@before-enter="onBeforeEnter"
@hide="onHide"
>
<template #reference>
<el-link type="primary" :underline="false" icon="InfoFilled" class="mr-1"></el-link>
</template>
<el-descriptions class="!w-full" border label-width="80">
<el-descriptions-item :label="$t('device.id')">
{{ device.id }}
</el-descriptions-item>
</el-descriptions>
<div v-loading="loading" :element-loading-text="$t('common.loading')" :class="connectFlag ? 'h-60' : ''" class="flex items-stretch p-2 space-x-2">
<div v-if="connectFlag" class="flex-none pb-1">
<el-image :src="deviceInfo.screencap" :preview-src-list="[deviceInfo.screencap]" class="!h-full !overflow-hidden !rounded-xl !shadow" />
</div>
<div class="flex-1 w-0 overflow-auto">
<el-descriptions border :column="1" class="el-descriptions--custom">
<el-descriptions-item :label="$t('device.id')">
{{ deviceInfo.id }}
</el-descriptions-item>
<template v-if="deviceInfo.battery">
<el-descriptions-item :label="$t('device.battery')">
{{ deviceInfo.battery.batteryPercentage ? `${deviceInfo.battery.batteryPercentage}%` : '-' }}
</el-descriptions-item>
<el-descriptions-item :label="$t('device.isCharging')">
{{ deviceInfo.battery.isCharging ? $t('common.yes') : $t('common.no') }}
</el-descriptions-item>
<el-descriptions-item :label="$t('device.temperature')">
{{ deviceInfo.battery.temperatureCelsius ? `${deviceInfo.battery.temperatureCelsius}` : '-' }}
</el-descriptions-item>
<el-descriptions-item :label="$t('device.powerSource')">
{{ deviceInfo.battery.powerSource || '-' }}
</el-descriptions-item>
<el-descriptions-item :label="$t('device.voltage')">
{{ deviceInfo.battery.voltageV ? `${deviceInfo.battery.voltageV}v` : '-' }}
</el-descriptions-item>
</template>
</el-descriptions>
</div>
</div>
</el-popover>
</template>
<script setup>
import { getDictLabel } from '$/dicts/helper'
const props = defineProps({
device: {
type: Object,
default: () => ({}),
},
})
const loading = ref(false)
const deviceInfo = ref({
screencap: void 0,
battery: void 0,
})
const connectFlag = computed(() => ['device'].includes(props.device.status))
const screencapTimer = ref()
async function onBeforeEnter() {
Object.assign(deviceInfo.value, { ...props.device })
if (!connectFlag.value) {
return false
}
loading.value = true
await Promise.allSettled([getScreencap(), getBattery()])
screencapTimer.value = setInterval(() => {
getScreencap()
getBattery()
}, 5 * 1000)
loading.value = false
}
async function getScreencap() {
const screencap = await window.adb.screencap(props.device.id, { returnBase64: true })
Object.assign(deviceInfo.value, { screencap: `data:image/png;base64,${screencap}` })
}
async function getBattery() {
const battery = await window.adb.battery(props.device.id)
Object.assign(deviceInfo.value, { battery: battery.computed })
}
function onHide() {
clearInterval(screencapTimer.value)
deviceInfo.value = {}
loading.value = false
}
</script>
<style>
<style lang="postcss" scoped>
:deep() .el-descriptions--custom .el-descriptions__label {
@apply !truncate !w-0;
}
</style>