Remote control & automation (unoautomate)
A debug build of UnoDOS pc64 can open a link to the PC you develop from and stream its logs to you, take commands from you, and exchange messages in either direction - all over the network, with a simple typed command language or a Python API. This is unoautomate: the OS's remote-control and automation channel. It is how you drive, observe, and update a machine that is sitting on a bench across the room.
UNO_DEBUG=1) arms it from DEBUG.CFG with every verb allowed and no code. Either way it is meant for a trusted LAN and speaks in plaintext - the code proves who may drive the box, it does not encrypt the wire - so never expose it to an untrusted network.Production: arming and the access code
On a production boot nothing listens and nothing dials. To hand your PC the keys, open Control Panel → Remote control… and tick what you are willing to grant - the three ticks map to the three powers every command is checked against:
| Tick | Grants |
|---|---|
| Watch | see the screen and system state - logs, probe, screenshots |
| Control | move the mouse, type, open apps |
| Full access | disks, files, run code, restart |
Arming mints a 6-digit code, shown on screen and never stored. The first thing a
client sends is auth <code>; until then every verb answers
err auth-required, and three wrong codes disarm the channel outright - the
operator can re-arm at the console, an attacker on the wire cannot, which is what the code's length
rests on. A verb outside the granted powers answers err denied naming the missing power.
The remote path never prompts (nobody is at the machine to answer), and signing out disarms - a code
can never outlive the login that made it. Grant the least you need: a monitoring script wants Watch and
nothing else.
Turning it on (debug builds)
pc64's network stack makes outbound connections only, so the device dials your PC rather
than the other way round. You tell it where to dial with one line in the stick's DEBUG.CFG
(the Developer-options file the flasher writes) - your PC's LAN address and a port:
remote=192.168.2.43:5099
Boot a debug stick with that key and, once networking is up, it connects to the listener you run on your PC (below). If the link drops it reconnects on its own.
You do not edit DEBUG.CFG by hand. Flash a debug stick with Developer options
turned on in the UnoDOS flasher (that is what selects the debug OS
and writes the file), and set the remote address there. To point an existing debug stick at a different PC,
use the flasher's Reconfigure button: it rewrites DEBUG.CFG in place without
erasing the disk, so you are not reflashing the whole image just to change an IP.
If you would rather not hardcode an address at all, set discover instead of remote=:
the device broadcasts on the LAN and any PC running the listener answers with its address, so the box finds
you automatically. And when the machine has no working network - because the NIC is the very thing you
are debugging - the link can ride a serial cable instead; see
when the only network is the one you are debugging below.
When the only network is the one you are debugging
The link normally rides TCP over the LAN - but that assumes a working NIC, and the hardest bring-up cases are
exactly the ones where the machine's only network card is the one that does not work yet. For that, the
same channel runs over a serial cable instead: no network required. Every command works
identically; only the wire underneath changes. Arm it in DEBUG.CFG with remote-serial
in place of remote=, and point the tool at the serial port:
# on the stick: URC over COM1, or name another port (2f8 = COM2, 3e8 = COM3)
remote-serial
remote-serial=3e8
# on your PC (needs pyserial):
python tools/unoauto_remote.py --serial COM3 # Windows
python tools/unoauto_remote.py --serial /dev/ttyUSB0 # Linux
Remote logging
While the link is up, every line the OS logs - boot, network, storage, UI, script output - streams to
your PC as it happens. Instead of pulling a stick and reading CRASH\NETLOG.TXT after the fact,
you watch the machine think in real time. Nothing in the OS changes to make this happen; the remote channel
simply subscribes to the same log stream the on-disk logs use.
The command language
Either end can send the other a command or a free-form message. The commands your PC can run on the
device are short, human-typable lines - you can even reach them with nc:
| Command | What it does |
|---|---|
probe | A snapshot of what the system is doing: subsystems (heap/net/fs/shell), open windows, loaded apps. |
vols | List storage volumes: index, kind (RAM / native-FAT / firmware), whether it is writable, and its name. |
key / pointer | Inject a keypress or a pointer event, processed exactly like a human's on the next frame. |
screen | Grab the desktop as a compressed image - the video feed behind the UnoRemote remote-desktop client. |
apps / apps list | How many apps there are, and what they are: one id name row each. The set depends on what is installed, so a script that wants to drive one has to be able to look it up. |
launch <id> / rescan / close | Open an app by its id, pick up apps that have appeared in APPS\ since boot, or close the top window. launch still takes a slot number, but a number is this boot's ordering of whatever happens to be installed - install one app and the same number opens a different one, without failing. Use the id. |
py <source> | Run a line of Python on the device and get its output back. |
test <suite> | Run a built-in conformance suite and stream the report. |
uptime / poweroff / reboot | Read the uptime, or shut down / restart the machine. |
put / bootnext | Write a file to a volume and pick the next boot device - the A/B update below. |
guard <secs> / pet / safe | Arm a dead-man's switch before a risky command: if the box stops answering within the timeout, it resets itself and dials back in - the guard below. |
devices | List the machine's PCI devices and which driver claimed each one (or UNCLAIMED) - what hardware is here and what has no driver yet. |
hwwdt <status|arm|pet|disarm> | Read or drive the chipset's PCH TCO hardware watchdog - the guard's last-resort backstop for a wedge that has interrupts disabled. status reports whether a usable one was found; the rest drive it directly. |
iwl / eth | Poke a live network driver's registers - Intel Wi-Fi / Realtek Ethernet - reading and writing registers or retrying its bring-up, with no rebuild. |
disks / arm / prepdisk | List raw disks, arm one for a destructive op (it refuses the boot disk and echoes the target's size), and partition + format it - preparing a disk to install onto. |
mkdir / install | Create a directory on a volume, or clone the running OS onto a prepared disk in one armed step - the headless install below. |
These are the everyday commands; the exhaustive list, with every argument and exact reply, is in
pc64/REMOTE.md.
The tool on your PC
One small script, pc64/tools/unoauto_remote.py, is both a listener you talk to interactively and
a Python library you script against. Run it, and it prints the device's logs as they arrive and lets you type
commands back:
$ python tools/unoauto_remote.py --listen 0.0.0.0:5099
unoauto_remote listening on 0.0.0.0:5099. Set pc64 DEBUG.CFG:
remote=<this-machine-ip>:5099 (QEMU SLIRP guest: 10.0.2.2:5099)
[SCRIPT ] remote: link up
[NET ] eth: DHCP lease 192.168.2.157
probe
2 0 0 4980736 heap
2 3 1 12 shell
1 1 1 0 Files
ok
apps list
control Control Panel
files Files
browser Browser
...
ok
launch browser
launched
ok
py print(6*7)
42
ok
The same thing as a library - drive the machine, read its state, run code on it, and register handlers so the device can drive your PC in return:
from unoauto_remote import UnoAutoLink
link = UnoAutoLink(port=5099)
link.on_log(lambda chan, text: print(chan, text)) # stream the OS log
link.listen()
link.wait_connected() # pc64 dials in
print(link.probe()) # [{'kind':2,'name':'heap',...}, ...]
link.launch("browser") # open an app by its id, never by number
print(link.eval("print(6*7)")) # run Python on the device -> ['42']
# commands can go the other way too: the device can drive your PC
link.on_command("save", lambda args: "saved " + args)
On the device side, an automation script written in Python (see Writing apps) can
talk back over the link with unoauto.remote_send() and unoauto.remote_recv().
Remote desktop (UnoRemote)
When you want to see and drive the machine rather than type commands at it, UnoRemote
is a Windows app that gives you a live remote-desktop view over the same link: the device's screen in a window,
your mouse and keyboard forwarded to it, a log pane, a clickable command bar, a raw-command box and a session
recorder. It is VNC-style - the desktop streams over the screen command and input goes back over
key and pointer - but tuned for UnoDOS's flat desktop: each frame sends only the tiles
that changed since the last one, so the view stays responsive even at full resolution.
- Build it once with
pc64\remote\build-remote.ps1(it producesUnoRemote.exe), run it, set the port (default 5099) and click Listen. - On the device, either point
DEBUG.CFGat your PC withremote=<your-ip>:5099, or just setdiscoverand let the device find your PC on its own (see the tip below); then boot a debug build. The UnoDOS desktop appears in the window. - Click and type on the view to drive the machine. A Scale control (1×–4×) trades resolution for bandwidth on a busy or high-resolution screen.
- The command bar under the log runs the everyday remote actions with a click - list volumes and disks, launch or close an app, check uptime, reboot or power off (these ask first) - while the box beside it still takes any raw URC command line.
Recording. Record saves the session to Videos\UnoRemote\ - an MP4
if ffmpeg is on your PATH, otherwise a folder of PNG frames. Tick on device first to
record on the machine itself: it captures at a steady frame rate independent of the network and UnoRemote
pulls the finished recording when you stop, which is smoother than saving whatever trickled over a slow link.
discover set in DEBUG.CFG (instead of a remote= address) the device broadcasts on the local network, and UnoRemote - or the unoauto_remote.py tool - answers with its own address, so the device connects with nothing configured. Both ends must be on the same LAN.Which end dials - and the Scan button. By default the device dials your
PC: you click Listen and it connects in. That suits a headless box whose address you
do not know - it calls home when its network is up. You can also flip it: put listen (or
listen=<port>) in the device's DEBUG.CFG and the box becomes a
server that waits to be dialed. Then click Scan… in UnoRemote:
it broadcasts on the LAN, lists every box in listen mode by name, and dials the one you pick. That is
the "browse the network and connect to a box" model - you choose the machine, with no address typed on
either end. (Give a box a friendly name with name=<label> so it is easy to spot in
the list.)
DEBUG.CFG on a debug one) and a trusted network.Scripting the OS, with permission (unoscript)
The
uno module is an app's own sandbox: its window, its canvas, its files. unoscript
is the step up from there - a surface for scripting the whole machine. Through import unoscript as u
a script can move the pointer and type, read what is on screen, launch and close apps, see what is running, read and
write the user's files, and - with permission - reach all the way down to memory, ports and power. It is how you
automate the OS, not just write an app inside it. You reach it interactively through the py
command above, or from an on-device automation app.
Every action needs a capability, and capabilities are tiered. A script begins
with only the ambient tier; anything higher it must ask for with u.request("<cap>"), and the
security subsystem decides - drawing the same consent sheet the login screen uses, honouring a role the signed-in
user holds, or, on a developer machine, an auto-grant policy. A denied action raises PermissionError;
a surface a given build does not carry raises NotImplementedError. This is the same gate the
login screen and Accounts manager enforce, so a script can never quietly do more
than the person running it is allowed to.
| Namespace | Tier | What it scripts |
|---|---|---|
u.ui | ambient | Move / click the pointer, press keys, read the on-screen window text, and the shared clipboard. |
u.app | ambient / user | Count, launch and close apps; send an app a message (focus, close, ask its state). |
u.fs | user / admin | Read and write files. A plain name is the user's own home (USERS/<id>/…); an absolute /volume/path reaches elsewhere and needs the higher tier. |
u.proc | admin | List what is running - each open app as a process, with its name and which is focused. |
u.mem / u.io | admin / kernel | Read and write raw memory and I/O ports - a kernel-level debugging surface, always audited. |
u.sys | admin | Power: shut down or restart the machine. |
u.hook | admin | Watch internal events (file writes, module loads) stream past - a debug-build observability tap. |
A short session over the py command, escalating as it goes:
# ambient - no permission needed: read the screen, drive the pointer
py import unoscript as u; print(u.ui.screen())
py import unoscript as u; u.ui.click(200, 160)
# the user's own files - u.fs.read/write ask for the 'fs.user' capability first
py import unoscript as u; u.request("fs.user"); u.fs.write("notes.txt", b"hello")
py import unoscript as u; u.request("fs.user"); print(u.fs.read("notes.txt"))
# 'what is running' is an admin surface - unescalated it is refused
py import unoscript as u; print(u.proc.list()) # -> PermissionError
py import unoscript as u; u.request("proc.enum"); print(u.proc.list())
unoscript surface itself is production - a trusted, signed automation app can use it on a normal machine, always under the permission gate. Only u.hook is debug-only (a production tap on hot internal events would cost every machine that ships), and the deep u.mem/u.io surfaces are kernel-tier: strongest escalation, every use audited. The full capability list and tiers are in UNOSCRIPT.md.Automation apps: caps without prompts (signed manifests)
Asking for a capability with u.request() works for a script you are driving - the
system can draw a consent sheet and you click Allow. But an automation app runs with nobody
watching, so there is no one to answer the prompt. A trusted one instead ships a signed manifest:
a small <app>.MFT file next to its .UNO that declares the caps it needs, signed by a
key the machine trusts. At launch UnoDOS verifies the signature and grants exactly those caps for the life of the
app - no prompts - and drops them again the moment it closes.
# once: make a signing key and the line that enrolls it on the machine
python tools/uno_manifest.py keygen --key-id acme > acme.line
# -> prints "acme <64 hex>" ; append that line to \TRUST.MFK on the boot disk
# per app: sign a manifest declaring what it needs, beside the .UNO
python tools/uno_manifest.py sign --name mybot --caps proc.enum,fs.sys \
--key-id acme --secret <64 hex> -o MYBOT.MFT
TRUST.MFK is what says “I trust apps this key signs” - guard it like any signing key.A/B updates: push a new build over the link
The headline use: iterate on the OS itself without touching a USB stick. Run two sticks -
A, the machine you are working on, and B, a spare - and push only a freshly
built kernel (EFI\BOOT\BOOTX64.EFI, about 4 MB) to stick B over the wire, then reboot into
it. A driver change touches only that one file; everything else on the stick is untouched, and stick A stays as
a known-good fallback.
# find which volume is the spare stick B (a writable "kind 2" volume)
python tools/unoauto_remote.py --listen 0.0.0.0:5099 # then type: vols
# push a freshly built kernel to stick B and reboot into it
python tools/unoauto_remote.py \
--push 2 "EFI\BOOT\BOOTX64.EFI" build/BOOTX64.EFI --reboot
The file is streamed in chunks, staged in the device's memory, and written in one step at the end with a size
check - so an interrupted transfer never leaves stick B half-written. Add --bootnext <n> to have
the machine boot the other stick automatically on the next restart, without anyone touching the firmware boot
menu. From the library the same flow is link.push_file(...) then link.reboot().
put and reboot are an arbitrary file write and a reset. In a production OS they sit behind the Full access power; grant it only over your trusted LAN. A single push is capped at 8 MB.Installing UnoDOS onto a disk over the link
The A/B push writes files onto an already-formatted stick. The channel can go further and stand up a
bootable disk from scratch - partition a raw disk, format it, and lay the whole OS down on it - so you can move
UnoDOS off the USB stick and onto an internal drive without ever touching the machine's keyboard. The one-shot
install command does all of it, cloning the running system straight onto the target:
disks # find the writable, non-boot target (say index 1)
arm 1 # echoes the disk's name and size; refuses the boot disk
install 1 # partition + format, then clone the whole OS onto it
reboot # writes are flushed to disk first
Because the copy is disk-to-disk on the device, none of the OS actually crosses the network. From the Python
library the same thing is link.install(1), or link.install_dir(1, "build/esp") to push a
freshly built tree from your PC instead of cloning the running one. The lower-level pieces are there too if you
want them - prepdisk to format, mkdir + put to build the tree by hand.
arm the specific disk, which auto-disarms after one op and always refuses the disk UnoDOS booted from.The guard: recover a wedged box automatically
Some commands push the device into code that has never run before - driving a network card's bring-up by hand is the classic case - and when that goes wrong it can wedge the machine: the remote channel stops answering and normally the only way back is to walk over and power-cycle it. The guard turns that into automatic recovery. Arm it right before the risky command, and if the box cannot answer the channel within your timeout, it resets itself and dials back in on its own.
# arm a 15-second dead-man's switch, then run the risky command
guard 15
<the risky command>
# if the box wedges, ~15s later it resets and reconnects by itself;
# if the command returns fine, stand the guard down:
safe
"Answering the channel" means any command getting through - each one you send pushes the
deadline out, so an ordinary interactive session keeps the guard happy without thinking about it. For a step
that legitimately takes a while, send pet as a keep-alive. From the Python library it is one
context manager that arms on the way in and stands down on the way out:
with link.guarded(15):
link.command("iwl", "rerun") # a wedge here resets the box, which reconnects
safe it afterwards, or use guarded(...), which does that for you. Debug builds only, like the rest of the channel.The hardware backstop
The guard normally resets a wedged box from software - a heartbeat in the main loop, a timer interrupt, or the firmware watchdog. All three need the CPU to still be taking interrupts. The one failure they cannot catch is a tight loop that has turned interrupts off (or a true bus hang): nothing runs, so nothing resets the machine. For that last case UnoDOS can enlist separate silicon that does not care what the CPU is doing - the Intel PCH TCO hardware watchdog. When a usable one is present the guard arms it automatically, a little past the software timeout, so it only ever fires on the wedge software cannot reach. You can also drive it directly to check it is there:
hwwdt status # is a usable TCO present on this chipset? which registers?
hwwdt arm 20 # arm it for ~20s; if nothing pets it, the chipset resets the box
hwwdt disarm
hwwdt status reports honestly: it says present only when it has proven the watchdog can actually fire. Where it cannot (some laptop firmwares lock it), the software guard still covers every wedge except the interrupts-off one. See HWWATCHDOG.md.Setting up a development environment
Putting the pieces together, a comfortable unoautomate workflow needs a one-time setup and then a tight loop:
- Two sticks. Flash two USB sticks with Developer options on:
A, a known-good build you keep as a fallback, and B, the spare you push
new builds to. Set
remote=<your-pc-ip>:5099on both when you flash them (or add it later with the flasher's Reconfigure button - no reflash). - The bench machine boots stick B and, once its network comes up, dials your PC. The
driver box builds attached (
-DUNO_NO_DETACH) so its own USB stick shows up as a writable volume you can push to. - Run the listener on your PC:
python tools/unoauto_remote.py --listen 0.0.0.0:5099. Its logs start streaming the moment the device connects. - Edit, build, push, reboot. Change a driver, rebuild
BOOTX64.EFI, push it to stick B, and reboot into it - seconds, no walking to the bench. Stick A is always there if a build will not boot.
The whole loop scripts cleanly from the Python library, so you can wrap it in one keystroke:
from unoauto_remote import UnoAutoLink
link = UnoAutoLink(port=5099)
link.on_log(lambda chan, text: print(f"[{chan}] {text}")) # watch the OS think
link.listen(); link.wait_connected() # stick B dials in
while True: # your edit / build / test loop
input("built a new BOOTX64.EFI? press enter to push it > ")
print(link.command("probe")) # inspect the live system...
link.push_file(2, r"EFI\BOOT\BOOTX64.EFI", "build/BOOTX64.EFI")
link.reboot() # ...then boot the new build
link.wait_connected() # it dials back in
What you can do with it
Driver development
This is the case unoautomate was built for. Bringing up a driver for real hardware - a NIC, a Wi-Fi chip, a storage controller - is a cycle of "change one thing, watch what the hardware does, change it again", and without a channel each turn means pulling a stick, reflashing it, walking it to the bench, booting, and reading a log file after the fact. unoautomate collapses that:
- Watch the bring-up live. Every line the driver logs - each register write, each
firmware handshake, exactly where it stalls - streams to your PC as it happens, instead of being read
from
CRASH\NETLOG.TXTafter a hang. - Reflash in seconds, not minutes. The A/B push writes only the ~4 MB kernel to the spare stick and reboots into it; a driver change touches one file and the round trip is a keystroke.
- Poke the hardware without a rebuild. Run a line of Python on the device with
py/link.eval()to read back state between builds, andprobeto confirm which subsystems actually came up. A debug build is exactly where you want this reach. - Read and write live registers. For a network card,
iwl(Intel Wi-Fi) andeth(Realtek Ethernet) reach straight into the running driver - peek and poke registers, retry the bring-up sequence - so you can try a fix in seconds and only rebuild once you know it works. Pair it with a guard so a bad poke that hangs the card resets the box instead of stranding it. - See what hardware is actually there.
devicesdumps the machine's whole PCI tree - every function's location, IDs, class and which driver claimed it - so the first question of any bring-up, "what is on this box and what has no driver yet?", is answered on-device instead of guessed from a spec sheet. The same registry is a Python call away withuno.pci()for a script to filter.
The Intel Wi-Fi bring-up is the working example: the driver is iterated this way, its firmware-load sequence streamed back register by register while builds are pushed to the bench machine over the link.
Automated conformance testing
A debug OS carries built-in conformance suites (storage, system,
network, frameworks, apps). Run one from your PC and stream its report,
then assert on a live probe() - a real regression test you can put in CI or run after every push:
link.wait_connected()
report = link.test("network") # run one built-in conformance suite
for line in report:
print(line) # e.g. S-NET-10 dhcp lease .......... ok
# probe the live system and assert on it
subs = {r["name"]: r for r in link.probe() if r["kind"] == 2}
assert subs["net"]["state"] == 1, "network subsystem did not come up"
Driving the desktop headlessly
Because key and pointer are injected exactly as a human's input on the next frame,
you can drive the whole desktop from a script: launch an app, type into it, walk a menu, and check the result -
no monitor or keyboard attached. This is how the screenshots in this manual are produced (see
Regenerate the manual screenshots): the harness boots the OS and drives it
entirely over the channel.
Unattended runs and remote triage
Leave a machine running a soak test on the bench, watch its log stream from your desk, and have it
poweroff itself when it finishes or reboot when it wedges. Wrap a run that might hang in a
guard and it recovers on its own - no one has to be in the room to power-cycle it. For an
intermittent bug you no longer reproduce-then-lose-the-evidence: the log is already on your PC when it happens.
The full wire protocol and every command's exact reply are in
pc64/REMOTE.md.