Skip to content

VS Code deployments

Large open-source Ethos Lua projects use the same development setup: VS Code, the Ethos simulator extension, and a deploy script that copies the project to the simulator or straight onto a USB-connected radio, then optionally streams the radio's print() output back. Press F5 and the code is on the radio.

Just want it working?

Start from the VS Code project template: the complete, tested setup (play buttons, radio and simulator deploys, serial debug, i18n, release zips and GitHub workflows) in a repository you can create with one click. This page explains how the pieces work.

This page explains each part and gives you a minimal standalone version to study or copy:

flowchart LR
  F5[F5 in VS Code] --> T[preLaunchTask:<br/>ethos_deploy.py]
  T -->|"--sim"| S["simulators/X20S_FCC@nightly26/scripts/"]
  T -->|"--radio"| H[USB HID: switch to storage]
  H --> D[find drive by *.cpuid]
  D --> C[copy changed files only]
  C -->|"--debug"| R[USB HID: switch to serial]
  R --> P[tail print output]
  S --> E[ethos.start: simulator panel]

1. Project layout

my-ethos-project/
├── src/
│   └── mywidget/            ← exactly what ends up in SCRIPTS:/mywidget/
│       ├── main.lua
│       └── gfx/icon.png
├── tools/
│   └── ethos_deploy.py
├── simulators/              ← created by the Ethos extension; git-ignore it
└── .vscode/
    ├── settings.json        ← board / protocol / release for the simulator
    ├── tasks.json           ← deploy commands
    ├── launch.json          ← makes them F5-able
    ├── ethos-menu.json      ← status-bar quick menu (optional)
    └── noop.py

Keep the deployable folder (src/mywidget/) separate from tooling, tests and docs. The deploy script then mirrors that folder and nothing else.

2. The Ethos simulator extension

The Ethos extension (bsongis.ethos) runs the Ethos WebAssembly simulator inside VS Code: display, controls and telemetry panels, audio, screenshots and a log file.

Setting Meaning
ethos.board Radio board, e.g. X20S, X20PRO, X18
ethos.protocol RF protocol variant, e.g. FCC, EU
ethos.release Ethos release or branch, e.g. nightly26 (default)
ethos.simulatorsFolder Workspace folder holding simulators (default simulators)
ethos.logfile Write the simulator console to a file

Each simulator keeps its own radio storage in <simulatorsFolder>/<board>_<protocol>@<release>/, which is a normal folder with radio.bin, models/, scripts/. That's what a deploy script writes into. Downloaded firmware builds are cached in VS Code's global storage (.../globalStorage/bsongis.ethos/cache/), which is also what ethos-tools uses.

Useful commands (Command Palette → Ethos: ...): ethos.start, ethos.stop, ethos.setSimulator, ethos.openDisplay, ethos.openControls, ethos.openTelemetry, ethos.takeScreenshot, ethos.openLuaDoc.

Status-bar menu. Clicking the extension's status-bar item can show your own quick-pick menu from .vscode/ethos-menu.json. Entries run commands, or tasks such as your deploy:

.vscode/ethos-menu.json
[
  { "label": "$(cloud-upload) Deploy to simulator", "task": "Deploy: simulator" },
  { "label": "$(play) Start simulator", "command": "ethos.start" },
  { "label": "$(debug-stop) Stop simulator", "command": "ethos.stop" },
  { "label": "", "separator": true },
  { "label": "$(device-mobile) Deploy to radio", "task": "Deploy: radio" },
  { "label": "$(output) Radio serial debug", "task": "Radio: serial debug" },
  { "label": "", "separator": true },
  { "label": "$(settings-gear) Open controls", "command": "ethos.openControls" },
  { "label": "$(broadcast) Open telemetry", "command": "ethos.openTelemetry" },
  { "label": "$(device-camera) Screenshot", "command": "ethos.takeScreenshot" },
  { "label": "$(arrow-swap) Change simulator", "command": ["ethos.setSimulator", "ethos.showMenu"] }
]

Simulated telemetry

Projects keep a sensors.json describing simulated sensors (name, value, update interval) in the repository and copy it to the simulator folder's root on deploy, so telemetry widgets have data from the first boot.

3. Tasks and F5

tasks.json defines the deploy commands. ${config:ethos.*} reuses the extension's settings, so the simulator target always matches the selected simulator:

.vscode/tasks.json
{
  // Copy to .vscode/tasks.json. Assumes your script is in src/<name>/ and
  // ethos_deploy.py is in tools/. Change "mywidget" to your folder name.
  "version": "2.0.0",
  "tasks": [
    {
      "label": "Deploy: simulator",
      "type": "shell",
      "command": "python",
      "windows": { "command": "${command:python.interpreterPath}" },
      "osx": { "command": "${command:python.interpreterPath}" },
      "args": [
        "${workspaceFolder}/tools/ethos_deploy.py",
        "--src", "${workspaceFolder}/src/mywidget",
        "--sim", "${workspaceFolder}/${config:ethos.simulatorsFolder}/${config:ethos.board}_${config:ethos.protocol}@${config:ethos.release}/scripts"
      ],
      "problemMatcher": [],
      "presentation": { "reveal": "always", "panel": "shared", "clear": true }
    },
    {
      "label": "Deploy: radio",
      "type": "shell",
      "command": "python",
      "windows": { "command": "${command:python.interpreterPath}" },
      "osx": { "command": "${command:python.interpreterPath}" },
      "args": ["${workspaceFolder}/tools/ethos_deploy.py", "--src", "${workspaceFolder}/src/mywidget", "--radio"],
      "problemMatcher": [],
      "presentation": { "reveal": "always", "panel": "shared", "clear": true }
    },
    {
      "label": "Deploy: radio + serial debug",
      "type": "shell",
      "command": "python",
      "windows": { "command": "${command:python.interpreterPath}" },
      "osx": { "command": "${command:python.interpreterPath}" },
      "args": ["${workspaceFolder}/tools/ethos_deploy.py", "--src", "${workspaceFolder}/src/mywidget", "--radio", "--debug"],
      "problemMatcher": [],
      "presentation": { "reveal": "always", "panel": "dedicated", "clear": true }
    },
    {
      "label": "Radio: serial debug",
      "type": "shell",
      "command": "python",
      "windows": { "command": "${command:python.interpreterPath}" },
      "osx": { "command": "${command:python.interpreterPath}" },
      "args": ["${workspaceFolder}/tools/ethos_deploy.py", "--radio", "--debug-only"],
      "problemMatcher": [],
      "presentation": { "reveal": "always", "panel": "dedicated", "clear": true }
    },
    {
      "label": "ethos.Start",
      "type": "shell",
      "command": "${command:ethos.start}",
      "problemMatcher": []
    }
  ]
}

The F5 trick. VS Code only lists launch configurations in the Run and Debug dropdown. Projects add debug configurations whose program is an empty Python file and whose preLaunchTask is the deploy, so F5 (or the green ▶) deploys. postDebugTask can then start the simulator:

.vscode/launch.json
{
  // Copy to .vscode/launch.json. Puts the deploy tasks in the Run and Debug
  // dropdown so F5 deploys. The "program" is a no-op Python file: the real
  // work happens in preLaunchTask (and postDebugTask starts the simulator).
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Deploy & launch [simulator]",
      "type": "debugpy",
      "request": "launch",
      "program": "${workspaceFolder}/.vscode/noop.py",
      "console": "integratedTerminal",
      "preLaunchTask": "Deploy: simulator",
      "postDebugTask": "ethos.Start"
    },
    {
      "name": "Deploy [radio]",
      "type": "debugpy",
      "request": "launch",
      "program": "${workspaceFolder}/.vscode/noop.py",
      "console": "integratedTerminal",
      "preLaunchTask": "Deploy: radio"
    },
    {
      "name": "Deploy [radio] + serial debug",
      "type": "debugpy",
      "request": "launch",
      "program": "${workspaceFolder}/.vscode/noop.py",
      "console": "integratedTerminal",
      "preLaunchTask": "Deploy: radio + serial debug"
    }
  ]
}

type: debugpy needs the Python extension. ${command:python.interpreterPath} in the tasks runs the deploy with the interpreter selected in VS Code, so dependencies installed there are found.

4. Talking to the radio over USB

When connected and powered on, an Ethos radio presents a composite USB device (VID 0483, PID 5750):

Interface Always? Is
0 Yes HID control channel, the one FrSky Suite uses
1 — Either USB mass storage (the radio's drive) or a CDC serial port (debug output)

Switching interface 1 between storage and serial is a HID command. The radio drops off the bus and re-enumerates, which takes a few seconds.

Request (HID report, after report id 0x00) Effect
21 06 Board information. Response starts 22; byte 2 is the board id
81 68 USB mode → serial debug (drive unmounts, a COM/tty port appears)
81 69 USB mode → mass storage (serial port goes, drive mounts)
61 66 Reboot the radio

Verified on hardware

On an X20S (Ethos 26.1.2): 21 06 returned 22 06 05 ... (board id 5, an SD-card board). 81 68 produced COM15 within a few seconds, and 81 69 remounted the drive within about 2 s.

The flash and storage-format commands also exist in this protocol. Leave them alone unless you are writing a flashing tool.

import hid                     # pip install hidapi

dev = hid.device()
dev.open(0x0483, 0x5750)       # some firmware states re-enumerate as 0x5740
dev.write(bytes([0x00, 0x21, 6]))
print("board id", dev.read(64, 500)[2])
dev.write(bytes([0x00, 0x81, 0x69]))   # make sure we are in storage mode
dev.close()

Finding the radio's drive

Don't guess drive letters. The radio's volumes carry marker files at their root: sdcard.cpuid, radio.cpuid or flash.cpuid. Scan removable drives (Windows), /Volumes/* (macOS) or /media/$USER/* (Linux) for one of these, and the scripts/ folder next to it is your target. On radios with both internal flash and an SD card, prefer sdcard, then radio.

FrSky Suite's CLI can do the same lookups: --get-path SCRIPTS and --serial start|stop. See FrSky Suite. Talking HID directly removes the dependency on Suite, works on macOS and Linux, and avoids the ELECTRON_RUN_AS_NODE trap when called from VS Code.

5. Copying safely to removable storage

Writing to the radio's drive over USB is slow, and a cable bump mid-write corrupts files. Robust deploy scripts:

  • Copy only what changed. Compare size first, then MD5 if sizes match, and delete files that no longer exist in the source (an rsync-style mirror). A typical redeploy then copies one or two files.
  • fsync every file so it really lands before the radio is unplugged or switched to serial.
  • Delete stale .luac next to changed .lua. Ethos compiles main.lua on load, and old bytecode can hide your change.
  • Stage first when you transform files. If the deploy runs build steps (generating translation tables, resolving i18n tags, building sound packs), do it in a local temp folder, then mirror the result to the radio, instead of many small edits on the slow drive.
  • Throttle very large copies. Some suites write in chunks with short pauses, because a burst of thousands of small writes has caused USB storage stalls on some systems.
  • Allow time to mount. After switching to storage mode, Windows can take 10–30 s to mount the volume. Poll for the marker file, and re-send the storage command at most once, and only if the serial port is still present after ~20 s, because every switch command restarts the enumeration.
  • Single instance. Keep a lock file so two deploys (an F5 double-press) can't write at once.

ethos_deploy.py implements the first four. The others matter once a project has hundreds of files.

6. Serial debug: print() from the radio

With the radio in serial mode, everything your scripts print() arrives on the serial port at 115200 baud. Find the port by VID/PID, not by name:

from serial.tools import list_ports   # pip install pyserial
port = next(p.device for p in list_ports.comports() if p.vid == 0x0483 and p.pid == 0x5750)

ethos_deploy.py --radio --debug deploys, switches to serial, prints lines until Ctrl+C, then switches back to storage so the next deploy finds the drive.

The radio only sends what scripts print. An idle radio is silent, which is normal. Prefix your messages ([mywidget] ...), because every script shares the port. FrSky Suite's Lua development tools → Debug log is the GUI equivalent.

7. The deploy tool

python tools/ethos_deploy.py --src src/mywidget --sim simulators/X20S_FCC@nightly26/scripts
python tools/ethos_deploy.py --src src/mywidget --radio            # incremental copy to the radio
python tools/ethos_deploy.py --src src/mywidget --radio --dry-run  # show what would change
python tools/ethos_deploy.py --src src/mywidget --radio --debug    # deploy, then tail print()
python tools/ethos_deploy.py --radio --debug-only                  # just tail print()
python tools/ethos_deploy.py --radio --info                        # board id over HID

Tested against an X20S on Windows: a first deploy copied the script, a second run copied nothing (0 copied, 0 removed), and --debug-only switched to COM15 and back to the mounted drive.

ethos_deploy.py (full source)
#!/usr/bin/env python3
"""Deploy an Ethos Lua script to a radio over USB, or to a simulator folder.

A small, dependency-light version of the deploy tooling used by large
open-source Ethos Lua projects:

  * finds the radio over USB HID (the same interface FrSky Suite uses)
  * switches it to USB storage mode if it is in serial-debug mode
  * finds the radio's drive by its *.cpuid marker file
  * mirror-copies only changed files (size, then MD5), deleting stale ones
  * optionally switches to serial-debug mode and prints the radio's
    Lua print() output until Ctrl+C, then switches back to storage

Usage:
  python ethos_deploy.py --src src/mywidget --radio
  python ethos_deploy.py --src src/mywidget --radio --debug
  python ethos_deploy.py --src src/mywidget --sim simulators/X20S_FCC/scripts
  python ethos_deploy.py --radio --debug-only          # just tail print() output
  python ethos_deploy.py --radio --debug-only --for 30 # ... for 30 seconds
  python ethos_deploy.py --radio --info                # board id via HID

Requires: Python 3.9+, `pip install hidapi pyserial` (radio only).
Docs: https://frskyrc.github.io/ethos-lua-doc/tools/vscode/
"""

from __future__ import annotations

import argparse
import hashlib
import os
import platform
import string
import sys
import time
from pathlib import Path

VID = 0x0483
PIDS = (0x5750, 0x5740)          # 0x5740 seen on some firmware during re-enumeration
INFO_REQUEST = 0x21              # -> response 0x22, byte[2] = board id
USB_MODE_REQUEST = 0x81
USB_MODE_SERIAL = 0x68           # interface 1 becomes a CDC serial port (print output)
USB_MODE_STORAGE = 0x69          # interface 1 becomes USB mass storage
MARKERS = ("sdcard.cpuid", "radio.cpuid", "flash.cpuid")


# ----------------------------------------------------------------- HID
def hid_open():
    try:
        import hid
    except ImportError:
        sys.exit("radio access needs hidapi: python -m pip install hidapi")
    for pid in PIDS:
        try:
            if hasattr(hid, "device"):           # 'hidapi' package
                dev = hid.device()
                dev.open(VID, pid)
            else:                                # older 'hid' package
                dev = hid.Device(vid=VID, pid=pid)
            return dev
        except Exception:
            continue
    sys.exit("no Ethos radio found over USB HID (is it connected and switched on?)")


def hid_send(payload: bytes) -> None:
    dev = hid_open()
    try:
        dev.write(bytes([0x00]) + payload)       # leading 0x00 = report id
    finally:
        dev.close()


def board_info() -> int | None:
    dev = hid_open()
    try:
        dev.write(bytes([0x00, INFO_REQUEST, 6]))
        r = dev.read(256, 500)
        return r[2] if r and r[0] == INFO_REQUEST + 1 else None
    finally:
        dev.close()


# ----------------------------------------------------------------- drive discovery
def candidate_roots():
    system = platform.system()
    if system == "Windows":
        for letter in string.ascii_uppercase:
            yield Path(f"{letter}:/")
    elif system == "Darwin":
        yield from Path("/Volumes").glob("*")
    else:
        user = os.environ.get("USER", "")
        for base in (Path("/media") / user, Path("/run/media") / user, Path("/mnt")):
            if base.is_dir():
                yield from base.glob("*")


def find_radio_drive() -> Path | None:
    for root in candidate_roots():
        try:
            if any((root / m).exists() for m in MARKERS):
                return root
        except OSError:
            continue
    return None


def serial_port():
    try:
        from serial.tools import list_ports
    except ImportError:
        return None
    for p in list_ports.comports():
        if p.vid == VID and p.pid in PIDS:
            return p.device
    return None


def ensure_storage(timeout=60) -> Path:
    drive = find_radio_drive()
    if drive:
        return drive
    print("[radio] drive not mounted, requesting USB storage mode ...")
    hid_send(bytes([USB_MODE_REQUEST, USB_MODE_STORAGE]))
    start = time.monotonic()
    while time.monotonic() - start < timeout:
        time.sleep(1)
        drive = find_radio_drive()
        if drive:
            print(f"[radio] mounted at {drive} after {time.monotonic() - start:.0f}s")
            return drive
    sys.exit(f"radio drive did not mount within {timeout}s")


# ----------------------------------------------------------------- copy
def md5(path: Path) -> str:
    h = hashlib.md5()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def needs_copy(src: Path, dst: Path) -> bool:
    if not dst.exists():
        return True
    if src.stat().st_size != dst.stat().st_size:
        return True
    return md5(src) != md5(dst)


def mirror(src: Path, dst: Path, dry_run=False) -> tuple[int, int]:
    """rsync-style: copy changed files, delete files that no longer exist in src."""
    copied = removed = 0
    changed: set[Path] = set()
    src_files = {p.relative_to(src) for p in src.rglob("*") if p.is_file()}
    for rel in sorted(src_files):
        s, d = src / rel, dst / rel
        if needs_copy(s, d):
            print(f"  + {rel.as_posix()}")
            copied += 1
            changed.add(rel)
            if not dry_run:
                d.parent.mkdir(parents=True, exist_ok=True)
                with open(s, "rb") as fi, open(d, "wb") as fo:
                    fo.write(fi.read())
                    fo.flush()
                    os.fsync(fo.fileno())    # removable storage: make sure it really landed
    if dst.exists():
        for p in sorted(dst.rglob("*"), reverse=True):
            rel = p.relative_to(dst)
            if p.is_file() and rel not in src_files and p.suffix != ".luac":
                print(f"  - {rel.as_posix()}")
                removed += 1
                if not dry_run:
                    p.unlink()
            elif p.is_file() and p.suffix == ".luac" and rel.with_suffix(".lua") in changed:
                # stale bytecode would shadow the new source; Ethos recompiles on load
                print(f"  - {rel.as_posix()} (stale bytecode)")
                removed += 1
                if not dry_run:
                    p.unlink()
    return copied, removed


# ----------------------------------------------------------------- serial debug
def tail_debug(seconds: float | None = None):
    try:
        import serial
    except ImportError:
        sys.exit("serial debug needs pyserial: python -m pip install pyserial")
    print("[radio] switching to serial debug mode (the drive will unmount) ...")
    hid_send(bytes([USB_MODE_REQUEST, USB_MODE_SERIAL]))
    port = None
    for _ in range(30):
        time.sleep(1)
        port = serial_port()
        if port:
            break
    if not port:
        sys.exit("debug serial port did not appear")
    print(f"[radio] {port}: printing Lua output, Ctrl+C to stop")
    end = time.monotonic() + seconds if seconds else None
    try:
        with serial.Serial(port, 115200, timeout=0.5) as s:
            while end is None or time.monotonic() < end:
                line = s.readline()
                if line:
                    print(line.decode("utf-8", errors="replace").rstrip())
    except KeyboardInterrupt:
        pass
    finally:
        print("\n[radio] back to USB storage mode")
        hid_send(bytes([USB_MODE_REQUEST, USB_MODE_STORAGE]))


# ----------------------------------------------------------------- main
def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--src", type=Path, help="script folder to deploy (its name becomes scripts/<name>)")
    ap.add_argument("--name", help="folder name on the radio (default: name of --src)")
    target = ap.add_mutually_exclusive_group(required=True)
    target.add_argument("--radio", action="store_true", help="deploy to a USB-connected radio")
    target.add_argument("--sim", type=Path, help="deploy into this simulator scripts/ folder")
    ap.add_argument("--debug", action="store_true", help="after deploying, tail the radio's print() output")
    ap.add_argument("--debug-only", action="store_true", help="only tail print() output, no copy")
    ap.add_argument("--for", dest="seconds", type=float, help="stop tailing after this many seconds")
    ap.add_argument("--info", action="store_true", help="print the radio's board id and exit")
    ap.add_argument("--dry-run", action="store_true", help="show what would change, copy nothing")
    args = ap.parse_args()

    if args.info:
        print(f"board id: {board_info()}")
        return 0
    if args.debug_only:
        tail_debug(args.seconds)
        return 0
    if not args.src or not args.src.is_dir():
        ap.error("--src must be an existing folder")

    name = args.name or args.src.name
    if args.radio:
        scripts = ensure_storage() / "scripts"
    else:
        scripts = args.sim
    dest = scripts / name
    print(f"[deploy] {args.src} -> {dest}{' (dry run)' if args.dry_run else ''}")
    t0 = time.monotonic()
    copied, removed = mirror(args.src, dest, args.dry_run)
    print(f"[deploy] {copied} copied, {removed} removed in {time.monotonic() - t0:.1f}s")
    if args.radio and args.debug and not args.dry_run:
        tail_debug(args.seconds)
    return 0


if __name__ == "__main__":
    sys.exit(main())

Checklist for a new project

  1. Install the Ethos extension and the Python extension; pip install hidapi pyserial.
  2. Copy ethos_deploy.py to tools/ and the four files from examples/tooling/vscode/ (plus noop.py) to .vscode/. Replace mywidget with your folder name.
  3. Run Ethos: Set Simulator once (picks board, protocol and release, then downloads the build).
  4. Add simulators/ to .gitignore.
  5. Pick Deploy & launch [simulator] in Run and Debug and press F5.
  6. Plug in a radio, choose Deploy [radio], press F5.