Skip to content

Open Source ​

Violoop OS is built on free and open source software (FLOSS). The bootloader and the Linux kernel are licensed under the GNU General Public License, version 2 (GPL-2.0), and a large part of the userland (Debian 12) is under GPL-3.0 / LGPL-3.0 and other licenses.

This page tells you two things every owner is entitled to:

  1. Where to get the source for the GPL-2 bootloader and kernel.
  2. How to install your own modified versions of the GPL-3 software running on your device.

Baseline: Violoop OS on Rockchip RK3576, Debian 12 (arm64), BSP rk3576-VLA11/v0.1.4, Linux kernel 6.1.99. A full compliance notice (with the exact build commit of every component and a per-package license breakdown) also ships on the device at docs/OPEN-SOURCE-NOTICE.md, and the precise build SHAs are recorded in kernel/prebuilt/manifest.json.


Boot & Kernel source code (GPL-2.0) ​

The bootloader (U-Boot) and the Linux kernel are the two GPL-2 components at the heart of the system. Their complete corresponding source code, including every modification we made for the RK3576 platform, is published in public Git repositories, pinned to the exact commit used to build the shipped firmware:

ComponentLicenseSource repositoryBranchBuild commit
Linux kernel 6.1.99GPL-2.0https://github.com/BvioTech/kernelbsp/rk3576-VLA11d6d74b512b7fd8534fd8eed3f14d8cb919feb2bf
U-Boot (bootloader)GPL-2.0+https://github.com/BvioTech/u-bootbsp/rk3576-VLA11c0bec16b14a265e6abccf8244f8095ac17b26e97
AIC8800D40 Wi-Fi/BT driverGPL-2.0https://github.com/BvioTech/rkwifibtfeat/vla11-aic8800d40aab3ec6245e4e6df23b7aa68ed573eac86f669f5
Rockchip rkbin (blob sources)mixedhttps://github.com/BvioTech/rkbinbsp/rk3576-VLA11d8a9250436dfb90a2d1ff042e1d1b34cf3177816

Clone a repository and check out the matching commit to obtain the exact source used in this firmware:

sh
git clone https://github.com/BvioTech/kernel
cd kernel && git checkout d6d74b512b7fd8534fd8eed3f14d8cb919feb2bf

What we changed: the kernel carries an added touchscreen driver (sw42000_touchscreen), several device-tree (DTS) adaptations, and configuration changes (overlayfs / squashfs-zstd / board driver options); the AIC8800D40 wireless driver is a ported adaptation shipped as a .ko module. The PCIe host-side driver for the compute card, pcie-rkep (GPL-2.0 OR MIT), lives inside the kernel tree (drivers/misc/rockchip/pcie-rkep.c, CONFIG_PCIE_FUNC_RKEP=m). Our changes to it are include rewiring for the in-tree layout, restoring the upstream IS_MODULE(...) inclusion guard, and fixing the MODULE_DEVICE_TABLE bus-type argument (upstream passed the driver name, so the module was never auto-loaded). Checking out the kernel commit above gives you its complete corresponding source, nothing separate to fetch. U-Boot is built unmodified from the revision above. Each repository also ships the corresponding COPYING / LICENSE file; the GPL-2.0 text is at https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt.

Compute-card runtime — proprietary prebuilt binaries ​

The compute card's userspace runtime and on-card firmware (librknn3_api*, rknn3_transfer_proxy, rknn-smi, pcie_upgrade_tool, rknn3_rk1820.img) are Rockchip's proprietary prebuilt binaries, redistributed under our vendor agreement. They come with no source code and carry no copyleft obligation; they are listed here only for completeness. This is distinct from the open pcie-rkep kernel driver above, whose source is fully published.

Also non-copyleft are ARM Trusted Firmware (bl31, BSD-3-Clause), OP-TEE (bl32, BSD-2-Clause), the Rockchip DDR-init and other rkbin blobs, and the AIC8800 wireless firmware (*.bin). None of them creates a source-disclosure obligation; they are listed for transparency only.

Boot logo — not covered by GPL-2 ​

The logo shown on screen at power-on (the U-Boot splash logo.bmp and the kernel splash logo_kernel.bmp) is stored as a bitmap asset inside resource.img in the boot partition — packed alongside the kernel device tree, not compiled into the bootloader or kernel. These images are Violoop's own branding artwork: a proprietary asset, not part of the GPL-2.0 U-Boot or Linux source and not subject to its terms. Accordingly, the source-disclosure obligation above covers the bootloader and kernel code only; it does not extend to the logo artwork in resource.img.


Modifying the GPL-3 software on your device ​

The userland is Debian 12 (bookworm), which includes many components under GPL-3.0 and LGPL-3.0. GPL-3 grants you, as the owner of the device, the right to install and run your own modified versions of that software — the anti-tivoization guarantee in section 6 of the license.

Which components are covered ​

None of these is modified by us. Their source is obtained as described under Debian userland source below:

  • GPL-3.0+ programs: bash (with libreadline8), coreutils, grep/sed/gzip/tar/diffutils/findutils/cpio, less, wget, parted and its libraries, dosfstools, cloud-guest-utils, the gnupg toolchain (gpgv/gpg/gpg-agent/dirmngr, etc.), runit-helper, the hwclock binary from util-linux-extra, the pam-auth-update script from libpam-runtime, plus libcdio*, libgdbm* and libmpv2 pulled in by the application stack (Debian builds libmpv2 against the GPL-3+ libsmbclient, so the binary is distributed under GPL-3+).
  • LGPL-3.0 libraries: libgmp10, libnettle8/libhogweed6, libunistring2, libidn2-0, libksba8, libgnutls30, libzmq5, libelf1/libdw1, and the GlusterFS client libraries libglusterfs0/libgfapi0/libgfrpc0/libgfxdr0.
  • GPL-3 WITH GCC Runtime Library Exception: libgcc-s1, libstdc++6, libgomp1, libgfortran5, gcc-12-base. The exception waives the combination obligations; they are listed for transparency only.

rsync (GPL-3+) exists only in debug builds and is not part of production firmware. The full per-package assessment, including components that look like GPL-3 but are not, is in §2.1 of docs/OPEN-SOURCE-NOTICE.md on the device.

The installation route: over the USB serial port, via the main application ​

Violoop is a headless edge device with no USB host port for a USB stick. The device's only stable external control port is the USB-C serial port — the same data cable you use to connect it to your computer. There is therefore one installation route: you send a self-built GPL-3.0 / LGPL-3.0 component file to the device's main application over USB serial, and the device installs it as root into a whitelisted target path.

The whole process requires no login, no account, no opening the enclosure, no signing key of ours and no proprietary client. The serial RPC is unauthenticated: whoever can plug in the USB-C cable is treated as the owner.

Step 1 — Build your modified version ​

On your own computer, fetch the source of the package you want to change, modify it, and cross-compile the target file for the device's architecture (aarch64). Installation is a whole-file replacement, so the result must match the image's ABI (an arm64 build against Debian 12 bookworm does). You can get the exact source with apt-get source — or request it via the written offer below:

sh
# e.g. coreutils (GPL-3.0)
apt-get source coreutils

# ... make your changes in the unpacked source tree ...

cd coreutils-*/
dpkg-buildpackage -us -uc -b        # build the modified binaries (arm64)

Take the file you want to replace out of the build output (for example /usr/bin/bash from the bash source).

Step 2 — Connect the USB-C cable and install ​

Pick one of two ways:

  • The reference script gpl_install_serial.js in the appendix of this page. It is a single Node file whose only dependency is the npm package serialport; save the appendix to a file and run it;
  • The settings page of the Violoop desktop app (an optional convenience layer, never the only route).

With the reference script, you need Node.js 18+ and the serialport package. Connect the data cable (the port is usually /dev/ttyACM0 on Linux and /dev/tty.usbmodem* on macOS), then:

sh
npm install serialport

# list the replaceable components and their state (factory / replaced)
node gpl_install_serial.js list

# upload and install your file; <targetId> is the id column of `list`, e.g. usr-bin-bash
node gpl_install_serial.js install usr-bin-bash ./bash

# put the factory file back at any time
node gpl_install_serial.js restore usr-bin-bash

# show the receipt of the last install / restore
node gpl_install_serial.js status

Pass --port <tty> if the serial port is not at the default path. Exit codes: 0 done, 2 the device reported failed or unconfirmed, 1 usage or transport error.

What happens when you run install:

  1. The script first declares the target id, file size and sha256 to the device, then uploads the file in order (at most 4 MB per chunk) and commits. The commit reply only means the file was handed to the device's root-side installer.
  2. The device then returns one receipt with state done, failed or unconfirmed (no receipt within 30 seconds; check again with status), together with the actual sha256 of the target file after the action.
  3. If the receipt says rebootRequired (shared libraries, firmware), the change takes effect after a power cycle; the device does not reboot itself. Other targets are live immediately.
  4. restore unconditionally restores the factory file — the original is preserved permanently by the device's root side before the first replacement.

A failed receipt carries a fixed reason code: not-allowed (target not on the allowlist), not-present (the file does not exist on this unit, e.g. debug-build-only components), size-mismatch / checksum (upload does not match the declaration), bad-payload, no-original (restore requested for a target that was never replaced), or io:<detail> (write failure).

Trust model ​

The serial RPC is unauthenticated — whoever can plug in a USB-C cable is treated as the owner, the same level of trust as being able to trigger a factory reset or change Wi-Fi. Neither the application nor the root side gates the operation; the allowlist is the hard boundary. GPL-3.0 §6 permits requiring the owner's authorization and only forbids preventing installation, so a future device-local owner confirmation (touch screen or button) would not change the nature of this route.

Why it actually sticks ​

  • The root filesystem is deliberately not signature-verified. The boot chain (boot.img) is signed, but the root filesystem (squashfs) has no dm-verity / roothash — the verification chain ends at the initramfs. None of the allowlisted targets is signature-checked, self-verified, or subject to logic that changes behaviour if replaced; a modified userland has to keep running normally.
  • Writes land in the overlay's writable layer (the upper dir on the data partition), shadowing the same paths in the read-only base, which is why they survive a reboot.
  • Nothing degrades or refuses service because something was modified: the image does not check whether userland binaries have been replaced, and we do not withhold updates or lock the device because of a replacement (§6 only allows declining warranty for problems it causes).

Things to know ​

  • A factory reset erases your modifications. Resetting is implemented by wiping the overlay's writable layer, so components you installed yourself disappear and the base returns to its factory state. This is an action you trigger yourself and is not a block on modification; it is also the recovery path if a replacement leaves the device unbootable.
  • An OTA update may overwrite a replacement. When the owning Debian package is upgraded, dpkg overwrites your file; you can reinstall over the same link.
  • This route does not depend on our authorization. It completes entirely on the device and is not conditioned on any permission from our servers, subscription status, or revocable credential.

Kernel note: the kernel and bootloader are GPL-2, which does not carry the anti-tivoization requirement, and production units use a verified (secure) boot chain (SSM-2). To run a modified kernel, rebuild from the kernel repository above; on secure-boot units a modified kernel image must be re-signed to boot. The GPL-3 anti-tivoization guarantee applies to the userland, which you install and run as shown above.


Debian userland source ​

The included Debian packages are unmodified. Their source is available per each package's license from the official Debian archive:


License texts ​

The component repositories and Debian packages also ship the corresponding COPYING / LICENSE / /usr/share/doc/<pkg>/copyright files.


Written offer ​

In addition to the public repositories above, for three (3) years from the date you received the product you may request the complete corresponding source code of the included GPL/LGPL components (GPL-2.0, GPL-3.0 and LGPL-3.0). We will provide it on physical media or via a download link for no more than the cost of media and shipping.

  • Email: [email protected]
  • Subject: Violoop OS source code request — version rk3576-VLA11/v0.1.4 (the version is shown in the device's system information screen)

This offer is valid to any third party in possession of a copy of the product, in accordance with GPL-2.0 section 3(b) and GPL-3.0 sections 6(b)/(c).


Appendix: reference script gpl_install_serial.js ​

The complete reference script used by the installation route, and the executable part of the "Installation Information" under GPL-3.0 §6. Save it as gpl_install_serial.js and run it as described in Step 2.

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 }