Skip to content

开源说明 ​

Violoop OS 构建于**自由与开源软件(FLOSS)**之上。引导加载器(U-Boot)与 Linux 内核以 GNU 通用公共许可证第 2 版(GPL-2.0) 授权,而用户态(Debian 12)中有相当一部分以 GPL-3.0 / LGPL-3.0 及其他许可证授权。

本页面向每一位设备所有者说明两件你有权做的事:

  1. 在哪里获取 GPL-2 引导加载器与内核的源代码。
  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.99GPL-2.0https://github.com/BvioTech/kernelbsp/rk3576-VLA11d6d74b512b7fd8534fd8eed3f14d8cb919feb2bf
U-Boot(引导加载器)GPL-2.0+https://github.com/BvioTech/u-bootbsp/rk3576-VLA11c0bec16b14a265e6abccf8244f8095ac17b26e97
AIC8800D40 Wi-Fi/蓝牙驱动GPL-2.0https://github.com/BvioTech/rkwifibtfeat/vla11-aic8800d40aab3ec6245e4e6df23b7aa68ed573eac86f669f5
Rockchip rkbin(blob 源)混合https://github.com/BvioTech/rkbinbsp/rk3576-VLA11d8a9250436dfb90a2d1ff042e1d1b34cf3177816

克隆仓库并检出对应提交,即为本固件所用的完整源码:

sh
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 取到,也可通过下文的书面要约索取:

sh
# 以 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*),然后:

sh
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 时会发生什么:

  1. 脚本先向设备核对目标 id、文件大小与 sha256,再按序分块上传(每次最多 4MB),最后提交。提交的应答只表示文件已交给设备的 root 侧安装程序。
  2. 设备随后返回一条回执,状态为 done、failed 或 unconfirmed(30 秒内未等到回执;可用 status 再查),并带上动作完成后目标文件的实际 sha256。
  3. 回执标明 rebootRequired 时(共享库、固件等),须断电重上电方可生效;设备不会自动重启。其余目标替换后立即生效。
  4. 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 官方归档获取:


许可证文本 ​

各组件仓库及 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,按第二步所述运行。

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 }