Files
artemis_control/cmd/artemis/main.go
Paperclip Builder 1115531684 feat: Phase 2 act primitives
Add the Artemis Go CLI and its packages:
- internal/adb: timeout-bounded adb wrapper, device list, wireless-debugging
  port scan and auto-connect (never hardcodes the moving port).
- internal/ui: uiautomator dump parsing, find-by-text, tap the centre of a
  text match (FR-4), type, swipe, scroll, back, home, launch, wake.
- cmd/artemis: CLI for the primitives plus 'demo', the Phase 2 DoD run.

Failures are loud: a missing or disabled element errors instead of tapping a
guessed coordinate. The screen is woken before acting, and every adb call has
a deadline.

Also record the section 13 Q4 decision (adb lives in the user profile on .13,
wireless ADB needs no udev rules; service packaging still open until Phase 6),
add unit tests, and document the phase evidence.
2026-10-16 11:36:03 +11:00

356 lines
8.5 KiB
Go

// Command artemis drives an Android phone over ADB wireless.
//
// This is the Phase 2 surface: act primitives on the command line. The Hub's
// HTTP action API arrives in Phase 6 and reuses these packages.
package main
import (
"fmt"
"os"
"strconv"
"time"
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/adb"
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/ui"
)
const usage = `artemis — drive the phone's screen over ADB wireless
usage: artemis <command> [args]
connection
connect [host] connect to the phone (scans ports if host given)
devices list adb devices
wake wake the screen
reading
dump print visible text nodes with bounds
find <text> print matching elements (no tap)
fg print the foreground package
acting
launch <package> launch an app and wait for it to be foreground
tap <text> find element by text, tap its centre (FR-4)
type <text> type into the focused field
swipe <x1> <y1> <x2> <y2> [ms]
scroll <up|down> scroll the current list
back press back
home press home
proof
demo Phase 2 DoD: launch an app, tap a named element
environment
ARTEMIS_PHONE phone tailscale IP (default 100.101.49.17)
ARTEMIS_ADB_SERIAL pin a specific adb serial
ARTEMIS_ADB path to the adb binary (default: PATH)
`
func main() {
if len(os.Args) < 2 {
fmt.Fprint(os.Stderr, usage)
os.Exit(2)
}
cmd := os.Args[1]
args := os.Args[2:]
r := adb.New(os.Getenv("ARTEMIS_ADB_SERIAL"))
var err error
switch cmd {
case "connect":
err = cmdConnect(r, args)
case "devices":
err = cmdDevices(r)
case "wake":
err = cmdWake(r)
case "dump":
err = cmdDump(r)
case "find":
err = cmdFind(r, args)
case "fg":
err = cmdFG(r)
case "launch":
err = cmdLaunch(r, args)
case "tap":
err = cmdTap(r, args)
case "type":
err = cmdType(r, args)
case "swipe":
err = cmdSwipe(r, args)
case "scroll":
err = cmdScroll(r, args)
case "back":
err = ensurePhone(r, func() error { return ui.Back(r) })
case "home":
err = ensurePhone(r, func() error { return ui.Home(r) })
case "demo":
err = cmdDemo(r)
case "-h", "--help", "help":
fmt.Print(usage)
return
default:
err = fmt.Errorf("unknown command %q\n\n%s", cmd, usage)
}
if err != nil {
fmt.Fprintf(os.Stderr, "artemis: %v\n", err)
os.Exit(1)
}
}
func phoneHost() string {
if h := os.Getenv("ARTEMIS_PHONE"); h != "" {
return h
}
return "100.101.49.17"
}
// ensurePhone makes sure we have a live serial before running fn.
func ensurePhone(r *adb.Runner, fn func() error) error {
if r.Serial != "" && r.DeviceOnline(r.Serial) {
return fn()
}
serial, err := r.ConnectAuto(phoneHost())
if err != nil {
return fmt.Errorf("phone is not reachable: %w", err)
}
fmt.Printf("connected to %s\n", serial)
return fn()
}
func cmdConnect(r *adb.Runner, args []string) error {
if len(args) > 0 {
msg, err := r.Connect(args[0])
if msg != "" {
fmt.Println(msg)
}
return err
}
serial, err := r.ConnectAuto(phoneHost())
if err != nil {
return err
}
fmt.Printf("connected to %s\n", serial)
return nil
}
func cmdDevices(r *adb.Runner) error {
devices, err := r.Devices()
if err != nil {
return err
}
if len(devices) == 0 {
fmt.Println("no devices")
return nil
}
for _, d := range devices {
fmt.Printf("%s\t%s\tmodel=%s device=%s\n", d.Serial, d.State, d.Model, d.Device)
}
return nil
}
func cmdWake(r *adb.Runner) error {
return ensurePhone(r, func() error {
if err := ui.Wake(r); err != nil {
return err
}
fmt.Println("screen awake")
return nil
})
}
func cmdDump(r *adb.Runner) error {
return ensurePhone(r, func() error {
if err := ui.Wake(r); err != nil {
return err
}
h, err := ui.Dump(r)
if err != nil {
return err
}
nodes := h.TextNodes()
fmt.Printf("%d text nodes\n", len(nodes))
for _, n := range nodes {
label := n.Text
if label == "" {
label = n.ContentDesc
}
fmt.Printf("[%s] %s bounds=%s\n", shortClass(n.Class), label, n.Bounds)
}
return nil
})
}
func cmdFind(r *adb.Runner, args []string) error {
if len(args) != 1 {
return fmt.Errorf("usage: artemis find <text>")
}
return ensurePhone(r, func() error {
h, err := ui.Dump(r)
if err != nil {
return err
}
matches := h.FindText(args[0], false)
if len(matches) == 0 {
return fmt.Errorf("no element matching %q", args[0])
}
for _, n := range matches {
cx, cy, _ := n.Center()
fmt.Printf("%s centre=(%d,%d)\n", n.Label(), cx, cy)
}
return nil
})
}
func cmdFG(r *adb.Runner) error {
return ensurePhone(r, func() error {
fg, err := ui.ForegroundPackage(r)
if err != nil {
return err
}
fmt.Println(fg)
return nil
})
}
func cmdLaunch(r *adb.Runner, args []string) error {
if len(args) != 1 {
return fmt.Errorf("usage: artemis launch <package>")
}
return ensurePhone(r, func() error {
if err := ui.Wake(r); err != nil {
return err
}
start := time.Now()
if err := ui.Launch(r, args[0]); err != nil {
return err
}
fmt.Printf("launched %s in %s\n", args[0], time.Since(start).Round(time.Millisecond))
return nil
})
}
func cmdTap(r *adb.Runner, args []string) error {
if len(args) != 1 {
return fmt.Errorf("usage: artemis tap <text>")
}
return ensurePhone(r, func() error {
if err := ui.Wake(r); err != nil {
return err
}
node, err := ui.TapText(r, args[0], false)
if err != nil {
return err
}
cx, cy, _ := node.Center()
fmt.Printf("tapped %q at (%d,%d) — %s\n", args[0], cx, cy, node.Label())
return nil
})
}
func cmdType(r *adb.Runner, args []string) error {
if len(args) != 1 {
return fmt.Errorf("usage: artemis type <text>")
}
return ensurePhone(r, func() error { return ui.Type(r, args[0]) })
}
func cmdSwipe(r *adb.Runner, args []string) error {
if len(args) < 4 || len(args) > 5 {
return fmt.Errorf("usage: artemis swipe <x1> <y1> <x2> <y2> [ms]")
}
nums := make([]int, 4)
for i, a := range args[:4] {
v, err := strconv.Atoi(a)
if err != nil {
return fmt.Errorf("swipe coordinate %q is not a number", a)
}
nums[i] = v
}
ms := 300
if len(args) == 5 {
v, err := strconv.Atoi(args[4])
if err != nil {
return fmt.Errorf("swipe duration %q is not a number", args[4])
}
ms = v
}
return ensurePhone(r, func() error { return ui.Swipe(r, nums[0], nums[1], nums[2], nums[3], ms) })
}
func cmdScroll(r *adb.Runner, args []string) error {
if len(args) != 1 {
return fmt.Errorf("usage: artemis scroll <up|down>")
}
return ensurePhone(r, func() error { return ui.Scroll(r, args[0]) })
}
// cmdDemo is the Phase 2 Definition of Done, runnable on demand:
// Artemis opens a named app and taps a named element on it.
func cmdDemo(r *adb.Runner) error {
return ensurePhone(r, func() error {
if err := ui.Wake(r); err != nil {
return err
}
fmt.Println("step 1: launch", ui.PackageSettings)
if err := ui.Launch(r, ui.PackageSettings); err != nil {
return err
}
// Launching resumes the last Settings page, so back out to the home
// screen first to make the demo repeatable.
const homeMarker = "Search Settings"
for attempt := 0; attempt < 4; attempt++ {
if _, err := ui.WaitForText(r, homeMarker, false, 3*time.Second); err == nil {
break
}
if attempt == 3 {
return fmt.Errorf("Settings home (%q) never appeared", homeMarker)
}
if err := ui.Back(r); err != nil {
return err
}
time.Sleep(700 * time.Millisecond)
}
// A stable, top-level Settings entry on the Pixel 8a.
const target = "Network and internet"
fmt.Printf("step 2: tap %q\n", target)
node, err := ui.TapText(r, target, false)
if err != nil {
return err
}
cx, cy, _ := node.Center()
fmt.Printf(" tapped %s at (%d,%d)\n", node.Label(), cx, cy)
// Prove the tap navigated: this text only exists on the target page.
const proof = "Aeroplane mode"
fmt.Printf("step 3: wait for %q\n", proof)
if _, err := ui.WaitForText(r, proof, false, 10*time.Second); err != nil {
return fmt.Errorf("tap did not open the expected page: %w", err)
}
fg, err := ui.ForegroundPackage(r)
if err != nil {
return err
}
fmt.Println("step 4: foreground package is", fg)
if fg != ui.PackageSettings {
return fmt.Errorf("expected to still be in %s after the tap, got %s", ui.PackageSettings, fg)
}
fmt.Println("demo OK")
return nil
})
}
func shortClass(class string) string {
for i := len(class) - 1; i >= 0; i-- {
if class[i] == '.' {
return class[i+1:]
}
}
return class
}