Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ docker compose logs -f api
| `GET /mission?events=<n>` | 로봇 미션 진행 상태. `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` | (로봇 쪽) 모션 결과 하나 |
Expand Down Expand Up @@ -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초마다 읽어 알린다. **팔은 건드리지 않는다.**
Expand Down
3 changes: 3 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")

Expand Down
70 changes: 70 additions & 0 deletions app/control.py
Original file line number Diff line number Diff line change
@@ -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")
3 changes: 2 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down
Loading