https://fms-api.zeroworks.co.kr (인증 endpoint — 환경별 분리가 필요한 경우 호스트 별도 안내)application/json; charset=utf-8. MQTT는 TLS + X.509 상호 인증 위 JSON 페이로드.{ "result": <ResultCode>, "data"?: object|string, "message"?: string }. result는 결과 코드 enum으로, SUCCESS·FAIL(일반 실패) 외 도메인별 구체 코드(ROBOT_NOT_REGISTERED·ROBOT_SIGNATURE_INVALID 등)가 사용됨. data·message는 있을 때만 노드 발현.occurredAt(ISO8601, +09:00 KST)·payload를 두고, 행위를 트리거하는 이벤트·명령(입찰·알람·PIN·박스 등)에는 멱등 처리를 위한 requestId(UUID v7)를 함께 둡니다(§3).+09:00(KST) — 모든 시각을 동일 표기 (예: 2026-06-04T14:30:00+09:00).X-Robot-ID, X-Robot-Ts, X-Robot-Signature (인증 endpoint 한정)상태(state)처럼 자발·고빈도로 발행되는 텔레메트리는 최신값만 의미가 있어 requestId를 생략할 수 있고, 관제는 occurredAt 비교로 더 최신일 때만 반영(last-write-wins)합니다. 반면 행위를 트리거하는 이벤트·명령(입찰 bid, 알람, PIN, 박스 닫힘 등)은 at-least-once 재전송에 대비해 requestId(또는 동등 식별자)를 유지해 멱등 처리합니다.
등록·인증은 REST로 진행합니다. 키쌍은 로봇이 직접 생성하며, 개인키는 디바이스 밖으로 내보내지 않습니다(TPM·보안 칩 권장).
POST /v1/robot/key로 제출. 관제가 robotCode 로 로봇을 조회해 내부 식별자 robotId(UUID)를 회신하며, 로봇은 이후 인증에 robotId 를 사용한다.POST /v1/robot/cert로 X.509 인증서 번들(다운로드 URL 목록) 수령 → 설치 후 MQTT 연결.로봇(Ubuntu·C++/OpenSSL)과 관제가 상호운용하도록 아래 규격을 제안합니다. 모든 문자열은 UTF-8, base64는 표준(RFC 4648, 개행 없음)입니다.
| 키쌍 | RSA-2048. 공개키(publicKey)는 DER SubjectPublicKeyInfo(SPKI)를 base64 인코딩(PEM 헤더·개행 없음). OpenSSL i2d_PUBKEY → base64. |
| 비밀키 암호화 ( secretKeyEncrypted) | RSA-OAEP — 해시 SHA-256 · MGF1 SHA-256 · label 없음. 서버가 로봇 공개키로 암호화한 base64 를 로봇이 개인키로 복호화. OpenSSL: RSA_PKCS1_OAEP_PADDING + set_rsa_oaep_md(sha256) + set_rsa_mgf1_md(sha256). |
| HMAC 비밀키 | 복호화 결과 문자열을 그대로(ASCII 바이트) HMAC 키로 사용 — base64 디코드 금지. |
| 서명 | HMAC-SHA256, 출력은 소문자 hex. X-Robot-Signature = HMAC-SHA256(secret, 정규화문자열). |
| 정규화 문자열 (서명 대상) | {METHOD}\n{path}\n{X-Robot-Ts}\n{sha256hex(body)} — METHOD 대문자, 구분자 \n(0x0A), sha256hex(body) = 요청 본문 바이트의 SHA-256 소문자 hex. |
| 인증 헤더 | X-Robot-ID(robotId UUID) · X-Robot-Ts(ISO-8601 +09:00) · X-Robot-Signature(위 HMAC hex) |
| 타임스탬프 | ISO-8601 KST(예 2026-07-08T14:30:00+09:00). 서버 시각과 ±60초 이내(NTP 동기 필요) — 초과 시 ROBOT_TIMESTAMP_OUT_OF_WINDOW. |
/v1/robot/key — 비밀키 발급| 인증 | 없음 (사전 등록된 robotCode + 공개키) |
| 요청 | |
| 200 응답 | |
| 재발급 | 이미 발급(활성화)된 로봇이 다시 호출하면 409 ROBOT_ALREADY_ACTIVATED로 거절됩니다. 재발급이 필요하면 관제 관리자 초기화 후 다시 발급받습니다(이전 Thing·인증서는 폐기). |
| 에러 | 404 ROBOT_NOT_REGISTERED(robotCode 미등록), 422 ROBOT_PUBLIC_KEY_INVALID(공개키 형식 오류) |
/v1/robot/cert — X.509 인증서 발급 (다운로드 URL 목록)관제가 AWS IoT Core에 로봇 Thing(thingName = robotId)과 X.509 인증서를 발급하고, 인증서 번들을 저장한 뒤 파일별 다운로드 URL 목록을 회신합니다. 로봇은 각 URL로 파일을 내려받아 설치하고 mTLS로 MQTT에 연결합니다.
| 인증 | HMAC (로봇) |
| 번들 파일 | certificate.pem.crt(디바이스 인증서) · private.pem.key(개인키) · public.pem.key(공개키) · AmazonRootCA1.pem(서버 검증 CA) |
| 200 응답 | |
발급받은 비밀키로 요청을 서명합니다. X-Robot-Signature = HMAC-SHA256(secret, 정규화문자열)(소문자 hex), 정규화 문자열은 {METHOD}\n{path}\n{X-Robot-Ts}\n{sha256hex(body)}입니다. X-Robot-Ts는 ±60초 윈도우를 벗어나면 ROBOT_TIMESTAMP_OUT_OF_WINDOW로 거절(replay 방지)됩니다.
예) POST /v1/robot/cert · body {} → 정규화 문자열:
POST
/v1/robot/cert
2026-07-08T14:30:00+09:00
44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
로봇(Ubuntu · C++)에서 위 규격을 구현하는 최소 예제입니다. OpenSSL 3.x 기준(빌드: g++ auth.cpp -lssl -lcrypto)이며, 지면상 에러 처리는 생략했습니다. HTTP 호출·JSON 파싱은 libcurl·nlohmann/json 등 임의 라이브러리를 사용합니다.
#include <openssl/evp.h>
#include <openssl/hmac.h>
#include <string>
#include <vector>
#include <stdexcept>
static std::string toHex(const unsigned char* d, size_t n) {
static const char* h = "0123456789abcdef";
std::string s; s.reserve(n * 2);
for (size_t i = 0; i < n; ++i) { s += h[d[i] >> 4]; s += h[d[i] & 0xf]; }
return s;
}
// 표준 base64(RFC 4648, 개행 없음)
static std::string b64encode(const unsigned char* d, int n) {
std::string out(4 * ((n + 2) / 3), '\0');
int len = EVP_EncodeBlock((unsigned char*)out.data(), d, n);
out.resize(len);
return out;
}
static std::vector<unsigned char> b64decode(const std::string& in) {
std::vector<unsigned char> out(3 * (in.size() / 4) + 3);
int len = EVP_DecodeBlock(out.data(), (const unsigned char*)in.data(), (int)in.size());
if (len < 0) throw std::runtime_error("base64 decode 실패");
if (!in.empty() && in.back() == '=') len--; // 패딩 보정
if (in.size() >= 2 && in[in.size() - 2] == '=') len--;
out.resize(len);
return out;
}
static std::string sha256Hex(const std::string& data) {
unsigned char md[EVP_MAX_MD_SIZE]; unsigned int n = 0;
EVP_Digest(data.data(), data.size(), md, &n, EVP_sha256(), nullptr);
return toHex(md, n);
}
static std::string hmacSha256Hex(const std::string& key, const std::string& msg) {
unsigned char mac[EVP_MAX_MD_SIZE]; unsigned int n = 0;
HMAC(EVP_sha256(), key.data(), (int)key.size(),
(const unsigned char*)msg.data(), msg.size(), mac, &n);
return toHex(mac, n);
}
#include <openssl/rsa.h>
#include <openssl/x509.h> // i2d_PUBKEY
// RSA-2048 키쌍 생성. 개인키는 디바이스에 안전 저장(예: PEM_write_PrivateKey_ex), 외부 반출 금지.
EVP_PKEY* generateRobotKeyPair() { return EVP_RSA_gen(2048); } // OpenSSL 3.0+
// 공개키 → base64(SPKI DER). POST /v1/robot/key 의 publicKey 필드에 그대로 사용.
std::string publicKeyB64(EVP_PKEY* pkey) {
unsigned char* der = nullptr;
int len = i2d_PUBKEY(pkey, &der); // SubjectPublicKeyInfo(DER)
if (len <= 0) throw std::runtime_error("i2d_PUBKEY 실패");
std::string b64 = b64encode(der, len);
OPENSSL_free(der);
return b64;
}
// secretKeyEncrypted(base64) → HMAC secret 문자열. OAEP 해시 SHA-256 / MGF1 SHA-256.
std::string decryptSecret(EVP_PKEY* priv, const std::string& secretKeyEncryptedB64) {
std::vector<unsigned char> ct = b64decode(secretKeyEncryptedB64);
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new(priv, nullptr);
EVP_PKEY_decrypt_init(ctx);
EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_OAEP_PADDING);
EVP_PKEY_CTX_set_rsa_oaep_md(ctx, EVP_sha256());
EVP_PKEY_CTX_set_rsa_mgf1_md(ctx, EVP_sha256()); // ← SHA-256 로 명시(서버와 일치)
size_t outLen = 0;
EVP_PKEY_decrypt(ctx, nullptr, &outLen, ct.data(), ct.size());
std::string secret(outLen, '\0');
EVP_PKEY_decrypt(ctx, (unsigned char*)secret.data(), &outLen, ct.data(), ct.size());
secret.resize(outLen);
EVP_PKEY_CTX_free(ctx);
return secret; // 그대로 HMAC 키로 사용 — base64 디코드 금지
}
#include <ctime>
// ISO-8601 KST (예: 2026-07-08T14:30:00+09:00). NTP 로 시각 동기 필요(±60초).
std::string nowKstIso8601() {
std::time_t t = std::time(nullptr) + 9 * 3600; // UTC+9
std::tm tm{}; gmtime_r(&t, &tm);
char buf[40];
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S+09:00", &tm);
return buf;
}
// POST /v1/robot/cert 서명 헤더 구성.
void buildCertHeaders(const std::string& robotId, const std::string& secret,
std::string& xId, std::string& xTs, std::string& xSig, std::string& body) {
body = "{}";
xId = robotId;
xTs = nowKstIso8601();
// 정규화: {METHOD}\n{path}\n{X-Robot-Ts}\n{sha256hex(body)}
std::string canonical = "POST\n/v1/robot/cert\n" + xTs + "\n" + sha256Hex(body);
xSig = hmacSha256Hex(secret, canonical);
}
int main() {
EVP_PKEY* priv = generateRobotKeyPair();
std::string pub = publicKeyB64(priv);
// 1) POST /v1/robot/key body: {"robotCode":"R-001","publicKey":pub}
// 응답(data): robotId, secretKeyEncrypted (HTTP·JSON 은 libcurl·nlohmann/json 등)
std::string robotId = /* 응답 파싱 */ "";
std::string secretEnc = /* 응답 파싱 */ "";
std::string secret = decryptSecret(priv, secretEnc); // HMAC 키 확보
// 2) POST /v1/robot/cert (서명 헤더 + body {})
std::string xId, xTs, xSig, body;
buildCertHeaders(robotId, secret, xId, xTs, xSig, body);
// 헤더: X-Robot-ID: xId · X-Robot-Ts: xTs · X-Robot-Signature: xSig
// 응답(data.files[]) 의 다운로드 URL 을 일반 HTTPS GET 으로 각각 다운로드 →
// certificate.pem.crt · private.pem.key · AmazonRootCA1.pem 으로 mTLS MQTT 접속(clientId=robotId).
return 0;
}
인증서 설치 후 로봇은 MQTT 브로커에 연결합니다. 토픽은 방향 프리픽스를 앞에 둔 {방향}/{robot_id}/{항목}/{명령} 형태입니다 — s2r(server→robot, 로봇 subscribe), r2s(robot→server, 로봇 publish). 브로커 정책으로 로봇은 r2s/{자기id}/#만 publish, s2r/{자기id}/#만 subscribe할 수 있어 디바이스 토픽이 격리됩니다.
| 토픽 | 방향 | 용도 | QoS |
|---|---|---|---|
s2r/{robot_id}/state/get | 관제 → 로봇 | 상태 질의 (조회 필드 지정) | 1 |
s2r/{robot_id}/state/set | 관제 → 로봇 | 상태 설정 | 1 |
r2s/{robot_id}/state | 로봇 → 관제 | 상태 발행 (변동 필드 이벤트, retain) | 0 |
r2s/{robot_id}/alarm | 로봇 → 관제 | 알람 발행 | 1 |
s2r/{robot_id}/alarm | 관제 → 로봇 | 알람 리셋 | 1 |
s2r/{robot_id}/goals/get | 관제 → 로봇 | goal 질의 | 1 |
s2r/{robot_id}/goals/set | 관제 → 로봇 | goal 설정 (입찰 없이 즉시 수행) | 1 |
r2s/{robot_id}/goals | 로봇 → 관제 | goal 수행 상태 발행 | 1 |
s2r/{robot_id}/goals/offer | 관제 → 로봇 | 입찰 요청 | 1 |
r2s/{robot_id}/goals/bid | 로봇 → 관제 | 입찰 응답 (예상 도착시간) | 1 |
s2r/{robot_id}/goals/result | 관제 → 로봇 | 입찰 결과 (낙찰 통보) | 1 |
s2r/{robot_id}/quote/offer | 관제 → 로봇 | 배송 가능여부 견적 질의 (가용성·ETA, 비구속) | 1 |
r2s/{robot_id}/quote/bid | 로봇 → 관제 | 배송 가능여부 견적 응답 (available·estArrival) | 1 |
r2s/{robot_id}/resource | 로봇 → 관제 | 인프라(엘베/로비폰) 요청 — 로비폰은 orderId+lobbyId, 엘베는 orderId+elevatorCode+탑승층·목적층 | 1 |
s2r/{robot_id}/resource/ready | 관제 → 로봇 | 요청 처리 완료 → 진행 신호 | 1 |
s2r/{robot_id}/resource/status | 관제 → 로봇 | 엘베 중간 상태(serviceStatus) + 배정 호기(elId) 중계 | 1 |
s2r/{robot_id}/box/open | 관제 → 로봇 | 박스 개방 지시 (오토메타 웹 개방·상차/하차) 제안 §6.1 | 1 |
r2s/{robot_id}/box/loaded | 로봇 → 관제 | 상차 완료 보고 (닫힘 = 출발 트리거) 제안 §6.1 | 1 |
r2s/{robot_id}/box/unloaded | 로봇 → 관제 | 하차 완료 보고 (수령 완료) 제안 §6.1 | 1 |
state) 토픽은 재접속 구독자가 마지막 상태를 즉시 복구하도록 retain 사용. 입찰·알람 등 이벤트/일회성 토픽은 retain 미사용(지난 사건 재생 방지).s2r/{robot_id}/state/get{
"requestId": "uuid",
"occurredAt": "2026-06-04T10:00:00+09:00",
"payload": { "fields": ["mode"] } // 조회할 상태 필드 목록
}
s2r/{robot_id}/state/set관제가 일부 상태 필드를 설정합니다. 본문은 상태 발행과 동일 구조의 부분 집합을 사용합니다.
r2s/{robot_id}/staterobot 상태가 변하는 필드값을 이벤트 단위로 로봇이 발행합니다. retain=true 권장, 최소 발행 간격(초 단위) 설정 가능. 자발·고빈도 텔레메트리이므로 requestId는 생략 가능하며 occurredAt으로 순서를 판단합니다.
{
"occurredAt": "2026-06-04T10:00:00+09:00",
"payload": {
"online": true,
"mode": "bringup",
"status": "doing",
"ems": ["button", "door", "LiDAR", "bumper"],
"location_score": 0,
"location": { "x": 0.0, "y": 0.0, "th": 0.0, "map_id": "" },
"battery": {
"voltage": 0, "current": 0, "soc": 0,
"est_charge_complete": 0, "est_discharge_complete": 0,
"soh": 0, "temperature": 0
}
}
}
| 필드 | 타입 | 단위 | 설명 |
|---|---|---|---|
payload.online | boolean | - | 로봇 접속 여부 — true(온라인) / false(오프라인). 접속 시 true, 연결 끊김 시 LWT로 false. 관제는 이 값으로 활성(배차 대상) 로봇을 판별합니다(아래 접속 상태 callout). |
payload.mode | enum | - | bringup · ready · navi · cruise · delivery · trash_pickup · mapping |
payload.status | enum | - | doing · done · paused |
payload.ems | string[] | - | EMS 트리거 소스 (button · door · LiDAR · bumper) |
payload.location_score | number | 0~100 | 위치 추정 신뢰도 |
payload.location.x / y / th | number | - | 위치 좌표 및 방향 |
payload.location.map_id | string | - | 맵 식별자 |
payload.battery.voltage | number | V | 전압 |
payload.battery.current | number | mA | 전류 |
payload.battery.soc | number | % (0~100) | 충전 상태 |
payload.battery.est_charge_complete | number | min | 충전 완료 예상 시간 |
payload.battery.est_discharge_complete | number | min | 방전 완료 예상 시간 |
payload.battery.soh | number | % (0~100) | 배터리 수명 |
payload.battery.temperature | number | °C | 배터리 온도 |
online)와 LWTonline은 로봇의 MQTT 접속 여부를 나타내며, 관제가 배차 대상(활성) 로봇을 추리는 기준입니다. 로봇은 다음과 같이 관리합니다.
online: true — MQTT 연결 직후 이벤트로 online: true를 발행하거나, 관제의 state/get 질의에 online: true로 응답합니다.online: false (LWT) — MQTT Last Will and Testament(LWT)로 등록해, 브로커가 로봇의 비정상 절단을 감지하면 대신 online: false를 발행하도록 구성합니다. 정상 종료 시에는 로봇이 종료 전 online: false를 직접 발행하는 것을 권장합니다(정상 DISCONNECT는 LWT가 발동하지 않음).state 토픽은 retain=true이므로, LWT의 online: false에도 retain(will retain)을 적용해야 이후 접속하는 관제가 마지막 접속 상태를 즉시 복구할 수 있습니다.r2s/{robot_id}/alarm{
"requestId": "uuid",
"occurredAt": "2026-06-04T10:00:00+09:00",
"payload": {
"alarm": [
{ "code": "0000", "level": "warn", "msg": "" },
{ "code": "1000", "level": "warn", "msg": "" }
]
}
}
| 필드 | 타입 | 설명 |
|---|---|---|
payload.alarm[].code | string | 알람 코드 |
payload.alarm[].level | enum | warn · error · fatal |
payload.alarm[].msg | string | 알람 메시지 |
s2r/{robot_id}/alarm{
"requestId": "uuid",
"occurredAt": "2026-06-04T10:00:00+09:00",
"payload": { "alarm": "reset" } // 알람 리셋 명령
}
레벨별 관제 동작 방향(예시)은 아래와 같으며, 실제 코드·레벨 매핑은 양사 시나리오 협의로 확정합니다. 정해지는 대로 관제 대응에 반영합니다.
| level | 관제 대응(예시) | 리셋 |
|---|---|---|
warn | 로그 저장 및 대시보드 표시 | 자동 |
error | 홈 복귀 및 배차 제외 → 운영자 알림 | 자동 |
fatal | 즉시 중단 및 배차 제외 → 운영자 확인 | 수동 |
로봇 서비스는 mode → goal → mission 3계층으로 정의합니다. mode는 로봇이 현재 무슨 서비스를 수행 중인지(robot 레벨), goal은 어디로 가는지(목적지 단위), mission은 그 지점에서 무엇을 하는지(동작)를 나타냅니다. 각 계층의 어휘는 서로 겹치지 않게 분리합니다 — 장소 성격은 goal.type, 동작은 mission.type에만 둡니다.
하나의 mission은 하나의 고객 오더 단위이며, 동일 mission.id가 여러 goal에 걸쳐 존재할 수 있습니다 — 예: 픽업 goal과 하차 goal이 같은 오더(mission_12)로 묶입니다. 미션을 로봇에 부여하는 경로는 입찰(offer → bid → result) 또는 즉시 설정(set) 두 가지이며, 부여 후 로봇은 r2s/{robot_id}/goals로 수행 상태를 발행합니다. 멀티스톱 배송은 goal 배열로 표현합니다.
| 계층 | 위치 | 의미 | 값(enum) |
|---|---|---|---|
mode | state.mode | 로봇 서비스 종류 / 동작 모드 | bringup · ready · navi · cruise · delivery · trash_pickup · mapping |
goal.type | goals[].type | 목적지(지점)의 성격 | customer(고객) · store(매장) · unloading(하역장) · charging(충전소) · waiting(대기장소) · none |
mission.type | goals[].mission[].type | 지점에서 수행하는 동작 | pickup · dropoff · wait · charge |
배송 로봇은 배차 전 대기·이동, 그리고 다음 배송으로의 연속 배차까지 mode=delivery를 유지합니다(일감 유무·진행은 state.status·goal.status로 표현). 단 미션 완료 후 충전소 복귀는 delivery가 아니라 mode=navi + goal.type=charging(mission.type=charge)으로 전이합니다 — 관제 지시 또는 다음 배차가 없을 때 로봇이 스스로 복귀하는 timeout 자동 회차. 동작 흐름은 시나리오 §4 참조. timeout 값 협의
s2r/{robot_id}/goals/set 입찰 없이 즉시 수행goal은 mission[] 배열을 품습니다. 아래 예시는 한 오더(mission_12)의 픽업(매장 goal)·하차(고객 goal)가 같은 mission.id로 두 goal에 걸친 모습입니다.
{
"requestId": "uuid",
"occurredAt": "2026-06-04T10:00:00+09:00",
"payload": {
"goals": [
{
"id": "goal_1126",
"type": "store",
"status": "going",
"arrivalOrder": 1,
"location": { "x": 0.0, "y": 0.0, "th": 0.0, "map_id": "" },
"mission": [
{
"id": "mission_12",
"type": "pickup",
"status": "ready",
"atomic": true,
"PIN": null,
"door": [ { "name": "door2", "status": "closed" } ],
"orderId": "",
"address": "",
"addressDetail": "",
"phoneNumber": "",
"message": ""
}
]
},
{
"id": "goal_1127",
"type": "customer",
"status": "ready",
"arrivalOrder": 2,
"location": { "x": 0.0, "y": 0.0, "th": 0.0, "map_id": "" },
"mission": [
{
"id": "mission_12",
"type": "dropoff",
"status": "ready",
"atomic": true,
"PIN": "1234",
"door": [ { "name": "door2", "status": "closed" } ],
"orderId": "",
"address": "",
"addressDetail": "",
"phoneNumber": "",
"message": ""
}
]
}
]
}
}
| 필드 | 타입 | 설명 |
|---|---|---|
goals[].id | string | goal 식별자 |
goals[].type | enum | 지점 성격 — customer · store · unloading · charging · waiting · none |
goals[].status | enum | ready · going · arrived · canceled |
goals[].arrivalOrder | number | 도착 순서 (미지정 시 가장 마지막) |
goals[].location.x / y / th | number | 위치 좌표 및 방향 |
goals[].location.map_id | string | 맵 식별자 |
goals[].mission[].id | string | 미션 식별자 (= 고객 오더 단위, 여러 goal에 걸쳐 동일 id 가능) |
goals[].mission[].type | enum | 동작 — pickup · dropoff · wait · charge |
goals[].mission[].status | enum | ready · doing · done · canceled |
goals[].mission[].atomic | boolean | 미션 원자성 — 묶음 순서 보존 여부 (아래 설명) |
goals[].mission[].PIN | string | null | 적재함 인증 PIN (로봇 PIN 입력 방식 시 값, 그 외 null — §6) |
goals[].mission[].door[].name | string | 대상 도어 이름 (관제가 지정) |
goals[].mission[].door[].status | enum | 도어 상태 — opening · opened · closing · closed (로봇이 발행) |
goals[].mission[].orderId | string | 주문 식별자 |
goals[].mission[].address / addressDetail | string | 주소 · 상세 주소 |
goals[].mission[].phoneNumber | string | 연락처 |
goals[].mission[].message | string | 메시지 |
goals[].mission[].autoDropOff | boolean | 비대면 자동 하차 여부. 배차 로봇의 robot.auto_drop_off=true일 때만 true(하차 mode auto). 그 외 false(하차 mode attended, PIN 선전달) — §6 |
atomic은 한 미션(고객 오더)에 속한 goal들의 순서가 깨지지 않고 묶여서 유지됨을 보장하는 옵션입니다. 이후 다른 미션이 추가되어 거리 최적화로 재정렬되더라도, atomic 미션에 속한 goal들의 상대 순서는 보존되며 다른 미션의 goal이 그 사이에 끼어들지 않습니다.
goal A → goal B가 들어온 뒤, 미션 2로 goal C → goal D가 추가되는 상황atomic: false — 거리 최적화로 사이에 끼어들 수 있음 → 예: A, C, B, Datomic: true — A·B 묶음이 보존됨(C·D가 사이에 끼지 못함) → 예: A, B, C, Dr2s/{robot_id}/goals수행 중 goal 상태(진행률·도착예상·상태)를 로봇이 발행합니다. retain=true. 관제는 필요 시 goals/get으로 on-demand 조회합니다.
{
"requestId": "uuid",
"occurredAt": "2026-06-04T10:00:00+09:00",
"payload": {
"goals": [
{
"id": "goal_1126",
"type": "store",
"status": "going",
"arrivalOrder": 1,
"estArrival": 0,
"progress": 0,
"location": { "x": 0.0, "y": 0.0, "th": 0.0, "map_id": "" },
"mission": [
{ "id": "mission_12", "type": "pickup", "status": "ready", "atomic": true, "PIN": null, "door": [ { "name": "door2", "status": "closed" } ], "orderId": "" }
]
}
]
}
}
| 필드 | 타입 | 단위 | 설명 |
|---|---|---|---|
goals[].estArrival | number | min | 도착 예상 시간 |
goals[].progress | number | 0~100 | 진행률 |
goals[].status | enum | - | ready · going · arrived · canceled |
goals[].mission[].* | - | - | goal 설정 토픽(§5.1)의 mission 필드와 동일 |
입찰은 3단계입니다. 관제가 후보 로봇에 offer로 제시 → 각 로봇이 bid로 goal별 예상 도착시간 응답 → 관제가 전체 ETA(각 goal estArrival 합) 최소 로봇으로 배차를 결정(추후 적재공간 기준 포함)해 result로 낙찰 여부를 통보합니다. 어느 로봇이 수행할지의 최종 배차 결정은 관제가 단독으로 가집니다(배차 확정·충돌 방지).
입찰·낙찰은 goalId 단위로 식별합니다(별도 오퍼 식별자 없음). 라운드는 단발이며 재오퍼하지 않습니다 — bid 타임아웃 시점에 관제가 모인 입찰로 배차를 결정하고, 마감 후 도착한 지각 bid는 배제합니다. (로봇, goal)당 in-flight 오퍼는 1건입니다. 입찰 가능한 로봇이 없으면 해당 미션은 수행 불가로 종료됩니다. 타임아웃 값 협의
online: true인 로봇 전체에 offer를 보냅니다(활성 판별은 §4.1 접속 상태 callout 참조). 오프라인 로봇은 offer 대상에서 제외됩니다.online 필터는 사전 선별일 뿐이므로, 응답 없는 로봇을 걸러내는 최종 기준은 이 타임아웃입니다. 운용 중 조정s2r/{robot_id}/goals/offergoals/set과 동일한 본문 구조(goal 전체 정의)를 사용합니다. 입찰 요청 시 전달할 필드 범위(전체 goal vs 요약)는 확정 대상입니다. 필드 범위 협의
r2s/{robot_id}/goals/bid{
"requestId": "uuid",
"occurredAt": "2026-06-04T10:00:00+09:00",
"payload": {
"goals": [
{ "id": "goal_1124", "arrivalOrder": 0, "estArrival": 0 }
]
}
}
s2r/{robot_id}/goals/result{
"requestId": "uuid",
"occurredAt": "2026-06-04T10:00:00+09:00",
"payload": {
"goals": [
{ "id": "goal_1124", "claimed": true, "reason": null }
]
}
}
| 필드 | 타입 | 설명 |
|---|---|---|
goals[].estArrival | number | 도착 예상 시간(min) bid |
goals[].claimed | boolean | 낙찰 여부 result |
goals[].reason | string | null | 미낙찰 사유 result |
주문이 확정되기 전, 관제가 후보 로봇에게 "지금 이 배송을 수행할 수 있는지"를 미리 물어 가용성·예상 도착시간을 받아 두는 사전 견적입니다. 미션(goal)을 생성하지 않는 비구속 질의이므로, 배차 입찰(§5.3 goals/*)과 토픽을 분리해 quote/* 네임스페이스로 운영합니다. 낙찰(result) 단계가 없어 입찰의 3단계를 2단계(질의 → 견적)로 축약합니다. 관제는 모든 goal 이 available인 로봇 중 전체 ETA(각 goal estArrival 합) 최소를 가용 ETA로 집계합니다(배차 낙찰과 동일 방식).
offer)는 별도 스키마가 아니라 goals/set과 같은 본문(§5.1)을 씁니다. 응답(bid)만 입찰 대비 available을 더합니다.result) 단계 없음(2단계).goals/*가 아닌 quote/*로 분리해 배차 입찰과 혼선을 막습니다.s2r/{robot_id}/quote/offer입찰 요청(goals/offer)과 마찬가지로 goals/set과 동일한 본문 구조(goal 전체 정의 — §5.1)를 사용합니다. 별도 축약 스키마를 두지 않으며, 미션을 생성하지 않는 비구속 질의라는 점만 다릅니다. 배차 입찰과는 quote/* 토픽으로 구분되며, 견적에는 별도 식별자(견적ID·goalId 바인딩)가 없어 응답 상관은 토픽의 robot_id로 판별합니다.
r2s/{robot_id}/quote/bid입찰 응답(goals/bid)과 동일한 goals[] 배열로 회신하되, 예상 도착시간(estArrival)에 수행 가능 여부(available)를 더합니다. 불가 시 available=false·estArrival=null·reason(사유)로 응답합니다. 관제는 모든 goal 이 available일 때만 가용으로 보고 각 goal estArrival을 합산해 예상 도착시간을 산출합니다.
{
"requestId": "uuid",
"occurredAt": "2026-07-05T10:00:03+09:00",
"payload": {
"goals": [
{ "id": "goal_1124", "arrivalOrder": 1, "available": true, "estArrival": 7, "reason": null }
]
}
}
| 필드 | 타입 | 단위 | 설명 |
|---|---|---|---|
goals[].id | string | - | goal 식별자(참고용, 빈 문자열 가능) — 견적 상관은 토픽 robot_id로 하며 이 값에 의존하지 않음 |
goals[].arrivalOrder | number | - | 도착 순서 |
goals[].available | boolean | - | 수행 가능 여부 (입찰 bid 대비 추가 필드) |
goals[].estArrival | number | null | min | 예상 도착시간 (불가 시 null) |
goals[].reason | string | null | - | 불가 사유 (available=false 시) |
입찰과 동일하게 단발 라운드(재질의 없음)이며, bid 타임아웃도 배차와 동일(초기값 3초)입니다. 타임아웃 내 무응답 로봇은 available=false로 간주하고 지각 응답은 배제합니다. 후보 전원이 unavailable·무응답이면 가용성 결과는 수행 가능 로봇 없음으로 집계됩니다. 상관(어느 라운드 응답인지)은 토픽의 robot_id로 판별합니다 — 견적 응답에는 별도 견적 식별자가 없습니다. 운용 중 조정
로봇이 미션 수행 중 엘리베이터·로비폰(공동현관)이 필요한 시점에 관제로 요청합니다. 주소 정보는 관제가 orderId 로 보유합니다. 단, 엘리베이터는 로봇이 도착한 엘베 코드(elevatorCode)와 탑승층·목적층(sourceFloor·destinationFloor)을 함께 전달합니다 — 관제가 elevatorCode로 해당 엘베 라인(홀)의 호출 식별자를 조회해 MIRI 콜에 사용합니다. 실제 탑승 호기는 로봇이 고르지 않고 엘베 그룹제어가 배정하며, 관제는 배정된 호기(elId)를 resource/status 로 로봇에 전달해 로봇이 그 호기 문 앞으로 이동하도록 안내합니다. 로비폰은 로봇이 개방 대상 lobbyId(예: GOCHEOK-101)를 함께 전달하면, 관제가 lobbyId로 DB(lobbyphone 레지스트리)를 조회해 해당 로비의 siteId·IP·개방시간을 얻어 자동 개방(inBase 호출)합니다 — 로봇은 inBase·IP·토큰을 몰라도 됩니다. 처리 완료 시 관제가 진행 신호를 보내고, 로봇은 다음 단계로 이동합니다.
// 로봇 → 관제 : r2s/{robot_id}/resource (엘베)
{ "orderId": "ORD-...", "resource": "elevator",
"elevatorCode": "EL-...", "sourceFloor": "3", "destinationFloor": "15" }
// 로봇 → 관제 : r2s/{robot_id}/resource (로비폰)
{ "orderId": "ORD-...", "resource": "lobbyphone", "lobbyId": "GOCHEOK-101" }
// 관제 → 로봇 : s2r/{robot_id}/resource/ready
// result(로비폰): opened | failed / result(엘베): ok
{ "orderId": "ORD-...", "resource": "lobbyphone", "result": "opened" }
{ "orderId": "ORD-...", "resource": "elevator", "result": "ok" }
엘베는 호출 후 탑승·하차 단계가 진행됩니다. 관제는 MIRI serviceStatus(호출확정→탑승→하차완료, 24종)를 받아 로봇에 중계(resource/status)하고, 로봇은 실제 탑승·하차 시점에 보고하면 관제가 이를 MIRI thing/status 로 중계합니다. 배정 호기는 콜 배정 확정(sourceFloorCallConfirmed) 시점부터 elId 로 함께 실려 오므로, 로봇은 그 호기 앞에서 탑승을 준비합니다.
// 관제 → 로봇 : s2r/{robot_id}/resource/status (중간 상태 중계)
{ "resource": "elevator",
"serviceStatus": "sourceFloorCallConfirmed" | "sourceFloorGetOn" | "destinationFloorGetOff" | …,
"elId": "EL-..." }
// 로봇 → 관제 : r2s/{robot_id}/resource (탑승/하차 보고 → 관제가 MIRI thing/status 중계)
{ "orderId": "ORD-...", "resource": "elevator", "status": "boarded" | "gotoff" }
고객 수령(하차) 방식은 로봇 H/W의 비대면 지원 여부(robot.auto_drop_off)로 결정되며, 배차/입찰 시점에 goal.mission으로 전달됩니다. 비스캣은 대면 배송에서 항상 userPin(4자리)을 발행하고 오토메타 배차 응답에 동봉합니다.
| 하차 mode | PIN | autoDropOff | 동작 |
|---|---|---|---|
비대면 (auto) — 로봇 auto_drop_off=true H/W 한정 | null | true | 도착 즉시 박스 자동 개방·하차 후 복귀 (고객 조작 불필요) |
대면 (attended) — 기본 | 랜덤(4자리) | false | ① 로봇 키패드 PIN 입력(로컬 검증) 또는 ② 오토메타 웹 개방(관제→로봇 문열림 지시) |
mission.PIN으로 선전달하고 로봇이 로컬 검증합니다. 검증 후 사용·결과(성공/실패·박스 개폐 인가)를 r2s 감사 이벤트로 보고합니다(보고 토픽·페이로드 형식 확정 대상).attended) 배송마다 랜덤 4자리를 생성·오토메타 배차 응답(userPin 필드)에 동봉합니다.autoDropOff 결정 주체: 배차 시 관제가 담당 로봇의 robot.auto_drop_off를 보고 결정합니다.박스 상호작용은 개방 지시(관제→로봇)와 상차·하차 완료 보고(로봇→관제)로 이뤄집니다. 이를 box/* 네임스페이스로 제안합니다 — 개방 지시 s2r/{robot_id}/box/open, 완료 보고 r2s/{robot_id}/box/loaded·r2s/{robot_id}/box/unloaded. 세 메시지는 모두 행위를 트리거하는 명령·이벤트이므로 §1 규약대로 requestId(멱등)를 유지하고 QoS 1로 전송합니다. 로봇은 주문번호(orderId)를 관장하지 않습니다 — 관제가 주문번호에 대응하는 박스 번호(boxSlots, 1~4)를 지정해 지시하고, 지시·보고는 모두 박스 번호 기준으로 처리합니다. 개방 지시는 로봇이 해당 지점에 도착한 뒤(점포=상차, 세대=하차)에만 유효합니다.
s2r/{robot_id}/box/open으로 지정 박스(boxSlots)를 엽니다(PIN 입력 불필요). 본 절이 그 문열림 지시의 구체 계약입니다.mission.PIN을 로컬 검증 후 스스로 개방하는 경로로, box/open 지시 없이 진행됩니다. 이때의 PIN 감사 이벤트(r2s) 형식은 별도 확정 대상입니다.autoDropOff=true): 도착 즉시 로봇이 자동 개방하며 box/open 지시·PIN 없이 진행됩니다(고객 조작 불필요).s2r/{robot_id}/box/open관제가 열 박스 번호(boxSlots)를 지정해 지시합니다. 관제가 주문번호에 매핑한 번호를 내려주며, 로봇은 지정된 물리 박스 칸만 개방하고 주문번호는 해석하지 않습니다. 상차(점포)·하차(세대) 공용입니다.
{
"requestId": "uuid",
"occurredAt": "2026-07-19T14:00:00+09:00",
"payload": {
"boxSlots": [1, 3] // 관제가 지정한 개방 박스 번호(1~4)
}
}
| 필드 | 타입 | 설명 |
|---|---|---|
boxSlots | number[] | 개방할 박스 번호 배열(1~4). 관제가 주문번호에 매핑해 지정 — 로봇은 해당 물리 칸만 개방 |
r2s/{robot_id}/box/loaded점포에서 개방된 박스에 적재하고 닫으면 로봇이 보고합니다. 이 닫힘 보고가 배송(세대 이동) 출발 트리거이며, 보고도 박스 번호 기준입니다.
{
"requestId": "uuid",
"occurredAt": "2026-07-19T14:03:10+09:00",
"payload": {
"boxSlots": [1, 3], // 닫힌 박스 번호
"result": "success" // success | fail
}
}
r2s/{robot_id}/box/unloaded세대에서 고객이 수령하고 닫으면 로봇이 보고합니다. 이 보고로 수령 완료·미션 종료를 처리합니다. 본문 구조는 box/loaded와 동일합니다.
| 필드 | 타입 | 설명 |
|---|---|---|
boxSlots | number[] | 대상 박스 번호(개방 지시와 동일 번호) |
result | enum | success · fail(기구 오류 등) |
개방 지시(box/open)의 완료 확인은 현재 별도 응답 토픽 없이 로봇의 goal 수행 발행(r2s/{robot_id}/goals, §5.2)에 실린 박스 개방 상태로 인지하도록 제안합니다. 필요 시 개방 결과 전용 응답 토픽(예: r2s/{robot_id}/box/opened — boxSlots·result)으로 승격할 수 있으며, 이 선택은 협의로 확정합니다.
box/open·box/loaded·box/unloaded 명칭·구조 확정(§3 반영).goals 개방 상태 피기백 유지 vs 전용 r2s box 결과 토픽 승격.boxSlots)만 사용하고 주문번호(orderId)는 관제가 관장(orderId↔boxSlots 매핑 보유). 필요 시 orderId를 불투명 상관 토큰(로봇 미해석·회신 전용)으로 동봉할지 여부.result:"fail"(미개방·기구 오류) 사유 코드, 다중 박스 지시 시 슬롯별 결과 표현, 재전송·requestId 멱등.box/loaded·box/unloaded 미도달 시 관제 처리).로봇이 보유한 지도와 의미 거점 노드(관제가 goal을 내릴 때 참조하는 명명된 지점)를 siteId 기준으로 관제에 등록합니다. 로봇 UI의 업로드 버튼을 누르면 로봇이 보유 맵을 자동 업로드합니다. 흐름은 등록 개시 → 파일 업로드 → 등록 확정 3단계로, 등록 개시 시 siteId·제시 mapId와 업로드할 파일 목록을 선언하면 관제가 파일별 업로드 URL을 발급하고, 로봇은 지도 이미지·메타(yml)·노드(json) 파일을 각각 해당 URL로 전송합니다(압축하지 않음).
mapId는 로봇(제로웍스)이 제시하며, MQTT 상태·goal의 location.map_id와 동일 값입니다. (siteId, mapId) 조합당 mapVersion이 증가하며 이력을 보관합니다. 등록 확정 시 최신본이 활성 맵이 되고 이전 버전은 비활성으로 전환됩니다(롤백·감사 가능). 관제는 등록된 노드 좌표를 goal의 location으로 사용합니다.
/v1/robot/maps인증: HMAC(로봇). siteId·제시 mapId와 함께 업로드할 파일 목록(files)을 선언하면, 관제가 mapVersion과 선언한 파일별 업로드 URL을 발급합니다. 지도 메타·노드는 본문에 싣지 않고 ②에서 각 파일로 업로드합니다.
| 인증 | HMAC (로봇) |
| 요청 | |
| 200 응답 | |
| 에러 | 404 SITE_NOT_REGISTERED |
| 필드 | 타입 | 설명 |
|---|---|---|
siteId | string | 단지·현장 식별자 (맵 매핑 기준) |
mapId | string | 로봇(제로웍스)이 제시하는 맵 식별자. MQTT location.map_id와 동일 값을 사용 |
files[].name | string | 업로드할 파일명 |
files[].type | enum | image(지도 이미지) · meta(yml) · nodes(json) |
{uploadUrl}로봇이 ① 응답의 uploads[]에 따라 각 파일을 해당 업로드 URL에 그대로 PUT합니다(압축하지 않음). uploadExpiresIn 내에 완료해야 합니다.
파일(type) | 내용 |
|---|---|
| image | occupancy grid 등 지도 이미지 (pgm/png 등) |
| meta (yml) | 지도 메타데이터 — 해상도(m/px)·원점(x/y/th)·크기 등 |
| nodes (json) | 의미 거점 노드 목록 — 이름·type(goal.type과 동일 어휘 — store·unloading·charging·waiting 등)·좌표(x/y/th) |
/v1/robot/maps/{mapId}/commit업로드 완료를 통보합니다. 관제가 ① 선언 목록의 모든 파일이 업로드됐는지 확인하고, 각 파일을 검증(파싱·필수 항목 확인)해 통과하면 활성화(이전 버전 비활성)합니다. 선언 파일이 하나라도 누락됐거나 검증에 실패하면 거절하며, 선언에 없는 파일은 반영하지 않고 삭제합니다.
| 인증 | HMAC (로봇) |
| 200 응답 | |
| 에러 | 422 MAP_UPLOAD_INCOMPLETE(선언 파일 누락), 422 MAP_VALIDATION_FAILED(파일 검증 실패), 409 MAP_UPLOAD_EXPIRED(업로드 URL 만료 후 commit) |
아래 항목은 1.5차 PoC 범위 밖이며 토픽 자리만 예약합니다. (지도 파일 등록은 §7 REST로 정의되며, 아래 map MQTT 토픽은 추후 실시간 조회·갱신용으로 별도 검토합니다.)
| 항목 | 토픽 |
|---|---|
| maps info (실시간 조회·갱신) | s2r/{robot_id}/map/get · s2r/{robot_id}/map/set · r2s/{robot_id}/map |
| setting info | s2r/{robot_id}/setting/get · s2r/{robot_id}/setting/set · r2s/{robot_id}/setting |
| remote control | s2r/{robot_id}/stream/get · r2s/{robot_id}/stream · s2r/{robot_id}/action/set |
| 코드 | HTTP | 의미 |
|---|---|---|
ROBOT_SIGNATURE_INVALID | 401 | HMAC 서명 검증 실패 |
ROBOT_TIMESTAMP_OUT_OF_WINDOW | 401 | timestamp ±60s 윈도우 초과 (replay 의심) |
ROBOT_CERT_REVOKED | 401 | 인증서 폐기됨 |
ROBOT_PUBLIC_KEY_INVALID | 422 | 로봇 공개키(publicKey) 형식 오류 — POST /v1/robot/key |
ROBOT_NOT_REGISTERED | 404 | 사전 등록되지 않은 robotCode |
ROBOT_ALREADY_ACTIVATED | 409 | 이미 활성화된 로봇의 /key·/cert 재발급 시도 (재발급은 관제 관리자 초기화 후) |
| 코드 | HTTP | 의미 |
|---|---|---|
SITE_NOT_REGISTERED | 404 | 등록되지 않은 siteId |
MAP_UPLOAD_INCOMPLETE | 422 | commit 시 ① 선언 목록(files)의 파일이 일부 업로드되지 않음 |
MAP_VALIDATION_FAILED | 422 | commit 시 업로드 파일(이미지·yml·json) 검증 실패(파싱·필수 항목 누락 등) |
MAP_UPLOAD_EXPIRED | 409 | 업로드 URL 만료 후 commit 시도 |
goals/result의 reason)입찰 단계의 미낙찰은 HTTP가 아니라 goals/result 메시지 goals[].reason 값으로 전달됩니다(claimed=false). 구체 사유 코드 집합은 bid 타임아웃 값과 함께 확정합니다. 사유 코드 협의