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.
This commit is contained in:
355
cmd/artemis/main.go
Normal file
355
cmd/artemis/main.go
Normal file
@@ -0,0 +1,355 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user