callsign-wire/1 · 갱신 2026-11-03 · 문의 wks0968@gmail.com

콜사인 통신 규약 — 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 로 짝을 맞춘다. 순서는 상관없다

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줄이면 된다.

  1. wss://<허브>/api/fleet/ws 에 Authorization: Bearer <기기 열쇠> 로 붙는다.
  2. hello 를 보낸다(clusters: ["shell.run"]).
  3. 10초마다 {"m":"beat","taints":[]} 와 WebSocket Ping 을 보낸다.
  4. {id, verb:"invoke", args:{cluster:"shell.run", cmd}} 를 받으면 돌리고 {"m":"result","id":…,"ok":true,"res":{"code":0,"out":"…","err":""}} 로 답한다.
  5. 줄이 끊기면 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. 호환 규칙