rack:「固件」页 —— 上传 .bin、选板推送 OTA、实时看进度 / 待验证 / 回滚
- firmware.py:固件库。上传时核 ESP 镜像头(0xE9 + app 描述 + project_name == phonerail + ≤3 MB),
存成 /root/phonerail/fw/<sha 前 16 位>.bin(nginx 7765 放出去),库里记版本 / 编译时间 / 上传人
- api:GET/POST/DELETE /api/firmware(请求体即 .bin,不引 multipart)、POST /api/boards/{id}/ota(进审计);
通用 /cmd 不放行 ota —— URL 与哈希只能由固件库生成;OTA 事件按 arg 显示中文、失败 / 回滚标红
- 前端:Firmware.vue + OtaState.vue;推送对话框默认不重启,勾「立刻重启」时列出每块板会断电的路并二次确认;
单板页显示 OTA 状态
- simboard 模拟 ota(下载进度 / 就绪 / 重启 / 自证);test_firmware.py 13 例,后端共 39 例(本机 + 服务器 3.8)
- service 加 --fw-dir;已部署。板 B 实测:上传 → 推送 → 就绪 → 同镜像带重启 → 不重下直接重启 → 自证健康
Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -7,6 +7,7 @@
|
||||
<router-link to="/events">事件</router-link>
|
||||
<router-link to="/audit">操作记录</router-link>
|
||||
<router-link to="/recovery">自动恢复</router-link>
|
||||
<router-link to="/firmware">固件</router-link>
|
||||
</nav>
|
||||
<div class="conn">
|
||||
<span :class="['dot', state.ws ? 'up' : 'down']" :title="state.ws ? '已连上服务器' : '与服务器断开,重连中'" />
|
||||
|
||||
@@ -29,6 +29,25 @@ export const api = {
|
||||
recovery: () => req('GET', '/api/recovery'),
|
||||
setRecoveryParams: p => req('PUT', '/api/recovery/params', p),
|
||||
resetRecovery: (board, rail) => req('POST', `/api/recovery/${board}/${rail}/reset`),
|
||||
firmware: () => req('GET', '/api/firmware'),
|
||||
deleteFirmware: sha => req('DELETE', `/api/firmware/${sha}`),
|
||||
ota: (board, sha256, reboot) => req('POST', `/api/boards/${board}/ota`, { sha256, reboot }),
|
||||
// .bin 原样作请求体(后端不收 multipart)
|
||||
uploadFirmware: async file => {
|
||||
const r = await fetch('/api/firmware?filename=' + encodeURIComponent(file.name), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: file,
|
||||
})
|
||||
const data = await r.json().catch(() => null)
|
||||
if (!r.ok) throw new Error((data && data.detail) || `${r.status} ${r.statusText}`)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
// status 里 ota.err(firmware/main/ota.h 的 ota_err_str)→ 人话
|
||||
export const OTA_ERR_ZH = {
|
||||
http: '下载失败(连不上下载口 / HTTP 错)', size: '大小不对(下载不完整)', hash: '哈希不对(文件被改过或传错)',
|
||||
flash: '写 flash 失败', image: '镜像校验不过(不是本工程 / 芯片不对)', boot: '切启动分区失败',
|
||||
noslot: '没有备用槽(分区表不对)',
|
||||
}
|
||||
|
||||
// 板子回的错误码 → 人话(firmware/main/cmd.h + ulink/mlink 的链路层错误)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<span class="ota">
|
||||
<template v-if="o.st === 'dl'">
|
||||
<el-progress :percentage="pct" :stroke-width="10" style="width: 140px" />
|
||||
<span class="muted">下载中</span>
|
||||
</template>
|
||||
<el-tag v-else-if="o.st === 'ready'" type="warning" size="small" title="已校验、已切启动分区,下次复位生效">已就绪 · 待重启</el-tag>
|
||||
<el-tag v-if="o.pend" type="warning" size="small" title="新固件还没连上 broker;120 s 内连不上会自动回滚">待验证</el-tag>
|
||||
<el-tag v-if="o.err && o.st !== 'dl'" type="danger" size="small">上次失败:{{ OTA_ERR_ZH[o.err] || o.err }}</el-tag>
|
||||
<el-tag v-if="o.bad" type="danger" size="small" effect="plain" title="这份固件在板子上没通过自证健康,被回滚掉了">回滚过:{{ o.bad }}</el-tag>
|
||||
<span v-if="!busy && !o.pend && !o.err && !o.bad && o.st !== 'ready'" class="muted">—</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 一块板的 OTA 状态,直接读遥测里的 `ota`(firmware/main/cmd_report.c 的 ota_obj)。
|
||||
import { computed } from 'vue'
|
||||
import { OTA_ERR_ZH } from '../api'
|
||||
|
||||
const props = defineProps({ status: Object })
|
||||
const o = computed(() => props.status?.ota || {})
|
||||
const busy = computed(() => o.value.st === 'dl')
|
||||
const pct = computed(() => (o.value.size ? Math.floor((o.value.got / o.value.size) * 100) : 0))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ota { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@ import Board from './pages/Board.vue'
|
||||
import Events from './pages/Events.vue'
|
||||
import Audit from './pages/Audit.vue'
|
||||
import Recovery from './pages/Recovery.vue'
|
||||
import Firmware from './pages/Firmware.vue'
|
||||
import { connect } from './store'
|
||||
|
||||
const router = createRouter({
|
||||
@@ -20,6 +21,7 @@ const router = createRouter({
|
||||
{ path: '/events', component: Events },
|
||||
{ path: '/audit', component: Audit },
|
||||
{ path: '/recovery', component: Recovery },
|
||||
{ path: '/firmware', component: Firmware },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
<div class="info">
|
||||
<el-tag v-if="!b.online || b.stale" type="info">通信中断 · 最后数据 {{ ago(b.last_tele) }}</el-tag>
|
||||
<el-tag v-else type="success">在线</el-tag>
|
||||
<span class="muted">ID {{ b.id }} · 固件 {{ b.fw }} · IP {{ b.ip }}</span>
|
||||
<span class="muted">ID {{ b.id }} · 固件 {{ b.status?.fw || b.fw }} · IP {{ b.ip }}</span>
|
||||
<OtaState :status="b.status" />
|
||||
<router-link to="/firmware" class="muted">推固件 →</router-link>
|
||||
</div>
|
||||
<div class="stats" v-if="b.status">
|
||||
<div><span class="muted">输入</span><b>{{ V(b.status.in?.mv) }} V</b><b>{{ mA(b.status.in?.ua) }} mA</b></div>
|
||||
@@ -63,6 +65,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import RailCard from '../components/RailCard.vue'
|
||||
import HistoryChart from '../components/HistoryChart.vue'
|
||||
import OtaState from '../components/OtaState.vue'
|
||||
import { api, rspText } from '../api'
|
||||
import { state, onMessage } from '../store'
|
||||
import { mA, V, C, ago, fmtTime } from '../util'
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div class="fw">
|
||||
<div class="card">
|
||||
<div class="ttl">
|
||||
固件库
|
||||
<el-button type="primary" size="small" :loading="uploading" @click="pick">上传 .bin</el-button>
|
||||
<input ref="file" type="file" accept=".bin" style="display: none" @change="upload" />
|
||||
</div>
|
||||
<div class="muted hint">
|
||||
传 <code>firmware/build/phonerail.bin</code>(不是 .elf,也不是合并镜像)。服务器会核镜像头:不是 PhoneRail 的固件、或大于一个 OTA 槽({{ slotMB }} MB)直接拒。
|
||||
</div>
|
||||
<el-table :data="items" size="small" empty-text="还没有上传过固件">
|
||||
<el-table-column label="版本" min-width="120">
|
||||
<template #default="{ row }"><b>{{ row.version }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="sha256" width="160">
|
||||
<template #default="{ row }"><code :title="row.sha256">{{ row.sha256.slice(0, 16) }}</code></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="大小" width="90"><template #default="{ row }">{{ kb(row.size) }}</template></el-table-column>
|
||||
<el-table-column label="编译于" width="170" prop="built" />
|
||||
<el-table-column label="上传" min-width="150">
|
||||
<template #default="{ row }">{{ row.uploaded_by }} · {{ fmtTime(row.uploaded) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" :disabled="!row.present" @click="openPush(row)">推送…</el-button>
|
||||
<el-button size="small" text type="danger" @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ttl">各板状态</div>
|
||||
<el-table :data="boards" size="small" empty-text="没有板子">
|
||||
<el-table-column label="板子" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<router-link :to="`/board/${row.id}`">{{ row.name || row.id }}</router-link>
|
||||
<el-tag v-if="!row.online || row.stale" type="info" size="small" style="margin-left: 6px">离线</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="当前固件" min-width="120">
|
||||
<template #default="{ row }">{{ row.status?.fw || row.fw || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="OTA" min-width="260">
|
||||
<template #default="{ row }"><OtaState :status="row.status" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开着的路" width="130">
|
||||
<template #default="{ row }">{{ railsOn(row).length ? railsOn(row).map(r => '#' + r).join(' ') : '无' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dlg" :title="`推送 ${target?.version || ''}`" width="min(560px, 94vw)" :close-on-click-modal="false">
|
||||
<div class="muted" style="margin-bottom: 8px">
|
||||
板子自己从 <code>{{ target?.url }}</code> 下载,sha256 对得上才切分区。
|
||||
</div>
|
||||
<el-checkbox-group v-model="chosen" class="boards">
|
||||
<el-checkbox v-for="b in boards" :key="b.id" :value="b.id" :disabled="!pushable(b)">
|
||||
{{ b.name || b.id }}
|
||||
<span class="muted">{{ b.status?.fw || b.fw }}{{ why(b) ? ' · ' + why(b) : '' }}</span>
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
|
||||
<div class="reboot">
|
||||
<el-switch v-model="reboot" />
|
||||
<span>校验通过后<b>立刻重启</b></span>
|
||||
</div>
|
||||
<el-alert v-if="reboot" type="error" :closable="false" show-icon>
|
||||
<template #title>重启 = 这块板上所有开着的手机<b>硬断电一次</b></template>
|
||||
ESP 复位时各路 EN 被下拉,起来后按原来的开关状态逐路恢复(约 1 秒)。
|
||||
<div v-for="b in chosenBoards" :key="b.id">
|
||||
{{ b.name || b.id }}:{{ railsOn(b).length ? `${railsOn(b).length} 台会断电(${railsOn(b).map(r => '#' + r).join(' ')})` : '没有开着的路' }}
|
||||
</div>
|
||||
</el-alert>
|
||||
<div v-else class="muted">不重启:下次复位(断电重启 / 看门狗 / 再推一次并勾选重启)时生效。已就绪的同一镜像再推一次不会重新下载。</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dlg = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!chosen.length" :loading="pushing" @click="push">
|
||||
推送到 {{ chosen.length }} 块板{{ reboot ? '并重启' : '' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import OtaState from '../components/OtaState.vue'
|
||||
import { api, ERR_ZH } from '../api'
|
||||
import { state, onMessage } from '../store'
|
||||
import { fmtTime } from '../util'
|
||||
|
||||
const items = ref([])
|
||||
const slot = ref(0x300000)
|
||||
const slotMB = computed(() => slot.value / 1048576)
|
||||
const uploading = ref(false)
|
||||
const file = ref(null)
|
||||
const dlg = ref(false)
|
||||
const target = ref(null)
|
||||
const chosen = ref([])
|
||||
const reboot = ref(false)
|
||||
const pushing = ref(false)
|
||||
|
||||
const boards = computed(() => Object.values(state.boards).sort((a, b) => a.id.localeCompare(b.id)))
|
||||
const chosenBoards = computed(() => boards.value.filter(b => chosen.value.includes(b.id)))
|
||||
const kb = n => (n / 1024).toFixed(0) + ' KB'
|
||||
const railsOn = b => (b.status?.rails || []).filter(r => r.en).map(r => r.r)
|
||||
|
||||
// 板子这一刻能不能推:离线 / 正在下载 / 新固件还没自证(备用槽里是回滚用的旧版)都不行
|
||||
function why(b) {
|
||||
if (!b.online || b.stale) return '离线'
|
||||
const o = b.status?.ota || {}
|
||||
if (o.st === 'dl') return '正在下载'
|
||||
if (o.pend) return '新固件待验证'
|
||||
return ''
|
||||
}
|
||||
const pushable = b => !why(b)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await api.firmware()
|
||||
items.value = r.items
|
||||
slot.value = r.slot
|
||||
} catch (e) { ElMessage.error(e.message) }
|
||||
}
|
||||
|
||||
function pick() { file.value.value = ''; file.value.click() }
|
||||
|
||||
async function upload() {
|
||||
const f = file.value.files[0]
|
||||
if (!f) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const row = await api.uploadFirmware(f)
|
||||
ElMessage.success(`已上传 ${row.version}`)
|
||||
await load()
|
||||
} catch (e) { ElMessage.error(e.message) } finally { uploading.value = false }
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除 ${row.version}(${row.sha256.slice(0, 16)})?正在下载它的板子会失败。`, '删除固件', { type: 'warning' })
|
||||
} catch { return }
|
||||
try { await api.deleteFirmware(row.sha256); await load() } catch (e) { ElMessage.error(e.message) }
|
||||
}
|
||||
|
||||
function openPush(row) {
|
||||
target.value = row
|
||||
chosen.value = boards.value.filter(b => pushable(b) && (b.status?.fw || b.fw) !== row.version).map(b => b.id)
|
||||
reboot.value = false
|
||||
dlg.value = true
|
||||
}
|
||||
|
||||
async function push() {
|
||||
if (reboot.value) {
|
||||
const n = chosenBoards.value.reduce((s, b) => s + railsOn(b).length, 0)
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
n ? `${n} 台手机会断电一次。确定推送并立刻重启?` : '确定推送并立刻重启?(没有开着的路)',
|
||||
'确认重启', { type: 'warning', confirmButtonText: '推送并重启' })
|
||||
} catch { return }
|
||||
}
|
||||
pushing.value = true
|
||||
const results = []
|
||||
for (const id of chosen.value) {
|
||||
try {
|
||||
const rsp = await api.ota(id, target.value.sha256, reboot.value)
|
||||
results.push(rsp.ok ? `${id}:${{ start: '开始下载', reboot: '已就绪,马上重启', ready: '已就绪(没变)' }[rsp.act] || '成功'}`
|
||||
: `${id}:${rsp.err === 'state' ? '新固件待验证 / 板子不支持 OTA' : ERR_ZH[rsp.err] || rsp.err}`)
|
||||
} catch (e) { results.push(`${id}:${e.message}`) }
|
||||
}
|
||||
pushing.value = false
|
||||
dlg.value = false
|
||||
ElMessageBox.alert(results.join('\n'), '推送结果', { customStyle: { whiteSpace: 'pre-line' } })
|
||||
}
|
||||
|
||||
const off = onMessage(m => { if (m.t === 'firmware') load() })
|
||||
onMounted(load)
|
||||
onBeforeUnmount(off)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fw { display: flex; flex-direction: column; gap: 16px; }
|
||||
.fw .card { min-width: 0; overflow: hidden; }
|
||||
.ttl { font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 12px; }
|
||||
.hint { margin-bottom: 8px; }
|
||||
code { font-size: 12px; background: #f1f5f9; padding: 1px 4px; border-radius: 4px; word-break: break-all; }
|
||||
.boards { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; margin-bottom: 12px; }
|
||||
.boards .muted { margin-left: 6px; }
|
||||
.reboot { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
|
||||
</style>
|
||||
在新工单中引用
屏蔽一个用户