콜사인 통신 규약 — callsign-wire/1
TL;DR
- 전송은 WebSocket 위 UTF-8 JSON 텍스트 프레임 하나뿐이다. 바이너리 프레임·전용 직렬화·OS API 가 없다 —
WebSocket 과 JSON 을 다루는 언어라면 어디서든(마이크로컨트롤러 포함) 노드를 새로 짤 수 있다.
- 노드는 밖으로 걸기만 한다(허브 → 노드 인바운드 포트 0개). 방화벽·NAT·통신사 망 뒤에서도 붙는다.
- 메시지는 다섯 가지: 노드 → 허브 hello·beat·attr·endpoints·result, 허브 → 노드 요청 {id, verb, args} 하나.
- 동사는 Matter 에서 빌린 넷: read·write·invoke·subscribe. 기기마다 다른 일은 invoke 의 클러스터 이름(shell.run·screen.shot…)으로만 갈린다.
규약을 지키는 실제 구현: 노드 agent-rs/src/main.rs(session)·agent-rs/src/proto.rs, 허브 server/src/adapters/http_fleet.rs(line)·server/src/domain/fleet.rs.
이 문서와 코드가 어긋나면 코드가 맞다 — 문서를 고쳐라.
1. 왜 이 모양인가
| 결정 | 이유 |
|---|---|
| WebSocket + JSON | 모든 OS·언어에 표준 라이브러리가 있다. 맥·윈도우·리눅스·안드로이드·iOS·ESP32 가 같은 글을 읽는다 |
| 노드가 바깥으로 건다 | 기기에 포트를 열지 않는다. 공유기·LTE·회사망 뒤에서도 그대로 |
열쇠는 Authorization 머리글로만 |
주소(?token=)에 실으면 프록시·접근 기록에 남는다 |
| 기기마다 열쇠 하나, 이름에 묶음 | 열쇠 하나가 새도 그 기기만 끊으면 된다. 남의 이름으로는 못 붙는다 |
| 동사 넷 + 클러스터 이름 | 새 기기가 새 동사를 만들 필요가 없다. 모르는 클러스터는 "모른다"고 답하면 된다 |
| 텍스트 한 줄 = 메시지 하나 | 조각 맞추기·길이 머리가 없다. 디버깅은 websocat 하나로 된다 |
2. 주소와 인증
노드 줄: wss://<허브>/api/fleet/ws (테일넷·LAN 안이면 ws://<허브>:8788/api/fleet/ws)
부르는 쪽: https://<허브>/api/fleet/* (HTTP JSON)
머리글: Authorization: Bearer <열쇠>
열쇠는 세 가지다.
| 열쇠 | 누가 쥐나 | 여는 것 |
|---|---|---|
| 기기 열쇠 | 노드 하나 | 그 노드 이름으로 /api/fleet/ws 에 붙기만. 다른 이름으로 hello 하면 허브가 끊는다 |
| 조작 열쇠 | CLI·에이전트(csn) |
/api/fleet/call·nodes·events 등 부르기 |
| 마스터 토큰 | 허브 주인 | 전부. 단, 터널을 거친 줄로는 노드 행세를 못 한다 |
2.1 설정 코드로 들이기(마스터 토큰을 기기에 복사하지 않는다)
허브 주인: csn fabric commission newpi → 8자리 코드(10분·한 번)
새 기기: POST https://<허브>/api/fleet/join
{"code":"12345678","node":"newpi","os":"linux"}
허브 답: 200 {"node":"newpi","token":"<기기 열쇠>"}
기기는 받은 열쇠를 자기 설정(~/.callsign-agent/agent.json, 권한 0600)에 적고 그 열쇠로 WebSocket 을 연다.
callsign-agent join <허브> <코드> 가 이 일을 한다(agent-rs/src/join.rs). 회수는 허브에서 그 줄을 지우면 끝 — 이 기기만 끊긴다.
3. 노드 줄(WebSocket)
3.1 순서
노드 허브
│── (TLS + Upgrade, Authorization) ──▶ │ 열쇠 검사. 모르면 401
│── hello ───────────────────────────▶ │ 첫 프레임은 반드시 hello. 아니면 {"error":…} 후 닫음
│── beat (10초마다) + WS Ping ────────▶ │ 30초(3번) 못 들으면 죽은 노드
│── attr (값이 바뀔 때만) ─────────────▶ │
│◀────────────────────── {id,verb,args} │ 일 시키기(여러 개가 동시에 올 수 있다)
│── {m:"result",id,ok,res|error,ms} ──▶ │ id 로 짝을 맞춘다. 순서는 상관없다
- 모든 프레임은 텍스트 프레임 하나에 JSON 객체 하나다. 메시지·프레임 상한은 64 MiB(파일 조각이 base64 로 실린다).
- 노드는 요청을 동시에 처리해도 된다. 허브는
id로만 짝을 맞춘다. - 노드가 30초 동안 프레임(Pong 포함)을 하나도 못 받으면 줄이 죽은 것으로 보고 다시 건다(와이파이를 갈아타면 TCP 는 15분을 버틴다).
- 같은 이름이 새 줄로 붙으면 허브는 옛 줄을 닫는다. 다시 붙을 때
hello부터 새로 한다.
3.2 hello — 노드 → 허브, 첫 프레임
{
"node": "pi",
"os": "linux",
"caps": ["shell", "files", "gpio"],
"clusters": ["shell.run", "file.get", "file.put", "gpio.read", "gpio.write", "sys.info"],
"taints": [],
"endpoints": [{"id": 1, "type": "ir-bridge", "clusters": ["onoff"]}]
}
| 키 | 필수 | 뜻 |
|---|---|---|
node |
✓ | 콜사인(노드 이름). 영문·숫자·-·_ 64자까지. .·/ 는 쓰지 않는다(CLI 주소 node/끝점.클러스터.속성 을 가른다) |
os |
✓ | macos·windows·linux·android·ios 또는 새 이름(소문자). 허브는 값을 해석하지 않고 고르기(--os)에만 쓴다 |
caps |
능력 라벨(screen·shell·hid·gpu·matter·adb…). 허브의 -r <cap> 고르기에 쓰인다 |
|
clusters |
알아듣는 invoke 클러스터. 비면 거르지 않는다(옛 노드) |
|
taints |
지금 일을 받지 않을 사정(low-battery·hot·busy…) |
|
endpoints |
품은 장치(끝점 1 이상). 없으면 끝점 0(노드 자신)만 |
3.3 노드 → 허브
m |
모양 | 언제 |
|---|---|---|
beat |
{"m":"beat","taints":["low-battery"]} |
10초마다. taint 를 늘 통째로 보낸다 |
attr |
{"m":"attr","attrs":{"battery":81,"idle_min":3},"endpoint":0} |
속성이 바뀔 때. endpoint 없으면 0 |
endpoints |
{"m":"endpoints","endpoints":[…]} |
품은 장치 목록이 바뀔 때(다시 hello 하지 않는다) |
result |
{"m":"result","id":17,"ok":true,"res":{…},"ms":12.3} |
요청 하나의 답 |
result(실패) |
{"m":"result","id":17,"ok":false,"error":"…"} |
모르는 클러스터·거부·실패. 사람이 읽을 글로 |
모르는 m 은 허브가 버린다 — 새 메시지를 더해도 옛 허브가 죽지 않는다.
3.4 허브 → 노드
{"id": 17, "verb": "invoke", "args": {"cluster": "shell.run", "cmd": "uptime", "timeout": 30}}
| 키 | 뜻 |
|---|---|
id |
허브가 매기는 수. 답에 그대로 돌려준다 |
verb |
read·write·invoke·subscribe |
args |
동사별 인자. endpoint(수)가 있으면 그 끝점에, 없으면 노드 자신에 |
error |
id 없이 이것만 오면 허브의 알림(예: "이 열쇠는 pi 전용입니다"). 답하지 않는다 |
4. 동사
| 동사 | args |
답 res |
|---|---|---|
read |
{"attr":"battery"} 또는 {} |
{"battery":81} 또는 속성 전체 |
write |
{"attr":"screen_off_min","value":5} |
쓴 뒤 값. 보기 전용 노드는 거부 |
invoke |
{"cluster":"<이름>", …} |
클러스터마다 다르다(§5) |
subscribe |
{"attr":"temp.value","min_s":1,"max_s":10} |
{"subscribed":{…}}. 이후 값은 attr 로 온다 |
5. 클러스터(invoke) — 모든 OS 공통 이름
노드는 할 수 있는 것만 hello.clusters 에 싣는다. 이름은 같고 OS 마다 속만 다르다
(screen.shot 은 맥에선 ScreenCaptureKit, 윈도우에선 GDI, 리눅스에선 grim, 폰에선 adb/WDA).
| 묶음 | 클러스터 | 대표 인자 → 답 |
|---|---|---|
| 셸 | shell.run |
cmd, timeout, shell(sh·bash·zsh·powershell), cwd, bg → {code,out,err} |
| 화면 | screen.shot·screen.state·window.list |
region, scale → {png\|jpg(base64), bytes, screen} |
| 입력 | input.click·input.move·input.type·input.key·input.scroll·input.batch |
x,y / text / key / steps[] |
| 파일 | file.get·file.put·file.list·file.stat |
path, data(base64), offset |
| 클립보드 | clip.get·clip.set |
text |
| 시스템 | sys.info·sys.health·proc.list·proc.kill·power.lock·net.wol·log.tail |
|
| 작업 | job.list·job.log |
shell.run bg 로 떼어 둔 일 |
| 장치 | gpio.read·gpio.write·gpu.info·matter.cmd |
|
| 시뮬레이터 | sim.*(iOS)·emu.*(안드로이드) |
|
| 권한 | perm.check·perm.request |
맥 TCC 등 |
| 에이전트 | agent.update·agent.restart·agent.rekey |
자기 갱신·열쇠 교체 |
새 클러스터는 묶음.동작 꼴로 이름 짓고, 모르는 이름이 오면 아는 목록을 붙여 실패로 답한다.
노드 설정의 deny(클러스터 글롭)에 걸린 것은 "모른다"가 아니라 "거부한다"고 답한다.
6. 부르는 쪽(HTTP) — POST /api/fleet/call
CLI(csn)·에이전트·스크립트는 노드 줄을 직접 열지 않고 허브에 HTTP 로 부탁한다.
POST /api/fleet/call
Authorization: Bearer <조작 열쇠>
Content-Type: application/json
{"requires":["gpu"], "os":"linux", "tolerate":["hot"], "selector":{"room":"lab"},
"verb":"invoke", "args":{"cluster":"shell.run","cmd":"nvidia-smi"},
"wait_s":30, "timeout_s":90, "all":false}
| 키 | 뜻 |
|---|---|
node |
이름을 찍으면 그 노드만 |
requires·os·tolerate·selector |
고르기(쿠버네티스의 nodeSelector·toleration·label selector) |
wait_s |
맞는 노드가 없을 때 기다릴 초(Pending). 0 이면 바로 409 |
timeout_s |
노드 답 상한(기본 90 — 클라우드플레어가 100초에 끊는다) |
all |
맞는 노드 모두에게 |
답은 노드의 result 를 그대로 싣는다. 줄이 없던 것(noline, 다른 노드로 넘겨도 안전)과 보낸 뒤 끊긴 것(dropped, 돌았을 수 있음)을 구분해 알린다.
그 밖의 노선: GET /api/fleet/nodes(목록)·status·events(SSE)·audit·cordon·claim·join.
7. 새 OS 에 노드를 짜는 최소 구현
아래 다섯 가지만 하면 csn ls 에 뜨고 csn sh <노드> -- … 가 돈다. 파이썬 40줄이면 된다.
wss://<허브>/api/fleet/ws에Authorization: Bearer <기기 열쇠>로 붙는다.hello를 보낸다(clusters: ["shell.run"]).- 10초마다
{"m":"beat","taints":[]}와 WebSocket Ping 을 보낸다. {id, verb:"invoke", args:{cluster:"shell.run", cmd}}를 받으면 돌리고{"m":"result","id":…,"ok":true,"res":{"code":0,"out":"…","err":""}}로 답한다.- 줄이 끊기면 1~5초 뒤 처음부터 다시 붙는다.
import asyncio, json, subprocess, websockets # pip install websockets
HUB, KEY, NODE = "wss://hub.example.com/api/fleet/ws", "<기기 열쇠>", "mybox"
async def main():
while True:
try:
async with websockets.connect(HUB, additional_headers={"Authorization": f"Bearer {KEY}"}) as ws:
await ws.send(json.dumps({"node": NODE, "os": "linux", "caps": ["shell"], "clusters": ["shell.run"], "taints": []}))
async def beat():
while True:
await ws.send(json.dumps({"m": "beat", "taints": []})); await asyncio.sleep(10)
asyncio.create_task(beat())
async for raw in ws:
r = json.loads(raw)
if "id" not in r: continue
a = r.get("args") or {}
if r.get("verb") == "invoke" and a.get("cluster") == "shell.run":
p = subprocess.run(a["cmd"], shell=True, capture_output=True, text=True, timeout=a.get("timeout", 120))
out = {"m": "result", "id": r["id"], "ok": True, "res": {"code": p.returncode, "out": p.stdout, "err": p.stderr}}
else:
out = {"m": "result", "id": r["id"], "ok": False, "error": f"{NODE} 는 {a.get('cluster')} 를 모릅니다. 아는 것: shell.run"}
await ws.send(json.dumps(out))
except Exception:
await asyncio.sleep(3)
asyncio.run(main())
8. 호환 규칙
- 더하기만 한다. 키를 새로 더할 수는 있어도 있던 키의 뜻을 바꾸거나 빼지 않는다. 받는 쪽은 모르는 키를 무시한다.
- 빈 배열·없는 키는 같은 뜻이다(
endpoints가 없으면 끝점 0 만). - 버전 번호를 주고받지 않는다 — 능력은
caps·clusters로 알린다. 뜻이 깨지는 변경이 필요하면 노선을 새로 연다(/api/fleet/ws2).