diff --git a/.env.example b/.env.example index ab4a0e6..842101f 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ CAMERAS=top,wrist # piper: PIPER Studio 가 카메라를 쥐고 있을 때 / ingest: LeRobot 이 카메라를 쥐고 프레임을 밀어 넣을 때 CAMERA_SOURCE=piper PIPER_URL=http://host.docker.internal +# 로봇 미션 프로그램(Piper `mission.py --serve`) 주소. /control/* 가 여기로 넘긴다. 토큰은 INGEST_TOKEN 을 같이 쓴다. +ROBOT_CONTROL_URL=http://host.docker.internal:8765 # 이름=PIPER카메라 라벨. PIPER 카메라 페이지에서 두 카메라에 라벨(top, wrist)을 붙이고 여기 적는다. # /dev/videoN 같은 id 도 되지만 재부팅하면 바뀔 수 있다. # 등록된 카메라 확인: curl -s http://localhost/api/cameras/current | python3 -m json.tool diff --git a/README.md b/README.md index 715879f..cec918e 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,11 @@ docker compose logs -f api | `GET /mission?events=` | 로봇 미션 진행 상태. `events` 를 주면 현재 회차의 최근 미션 이벤트 n 개도 | | `POST /ingest/mission` | (로봇 쪽) 미션 진행 이벤트 하나 | | `GET /arm` | 로봇팔 상태 (PIPER 에서 3초마다 읽기만 한다). `ok` 가 false 면 `message` 에 이유 | +| `GET /control/status` | 로봇 미션 프로그램 상태 `{state, error, index, placed, results}` | +| `POST /control/start` | 미션 시작 `{"apples": 5}` (`"all"` = 트레이가 빌 때까지, 생략 = 로봇 설정) | +| `POST /control/estop` | 비상정지 (그 자리 정지 → 쥔 사과를 집은 자리에 되돌림 → 팔을 낮게 → 모터 정지) | +| `POST /control/resume` | 비상정지·오류 정지 해제 → 멈춘 사과부터 이어서 | +| `POST /control/stop` | 지금 사과까지만 하고 멈춤 | | `GET /health` | 서버 상태, 카메라 소스 | | `POST /ingest/judge` | (로봇 쪽) 판정 하나 | | `POST /ingest/motion` | (로봇 쪽) 모션 결과 하나 | @@ -281,6 +286,18 @@ reset 직후 모든 `/ws/judge` 에 연결 직후와 같은 순서로 snapshot "red_low": 0.25, "reasons": ["흠 0.140 > 0.1"]}} ``` +### 로봇 원격 제어 (`/control/*`) + +프론트의 시작·비상정지·해제 버튼이 부른다. 서버는 로봇 PC 의 미션 프로그램 +(Piper `mission.py --serve`, 기본 `:8765`)으로 그대로 넘긴다. 로봇 주소는 `ROBOT_CONTROL_URL` +(기본 `http://host.docker.internal:8765`), 토큰은 `/ingest` 와 같은 `INGEST_TOKEN` 이라 브라우저에는 나가지 않는다. + +- `state`: `idle` 대기 / `running` 실행 중 / `stopping` 비상정지 처리 중 / `estopped` 비상정지 / `error` 오류로 그 자리 정지 / `done` 완료 +- 지금 할 수 없는 명령(실행 중에 `start` 등)은 로봇 응답 그대로 `ok: false` 와 `message` 를 준다. +- 로봇 미션 프로그램이 안 떠 있으면 `503`, 토큰이 안 맞으면 `502`. +- ⚠ 비상정지 해제 순간 모터 전원이 잠깐 빠져 팔이 처질 수 있다 — 해제 버튼에는 확인창을 띄운다. +- 상태가 바뀔 때마다 로봇이 `POST /ingest/mission` 으로 `{"event": "control", "state", "error"}` 도 보낸다. + ### 로봇팔 상태 `GET /arm` — PIPER 의 `/api/robots/current` 를 3초마다 읽어 알린다. **팔은 건드리지 않는다.** diff --git a/app/config.py b/app/config.py index 9d59d5f..aead06b 100644 --- a/app/config.py +++ b/app/config.py @@ -50,6 +50,9 @@ def _map(name: str) -> dict[str, str]: # CSV 의 time 열 시간대 (UTC 기준 시). 컨테이너는 UTC 라서 한국 시간으로 맞춘다. CSV_UTC_OFFSET_HOURS = float(os.environ.get("CSV_UTC_OFFSET_HOURS", "9")) +# 로봇 미션 프로그램(Piper `mission.py --serve`)의 원격 제어 주소. /control/* 가 여기로 넘긴다. +ROBOT_CONTROL_URL = os.environ.get("ROBOT_CONTROL_URL", "http://host.docker.internal:8765").rstrip("/") + # /ingest/* 를 보호하는 토큰. 비어 있으면 검사하지 않는다(LAN 신뢰). INGEST_TOKEN = os.environ.get("INGEST_TOKEN", "") diff --git a/app/control.py b/app/control.py new file mode 100644 index 0000000..b79e38c --- /dev/null +++ b/app/control.py @@ -0,0 +1,70 @@ +"""로봇 미션 원격 제어 — 프론트가 시작·비상정지·해제(이어하기)·멈춤을 보낸다. + +실제 제어는 로봇 PC 의 미션 프로그램(Piper `mission.py --serve`, 기본 :8765)이 한다. +이 라우터는 브라우저 요청을 그쪽으로 넘기기만 한다. 로봇 주소와 토큰은 서버에만 있다 +(토큰은 /ingest 와 같은 INGEST_TOKEN). + + GET /control/status {state, error, index, placed, results} + POST /control/start {"apples"} 미션 시작 (숫자 / "all" / 생략) + POST /control/estop 비상정지 + POST /control/resume 비상정지(또는 오류 정지) 해제 → 멈춘 사과부터 이어서 + POST /control/stop 지금 사과까지만 하고 멈춤 + +state: idle / running / estopped / error / done. 로봇 프로그램이 안 떠 있으면 503. +""" + +import httpx +from fastapi import APIRouter, Body, HTTPException + +from app import config + +router = APIRouter(prefix="/control", tags=["control"]) + +# 비상정지는 빨리 가야 하고, 해제는 로봇이 모터를 다시 켜느라 몇 초 걸린다. +_TIMEOUT = {"estop": 3.0, "resume": 15.0} + + +async def _call(method: str, path: str, body: dict | None = None) -> dict: + headers = {"Authorization": f"Bearer {config.INGEST_TOKEN}"} if config.INGEST_TOKEN else {} + timeout = _TIMEOUT.get(path.strip("/"), 5.0) + try: + async with httpx.AsyncClient(timeout=timeout) as client: + r = await client.request(method, f"{config.ROBOT_CONTROL_URL}{path}", json=body, headers=headers) + except httpx.HTTPError as e: + raise HTTPException(503, f"로봇 미션 프로그램에 연결할 수 없습니다 ({config.ROBOT_CONTROL_URL}): {e}") from e + try: + data = r.json() + except ValueError: + data = {"ok": False, "error": r.text[:200]} + if r.status_code == 401: + raise HTTPException(502, "로봇 미션 프로그램이 토큰을 거부했습니다 (INGEST_TOKEN 과 로봇 쪽 SSORRY_TOKEN 이 같은지 확인)") + # 409 = 지금 할 수 없는 명령 (실행 중에 /start 등). 메시지를 그대로 돌려준다 + if r.status_code >= 400 and r.status_code != 409: + raise HTTPException(502, data.get("error") or f"로봇 쪽 오류 {r.status_code}") + return data + + +@router.get("/status") +async def status(): + return await _call("GET", "/status") + + +@router.post("/start") +async def start(body: dict = Body(default={})): + apples = body.get("apples") + return await _call("POST", "/start", {"apples": apples} if apples is not None else {}) + + +@router.post("/estop") +async def estop(): + return await _call("POST", "/estop") + + +@router.post("/resume") +async def resume(): + return await _call("POST", "/resume") + + +@router.post("/stop") +async def stop(): + return await _call("POST", "/stop") diff --git a/app/main.py b/app/main.py index c25f814..fa7c936 100644 --- a/app/main.py +++ b/app/main.py @@ -13,7 +13,7 @@ from fastapi.staticfiles import StaticFiles from starlette.exceptions import HTTPException as StarletteHTTPException -from app import config, ingest +from app import config, control, ingest from app.judge import HISTORY_MAX, SubscriberOverflow from app.sessions import COOKIE_NAME, Session from app.db import Store, _number @@ -88,6 +88,7 @@ async def lifespan(_: FastAPI): app = FastAPI(title="Ssorry Web API", lifespan=lifespan) app.include_router(ingest.router) +app.include_router(control.router) @app.middleware("http") diff --git a/docker-compose.yml b/docker-compose.yml index 527f752..52e0595 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: CAMERAS: ${CAMERAS:-top,wrist} CAMERA_SOURCE: ${CAMERA_SOURCE:-ingest} PIPER_URL: ${PIPER_URL:-http://host.docker.internal} + ROBOT_CONTROL_URL: ${ROBOT_CONTROL_URL:-http://host.docker.internal:8765} PIPER_CAMERAS: ${PIPER_CAMERAS:-} PIPER_AUTO_CONNECT: ${PIPER_AUTO_CONNECT:-1} ARM_MONITOR: ${ARM_MONITOR:-}