Wire Protocol¶
From 0.3, AmiPilotServer can carry its whole command set over a byte
stream as well as ARexx — same verbs, same arguments, same @name
manifest locators — so a host machine can drive Amiga GUIs. Two
transports carry it: serial.device (0.3) and, from 0.4, TCP via
bsdsocket.library. This page is the practical guide; the formal
contract is
server/WIRE.md
in the repository.
Starting it¶
> Run AmiPilotServer SERIAL
> Run AmiPilotServer TCP TCPPORT=6800
> Run AmiPilotServer SERIAL TCP TCPPORT=6800
> Run AmiPilotServer SERIAL FSROOT=Work:amipilot-staging
SERIAL and TCP are independent — enable either or both. Options
(ReadArgs template SERIAL/S,SERDEVICE/K,SERUNIT/K/N,BAUD/K/N,TCP/S,
TCPPORT/K/N,FSROOT/K/M,TCPALLOW/K,TCPPASSWORD/K):
| Argument | Default | Meaning |
|---|---|---|
SERIAL |
off | Enable the serial.device transport (the ARexx port is always on) |
SERDEVICE |
serial.device |
Device driver — name a multi-port card's driver here |
SERUNIT |
0 |
Device unit |
BAUD |
19200 |
Line rate; both ends must agree. 19200 is a safe floor for a plain 68000; faster CPUs handle more |
TCP |
off | Enable the TCP transport (bsdsocket.library) |
TCPPORT |
(required with TCP) |
Listen port |
FSROOT |
off (file API disabled) | Grants a directory to the file API (see File API below). Repeatable — FSROOT=Work:a FSROOT=Work:b grants both. The directory must already exist; it's locked once at startup and held for the server's whole run. |
TCPALLOW |
off (every source accepted) | A source-IP/CIDR allowlist for TCP, comma-separated for multiple entries in this one value (TCPALLOW=192.168.1.0/24,10.0.0.5) — not repeatable like FSROOT, see Securing TCP for why. |
TCPPASSWORD |
amipilot (public default) |
The password the AUTH verb checks, TCP only. See Securing TCP — this is not real security on its own. |
The serial line is 8N1 with xon/xoff disabled (responses are binary-safe; software flow control would corrupt them). TCP is listen-mode only today — the server binds and listens, the host connects in, and only one connection is treated as active at a time (a new one replaces the old). If a requested transport can't be opened, the server exits with an error rather than silently running without it.
Under an emulator you usually don't need a real cable or a network
bridge for the serial transport: Copperline's [serial] mode = "tcp"
(or --serial tcp) bridges the guest's serial port to a host TCP
socket (default 127.0.0.1:1234), which is exactly what the host test
harness uses. Reaching the TCP transport instead needs the guest's
own network reachable from the host — Copperline's [hostsocket]
board bridged to a real or virtual adapter, or a real Amiga with
TCP/IP on the LAN.
Connecting from a real serial port (host side)¶
The Copperline-bridge path above is convenience, not the only option
— host/amipilot can also connect directly over a real (or virtual)
serial port on the host machine, no TCP bridge involved at all. This
is what you need for real Amiga hardware over a real cable, or a
Copperline config that itself uses a real serial device instead of
[serial] mode = "tcp". Requires the optional pyserial dependency
(pip install amipilot[serial] once published on PyPI, or
pip install -e 'host/[serial]' from a checkout) — everything else
about amipilot works with no pyserial installed at all.
from amipilot import Amipilot
client = Amipilot.connect_serial("/dev/tty.usbserial-1420", 19200)
device is OS-specific — /dev/tty.usbserial-* on macOS,
/dev/ttyUSB0//dev/ttyS0 on Linux, COM3 on Windows. baud must
match whatever AmiPilotServer SERIAL was actually started with on
the Amiga side — BAUD in the table above, default 19200 on both
ends. This is genuinely the same wire, the same verbs, the same RC
semantics as the TCP path; only the transport differs.
Under pytest, the amipilot fixture (see
Building and Testing) takes the same
config via --amipilot-serial-device/--amipilot-serial-baud (or the
amipilot_serial_device ini setting) instead of --amipilot-config —
whichever one is set is what the fixture connects with; setting both
is a configuration mistake and fails immediately rather than silently
picking one. Unlike the Copperline path, this mode doesn't boot or
manage any process itself — whatever's on the other end of the cable
must already be running AmiPilotServer SERIAL before the test
session starts.
Talking to it¶
Send one command per line (LF-terminated; CRLF is fine). Every command gets exactly one response:
RC <code> <byte-count>
followed by exactly <byte-count> bytes of payload. The codes are the
same as the ARexx port's RC values: 0 OK, 5 warning (nothing
matched), 10 error (bad command), 15 timeout (an awaited condition
or payload — WAITFOR, CLICK ... EXPECT=, FSPUT — never arrived
within TIMEOUT=), 20 failure (the action didn't deliver). Parse
payloads by the byte count, never by scanning for delimiters — a
TREE payload contains newlines.
A quick manual session over the Copperline bridge:
$ nc 127.0.0.1 1234
VERSION
RC 0 217
AMIPILOT 1.0 PROTOCOL 1
STABLE VERSION TREE CLICK TYPE GETTEXT MANIFEST LAUNCH FSLIST FSSTAT FSMKDIR FSDELETE FSGET FSPUT WBLAUNCH MENU MENUPICK DRAG WINDOWMOVE WINDOWSIZE WAITFOR SCREENS SCREENSHOT AUTH MUIREXX WHERE PICK QUIT
GETTEXT GadTools 2
RC 0 10
aminet.net
VERSION is the handshake: a client should send it first and check the
PROTOCOL number before anything else. It works over ARexx too, for
on-Amiga feature tests.
The host client¶
The repository's host/ Python package speaks this protocol
(amipilot.wire.WireClient) — connect, handshake, command:
from amipilot.wire import WireClient
client = WireClient.connect("127.0.0.1", 1234)
info = client.handshake() # checks PROTOCOL 1
client.command("TYPE GadTools 2 aminet.net")
reply = client.command("GETTEXT GadTools 2")
assert reply.text == "aminet.net"
The commands themselves are documented in the
ARexx Reference — the wire adds no verbs of its
own beyond VERSION.
The object API and amipilot dump¶
Most scripts won't touch WireClient directly — amipilot.client
wraps it in a Pythonic API that raises exceptions on non-OK replies
instead of requiring you to check RC codes by hand:
from amipilot import Amipilot, NotFound
with Amipilot.connect("127.0.0.1", 1234) as client:
client.type("GadTools", 2, "aminet.net")
window = client.tree("GadTools") # -> Window, gadgets parsed
try:
client.click("GadTools", 99)
except NotFound:
...
amipilot dump "<window>" (installed via pip install -e host/) is the
host-side half of the inspector:
connects, fetches a window's tree, and prints it — either the same text
AmiInspect prints (--format text, the default) or quirk-profile-ready
# name = <id> suggestions (--format python) to copy into a test.
LAUNCH¶
From 0.4, a connected session can start its own test subject instead of
requiring it pre-staged (e.g. via S:User-Startup):
from amipilot import Amipilot, NotFound
with Amipilot.connect("127.0.0.1", 1234) as client:
try:
client.tree("GadTools")
raise AssertionError("should not be running yet")
except NotFound:
pass
client.launch("SRC:build/fixtures/GTApp", stack=8192)
# LAUNCH is asynchronous -- poll for the effect, don't assume timing.
import time
for _ in range(20):
try:
window = client.tree("GadTools")
break
except NotFound:
time.sleep(0.5)
stack sets the new process's stack size in bytes (AmigaDOS's own
default is 4000 if omitted — most Intuition/ReAction GUI apps need
more). On the wire this is LAUNCH [STACK=<n>] <command-line...>; the
command line is Shell syntax, sent verbatim, and must not itself
contain a line terminator.
Read this before relying on RC 0: the launch is asynchronous
(SystemTagList(), SYS_Asynch) so AmiPilotServer keeps servicing the
connection while the launched process runs — necessary for anything
that doesn't exit on its own, like a GUI app. That means RC 0 only
confirms the new AmigaDOS process could be created (no memory
exhaustion, a free process slot) — not that the command was found
or that it ran successfully; the shell resolves the command name after
LAUNCH's own reply has already gone out, and there's no output
capture yet to see that failure. Always assert on the expected effect
(a window appearing, as above) rather than trusting the RC alone. A
proc-wait verb for real exit-code retrieval is planned but not built
yet — see server/README.md.
WBLAUNCH¶
From 1.0, a connected session can also start a target the way a user
actually would — double-clicking its icon — instead of launch()'s
Shell-style start:
from amipilot import Amipilot, CommandError
with Amipilot.connect("127.0.0.1", 1234) as client:
client.wb_launch("SRC:build/fixtures/WBApp")
client.wb_launch(
"SRC:build/fixtures/WBApp",
tooltypes={"PORT": "7777"},
args=["Work:data/one.txt"],
)
try:
client.wb_launch("SRC:build/fixtures/NoSuchApp")
except CommandError:
pass # RC 10, bad icon path
This is a genuinely different mechanism from launch(), not a
cosmetic variant: it hand-builds a real WBStartup/WBArg message and
sends it to a real non-CLI CreateNewProc() process, the exact
handshake a Workbench-aware program's own startup code (libnix's
_WBenchMsg, or any compiler's equivalent) waits for — so tooltype
parsing and WBStartup argument handling are code paths the launched
program actually exercises, which launch()'s Shell start never
touches at all.
icon_path is the tool or project icon's path without the
".info" suffix (icon.library's own convention) — wb_launch() reads
the real icon, and for a project icon resolves its own default tool
automatically (the project itself becomes a second argument, matching
what double-clicking a project icon actually does).
tooltypes, if given, overrides (or adds, if not already present)
just the named tooltypes for this one launch — everything else on the
real icon is left alone. This needs a real, if surprising, mechanism:
there is no in-memory channel to hand a tooltype override to an
off-the-shelf binary, since the launched program discovers its own
tooltypes by reading its own icon file back off disk. wb_launch()
instead writes a scratch copy of the icon (merged tooltypes) to
T: — never touching the app's real .info — and points the launch
at that instead; it's cleaned up automatically once the launched
process exits.
args, if given, are additional fully-qualified paths passed as
further project-file arguments (the "multiple files selected, one is a
tool" case double-clicking with extended-select icons produces).
Same async honesty as launch(): this raises CommandError as soon
as the icon/path itself is rejected (bad icon, unsupported icon type,
an unlockable ARG=/TOOLTYPE=-scratch path), ActionFailed if
CreateNewProc() itself fails (out of memory, no process slot) — but
neither confirms the launched program actually finished starting;
assert on its expected effect, same as launch().
Unlike FSPUT, WBLAUNCH carries no binary wire payload, so — unlike
that verb — it's fully answerable over ARexx too; there's no wire-only
asymmetry here.
SCREENSHOT¶
From 1.0, a connected session can capture a screen's raw pixels for human viewing/debugging/documentation — [GitHub issue
41](https://github.com/sidick/amipilot/issues/41). This is¶
inspector tooling, not a locator mechanism: click()/type()/
get_text() stay structural/semantic, this doesn't change that.
from amipilot import Amipilot, NotFound
with Amipilot.connect("127.0.0.1", 1234) as client:
shot = client.screenshot() # frontmost screen
ilbm_path, png_path = shot.save("/tmp/capture") # writes both
window_shot = client.screenshot(window="GadTools")
print(window_shot.crop) # (x, y, w, h) within its owning screen
try:
client.screenshot(screen="NoSuchScreen")
except NotFound:
pass
Same "wire stays simple, host does the rendering" split this project
already uses for TREE/amipilot dump (no JSON on the wire, ever):
the Amiga side sends raw, uncompressed pixel bytes plus a small
header — see server/include/screenshot.h's own header comment for
the exact byte layout. All image-format and colour-space work
happens host-side, amipilot.screenshot, stdlib only (zlib, no
Pillow):
shot.to_ilbm()— a real IFFFORM ILBM(BMHD/CMAP/CAMG/BODY). For a planar capture, a near-direct copy since that's ILBM's own native shape; for a Picasso96 CLUT (palette) capture, a re-plane into a standard 8-bitplane/256-colour ILBM. Raises for a P96 truecolor/hicolor capture — ILBM is fundamentally planar and has no standard way to carry 15-32 bits of true colour; useto_png()orto_rgb888()instead. Viewable with Multiview or any Amiga paint program, and preserves the exact view mode the capture came from.shot.to_png()— indexed-colour (palette) for anything with a palette (planar or P96 CLUT), true-colour (24-bit RGB, no palette) for a P96 truecolor/hicolor capture. Easier for modern tooling (browsers, CI artifact viewers, image diffing) to work with than ILBM, at the cost of real per-pixel unpacking/decoding work host-side — cheap there, which is the whole point of doing it host-side rather than on the 68000.shot.save(path)writes both<path>.iffand<path>.pngin one call (raises fromto_ilbm()for a P96 truecolor/hicolor capture — callto_png()directly for those).
Picasso96/RTG support is real, optional, and never required —
GitHub issue #44. A
Picasso96/CGX screen's BitMap still has Planes[]/BytesPerRow/
Depth fields, but they're not real chip-mem bitplanes; walking them
risks reading garbage pointers, the same risk class as the
WBPattern/GTYP_CUSTOMGADGET hang found and fixed in issue #36. An
earlier attempt to guard against this (BitMap->Flags & BMF_STANDARD)
turned out to be simply wrong, not just imperfect — live testing
against a real, completely ordinary Copperline Workbench screen showed
that flag is never set on Intuition's own screen bitmaps regardless of
whether they're planar. The REAL, verified check, from
Picasso96API.library's own published SDK interface data (not
redistributed here — see server/include/p96_compat.h's own header
comment): p96GetBitMapAttr(bm, P96BMA_ISP96), safe to call
unlocked on any bitmap. The library is opened OPTIONALLY at server
startup — absent, or the target screen isn't genuinely P96-backed,
and the classic planar capture runs completely unchanged. When it IS
a genuine P96 screen, its native pixel format (CLUT, or one of several
truecolor/hicolor RGB byte orders — YUV formats aren't supported, the
SDK's own docs mark them hardware-only) is sent raw over the wire and
decoded host-side, including the documented PC-suffix byte-swap
pitfall (16-bit PC formats are little-endian, non-PC big-endian —
never assumed uniform). The P96-active capture path has been verified
against real Picasso96 3.6 + uaegfx under Amiberry (both a CLUT and
a truecolor screen, each decoding back correctly), and — since
Copperline 0.15 added its own RTG support — is now a real, automated
make test-target regression check too (run_screenshot_p96_check),
skip-safe on machines without an RTG board configured; see
server/README.md's own SCREENSHOT section for the fuller story.
With both screen/window omitted, screenshot() captures the
frontmost/default public screen; screen alone selects one by
DefaultTitle substring; window (optionally narrowed by screen,
resolved exactly like click()'s own window pattern) captures that
window's OWNING SCREEN in full, with the window's rectangle on
shot.crop — there is no separate per-window pixel buffer to grab on
classic Intuition (overlapping windows share one screen bitmap), so
that's what a "window screenshot" actually is on any windowing system.
Palette precision is 4-bit-per-gun (12-bit RGB, expanded to 8-bit-per-
channel for the wire/PNG/ILBM) — the real, V33-era-safe way to read a
ColorMap (GetRGB4(), not ColorMap->ColorTable directly, which is
an opaque APTR in the real struct, not the plain array it might look
like). A real AGA 8-bit-per-gun capture would need GetRGB32() (V39+,
above this project's V37 floor) — not attempted here.
Serial transfer time, no compression on the wire (8N1, byte rate ≈ baud/10):
| Capture (typical example) | Size | 9600 baud | 19200 baud (default) | 38400 baud | 57600 baud |
|---|---|---|---|---|---|
| 320x256, 16 colours (4 planes) | ~41 KB | ~43 s | ~21 s | ~11 s | ~7 s |
| 320x256, 32 colours (5 planes) | ~50 KB | ~53 s | ~27 s | ~13 s | ~9 s |
| 640x512 interlaced, 256 colours (8 planes, AGA) | ~320 KB | ~5.7 min | ~2.9 min | ~1.4 min | ~57 s |
Server's own size cap (AMIP_SCREENSHOT_MAX_BYTES) |
512 KB | ~9.1 min | ~4.6 min | ~2.3 min | ~1.5 min |
Serial is genuinely slow for anything beyond a small/low-colour
screen — prefer TCP for SCREENSHOT, which isn't baud-limited at
all. These numbers exist so a serial-only setup (real hardware without
a network card, or a Copperline config without --serial tcp) knows
what to expect rather than guessing why a capture appears to hang.
Since a capture can genuinely take anywhere from seconds to several
minutes with the client otherwise giving zero feedback, screenshot()
(and fs_get(), below) accept an optional on_progress(bytes_so_far,
total_bytes) callback, invoked as the payload streams in —
amipilot.stderr_progress() is a ready-made callback for the common
"print a progress line" case:
from amipilot import stderr_progress
shot = client.screenshot(on_progress=stderr_progress("screenshot"))
# stderr: "screenshot: 51200/524288 bytes (9%)" ... updated in place ...
Pass your own callback instead for a GUI progress bar or structured logging; omit it entirely for today's exact zero-overhead behavior.
File API¶
From 0.4, a connected session can list/stat/create/delete files and
read one back, scoped to directories explicitly granted at startup via
one or more FSROOT options (above) — nothing is granted implicitly,
and there is no way to grant a root once the server is running:
from amipilot import Amipilot, CommandError, NotFound
with Amipilot.connect("127.0.0.1", 1234) as client:
for entry in client.fs_list("Work:amipilot-staging"):
print(entry.name, "dir" if entry.is_dir else "file", entry.size)
data = client.fs_get("Work:amipilot-staging/results.log")
client.fs_mkdir("Work:amipilot-staging/run3")
client.fs_delete("Work:amipilot-staging/run3")
try:
client.fs_list("SYS:") # outside every granted root
except CommandError:
pass # RC 10, expected
fs_stat() returns the same FsEntry shape as one fs_list() row —
name, is_dir, size, prot (the classic four-character rwed
string, inverted-bit gotcha already handled), date, comment — for
a single path without listing a whole directory.
Containment is checked by lock identity, not string matching —
Lock()/ParentDir()/SameLock(), walking up from the target to see
whether it lands on a granted root. Amiga assigns mean two different
path strings can name the same or a nested location, so a prefix
check would be both wrong (missing genuine matches) and unsafe
(missing genuine escapes). A path outside every granted root raises
CommandError (RC 10) naming what is granted; a genuinely missing
path raises NotFound (RC 5).
fs_get() returns the file's exact bytes (Reply.payload, not the
NUL-terminated .text other verbs use — a file's contents may
legitimately contain embedded NULs, and the wire's length-prefixed
framing carries them intact). The server caps this at its own small
internal buffer (server/src/fs.c's AMIP_FS_BUF_SIZE, currently
16KB) and raises ActionFailed (RC 20) for anything larger — this
is a test-staging channel for small fixtures, config, and log files,
not a general file transfer mechanism.
fs_put(path, data, *, timeout=30.0) writes a file host-to-Amiga,
creating it if it doesn't exist and overwriting it if it does:
client.fs_put("Work:amipilot-staging/results.log", b"hello\x00world")
assert client.fs_get("Work:amipilot-staging/results.log") == b"hello\x00world"
This is the wire's first request to carry a raw binary body: on the
wire it's FSPUT <path> <byte-count> [TIMEOUT=<n>], the request-line
declaring how many raw bytes immediately follow it (no delimiter, no
escaping — the same length-prefixed idea the response side already
uses, just inverted). fs_put() is wire-only — there is no ARexx
equivalent at all, because RexxMsg/ARG0() only ever carries string
arguments, so there's genuinely no channel to receive a binary payload
over that transport. Same allowlist/containment rules and
AMIP_FS_BUF_SIZE cap as fs_get(). timeout (default 30s, longer
than most other waits here — a real multi-KB payload over a slow
serial link needs it) bounds how long the server waits for the
declared payload to fully arrive after the request line; if it
doesn't, fs_put() raises Timeout (RC 15), distinct from
ActionFailed (RC 20) on a write that fails after a complete
payload arrived (e.g. disk full).
Menus¶
From 0.4, a connected session can read a window's menu strip and select an item by its keyboard shortcut:
from amipilot import ActionFailed, Amipilot
with Amipilot.connect("127.0.0.1", 1234) as client:
strip = client.menu("GadTools")
project = strip.menus[0]
print(project.title, [i.text for i in project.items])
about = strip.find("About") # look up by label instead
client.menu_pick("GadTools", about.menu_num, about.item_num)
try:
client.menu_pick("GadTools", 0, 2) # a disabled item, say
except ActionFailed:
pass # RC 20, expected
menu() returns a MenuStrip: one Menu per pulldown title, each
with a list of MenuItems and — one level deep, matching classic
Intuition's own limit — their submenu items. Each MenuItem carries
text, checkit/checked (for a checkmark toggle item),
enabled, shortcut (the single keyboard character, or None), and
menu_num/item_num/sub_num — the same 0-based chain positions
Intuition itself reports via IDCMP_MENUPICK's MENUNUM()/
ITEMNUM()/SUBNUM() macros. MenuStrip.find("some label") looks up
an item by its text instead of hand-counting positions.
menu_pick() chooses the pick mechanism automatically, per item.
For an item with a keyboard shortcut, it activates the window, then
strikes the shortcut character with the right-Amiga qualifier held —
the same input.device path a human pressing Right-Amiga+key produces;
Intuition resolves that combination against the window's own live
menu strip, so RC 0 is real evidence the pick reached the app
through the genuine menu-shortcut path. For an item with no
shortcut, it instead drives a genuine synthesized RMB-down, moves onto
the target menu's own title, moves onto the item (auto-opening its
one-level submenu if it has one, purely as Intuition's own reaction to
pointer position), moves into the submenu if picking a sub-item, then
releases (RMB-up) over the target — exactly what Intuition turns into
a real IDCMP_MENUPICK, the same "genuinely synthesized input, not a
shortcut" path click()/drag() already use. Every box this needs is
resolved live off the window's own current menu structure immediately
before each move, never cached — menu layout depends on the user's own
screen/menu font. Raises ActionFailed (RC 20) if the item is
disabled, or — pointer path only — if the window traps the right mouse
button (WFLG_RMBTRAP, a real Intuition window flag some apps set):
no synthesized RMB-down can ever open that window's menu strip, a
permanent limit for that window rather than a transient failure. See
server/README.md for the full mechanism, including the (largely
undocumented-by-Commodore) submenu geometry this was measured against
a real screenshot to confirm.
Screens¶
From 0.4, a connected session can see every open screen and target a specific one when a window-title pattern is ambiguous:
from amipilot import Amipilot
with Amipilot.connect("127.0.0.1", 1234) as client:
for screen in client.screens():
print(screen.title, screen.width, screen.height, screen.frontmost)
# Two windows both matching "GadTools" on different screens --
# narrow the search to a specific one:
window = client.tree("GadTools", screen="Second Screen")
# Waiting for an asynchronously launched program's own screen to
# appear, instead of a fixed sleep:
client.launch("SRC:build/fixtures/SecondScreenApp")
screen = client.wait_for_screen("Second Screen", timeout=20.0)
window = client.wait_for_window("GadTools", screen=screen.title)
screens() returns every screen's title (its DefaultTitle — the
app's own stable name for the screen, set once at open time — not
the live title-bar text, which tracks whichever window is currently
active on that screen via that window's own WA_ScreenTitle and so
isn't a safe identity to match against), position, size, and whether
it's frontmost.
tree(), click(), type(), get_text(), menu(), and
menu_pick() all accept an optional screen= keyword that narrows
the window search to screens whose title contains it — this is purely
for disambiguating two same-titled windows on different screens;
window-finding already searched every screen before this existed, it
just couldn't tell two matches apart. TREE/MENU's own payload
gains a screen="..." field on the window line for the same reason
(Window.screen/MenuStrip.screen on the host side), so you can
confirm which screen a match actually landed on.
Acting on a window brings its screen forward automatically.
click(), type(), and menu_pick() already call ScreenToFront()
on the target window's own screen before injecting anything — this
predates SCREEN=/SCREENS, and there's no separate "bring this
screen to front" verb because none is needed. Read-only calls
(tree(), get_text(), menu()) deliberately leave screen order
alone.
wait_for_window()/wait_for_screen() poll tree()/screens()
until the target appears or timeout elapses (TimeoutError on
expiry) — a host-side poll loop, useful when there's no accompanying
action to anchor a wait to (e.g. waiting for an externally-launched
process's window). For anything tied to an action you just took, see
WAITFOR below and click()'s own expect= parameter instead —
these poll server-side, in one wire round trip, not from the host.
WAITFOR¶
From 0.5, a connected session can wait for a condition to become true
without a host-side poll loop at all: WAITFOR [SCREEN=<substring>]
<condition> [TIMEOUT=<n>] blocks entirely server-side (one wire round
trip) until condition becomes true or TIMEOUT= (default 10s)
elapses.
from amipilot import Amipilot, Timeout
with Amipilot.connect("127.0.0.1", 1234) as client:
client.wait_for("window:Settings") # a matching window appears
client.wait_for("nowindow:Settings") # no window matches (fresh re-search each poll)
try:
client.wait_for("window:NeverOpens", timeout=2.0)
except Timeout:
pass # RC 15
click() also takes its own expect= parameter ("window:<pattern>"
or "nowindow", plus timeout=) to compose an action atomically with
a wait for its effect — expect="nowindow" there is checked by
POINTER IDENTITY against the exact window click() itself just acted
on, not a fresh pattern re-search, which is the more precise "did the
window I just clicked actually close" guarantee a standalone
wait_for("nowindow:...") can't give (a different, same-titled window
could satisfy a pattern re-search without the original ever closing).
wait_for_text()/wait_for_text_by_role()/wait_for_text_by_name()
cover waiting on a GADGET's text/state instead of a window — a
different condition shape (needs a gadget locator) wait_for()'s
plain condition string doesn't carry.
wait_for("requester") waits for a genuine Intuition Requester to
appear (GitHub issue #52's original "cheap first step"). No pattern
argument, and window-attached Requesters only — a system-wide one
with no owning window (a disk-swap prompt, say) is a stated, harder
problem left open. See userdocs/ARexx-Reference.md's own WAITFOR
section for the full detection story, including two genuine surprises
found building it: neither window->FirstRequest nor the
GTYP_REQGADGET bit BuildSysRequest()'s own 1990s autodoc documents
turned out to be set by AutoRequest()/BuildSysRequest()/
EasyRequest() on this project's target OS/ROM when given a real
owning window — detection instead relies on the confirmed-live fact
that these calls open a genuinely separate window sharing their
owner's exact title text.
Once detected, a window-owned requester's own gadgets need no new
client method at all: since it's a genuinely separate window sharing
its owner's exact title, client.click(window_pattern, gadget_id=1)
(the RKRM-documented, fixed GadgetID for the positive/"Yes" choice —
0 for negative/"No") already reaches it through the ordinary click()
path. Confirmed live in tests/copperline/requester-test.py.
System-wide requesters (no owning window, e.g. a real disk-swap
prompt via BuildSysRequest(NULL, ...)) are detectable AND
click-able too now: confirmed live to produce a window titled EXACTLY
"System Request" (Intuition's own documented default), which
wait_for("requester") now matches, and which client.click("System
Request", gadget_id=1) reaches through the exact same mechanism as
the window-owned case.
get_text() cannot read a requester's own body text -- confirmed as a
permanent limit (2026-08-09): struct Requester->ReqText would be the
obvious field, but a live dump of every open window's FirstRequest
while a real AutoRequest() was up showed it NULL on both the owning
window and the requester's own separate window -- no struct
Requester exists anywhere reachable here for this OS/ROM's
AutoRequest(), so there is no field for get_text() to query.
This IS a genuine server-side blocking wait, not a host-side
workaround: AmiPilotServer's dispatch is single-threaded, so while
one WAITFOR/CLICK ... EXPECT= call is polling server-side, no
other request on that connection is serviced until it resolves — by
design, since TCP already only serves one active client connection at
a time (server/README.md's own TCP section). Keep TIMEOUT= values
reasonable for this reason.
Window move and resize¶
From phase 1.0, a connected session can move or resize a whole window
via genuine input-device drags — the same synthesized press/move/
release primitive drag() already uses for gadgets, just anchored on
the window's own title bar or sizing gadget instead of a gadget's:
from amipilot import Amipilot, ActionFailed
with Amipilot.connect("127.0.0.1", 1234) as client:
before = client.tree("GadTools")
print(before.left, before.top, before.width, before.height)
client.window_move("GadTools", 30, 20) # relative offset
client.window_resize("GadTools", 400, 300) # absolute target size
after = client.tree("GadTools")
print(after.left, after.top, after.width, after.height)
try:
client.window_resize("SomeFixedSizeWindow", 800, 600)
except ActionFailed:
pass # RC 20 -- window has no sizing gadget (WFLG_SIZEGADGET unset)
window_move(pattern, dx, dy) sends WINDOWMOVE [SCREEN=<s>] <pattern>
<dx> <dy>: a real drag of the window's title bar, anchored at its
horizontal center vertically centered in the window's own BorderTop
strip — a documented, honest heuristic (not an attempt to locate the
close/depth/zoom system gadgets' exact pixel extents and dodge them
precisely) that clears them for any window wider than roughly 120px.
Raises ActionFailed if the window has no drag bar at all
(WFLG_DRAGBAR unset).
window_resize(pattern, width, height) sends WINDOWSIZE
[SCREEN=<s>] <pattern> <width> <height>: a real drag of the window's
sizing gadget from its current bottom-right corner to wherever that
corner needs to land to reach the given ABSOLUTE target size. It does
not pre-check width/height against the window's own
min_width/min_height/max_width/max_height — Intuition clamps
the drag exactly as it would a genuine user drag, so confirm the
actual resulting size with a follow-up tree() call rather than
assuming the exact target was reached, the same "verify the real
outcome" precedent drag() already sets. Raises ActionFailed if the
window has no sizing gadget at all (WFLG_SIZEGADGET unset).
Both take an optional screen= keyword, same as tree()/click(),
and both bring the window/screen forward first, same as every other
action call. Neither has a manifest (@name) form — this acts on a
whole window, the same scope tree()/menu() already have, neither
of which takes one either. There's no separate "get window position/
size" call: tree()'s own result already carries left/top/
width/height, so query before or after a move/resize with a
regular tree() call.
PICK¶
The platform's first genuine interactive element-picker equivalent
(issue #65): point at
a gadget, get back its exact locator, no batch tree() dump required.
from amipilot import Amipilot, NotFound
with Amipilot.connect("127.0.0.1", 1234) as client:
result = client.pick() # frontmost screen
print(result.title, result.gadgets) # gadgets: 0 or 1 entry
scoped = client.pick(screen="Second Screen")
try:
client.pick(screen="NoSuchScreen")
except NotFound:
pass
pick() hit-tests the LIVE global pointer position against
screen's windows (the frontmost screen if omitted, same convention
screenshot()'s own screen parameter uses) and returns the Window
containing it. result.gadgets has at most one entry — the gadget
under the pointer, if any; an empty list means the pointer is over
bare window background, not an error (this can still include a real
system gadget, e.g. gadget_id == 0 with class_name ==
"gadgetclass", for the drag bar/close/depth/size decorations —
tree() already reports those the same way). Raises NotFound if no
window on the target screen contains the pointer at all, including
when screen itself doesn't match any open screen.
This is a single point-in-time snapshot, not a live subscription —
call it repeatedly (e.g. in a poll loop) for a "hover and watch it
update" experience. AmiInspect PICK (see AmiInspect
Reference) is the standing-at-the-machine
equivalent, looping locally with no host connection at all.
Built on intuition-model's own coordinate-to-gadget hit test over an
already-walked screen model (AmipHitTest()), reusing the same
role/label classification TREE/AmiInspect already do — not a new
Intuition mechanism. The server corrects for a real, live-confirmed
quirk in the live pointer position it reads back internally (roughly
2x the real pixel Y, present on this project's own default Workbench
screen configuration); this is handled server-side and needs nothing
from the caller — see server/README.md's own PICK section for the
full story, including the two real findings (window z-order and
pointer-Y scaling) building this turned up.
Securing TCP¶
AmiPilot's TCP transport is meant for a trusted LAN or a direct
machine-to-machine link — never expose it on an open/internet-facing
port. This server can run arbitrary shell commands (LAUNCH),
read/write files inside a granted FSROOT, and inject GUI input;
that's real exposure on any network it's reachable from. Two
independent, opt-in, combinable options narrow who can connect and
what they can do without one:
TCPALLOW=<ip-or-cidr>[,<ip-or-cidr>...]— a source-address allowlist, e.g.TCPALLOW=192.168.1.0/24,10.0.0.5. A single value, comma-separated for more than one entry (not repeatable likeFSROOT— AmigaDOS'sReadArgs()only allows one repeatable/Mkeyword per template, andFSROOTalready uses it). With none granted, every source is accepted — today's unchanged default. A rejected connection is closed immediately, with no reply ever sent.TCPPASSWORD=<value>gates a newAUTH <password>verb — TCP only, not ARexx or serial.device. If omitted, defaults to"amipilot", whichAmipilot.connect()/connect_with_retry()already send automatically, so TCP keeps working with zero config changes on either side.
from amipilot import Amipilot, CommandError
# Matches a server started with TCPPASSWORD=correct-horse-battery-staple
try:
client = Amipilot.connect("192.168.1.50", 6800, password="correct-horse-battery-staple")
except CommandError:
... # wrong password
Neither option is real security, and you should say so to anyone
relying on this. The default password is public — it's in this
open-source repository. There's no TLS, so even a custom password
crosses the wire in cleartext, sniffable by anything on the same
network segment. There's no rate-limiting or lockout on repeated
AUTH guesses. TCPALLOW/TCPPASSWORD raise the bar above "wide
open to anyone," the same way a router's default admin password does
— nothing more. AmiPilotServer prints a warning to this effect every
time TCP is enabled, precisely so this isn't something you only find
out by reading documentation.