开源说明
Violoop OS 构建于**自由与开源软件(FLOSS)**之上。引导加载器(U-Boot)与 Linux 内核以 GNU 通用公共许可证第 2 版(GPL-2.0) 授权,而用户态(Debian 12)中有相当一部分以 GPL-3.0 / LGPL-3.0 及其他许可证授权。
本页面向每一位设备所有者说明两件你有权做的事:
- 在哪里获取 GPL-2 引导加载器与内核的源代码。
- 如何安装你自己修改版本的 GPL-3 软件(设备上运行的那些)。
版本基线:Violoop OS,Rockchip RK3576 平台,Debian 12(arm64),BSP
rk3576-VLA11/v0.1.4,Linux 内核6.1.99。 设备内还随附一份完整的合规声明(含每个组件的精确构建提交与逐包许可清单):docs/OPEN-SOURCE-NOTICE.md;各组件精确构建 SHA 亦记录于kernel/prebuilt/manifest.json。
Boot 与 Kernel 源代码(GPL-2.0)
引导加载器(U-Boot)与 Linux 内核是系统核心的两个 GPL-2 组件。它们的完整对应源代码——包含我们为 RK3576 平台所做的全部修改——已托管于公开 Git 仓库,并以精确提交锁定至出厂固件的实际构建版本:
| 组件 | 许可证 | 源码仓库 | 分支 | 构建提交 |
|---|---|---|---|---|
Linux 内核 6.1.99 | GPL-2.0 | https://github.com/BvioTech/kernel | bsp/rk3576-VLA11 | d6d74b512b7fd8534fd8eed3f14d8cb919feb2bf |
| U-Boot(引导加载器) | GPL-2.0+ | https://github.com/BvioTech/u-boot | bsp/rk3576-VLA11 | c0bec16b14a265e6abccf8244f8095ac17b26e97 |
| AIC8800D40 Wi-Fi/蓝牙驱动 | GPL-2.0 | https://github.com/BvioTech/rkwifibt | feat/vla11-aic8800d40 | aab3ec6245e4e6df23b7aa68ed573eac86f669f5 |
Rockchip rkbin(blob 源) | 混合 | https://github.com/BvioTech/rkbin | bsp/rk3576-VLA11 | d8a9250436dfb90a2d1ff042e1d1b34cf3177816 |
克隆仓库并检出对应提交,即为本固件所用的完整源码:
git clone https://github.com/BvioTech/kernel
cd kernel && git checkout d6d74b512b7fd8534fd8eed3f14d8cb919feb2bf我们改了什么: 内核新增了触摸屏驱动 sw42000_touchscreen、若干设备树(DTS)适配,以及内核配置改动(overlayfs / squashfs-zstd / 板级驱动开关);AIC8800D40 无线驱动做了移植适配,以 .ko 形式随产品分发。算力卡的 PCIe 主机侧驱动 pcie-rkep(GPL-2.0 OR MIT)已收进内核树(drivers/misc/rockchip/pcie-rkep.c,CONFIG_PCIE_FUNC_RKEP=m)——我们的改动包括 include 重接线、恢复上游的 IS_MODULE(...) 包含守卫,以及修正 MODULE_DEVICE_TABLE 的总线类型参数(原写成驱动名,导致模块永不自动加载);检出上表内核那个提交即含其完整对应源码,无需另行获取。U-Boot 按上表版本原样构建、未做源码修改。各仓库内亦随附对应的 COPYING / LICENSE 文件;GPL-2.0 全文见 https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt。
算力卡运行时 —— 私有预编二进制
设备上算力卡的用户态运行时与卡上固件(librknn3_api*、rknn3_transfer_proxy、rknn-smi、pcie_upgrade_tool、rknn3_rk1820.img)是 Rockchip 的私有预编二进制,我们按 vendor 协议再分发,没有源代码,也不受 copyleft 条款约束。列出仅为完整透明;上文那个开源的 pcie-rkep 内核驱动与之不同,其源码已完整公开。
同属非 copyleft 的还有 ARM Trusted Firmware(bl31,BSD-3-Clause)、OP-TEE(bl32,BSD-2-Clause)、Rockchip DDR 初始化等 rkbin blob,以及 AIC8800 无线固件(*.bin)。它们不产生源码开放义务,仅为透明起见列出。
开机 Logo —— 不属于 GPL-2
开机时屏幕上显示的 Logo(U-Boot 启动画面 logo.bmp 与内核启动画面 logo_kernel.bmp)以位图素材的形式存放于 boot 分区内的 resource.img 中——与内核设备树一起打包,并未编译进引导加载器或内核。这些图片是 Violoop 自有的品牌美术素材:属于专有素材,不属于 GPL-2.0 的 U-Boot 或 Linux 源码,不受其条款约束。因此,上述源码开放义务仅覆盖引导加载器与内核的代码,并不延伸至 resource.img 中的 Logo 美术素材。
修改设备上的 GPL-3 软件
用户态基于 Debian 12(bookworm),其中包含大量 GPL-3.0 与 LGPL-3.0 组件。GPL-3 赋予你——作为设备所有者——安装并运行你自己修改版本的权利,即许可证第 6 条的「反 Tivo 化(anti-tivoization)」保障。
哪些组件受该条款覆盖
这些组件均未经我们修改,源码按下文「Debian 用户态源码」获取:
- GPL-3.0+ 程序:
bash(含libreadline8)、coreutils、grep/sed/gzip/tar/diffutils/findutils/cpio、less、wget、parted及其库、dosfstools、cloud-guest-utils、gnupg工具链(gpgv/gpg/gpg-agent/dirmngr等)、runit-helper、util-linux-extra中的hwclock、libpam-runtime中的pam-auth-update脚本,以及由应用栈依赖引入的libcdio*、libgdbm*与libmpv2(Debian 构建链接了 GPL-3+ 的libsmbclient,故二进制按 GPL-3+ 分发)。 - LGPL-3.0 库:
libgmp10、libnettle8/libhogweed6、libunistring2、libidn2-0、libksba8、libgnutls30、libzmq5、libelf1/libdw1,以及 GlusterFS 客户端库libglusterfs0/libgfapi0/libgfrpc0/libgfxdr0。 - GPL-3 WITH GCC Runtime Library Exception:
libgcc-s1、libstdc++6、libgomp1、libgfortran5、gcc-12-base。例外条款已豁免组合义务,仅为透明列出。
rsync(GPL-3+)只存在于调试构建,量产固件不含。完整的逐包核实结果(含「易被误判、实际并非 GPL-3」的组件说明)见设备内docs/OPEN-SOURCE-NOTICE.md§2.1。
安装途径:经 USB 串口由主程序写入
Violoop 是无键鼠的边缘设备,没有 USB host 口可插 U 盘;设备唯一稳定的对外控制口是 USB-C 串口——也就是你平时连接电脑用的那条数据线。因此安装途径只有一条:你经 USB 串口把自行编译的 GPL-3.0 / LGPL-3.0 组件文件传给设备主程序,由设备以 root 权限写入白名单内的目标路径。
整个过程无需登录、无需账号、无需拆机、无需我方任何签名密钥或专有客户端。串口 RPC 无鉴权:能插上 USB-C 线的人即视为设备所有者。
第一步 —— 构建你的修改版本
在你自己的电脑上获取要修改的软件包源码,改动它,并交叉编译出面向设备架构(aarch64)的目标文件。安装是整文件替换,产物须与镜像 ABI 匹配(同为 Debian 12 bookworm 的 arm64 构建即可)。源码可用 apt-get source 取到,也可通过下文的书面要约索取:
# 以 coreutils(GPL-3.0)为例
apt-get source coreutils
# …… 在解包后的源码树中做你的修改 ……
cd coreutils-*/
dpkg-buildpackage -us -uc -b # 构建修改后的二进制(arm64)从产物中取出要替换的那个文件(例如 bash 源码得到的 /usr/bin/bash)。
第二步 —— 用 USB-C 线连接设备,发起安装
两种方式任选:
- 本页附录中的参考脚本
gpl_install_serial.js。它是 Node 单文件,唯一依赖是 npm 包serialport,把附录内容保存为文件即可运行; - Violoop 桌面客户端的设置页(可选的便利层,不是唯一途径)。
用参考脚本的话,准备 Node.js 18+ 与 serialport 包,用数据线连上设备(Linux 上串口通常是 /dev/ttyACM0,macOS 上是 /dev/tty.usbmodem*),然后:
npm install serialport
# 列出可替换组件及其状态(出厂版 / 已替换)
node gpl_install_serial.js list
# 上传并安装你的文件;<targetId> 取自 list 的 id 列,例如 usr-bin-bash
node gpl_install_serial.js install usr-bin-bash ./bash
# 随时还原为出厂件
node gpl_install_serial.js restore usr-bin-bash
# 查看最近一次安装/还原的回执
node gpl_install_serial.js status串口不是默认路径时加 --port <tty>。脚本退出码:0 成功,2 设备报告失败或未确认,1 用法或传输错误。
执行 install 时会发生什么:
- 脚本先向设备核对目标 id、文件大小与 sha256,再按序分块上传(每次最多 4MB),最后提交。提交的应答只表示文件已交给设备的 root 侧安装程序。
- 设备随后返回一条回执,状态为
done、failed或unconfirmed(30 秒内未等到回执;可用status再查),并带上动作完成后目标文件的实际 sha256。 - 回执标明
rebootRequired时(共享库、固件等),须断电重上电方可生效;设备不会自动重启。其余目标替换后立即生效。 restore可无条件还原为出厂件——首次替换前的原件由设备的 root 侧永久保留。
回执 failed 时附固定的原因码:not-allowed(目标不在白名单)、not-present(本机没有该文件,例如仅调试构建才有的组件)、size-mismatch / checksum(上传件与声明不符)、bad-payload、no-original(从未替换过却要求还原)、io:<简述>(写入失败)。
信任模型
串口 RPC 无鉴权——能插上 USB-C 线的人即视为设备所有者,与「能触发恢复出厂、能改 Wi-Fi」是同一信任等级。主程序与 root 侧不设授权门槛,白名单是硬边界。GPL-3.0 §6 允许要求所有者授权、只禁止阻止安装;若将来引入设备本地的所有者确认(触屏或按键),也不会改变这条途径的性质。
为什么改得动
- 根文件系统刻意不做签名校验。 引导链(
boot.img)是签名的,但根文件系统(squashfs)没有 dm-verity / roothash——验证链止于 initramfs。白名单里的每个目标都没有签名校验、完整性自检或据此改变行为的逻辑,修改过的用户态必须能照常运行。 - 写入落在 overlay 可写层(
data分区的 upper),覆盖只读基底中的同路径文件,因此重启后依然生效。 - 不因「已被修改」而降级或拒绝服务:镜像不检测用户态二进制是否被替换;我方也不会因你替换了组件而拒绝更新或封锁设备(§6 仅允许对由此导致的问题不提供保修)。
几点须知
- 恢复出厂会清除你的修改。 出厂化的实现就是擦掉 overlay 可写层,你自行安装的组件随之消失,基底回到出厂状态。这是由你主动触发的动作,不构成对修改的阻止;若替换导致设备无法启动,恢复出厂也是还原路径。
- OTA 升级可能覆盖替换。 所属 Debian 包被 OTA 升级时,dpkg 会覆盖你替换的文件;你可以用同一条链路重新安装。
- 这条途径不依赖我方授权。 串口途径完全在设备本地完成,不以我方服务端的许可、订阅状态或任何可撤销的凭据为前提。
关于内核: 内核与引导加载器为 GPL-2,不含反 Tivo 化要求,且生产设备采用验证(安全)启动链(SSM-2)。若要运行修改后的内核,请从上文的内核仓库重新构建;在安全启动设备上,修改后的内核镜像需重新签名方可启动。GPL-3 的反 Tivo 化保障作用于用户态——如上所示,你可以按上述方式安装并运行它。
Debian 用户态源码
所含 Debian 软件包未经修改。其源代码可依各包许可证从 Debian 官方归档获取:
- https://www.debian.org/distrib/packages,或在任意 Debian 12 机器上执行
apt-get source <包名> - 源码镜像:http://deb.debian.org/debian/
许可证文本
- GPL-2.0:https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt
- GPL-3.0:https://www.gnu.org/licenses/gpl-3.0.txt
- LGPL-3.0:https://www.gnu.org/licenses/lgpl-3.0.txt
- GCC Runtime Library Exception:https://www.gnu.org/licenses/gcc-exception-3.1.html
- MIT License(
pcie-rkep双许可的另一选项):https://opensource.org/licenses/MIT
各组件仓库及 Debian 软件包内亦随附对应的 COPYING / LICENSE / /usr/share/doc/<包名>/copyright 文件。
书面要约(Written Offer)
除上述公开仓库外,自您收到本产品之日起 三(3)年内,您亦可来函索取所含 GPL/LGPL(含 GPL-2.0、GPL-3.0、LGPL-3.0)组件的完整对应源代码。我们将以不超过介质与邮寄成本的费用,通过物理介质或下载链接向您提供。
- 邮箱:
[email protected] - 主题请注明:
Violoop OS 源代码索取 — 版本 rk3576-VLA11/v0.1.4(版本号可在设备的系统信息页查看)
本要约向任何持有本产品副本的第三方开放,符合 GPL-2.0 第 3(b) 条及 GPL-3.0 第 6(b)/(c) 条。
附录:参考脚本 gpl_install_serial.js
以下是安装途径所用参考脚本的完整内容,也是 GPL-3.0 §6「安装信息」的可执行部分。将其保存为 gpl_install_serial.js,按第二步所述运行。
#!/usr/bin/env node
/**
* gpl_install_serial.js — install your own build of a GPL/LGPL-3 component
* into a Violoop device over its USB-C serial port, or restore the factory
* file. This is the reference client for that mechanism and the executable
* half of the GPL-3 §6 "Installation Information": it is deliberately a
* single file with no dependency on the rest of this repository, so it can
* be copied next to a checkout of the sources and run as is.
*
* Requirements
* - Node.js 18+
* - the `serialport` npm package (npm install serialport)
* - the device's pairing port on this machine (Linux: /dev/ttyACM0;
* macOS: /dev/tty.usbmodem*). No login, account, or Violoop desktop app
* is needed: the serial control plane is unauthenticated by design —
* physical possession of the cable is the trust model.
*
* Usage
* node gpl_install_serial.js [--port <tty>] [--baud <n>] <command> …
*
* list allowlisted components and their state
* status last install/restore receipt, in-flight?
* install <targetId> <file> upload <file> and install it as <targetId>
* restore <targetId> put the saved factory file back
* abort <transferId> drop a half-uploaded transfer
*
* <targetId> is the `id` column of `list` (e.g. usr-bin-bash). The set of
* replaceable files is fixed by the allowlist the device ships
* (/usr/local/share/violoop/gpl-replaceable.json); the device never
* accepts a path.
*
* Exit codes: 0 done · 2 the device reported failed/unconfirmed · 1 usage or
* transport error.
*
* What happens on the device (see the docs for the full picture)
* 1. gpl.installBegin validates target/size/sha256, opens a staging file
* 2. gpl.installChunk ×N, 4MB per RPC (one RPC can't carry more over the
* serial link), appended in order
* 3. gpl.installCommit device verifies the sha256 and hands the file to a
* root-owned installer (it is not root itself)
* 4. gpl.install.result pushed back when the installer's receipt lands:
* state done | failed | unconfirmed, and whether the
* change needs a power cycle to take effect
*
* Wire format
* frame: AA 55 | type(1) | rsv(1) | rsv(1) | sum(1) | len(4 BE) | body | 0D
* type 0x10 = envelope, 0x11 = keepalive (ignored); sum = uint8
* sum of body bytes
* body: JSON `frag` wrapper { type:'frag', from, to, txId, seq, total,
* data:<base64 slice of the inner JSON> } — every envelope is
* wrapped, even when it fits in one fragment (≤14KB of base64)
* inner: { type:<method>, id:<request id>, time, ...params }; the reply
* is { type:'reply', id, ...result } or { …, error }
*/
'use strict'
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
// ─── defaults ───────────────────────────────────────────────────────────────
const DEFAULT_PORT = process.platform === 'darwin' ? '/dev/tty.usbmodem*' : '/dev/ttyACM0'
const DEFAULT_BAUD = 1500000 // advisory for USB-CDC; matches the device
const RPC_TIMEOUT_MS = 30_000
const CHUNK_RPC_TIMEOUT_MS = 180_000 // 4MB of base64 both ways over the cable
const RESULT_TIMEOUT_MS = 90_000 // installer receipt: seconds normally; the device gives up at 30s
const FRAME_TYPE_ENVELOPE = 0x10
const FRAME_TYPE_KEEPALIVE = 0x11
const FRAG_CHUNK = 14 * 1024
const MAX_FRAGMENTS = 1024
const FROM_ID = `gpl-client-${process.pid}`
// ─── AA55 framing ───────────────────────────────────────────────────────────
function checksum(body) {
let sum = 0
for (let i = 0; i < body.length; i++) sum = (sum + body[i]) & 0xff
return sum
}
function buildFrame(type, body) {
const frame = Buffer.alloc(2 + 8 + body.length + 1)
frame[0] = 0xaa
frame[1] = 0x55
frame[2] = type & 0xff
frame[3] = 0
frame[4] = 0
frame[5] = checksum(body)
frame.writeUInt32BE(body.length >>> 0, 6)
body.copy(frame, 10)
frame[frame.length - 1] = 0x0d
return frame
}
/**
* Incremental frame parser. Feed it bytes as they arrive; it calls
* onFrame(type, body) for each complete, checksum-valid frame and resyncs on
* garbage by scanning for the next AA 55.
*/
class FrameParser {
constructor(onFrame) {
this._buf = Buffer.alloc(0)
this._onFrame = onFrame
}
feed(chunk) {
this._buf = this._buf.length ? Buffer.concat([this._buf, chunk]) : Buffer.from(chunk)
for (;;) {
const start = this._buf.indexOf(Buffer.from([0xaa, 0x55]))
if (start < 0) {
// Keep a trailing 0xAA: its 0x55 may be the next byte to arrive.
const last = this._buf[this._buf.length - 1]
this._buf = last === 0xaa ? Buffer.from([0xaa]) : Buffer.alloc(0)
return
}
if (start > 0) this._buf = this._buf.subarray(start)
if (this._buf.length < 10) return
const type = this._buf[2]
const sum = this._buf[5]
const len = this._buf.readUInt32BE(6)
if (len > 64 * 1024 * 1024) { this._buf = this._buf.subarray(2); continue } // not a real header
const total = 10 + len + 1
if (this._buf.length < total) return
const body = this._buf.subarray(10, 10 + len)
const tail = this._buf[10 + len]
if (tail !== 0x0d || checksum(body) !== sum) {
// Desync: skip this sync word and search again.
this._buf = this._buf.subarray(2)
continue
}
this._buf = this._buf.subarray(total)
this._onFrame(type, Buffer.from(body))
}
}
}
// ─── frag wrapper ───────────────────────────────────────────────────────────
let _txCounter = 0
function fragSplit(inner) {
const b64 = Buffer.from(JSON.stringify(inner), 'utf8').toString('base64')
const total = Math.max(1, Math.ceil(b64.length / FRAG_CHUNK))
if (total > MAX_FRAGMENTS) throw new Error(`message too large: ${total} fragments > ${MAX_FRAGMENTS}`)
const txId = `${FROM_ID}-${Date.now().toString(36)}-${(++_txCounter).toString(36)}`
const now = Math.floor(Date.now() / 1000)
const out = []
for (let seq = 0; seq < total; seq++) {
out.push({ time: now, type: 'frag', from: FROM_ID, to: 'edge', txId, seq, total, data: b64.slice(seq * FRAG_CHUNK, (seq + 1) * FRAG_CHUNK) })
}
return out
}
class FragReassembler {
constructor() { this._tx = new Map() }
/** Returns the inner envelope when a txId completes, else null. */
feed(wrap) {
const key = `${wrap.from}/${wrap.txId}`
let e = this._tx.get(key)
if (!e) { e = { chunks: new Array(wrap.total).fill(null), got: 0 }; this._tx.set(key, e) }
if (e.chunks[wrap.seq] !== null) return null
e.chunks[wrap.seq] = Buffer.from(wrap.data, 'base64')
e.got++
if (e.got < wrap.total) return null
this._tx.delete(key)
return JSON.parse(Buffer.concat(e.chunks).toString('utf8'))
}
}
// ─── serial link ────────────────────────────────────────────────────────────
class EdgeLink {
constructor({ portPath, baudRate }) {
this._path = portPath
this._baud = baudRate
this._port = null
this._waiters = [] // { match(env) → bool, resolve, reject, timer }
this._reasm = new FragReassembler()
this._parser = new FrameParser((type, body) => this._onFrame(type, body))
}
async open() {
let mod
try { mod = require('serialport') } catch {
throw new Error('the `serialport` package is not installed — run: npm install serialport')
}
const Port = mod.SerialPort || mod.default || mod
this._port = new Port({
path: this._path, baudRate: this._baud, autoOpen: false,
dataBits: 8, stopBits: 1, parity: 'none', rtscts: false, xon: false, xoff: false, xany: false,
})
this._port.on('data', (chunk) => this._parser.feed(chunk))
this._port.on('error', (err) => this._failAll(err))
this._port.on('close', () => this._failAll(new Error(`serial port ${this._path} closed`)))
await new Promise((resolve, reject) => this._port.open((err) => (err ? reject(err) : resolve())))
}
async close() {
if (!this._port || !this._port.isOpen) return
await new Promise((resolve) => this._port.close(() => resolve()))
}
_failAll(err) {
for (const w of this._waiters.splice(0)) { clearTimeout(w.timer); w.reject(err) }
}
_onFrame(type, body) {
if (type === FRAME_TYPE_KEEPALIVE) return
if (type !== FRAME_TYPE_ENVELOPE) return
let env
try { env = JSON.parse(body.toString('utf8')) } catch { return }
if (env && env.type === 'frag') {
try { env = this._reasm.feed(env) } catch { return }
if (!env) return
}
if (!env || typeof env.type !== 'string') return
for (const w of this._waiters.slice()) {
if (!w.match(env)) continue
this._waiters.splice(this._waiters.indexOf(w), 1)
clearTimeout(w.timer)
w.resolve(env)
}
}
_write(buf) {
return new Promise((resolve, reject) => {
this._port.write(buf, (err) => (err ? reject(err) : this._port.drain((e) => (e ? reject(e) : resolve()))))
})
}
/** Resolve with the first inbound envelope `match` accepts, or reject after `timeoutMs`. */
waitFor(match, timeoutMs, what) {
return new Promise((resolve, reject) => {
const w = { match, resolve, reject, timer: null }
w.timer = setTimeout(() => {
const i = this._waiters.indexOf(w)
if (i >= 0) this._waiters.splice(i, 1)
reject(new Error(`timed out after ${timeoutMs}ms waiting for ${what}`))
}, timeoutMs)
this._waiters.push(w)
})
}
/** One unary RPC. Rejects on transport error, timeout, or an `error` reply. */
async rpc(type, params = {}, timeoutMs = RPC_TIMEOUT_MS) {
const id = `gpl-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`
const envelope = { type, id, from: FROM_ID, time: Math.floor(Date.now() / 1000), ...params }
// Register the waiter BEFORE writing so a fast reply can't slip past.
const reply = this.waitFor((env) => env.id === id && env.type === 'reply', timeoutMs, `reply to ${type}`)
for (const wrap of fragSplit(envelope)) {
await this._write(buildFrame(FRAME_TYPE_ENVELOPE, Buffer.from(JSON.stringify(wrap), 'utf8')))
}
const env = await reply
if (env.error) throw new Error(`${type}: ${env.error}`)
return env
}
}
// ─── helpers ────────────────────────────────────────────────────────────────
function sha256File(file) {
return new Promise((resolve, reject) => {
const h = crypto.createHash('sha256')
fs.createReadStream(file).on('data', (c) => h.update(c)).on('error', reject).on('end', () => resolve(h.digest('hex')))
})
}
function fail(msg, code = 1) {
console.error(`error: ${msg}`)
process.exit(code)
}
/** A device reply that says success:false carries `reason` (a fixed code) and `error` (a sentence). */
function checkOk(reply, what) {
if (reply.success === false) {
throw new Error(`${what}: ${reply.error || 'refused'}${reply.reason ? ` [${reply.reason}]` : ''}`)
}
return reply
}
function printReceipt(r) {
const lines = [
`state: ${r.state}${r.reason ? ` (${r.reason})` : ''}`,
`op: ${r.op || '?'} target: ${r.targetId || '?'}`,
]
if (r.sha256) lines.push(`sha256 on disk: ${r.sha256}`)
if (r.allowlistVersion) lines.push(`allowlist: ${r.allowlistVersion}`)
if (r.ts) lines.push(`installer time: ${r.ts}`)
console.log(lines.join('\n'))
if (r.state === 'done' && r.rebootRequired) {
console.log('\nThe change takes effect after a power cycle: unplug the device\'s power and plug it back in.')
console.log('(Do not use a warm reboot — on some boards it leaves the display black until power is cut.)')
} else if (r.state === 'done' && r.postActionOk === false) {
console.log('\nInstalled, but the post-install action failed; power-cycle the device to be sure.')
} else if (r.state === 'unconfirmed') {
console.log('\nThe installer did not confirm in time. It may still complete; run `status` in a minute.')
}
}
/** Wait for the device's pushed result for our request; fall back to polling status. */
async function awaitResult(link, requestId) {
try {
return await link.waitFor(
(env) => env.type === 'gpl.install.result' && env.requestId === requestId,
RESULT_TIMEOUT_MS, 'the install result',
)
} catch {
const st = await link.rpc('gpl.install.status')
if (st.last && st.last.requestId === requestId) return st.last
return { state: 'unconfirmed', reason: 'no result received', requestId }
}
}
// ─── commands ───────────────────────────────────────────────────────────────
async function cmdList(link) {
const r = checkOk(await link.rpc('gpl.targets'), 'gpl.targets')
console.log(`allowlist ${r.allowlistVersion || '?'}${r.allowlistSource ? ` (${r.allowlistSource})` : ''}\n`)
const rows = r.targets.map((t) => [
t.id,
t.section || '-',
t.present ? (t.replaced ? 'replaced' : t.replaced === false ? 'factory' : '?') : 'absent',
t.postAction === 'reboot' ? 'power-cycle' : '-',
t.path,
])
const head = ['id', 'sec', 'state', 'after', 'path']
const w = head.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length)))
const fmt = (row) => row.map((c, i) => (i === row.length - 1 ? c : c.padEnd(w[i]))).join(' ')
console.log(fmt(head))
for (const row of rows) console.log(fmt(row))
console.log(`\n${rows.length} targets. "absent" = listed but not on this image (debug-only builds).`)
}
async function cmdStatus(link) {
const r = checkOk(await link.rpc('gpl.install.status'), 'gpl.install.status')
console.log(`supported: ${r.supported} in flight: ${r.inFlight}${r.requestId ? ` (${r.requestId})` : ''} installer running: ${r.unitActive}`)
if (r.last) { console.log('\nlast receipt:'); printReceipt(r.last) } else console.log('\nno receipt on the device yet')
}
async function cmdInstall(link, targetId, file) {
if (!targetId || !file) fail('usage: install <targetId> <file>')
let st
try { st = fs.statSync(file) } catch (err) { fail(`cannot read ${file}: ${err.message}`) }
if (!st.isFile()) fail(`${file} is not a regular file`)
const size = st.size
const sha256 = await sha256File(file)
console.log(`${path.basename(file)}: ${size} bytes, sha256 ${sha256}`)
const begun = checkOk(await link.rpc('gpl.installBegin', { targetId, size, sha256 }), 'gpl.installBegin')
const { transferId, chunkBytes } = begun
console.log(`transfer ${transferId}, ${chunkBytes} bytes per chunk`)
const fd = fs.openSync(file, 'r')
try {
const buf = Buffer.alloc(chunkBytes)
let seq = 0
let sent = 0
while (sent < size) {
const n = fs.readSync(fd, buf, 0, chunkBytes, sent)
if (n <= 0) break
const r = await link.rpc('gpl.installChunk', { transferId, seq, b64: buf.subarray(0, n).toString('base64') }, CHUNK_RPC_TIMEOUT_MS)
if (r.success === false) {
throw new Error(`gpl.installChunk: ${r.error || 'refused'}${r.reason ? ` [${r.reason}]` : ''}`)
}
sent += n
seq++
process.stdout.write(`\ruploaded ${sent}/${size} bytes (${Math.floor((sent * 100) / size)}%) `)
}
process.stdout.write('\n')
} catch (err) {
try { await link.rpc('gpl.installAbort', { transferId }) } catch {}
throw err
} finally {
fs.closeSync(fd)
}
const committed = checkOk(await link.rpc('gpl.installCommit', { transferId }), 'gpl.installCommit')
console.log(`handed to the installer (request ${committed.requestId}); waiting for its receipt…`)
const result = await awaitResult(link, committed.requestId)
printReceipt(result)
return result.state === 'done' ? 0 : 2
}
async function cmdRestore(link, targetId) {
if (!targetId) fail('usage: restore <targetId>')
const r = checkOk(await link.rpc('gpl.restore', { targetId }), 'gpl.restore')
console.log(`restore requested (request ${r.requestId}); waiting for the installer's receipt…`)
const result = await awaitResult(link, r.requestId)
printReceipt(result)
return result.state === 'done' ? 0 : 2
}
async function cmdAbort(link, transferId) {
if (!transferId) fail('usage: abort <transferId>')
checkOk(await link.rpc('gpl.installAbort', { transferId }), 'gpl.installAbort')
console.log('aborted')
}
// ─── main ───────────────────────────────────────────────────────────────────
function usage() {
console.error(`usage: node ${path.basename(process.argv[1])} [--port <tty>] [--baud <n>] <command>
list allowlisted components and their state on the device
status last install/restore receipt
install <targetId> <file> upload <file> and install it as <targetId>
restore <targetId> restore the factory file for <targetId>
abort <transferId> drop a half-uploaded transfer
defaults: --port ${DEFAULT_PORT} --baud ${DEFAULT_BAUD}`)
process.exit(1)
}
function resolvePort(spec) {
if (!spec.includes('*')) return spec
// macOS: expand /dev/tty.usbmodem* to the single match.
const dir = path.dirname(spec)
const re = new RegExp(`^${path.basename(spec).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`)
const hits = fs.readdirSync(dir).filter((n) => re.test(n))
if (hits.length !== 1) fail(`--port ${spec} matches ${hits.length} devices${hits.length ? `: ${hits.join(', ')}` : ''}; pass one explicitly`)
return path.join(dir, hits[0])
}
async function main() {
const argv = process.argv.slice(2)
let portPath = DEFAULT_PORT
let baud = DEFAULT_BAUD
const rest = []
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--port') portPath = argv[++i]
else if (argv[i].startsWith('--port=')) portPath = argv[i].slice(7)
else if (argv[i] === '--baud') baud = Number(argv[++i])
else if (argv[i].startsWith('--baud=')) baud = Number(argv[i].slice(7))
else if (argv[i] === '-h' || argv[i] === '--help') usage()
else rest.push(argv[i])
}
const [cmd, a, b] = rest
if (!cmd || !['list', 'status', 'install', 'restore', 'abort'].includes(cmd)) usage()
if (!portPath || !Number.isFinite(baud)) usage()
const link = new EdgeLink({ portPath: resolvePort(portPath), baudRate: baud })
await link.open()
let code = 0
try {
if (cmd === 'list') await cmdList(link)
else if (cmd === 'status') await cmdStatus(link)
else if (cmd === 'install') code = await cmdInstall(link, a, b)
else if (cmd === 'restore') code = await cmdRestore(link, a)
else if (cmd === 'abort') await cmdAbort(link, a)
} finally {
await link.close()
}
process.exit(code)
}
if (require.main === module) {
main().catch((err) => fail(err.message))
}
module.exports = { FrameParser, buildFrame, fragSplit, FragReassembler, EdgeLink, checksum }