최초 커밋

This commit is contained in:
2025-10-02 16:49:32 +09:00
commit 8734a149a6
5 changed files with 1294 additions and 0 deletions

564
socket/tcp_server.py Normal file
View File

@@ -0,0 +1,564 @@
import os
import asyncio
import base64
import json
from datetime import datetime, timezone
from typing import Dict, Optional, List
TCP_HOST = os.getenv("TCP_HOST", "0.0.0.0")
TCP_PORT = int(os.getenv("TCP_PORT", "8181"))
MODE = os.getenv("MODE", "stream").lower()
LENGTH_BYTES = int(os.getenv("LENGTH_BYTES", "2"))
READ_CHUNK = int(os.getenv("READ_CHUNK", "65536"))
MAX_MSG = int(os.getenv("MAX_MSG", "1048576"))
IDLE_TIMEOUT = int(os.getenv("IDLE_TIMEOUT", "90"))
ACK_JSON = b'{"ok":true}\n'
LOG_MODE = os.getenv("LOG_MODE", "both").lower()
LOG_MAX_BYTES = int(os.getenv("LOG_MAX_BYTES", "4096"))
CTRL_HOST = os.getenv("CTRL_HOST", "0.0.0.0")
CTRL_PORT = int(os.getenv("CTRL_PORT", "8182"))
CLIENTS: Dict[str, asyncio.StreamWriter] = {}
META: Dict[str, Dict] = {}
DEV_INDEX: Dict[str, str] = {}
INBOX: Dict[str, asyncio.Queue] = {}
def bytes_to_hex(b: bytes) -> str:
return "".join(f"{x:02x}" for x in b)
def hexdump(b: bytes, width=16):
def p(x): return chr(x) if 32 <= x < 127 else "."
lines = []
for i in range(0, len(b), width):
chunk = b[i:i+width]
hexpart = " ".join(f"{c:02x}" for c in chunk)
lines.append(f"{i:04x} {hexpart:<{width*3}} |{''.join(p(c) for c in chunk)}|")
return "\n".join(lines)
def print_payload(peer, data: bytes):
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%fZ")
size = len(data)
print(f"[tcp] {ts} {peer} -> {size} bytes", flush=True)
sample = data[:LOG_MAX_BYTES]
if LOG_MODE in ("text", "both"):
print("----- TEXT (utf-8, truncated) -----", flush=True)
print(sample.decode("utf-8", errors="replace"), flush=True)
if LOG_MODE in ("hex", "both"):
print("----- HEXDUMP (truncated) -----", flush=True)
print(hexdump(sample), flush=True)
if size > LOG_MAX_BYTES:
print(f"... (truncated, showed first {LOG_MAX_BYTES} of {size} bytes)", flush=True)
async def read_stream(r: asyncio.StreamReader): return (await r.read(READ_CHUNK)) or None
async def read_line(r: asyncio.StreamReader): return (await r.readline()) or None
async def read_length_prefixed(r: asyncio.StreamReader):
hdr = await r.readexactly(LENGTH_BYTES)
n = int.from_bytes(hdr, "big")
if n < 0 or n > MAX_MSG: return None
return await r.readexactly(n)
async def handle_conn(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
addr = writer.get_extra_info("peername")
key = f"{addr[0]}:{addr[1]}"
CLIENTS[key] = writer
META[key] = {"addr": addr, "device_id": None, "last": datetime.now(timezone.utc)}
INBOX[key] = asyncio.Queue()
print(f"[tcp] connected: {key} (mode={MODE})", flush=True)
read_once = read_stream if MODE == "stream" else (read_line if MODE == "line" else read_length_prefixed)
try:
while True:
try:
chunk = await asyncio.wait_for(read_once(reader), timeout=1.0)
except asyncio.TimeoutError:
if (datetime.now(timezone.utc) - META[key]["last"]).total_seconds() > IDLE_TIMEOUT:
print(f"[tcp] idle timeout: {key}", flush=True)
break
continue
except asyncio.IncompleteReadError:
break
if not chunk: break
META[key]["last"] = datetime.now(timezone.utc)
print_payload(key, chunk)
try: INBOX[key].put_nowait(chunk)
except Exception: pass
try:
obj = json.loads(chunk.decode("utf-8", errors="ignore"))
if isinstance(obj, dict) and obj.get("device_id"):
META[key]["device_id"] = str(obj["device_id"])
DEV_INDEX[str(obj["device_id"]) ] = key
except Exception:
pass
try:
writer.write(ACK_JSON)
await writer.drain()
except ConnectionResetError:
break
finally:
try:
writer.close()
await writer.wait_closed()
if key in INBOX:
q = INBOX.pop(key)
while not q.empty():
try: q.get_nowait()
except Exception: break
except Exception:
pass
CLIENTS.pop(key, None)
did = META.get(key, {}).get("device_id")
if did and DEV_INDEX.get(did) == key: DEV_INDEX.pop(did, None)
META.pop(key, None)
print(f"[tcp] disconnected: {key}", flush=True)
from fastapi import FastAPI, HTTPException, Body, Form
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
try:
from fastapi.middleware.cors import CORSMiddleware
except Exception:
CORSMiddleware = None
app = FastAPI(title="TCP Push Control")
if CORSMiddleware:
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class PushReq(BaseModel):
target: str = Field(..., description='"*" | "ip:port" | "dev:<device_id>"')
data: str = Field(..., description='payload 문자열 (encoding에 따라 해석)')
encoding: str = Field("text", description='"text" | "hex" | "base64"')
append_newline: bool = Field(True, description='text일 때 끝에 \n 추가 여부')
await_response: bool = Field(False, description="보낸 뒤 TCP 응답을 기다려 HTTP로 반환할지")
response_timeout_ms: int = Field(3000, ge=1, le=30000, description="응답 대기 타임아웃(ms)")
def resolve_targets(target: str) -> List[str]:
if target == "*": return list(CLIENTS.keys())
if target.startswith("dev:"):
key = DEV_INDEX.get(target[4:])
return [key] if key else []
return [target] if target in CLIENTS else []
def decode_payload(data: str, enc: str, append_newline: bool) -> bytes:
enc = enc.lower()
if enc == "text":
b = data.encode("utf-8")
return b + (b"\n" if append_newline else b"")
if enc == "hex":
h = "".join(ch for ch in data if ch in "0123456789abcdefABCDEF")
return bytes.fromhex(h)
if enc == "base64":
return base64.b64decode(data)
raise HTTPException(status_code=400, detail="encoding must be text|hex|base64")
@app.get("/clients")
def list_clients():
out = [{"key": k, "device_id": m.get("device_id"), "last": m.get("last").isoformat()} for k,m in META.items()]
return {"count": len(out), "items": out}
# ★ FIX: 경로를 '/push' 로 명시 (기존의 "/" 결합 실수 수정)
@app.post("/push")
async def push(req: PushReq):
keys = resolve_targets(req.target)
if not keys: raise HTTPException(status_code=404, detail="target not found or not connected")
if req.await_response and len(keys) != 1:
raise HTTPException(status_code=400, detail="await_response는 단일 대상일 때만 지원한다")
payload = decode_payload(req.data, req.encoding, req.append_newline)
sent, failed, responses = 0, 0, []
for k in keys:
w = CLIENTS.get(k)
if not w: failed += 1; continue
if req.await_response:
q = INBOX.get(k)
if q is None: raise HTTPException(status_code=500, detail="internal inbox missing")
while not q.empty():
try: q.get_nowait()
except Exception: break
try:
w.write(payload); await w.drain(); sent += 1
except Exception:
failed += 1; continue
if req.await_response:
try:
chunk = await asyncio.wait_for(INBOX[k].get(), timeout=req.response_timeout_ms/1000.0)
responses.append({"key": k, "size": len(chunk), "hex": bytes_to_hex(chunk), "text_preview": chunk.decode("utf-8", errors="replace")})
except asyncio.TimeoutError:
responses.append({"key": k, "timeout": True, "hex": None, "text_preview": None})
return {"ok": True, "sent": sent, "failed": failed, "await_response": req.await_response, "responses": responses if req.await_response else None}
# ---- Parser endpoints ----
import struct, re
def crc16_modbus(data: bytes) -> int:
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
if crc & 0x0001:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
return crc & 0xFFFF
def _clean_hex(s: str) -> str:
s = s.strip().replace("0x","").replace("0X","")
s = re.sub(r"[^0-9A-Fa-f]", "", s)
if len(s) % 2 == 1: s = "0" + s
return s.lower()
def parse_modbus_response(hex_str: str):
raw_hex = _clean_hex(hex_str)
if not raw_hex: return {"error": "no hex"}
buf = bytes.fromhex(raw_hex)
if buf.endswith(b"\r\n"): buf = buf[:-2]
if len(buf) < 5: return {"error": "frame too short", "length": len(buf), "raw_hex": raw_hex}
slave_id, function = buf[0], buf[1]
if function in (1,2,3,4):
byte_count = buf[2]
data_start, data_end = 3, 3 + byte_count
if data_end + 2 > len(buf): return {"error": "byte_count mismatch", "length": len(buf), "raw_hex": raw_hex}
data = buf[data_start:data_end]
provided_crc_lo, provided_crc_hi = buf[data_end], buf[data_end+1]
provided_crc = (provided_crc_hi << 8) | provided_crc_lo
calc_crc = crc16_modbus(buf[:data_end])
crc_ok = (calc_crc == provided_crc)
regs = [int.from_bytes(data[i:i+2], "big") for i in range(0, len(data), 2) if i+2<=len(data)]
floats = []
if len(data) >= 4 and len(data) % 4 == 0:
for i in range(0, len(data), 4):
w1, w2 = data[i:i+2], data[i+2:i+4]
floats.append(struct.unpack(">f", w2 + w1)[0])
return {"id": slave_id, "function": function, "byte_count": byte_count, "data_hex": data.hex(), "registers_be": regs, "floats_wordswap": floats, "crc_provided_hex": f"{provided_crc:04x}", "crc_calculated_hex": f"{calc_crc:04x}", "crc_ok": crc_ok, "frame_hex": buf.hex()}
return {"id": slave_id, "function": function, "frame_hex": buf.hex(), "note": "non-standard or write-response"}
from fastapi import HTTPException
@app.post("/test")
def test_parse(hex_form: Optional[str] = Form(None), body: Optional[dict] = Body(None)):
hex_input = hex_form or (body.get("hex") if body else None) or (body.get("data") if body else None)
if not hex_input:
raise HTTPException(status_code=400, detail="hex (또는 data) 가 필요하다")
return {"ok": True, "input": hex_input, "result": parse_modbus_response(hex_input)}
@app.post("/test_auto")
def test_auto(push_json: dict = Body(...)):
"""
/push 의 JSON 응답을 그대로 넣으면 내부에서 hex를 뽑아 파싱한다.
"""
hexv = None
try:
if push_json and push_json.get("responses"):
for r in push_json["responses"]:
if isinstance(r, dict) and r.get("hex"):
hexv = r["hex"]
break
except Exception:
pass
if not hexv:
raise HTTPException(status_code=400, detail="push JSON에서 hex 응답을 찾지 못함 (target='*'이거나 timeout)")
return {"ok": True, "input": {"from":"test_auto","hex":hexv}, "result": parse_modbus_response(hexv)}
@app.get("/ui", response_class=HTMLResponse)
def ui():
return """<!doctype html>
<html lang=\"ko\">
<head>
<meta charset=\"utf-8\">
<title>/push 제어 + 데이터 읽기 (auto-parse)</title>
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,\"Noto Sans KR\",sans-serif;max-width:1024px;margin:24px auto;padding:0 16px}
h1{margin:0 0 12px}
.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:16px;box-shadow:0 1px 2px rgba(0,0,0,.03);margin-bottom:12px}
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
label{font-weight:600}
textarea,input,select{width:100%;box-sizing:border-box}
textarea{font-family:ui-monospace,Menlo,Consolas,monospace;padding:10px;border:1px solid #d1d5db;border-radius:8px}
input[type=text],select{padding:10px;border:1px solid #d1d5db;border-radius:8px}
button{padding:10px 14px;border:1px solid #111827;background:#111827;color:#fff;border-radius:10px;cursor:pointer}
button.ghost{background:#fff;color:#111827;border-color:#d1d5db}
.actions{display:flex;flex-wrap:wrap;gap:8px}
.muted{color:#6b7280;font-size:12px}
.badge{display:inline-block;background:#f3f4f6;border:1px solid #e5e7eb;border-radius:999px;padding:2px 8px;font-size:12px;margin-right:6px}
pre{background:#0b1021;color:#d1d5db;padding:12px;border-radius:10px;overflow:auto;max-height:360px}
.kv{display:grid;grid-template-columns:160px 1fr;gap:6px;margin-top:8px}
</style>
</head>
<body>
<h1>/push 제어 + 데이터 읽기</h1>
<div class=\"card\">
<div class=\"row\">
<div>
<label>엔드포인트</label>
<input id=\"endpoint\" type=\"text\" value=\"/push\">
</div>
<div>
<label>target</label>
<select id=\"target\"></select>
<div class=\"muted\">특정 ip:port를 선택해야 응답을 기다려 파싱 가능. '*'는 방송용.</div>
</div>
</div>
<div class=\"muted\" id=\"clientsInfo\">연결 조회 중…</div>
</div>
<div class=\"card\">
<h2 style=\"margin:0 0 8px;\">제어</h2>
<div class=\"actions\" style=\"margin-bottom:10px\">
<button class=\"ghost\" id=\"ex_uv_on\">UV ON</button>
<button class=\"ghost\" id=\"ex_uv_off\">UV OFF</button>
<button class=\"ghost\" id=\"ex_fan_on\">FAN ON</button>
<button class=\"ghost\" id=\"ex_fan_off\">FAN OFF</button>
<button class=\"ghost\" id=\"ex_unlock\">UNLOCK</button>
<button class=\"ghost\" id=\"ex_lock\">LOCK</button>
</div>
<form id=\"pushForm\">
<div>
<label for=\"data\">HEX 데이터</label>
<textarea id=\"data\" name=\"data\" rows=\"3\" placeholder=\"예) 01 06 00 14 00 01 08 0E\"></textarea>
</div>
<div class=\"row\" style=\"margin-top:8px\">
<div><label>encoding</label>
<select id=\"encoding\"><option value=\"hex\" selected>hex</option><option value=\"base64\">base64</option><option value=\"text\">text</option></select>
</div>
<div><label>append_newline</label>
<select id=\"append_newline\"><option value=\"false\" selected>false</option><option value=\"true\">true</option></select>
</div>
</div>
<div class=\"actions\" style=\"margin-top:12px\">
<button type="button" id="sendBtn">/push 전송</button>
</div>
</form>
</div>
<div class=\"card\">
<h2 style=\"margin:0 0 8px;\">데이터 읽기</h2>
<div class=\"actions\" style=\"margin-bottom:8px\">
<button id=\"btnTemp\">온도</button>
<button id=\"btnHum\">습도</button>
<button id=\"btnSoc\">배터리</button>
<button class=\"ghost\" id=\"btnStatus\">상태블록</button>
<button class=\"ghost\" id=\"btnAll\">전체</button>
</div>
</div>
<div class=\"card\"><h2 style=\"margin:0 0 8px;\">/push 응답</h2><pre id=\"rawView\">대기 중…</pre></div>
<div class=\"card\">
<h2 style=\"margin:0 0 8px;\">파싱 결과</h2>
<div class=\"kv\">
<div>CRC 유효성</div><div id=\"crcOk\">-</div>
<div>레지스터(2B)</div><div id=\"regs\">-</div>
<div>Float(워드스왑)</div><div id=\"floats\">-</div>
<div>파생값</div><div id=\"derived\">-</div>
</div>
<pre id=\"jsonView\" style=\"margin-top:10px\">대기 중…</pre>
</div>
<script>
// ==================== 셀렉트박스 'target' 선택 유지 개선 ====================
const sel = document.getElementById('target');
let isUserInteracting = false;
let lastKeysFingerprint = '';
let pollTimer = null;
// 사용자 상호작용 중에는 갱신 보류
['focus','mousedown'].forEach(ev=>{
sel.addEventListener(ev, ()=>{ isUserInteracting = true; });
});
['blur','change'].forEach(ev=>{
sel.addEventListener(ev, ()=>{
isUserInteracting = false;
localStorage.setItem('ui.target', sel.value || '');
});
});
// 초기 로드시 이전 선택 복원 예약
(function restoreSelection(){
const saved = localStorage.getItem('ui.target');
if (saved) sel.dataset.restore = saved;
})();
function renderOptions(items){
const options = items.map(it=>{
const label = it.device_id ? `${it.device_id} (${it.key})` : it.key;
return { value: it.key, label };
});
const current = sel.value;
const restore = sel.dataset.restore;
// 기존과 동일하면 DOM 갱신 스킵
const curValues = Array.from(sel.options).map(o=>o.value);
const newValues = options.map(o=>o.value);
const sameOrder = curValues.length === newValues.length && curValues.every((v,i)=>v===newValues[i]);
if (!sameOrder){
sel.innerHTML = '';
// 첫 줄: broadcast
const optAll = document.createElement('option');
optAll.value='*';
optAll.textContent = `*(broadcast) - ${items.length} connected`;
sel.appendChild(optAll);
for (const o of options){
const opt = document.createElement('option');
opt.value = o.value;
opt.textContent = o.label;
sel.appendChild(opt);
}
}else{
// 라벨만 갱신
let idx = 1; // 0은 broadcast
for (const o of options){
if (sel.options[idx] && sel.options[idx].textContent !== o.label){
sel.options[idx].textContent = o.label;
}
idx++;
}
}
// 선택 유지(현재값 > 저장값 > 유지 실패 시 기존 선택 유지)
const want = current || restore || '';
if (want && newValues.includes(want)) sel.value = want;
delete sel.dataset.restore; // 한 번 복원 후 제거
}
async function pollClients(){
if (isUserInteracting) return; // 드롭다운 열려 있으면 스킵
const info=document.getElementById('clientsInfo');
try{
const res = await fetch('/clients', { cache:'no-store' });
if (!res.ok) throw new Error('HTTP '+res.status);
const data = await res.json();
const items = (data.items||[]).sort((a,b)=>
(a.device_id||'').localeCompare(b.device_id||'') || a.key.localeCompare(b.key)
);
// 변경 없으면 렌더 스킵
const fp = JSON.stringify(items.map(it=>it.key));
if (fp !== lastKeysFingerprint){
renderOptions(items);
lastKeysFingerprint = fp;
}
info.textContent = `연결: ${items.length}개`;
}catch(e){
info.textContent = '클라이언트 조회 실패: '+(e?.message||e);
}
}
function startPolling(){
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollClients, 3000);
pollClients(); // 즉시 1회
}
// ==================== /clients 폴링 개선 끝 ====================
function cleanHexForServer(hex){ return (hex||"").replace(/0x/gi,"").replace(/[^0-9a-fA-F]/g,""); }
// 버튼 컨텍스트 저장 (TEMP|HUM|SOC|STATUS|ALL)
window.__lastKind = null;
async function pushHex(hex, forceAwait=false){
const url=document.getElementById('endpoint').value.trim()||'/push';
const targetSel=document.getElementById('target').value;
const awaitResp=forceAwait?true:(targetSel!=='*');
const payload={target:targetSel,data:cleanHexForServer(hex),encoding:document.getElementById('encoding')?.value||'hex',
append_newline:(document.getElementById('append_newline')?.value||'false')==='true',
await_response:awaitResp,response_timeout_ms:3000};
const res=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const text=await res.text(); document.getElementById('rawView').textContent=text;
try{ window.__lastPushJson=JSON.parse(text);}catch(e){ window.__lastPushJson=null; }
return window.__lastPushJson||text;
}
async function parseAuto(){
if(!window.__lastPushJson){ document.getElementById('jsonView').textContent='push 응답 JSON 없음(타겟 *, 또는 장치 무응답)'; return; }
const r=await fetch('/test_auto',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(window.__lastPushJson)});
if(!r.ok){ document.getElementById('jsonView').textContent='파서(/test_auto) 실패: http '+r.status; return; }
const json=await r.json(); document.getElementById('jsonView').textContent=JSON.stringify(json,null,2);
const ok=json?.result?.crc_ok; const regs=json?.result?.registers_be||[]; const flts=json?.result?.floats_wordswap||[];
document.getElementById('crcOk').textContent=ok?'✅ OK':'❌ FAIL';
document.getElementById('regs').textContent=regs.length?regs.map(v=>'0x'+v.toString(16).padStart(4,'0')).join(', '):'-';
document.getElementById('floats').textContent=flts.length?flts.map(v=>Number(v).toFixed(3)).join(', '):'-';
// 파생값(습도 %RH 계산: /1000)
try{
const derivedEl = document.getElementById('derived');
if (derivedEl) {
derivedEl.textContent = '-';
if (window.__lastKind === 'HUM') {
const dataHex = json?.result?.data_hex; // 4바이트
if (dataHex && dataHex.length === 8) {
const be = parseInt(dataHex, 16);
const humPct = be / 1000;
if (!Number.isNaN(humPct)) derivedEl.textContent = humPct.toFixed(3) + ' %RH (/1000)';
}
}
}
}catch(e){}
}
/* 제어 샘플 */
const UV_ON="01 06 00 14 00 01 08 0E", UV_OFF="01 06 00 14 00 00 C9 CE", FAN_ON="01 06 00 15 00 01 59 CE", FAN_OFF="01 06 00 15 00 00 98 0E", UNLOCK="01 06 00 16 00 01 A9 CE", LOCK="01 06 00 16 00 00 68 0E";
document.getElementById('ex_uv_on').onclick=()=>document.getElementById('data').value=UV_ON;
document.getElementById('ex_uv_off').onclick=()=>document.getElementById('data').value=UV_OFF;
document.getElementById('ex_fan_on').onclick=()=>document.getElementById('data').value=FAN_ON;
document.getElementById('ex_fan_off').onclick=()=>document.getElementById('data').value=FAN_OFF;
document.getElementById('ex_unlock').onclick=()=>document.getElementById('data').value=UNLOCK;
document.getElementById('ex_lock').onclick=()=>document.getElementById('data').value=LOCK;
document.getElementById('sendBtn').addEventListener('click', async ()=>{
await pushHex(document.getElementById('data').value, false);
});
/* 읽기 프레임 */
const READ_TEMP="01 03 00 01 00 02 95 CB", READ_HUM="01 03 00 03 00 02 34 0B", READ_SOC="01 03 00 19 00 02 15 CC", READ_STATUS="01 03 00 07 00 06 74 09", READ_ALL="01 03 00 01 00 1E 94 02";
async function doRead(hex, kind){ window.__lastKind = kind || null; const d=document.getElementById("derived"); if(d) d.textContent="-"; await pushHex(hex,true); await parseAuto(); }
document.getElementById('btnTemp').onclick=()=>doRead(READ_TEMP, "TEMP");
document.getElementById('btnHum').onclick=()=>doRead(READ_HUM, "HUM");
document.getElementById('btnSoc').onclick=()=>doRead(READ_SOC, "SOC");
document.getElementById('btnStatus').onclick=()=>doRead(READ_STATUS, "STATUS");
document.getElementById('btnAll').onclick=()=>doRead(READ_ALL, "ALL");
// 폴링 시작 (선택 유지 로직을 사용하는 새 폴링)
startPolling();
</script>
</body>
</html>"""
async def run_tcp():
server = await asyncio.start_server(handle_conn, TCP_HOST, TCP_PORT, reuse_port=True)
addrs = ", ".join(str(s.getsockname()) for s in server.sockets)
print(f"[tcp] listening on {addrs} (mode={MODE}, log={LOG_MODE})", flush=True)
async with server: await server.serve_forever()
async def run_http():
import uvicorn
server = uvicorn.Server(uvicorn.Config(app, host=CTRL_HOST, port=CTRL_PORT, log_level="info", reload=False))
await server.serve()
async def main(): await asyncio.gather(run_tcp(), run_http())
if __name__ == "__main__": asyncio.run(main())