- ping the board every 2 s so an unplugged or silent board shows as gone within seconds - never nudge the pointer right after the board connects or while the phone is not mirroring - read and log the board's hello (last reset reason, uptime) - serve the web UI with Cache-Control: no-cache so updates show on refresh - LLM_TOKEN_PARAM (auto by default) for models that want max_completion_tokens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
193 lines
5.1 KiB
Python
193 lines
5.1 KiB
Python
"""Web API + control page. Run with `python -m hidagent`."""
|
|
|
|
import asyncio
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, HTTPException, Response
|
|
from fastapi.responses import FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel
|
|
|
|
from .agent import Agent
|
|
from .debug import Trace
|
|
from .device import Device, DeviceError
|
|
from .llm import LLM
|
|
from .phone import Phone
|
|
from .screen import Screen
|
|
|
|
DATA_DIR = Path(os.environ.get("HIDAGENT_DATA", Path.home() / ".hidagent"))
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
DEBUG = os.environ.get("HIDAGENT_DEBUG") == "1"
|
|
|
|
device = Device()
|
|
screen = Screen(os.environ.get("AIRPLAY_NAME", "iPhone Agent"))
|
|
phone = Phone(device, DATA_DIR / "calibration.json", screen)
|
|
agent = Agent(phone, screen, LLM.from_env("ACTOR"), LLM.from_env("PLANNER"),
|
|
Trace(DATA_DIR / "traces" if DEBUG else None))
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_):
|
|
server = await device.serve("0.0.0.0", int(os.environ.get("DEVICE_PORT", 7300)))
|
|
screen.start()
|
|
awake = asyncio.create_task(device.keep_awake(screen=screen))
|
|
yield
|
|
awake.cancel()
|
|
screen.stop()
|
|
server.close()
|
|
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
|
class Static(StaticFiles):
|
|
"""Static files that browsers re-check on every load, so a new version shows up on refresh."""
|
|
|
|
async def get_response(self, path, scope):
|
|
response = await super().get_response(path, scope)
|
|
response.headers["Cache-Control"] = "no-cache"
|
|
return response
|
|
|
|
|
|
app.mount("/static", Static(directory=Path(__file__).parent / "static"), name="static")
|
|
|
|
|
|
class Point(BaseModel):
|
|
x: float
|
|
y: float
|
|
|
|
|
|
class Swipe(BaseModel):
|
|
x1: float
|
|
y1: float
|
|
x2: float
|
|
y2: float
|
|
|
|
|
|
class Scroll(Point):
|
|
lines: int
|
|
|
|
|
|
class Text(BaseModel):
|
|
text: str
|
|
|
|
|
|
async def act(coro):
|
|
try:
|
|
await coro
|
|
except (DeviceError, RuntimeError) as e:
|
|
raise HTTPException(409, str(e)) from e
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/")
|
|
def index():
|
|
return FileResponse(Path(__file__).parent / "static" / "index.html")
|
|
|
|
|
|
@app.get("/api/status")
|
|
def status():
|
|
return {"device": device.connected, "screen": screen.live, "calibrated": phone.calibrated,
|
|
"debug": DEBUG, "trace": str(agent.trace.dir or ""), "aspect": round(screen.aspect, 4),
|
|
"models": {"planner": agent.planner.model, "actor": agent.actor.model},
|
|
"agent": {"running": agent.running, "task": agent.task, "steps": agent.steps}}
|
|
|
|
|
|
@app.get("/api/frame.jpg")
|
|
def frame():
|
|
if not screen.live:
|
|
raise HTTPException(404, "no live screen: start screen mirroring on the phone")
|
|
return Response(screen.frame, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
|
|
|
|
|
|
@app.get("/api/steps/{index}.jpg")
|
|
def step_shot(index: int):
|
|
if index not in agent.shots:
|
|
raise HTTPException(404)
|
|
return Response(agent.shots[index], media_type="image/jpeg", headers={"Cache-Control": "max-age=3600"})
|
|
|
|
|
|
@app.post("/api/tap")
|
|
async def tap(p: Point):
|
|
return await act(phone.tap(p.x, p.y))
|
|
|
|
|
|
@app.post("/api/swipe")
|
|
async def swipe(s: Swipe):
|
|
return await act(phone.swipe(s.x1, s.y1, s.x2, s.y2))
|
|
|
|
|
|
@app.post("/api/scroll")
|
|
async def scroll(s: Scroll):
|
|
return await act(phone.scroll(s.x, s.y, s.lines))
|
|
|
|
|
|
@app.post("/api/type")
|
|
async def type_text(t: Text):
|
|
return await act(phone.type(t.text))
|
|
|
|
|
|
@app.post("/api/key")
|
|
async def key(t: Text):
|
|
return await act(phone.key(t.text))
|
|
|
|
|
|
@app.post("/api/home")
|
|
async def home():
|
|
return await act(phone.go_home())
|
|
|
|
|
|
@app.post("/api/back")
|
|
async def back():
|
|
return await act(phone.go_back())
|
|
|
|
|
|
@app.post("/api/raw")
|
|
async def raw(t: Text):
|
|
"""Debug only: send one protocol line to the board as-is."""
|
|
if not DEBUG:
|
|
raise HTTPException(404)
|
|
return await act(device.send(t.text))
|
|
|
|
|
|
@app.post("/api/calibrate/start")
|
|
async def calibrate_start():
|
|
"""Calibrate the pointer. {"auto": true}: done by itself; otherwise the user clicks the pointer next."""
|
|
try:
|
|
return {"ok": True, "auto": await phone.start_calibration(), "span": phone.span}
|
|
except (DeviceError, RuntimeError) as e:
|
|
raise HTTPException(409, str(e)) from e
|
|
|
|
|
|
@app.post("/api/calibrate/finish")
|
|
def calibrate_finish(p: Point):
|
|
phone.finish_calibration(p.x, p.y)
|
|
return {"ok": True, "span": phone.span}
|
|
|
|
|
|
@app.post("/api/agent")
|
|
async def agent_start(t: Text):
|
|
try:
|
|
agent.start(t.text)
|
|
except RuntimeError as e:
|
|
raise HTTPException(409, str(e)) from e
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/api/agent/stop")
|
|
async def agent_stop():
|
|
"""Emergency stop: cancel the agent and release the mouse button."""
|
|
agent.stop()
|
|
await asyncio.sleep(0.1) # let a gesture in flight finish its own release
|
|
return await act(phone.reset()) if device.connected else {"ok": True}
|
|
|
|
|
|
@app.post("/api/agent/clear")
|
|
def agent_clear():
|
|
"""Forget the last run (steps, screenshots, plan) so the page starts clean."""
|
|
try:
|
|
agent.clear()
|
|
except RuntimeError as e:
|
|
raise HTTPException(409, str(e)) from e
|
|
return {"ok": True}
|