This repository contains scripts used to build a customised EV3 image containing Sling, Sinter, and Pynter (Python support).
If you want to know exactly how the build process works, read the GitHub workflow.
In a nutshell:
build_control_panel.sh: Cross-compiles theservice_controlbinary to manage Source-Academy related services and settings directly from the EV3build_sling.sh: Cross-compiles Sling and Sinter for ARM using ev3dev's cross-compilation Docker imageev3dev/debian-stretch-cross, with some additional dependencies added inDockerfile.slingbuild_qrcode.sh: Cross-compiles theshow_qrcodebinary to display the QR code of the device secret on the EV3 screenbuild_uuidtob62.sh: Cross-compiles theuuidtob62CLI utility to represent the device secret in a more compact formatbuild_pynter.sh: Cross-compilespynter-ev3, the Python interpreter binary, by delegating topynter's own self-containeddevices/ev3/build.shbuild_image.sh: Builds the EV3 root filesystem usingimage/Dockerfileandimage/bootstrap.sh, and then uses Brickstrap to build the final image
To run this locally:
- You must be on Linux.
- You need a static build of QEMU configured for user-mode ARM emulation. It must be registered to handle ARM ELF files using
binfmt_misc. - You need libguestfs tools.
- To satisfy the above dependencies:
- On Ubuntu, install
libguestfs-tools qemu-user-static binfmt-support. - On Arch, install
binfmt-qemu-static qemu-user-static-bin libguestfs. (Note, the first two are AUR packages.)
- On Ubuntu, install
brickstrap.sh is vendored at the repo root rather than downloaded fresh from upstream each time - see its own comment for why (a real ext4-feature/e2fsck version-skew fix that a plain re-download would silently drop).
To build the image from the source code, make sure you are the the root of the repository, then run the following commands in order:
./build_control_panel.sh
./build_sling.sh
./build_qrcode.sh
./build_uuidtob62.sh
./build_pynter.sh
./build_image.shThis section exists because bringing up real hardware against this repo for the first time means re-deriving all of the following from scratch, across five separate repositories, with no single place any of it is written down. If you're the next person doing that, start here.
Browser (frontend)
│ student writes Python, hits Run
▼
py-slang (EV3Engine.ts)
│ compiles Python -> PVML bytecode (never interprets it locally for EV3 -
│ the compiled blob is what gets shipped to the device)
▼
Backend (api.sourceacademy.nus.edu.sg or api.stg.*)
│ device pairing / auth: given a device's secret, hands back MQTT
│ connection info + a signed cert/key for that specific device
▼
AWS IoT Core (MQTT broker - one per backend, prod and stg are entirely
│ separate: separate device databases, separate brokers)
│ browser and EV3 both connect here independently; this is the relay,
│ not a direct connection between them
▼
sling (persistent process on the physical EV3, `sling.service`/`sling-python.service`)
│ holds the MQTT connection, receives the bytecode blob, writes it to
│ disk, then fork()+exec()s an interpreter binary as a child process to
│ actually run it, and relays that child's output back over MQTT
▼
sinter_host (Source pipeline) OR pynter-ev3 (Python pipeline)
│ the actual interpreter for the compiled bytecode. Two independent,
│ parallel pipelines with the same shape (see below)
▼
ev3_functions.c (inside pynter's `devices/ev3/`, or sinter's equivalent)
│ the primitives (ev3_motorA(), ev3_colorSensor(), ...) that actually
│ read/write ev3dev's sysfs device files
▼
Physical hardware
This device has always run two completely separate interpreters side by side, each with its own systemd service, its own on-disk secret/identity, and its own registration with the backend - they don't share state or interfere with each other:
| Source-language pipeline | Python pipeline | |
|---|---|---|
| Compiler | js-slang (in the frontend) | py-slang's EV3Engine.ts |
| Bytecode format | SVML | PVML |
| systemd service | sling.service |
sling-python.service |
| Secret/identity dir | /var/lib/sling |
/var/lib/sling-python |
| Interpreter binary | sinter_host (from sling's deps/sinter) |
pynter-ev3 (from pynter) |
sling itself is transport-only and doesn't know or care which language it's relaying bytecode for - it just spawns whatever SINTER_HOST_PATH points at. This is why pynter-ev3 had to independently learn a lesson sinter_host already knew (see below): they're siblings, not the same code, and a fix in one doesn't automatically apply to the other.
sling invokes the interpreter as <binary> --from-sling <program_path> (see sling/linux/src/main.c's begin_run_program), after first setting up a SOCK_DGRAM socketpair and dup2-ing one end onto fd 998 in the child. A socketpair is bidirectional by construction - this is not a one-way pipe. sling's own protocol (sling/common/sling_message.h) defines structured messages both ways, including an input topic for feeding typed user input back into a running program (e.g. Python's input()).
The interpreter uses this same fd to send print()/display() output back to sling, which relays it over MQTT to the browser as a display topic message. sinter_host has always done this correctly. pynter-ev3, however, is built from a generic CLI-testing tool (pynter's runner/src/runner.c) whose print callbacks were plain printf to whatever stdout the process happened to inherit - which, under sling, just vanished, discarded rather than actually being one-directional. This has since been fixed (see "Known repos and pending work" below) by porting sinter_host's exact relay logic into pynter.
Production (sourceacademy.nus.edu.sg, backend api.sourceacademy.nus.edu.sg) and staging (stg.sourceacademy.nus.edu.sg, backend api.stg.sourceacademy.nus.edu.sg) are entirely separate deployments - separate device databases, separate AWS IoT brokers. Pairing a device's secret against one backend does nothing for the other; a device paired only on stg will never show as connected on prod, and vice versa. image/start-sling.sh/image/start-sling-python.sh accept a backend host as an argument for exactly this reason, and each backend now gets its own persistent service (sling.service/sling-stg.service, sling-python.service/sling-python-stg.service) so a device holds a genuinely live connection to both at once, regardless of which frontend a user happens to be testing from.
Each device generates its own secret independently, per pipeline, the first time that pipeline's service ever runs (see image/start-sling.sh/image/start-sling-python.sh):
uuidgen -r > secret # a cryptographically random (v4) UUID
uuidtob62 secret > secret_b62 # re-encoded as base62 - shorter, URL-safe, same entropyNothing about the secret is derived from the device's hardware, and it isn't shared between the two pipelines on the same physical EV3 - the Source secret and the Python secret are two independent random values, generated independently, the first time each pipeline's own service happens to run.
The secret itself is never an MQTT credential. Its only job is as a one-time bootstrapping token against the backend's HTTP API:
- The device calls
GET /v2/devices/<secret>/mqtt_endpointand/client_idover HTTPS. The backend only answers if that secret has actually been claimed - i.e. a user registered it via the frontend's "Add new device" flow, which is the action that actually provisions a real device identity (an AWS IoT "Thing") behind the scenes. An unclaimed secret 404s on these endpoints (this is exactly what a device stuck insling(-python)?(-stg)?.service's restart loop looks like - see "Known repos and pending work" below for the bug this surfaced). - Once claimed, the device also calls
/keyand/cert, which hand back a real X.509 client certificate and private key specific to that Thing. - The actual MQTT connection to AWS IoT Core authenticates via mutual TLS using that certificate - not the secret string. AWS IoT validates the certificate against its own device registry, and that Thing's IoT policy scopes exactly which MQTT topics it may publish/subscribe to, so one device's certificate can't impersonate or snoop on another device's topics.
- The browser goes through the same claim step (it's the one doing the claiming) and receives its own separate, similarly-scoped connection credentials for the same device's topic namespace, letting it publish "run" commands and subscribe to that device's
display/statusoutput.
So the secret's actual role is narrow and short-lived: it's what you type or scan once to prove "I own this physical device" to the backend, which then hands out real, properly-scoped TLS credentials for the actual data channel - the secret itself never touches AWS IoT directly. "Invalidate Bot Token" (in the on-device Source Academy Settings app) works by discarding that claim and generating a fresh random secret in its place - it does not touch or rotate the underlying certificate machinery, it just means the old secret (and whatever it was claimed as) no longer corresponds to anything.
| Fix | Repo | PR |
|---|---|---|
| Boot-reliability fixes (fstab timeout, connman deadlock, udev UDC tagging, ext4/e2fsck version-skew) + dual prod/stg connectivity + CI Python build | ev3-source |
#21 |
| EV3 Python conductor UI, device pairing flow, REPL support during remote execution | frontend |
#4025 |
Python -> PVML compiler, ev3_* stdlib bindings |
py-slang |
#461 |
runner's --from-sling argv parsing + print() output relay over the sling IPC channel |
pynter |
#41 |
If you're reading this after some of these have merged, treat the PR links as historical context for why the surrounding code looks the way it does, not as an up-to-date "still pending" list.