최초 커밋
This commit is contained in:
28
compose.yaml
Normal file
28
compose.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
name: helmet
|
||||
|
||||
volumes:
|
||||
mysqldata:
|
||||
grafana-data:
|
||||
|
||||
networks:
|
||||
appnet:
|
||||
|
||||
services:
|
||||
|
||||
# ---------- TCP socket ----------
|
||||
socket_server:
|
||||
build:
|
||||
context: ./socket
|
||||
dockerfile: Dockerfile
|
||||
container_name: socket
|
||||
environment:
|
||||
TCP_PORT: 8181
|
||||
MODE: line # line | length
|
||||
ACK: "true"
|
||||
IDLE_TIMEOUT: 90
|
||||
LENGTH_BYTES: 2 # MODE=length일 때만 의미 (2 or 4)
|
||||
ports:
|
||||
- "8181:8181"
|
||||
# - "127.0.0.1:8182:8182"
|
||||
- "8182:8182"
|
||||
networks: [ appnet ]
|
9
socket/Dockerfile
Normal file
9
socket/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY modbus.py .
|
||||
ENV TCP_PORT=8181 MODE=stream LOG_MODE=both
|
||||
ENV CTRL_HOST=0.0.0.0 CTRL_PORT=8182
|
||||
EXPOSE 8181 8182
|
||||
CMD ["python","modbus.py"]
|
689
socket/modbus.py
Normal file
689
socket/modbus.py
Normal file
@@ -0,0 +1,689 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
RTU-over-TCP Modbus Master server
|
||||
- TCP(8181): device connects
|
||||
- HTTP(8182): /ui, /clients, /push_raw, /modbus/read, /modbus/write, /modbus/read_bulk8
|
||||
|
||||
PPT 8페이지(일괄 읽기) 최신 매핑(사용자 제공):
|
||||
온도 : 1~3 (3워드)
|
||||
습도 : 4~6 (3워드)
|
||||
UV 상태 : 7 (1워드, uint16)
|
||||
FAN 상태 : 8 (1워드, uint16)
|
||||
잠김 상태 : 9 (1워드, uint16)
|
||||
충전 중 상태 : 10 (1워드, uint16)
|
||||
충전 완료 상태 : 11 (1워드, uint16)
|
||||
안전모 유무 상태 : 12 (1워드, uint16)
|
||||
태양광 전압 : 13~15 (3워드)
|
||||
태양광 전류 : 16~18 (3워드)
|
||||
GPS 위도 : 19~21 (3워드)
|
||||
GPS 경도 : 22~24 (3워드)
|
||||
배터리 잔량 % : 25~27 (3워드)
|
||||
|
||||
메모:
|
||||
- 3워드 필드는 장치 펌웨어에 따라 ASCII(예: "26.4"), BCD/정수스케일, 또는 32bit float(+예약워드)일 수 있음.
|
||||
- 아래 디코더는 우선순위로 [ASCII -> 24/48bit 정수스케일 -> 32bit float(word-swap/normal)]을 시도한다.
|
||||
- 값의 합리성 체크(클램프)를 통해 가장 그럴듯한 해석을 최종 선택한다.
|
||||
"""
|
||||
|
||||
import os, asyncio, struct, base64, re, socket
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
|
||||
TCP_HOST = os.getenv("TCP_HOST", "0.0.0.0")
|
||||
TCP_PORT = int(os.getenv("TCP_PORT", "8181"))
|
||||
CTRL_HOST = os.getenv("CTRL_HOST", "0.0.0.0")
|
||||
CTRL_PORT = int(os.getenv("CTRL_PORT", "8182"))
|
||||
IDLE_TIMEOUT = int(os.getenv("IDLE_TIMEOUT", "120"))
|
||||
READ_CHUNK = int(os.getenv("READ_CHUNK", "65536"))
|
||||
LOG_MAX_BYTES = int(os.getenv("LOG_MAX_BYTES", "4096"))
|
||||
|
||||
CLIENTS: Dict[str, asyncio.StreamWriter] = {}
|
||||
META: Dict[str, Dict] = {}
|
||||
INBOX: Dict[str, asyncio.Queue] = {}
|
||||
|
||||
from collections import deque
|
||||
|
||||
# ===== In-memory time-series buffers =====
|
||||
TS_BUFFERS: Dict[str, deque] = {}
|
||||
TS_MAX = int(os.getenv("TS_MAX", "500")) # keep up to 500 samples per device
|
||||
TS_PERIOD_SEC = float(os.getenv("TS_PERIOD_SEC", "2.0")) # poll every 2 seconds
|
||||
|
||||
async def _poll_devices_task():
|
||||
"""Periodically read bulk8 (1..30) from all connected devices and store last TS_MAX samples."""
|
||||
while True:
|
||||
keys = list(CLIENTS.keys())
|
||||
for key in keys:
|
||||
try:
|
||||
start_addr, qty = 0x0001, 30
|
||||
frame = build_read_holding(1, start_addr, qty)
|
||||
parsed = parse_modbus_03_or_06(await _send_and_wait(key, frame, timeout=2.0))
|
||||
regs = parsed.get("registers_be", [])
|
||||
if not regs:
|
||||
continue
|
||||
fields = _decode_bulk_fields_v2(regs)
|
||||
def g(k):
|
||||
for f in fields:
|
||||
if f.get("key")==k:
|
||||
return f.get("value")
|
||||
return None
|
||||
sample = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"key": key,
|
||||
"temp": g("temp"),
|
||||
"humi": g("humi"),
|
||||
"pv_volt": g("pv_volt"),
|
||||
"pv_curr": g("pv_curr"),
|
||||
"batt_pct": g("batt_pct"),
|
||||
"uv_state": g("uv_state"),
|
||||
"fan_state": g("fan_state"),
|
||||
"lock": g("lock"),
|
||||
"chg_ing": g("chg_ing"),
|
||||
"chg_done": g("chg_done"),
|
||||
"helmet": g("helmet"),
|
||||
}
|
||||
dq = TS_BUFFERS.get(key)
|
||||
if dq is None:
|
||||
dq = deque(maxlen=TS_MAX)
|
||||
TS_BUFFERS[key] = dq
|
||||
dq.append(sample)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(TS_PERIOD_SEC)
|
||||
|
||||
|
||||
|
||||
def hexdump(b: bytes, width=16) -> str:
|
||||
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 log_payload(peer: str, 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")
|
||||
sample = data[:LOG_MAX_BYTES]
|
||||
print(hexdump(sample))
|
||||
if size > LOG_MAX_BYTES:
|
||||
print(f"... (truncated, showed first {LOG_MAX_BYTES} of {size} bytes)")
|
||||
|
||||
# ----- MODBUS RTU helpers -----
|
||||
def crc16_modbus(data: bytes) -> int:
|
||||
crc = 0xFFFF
|
||||
for b in data:
|
||||
crc ^= b
|
||||
for _ in range(8):
|
||||
if crc & 1: crc = (crc >> 1) ^ 0xA001
|
||||
else: crc >>= 1
|
||||
return crc & 0xFFFF
|
||||
|
||||
def add_crc(frame_wo_crc: bytes) -> bytes:
|
||||
c = crc16_modbus(frame_wo_crc)
|
||||
return frame_wo_crc + bytes([c & 0xFF, (c >> 8) & 0xFF])
|
||||
|
||||
def build_read_holding(unit: int, addr: int, qty: int) -> bytes:
|
||||
return add_crc(struct.pack(">B B H H", unit, 0x03, addr, qty))
|
||||
|
||||
def build_write_single(unit: int, addr: int, value: int) -> bytes:
|
||||
return add_crc(struct.pack(">B B H H", unit, 0x06, addr, value))
|
||||
|
||||
def clean_hex(s: str) -> str:
|
||||
s = s.strip().replace("0x","").replace("0X","")
|
||||
return re.sub(r"[^0-9A-Fa-f]", "", s).rjust((len(s)+1)//2*2, "0")
|
||||
|
||||
def parse_modbus_03_or_06(resp: bytes) -> dict:
|
||||
buf = resp[:-2] if resp.endswith(b"\r\n") else resp
|
||||
if len(buf) < 5: return {"error":"frame too short","len":len(buf),"hex":buf.hex()}
|
||||
uid, fc = buf[0], buf[1]
|
||||
if fc == 0x03:
|
||||
bc = buf[2]; data_end = 3 + bc
|
||||
if data_end + 2 > len(buf): return {"error":"byte_count mismatch","len":len(buf),"hex":buf.hex()}
|
||||
data = buf[3:data_end]
|
||||
pcrc = (buf[data_end+1] << 8) | buf[data_end]
|
||||
ccrc = crc16_modbus(buf[:data_end])
|
||||
regs = [int.from_bytes(data[i:i+2],"big") for i in range(0,len(data),2) if i+2<=len(data)]
|
||||
# 2워드 단위 float(word-swap)도 병행 저장(후보 해석용)
|
||||
floats_wordswap = []
|
||||
if len(data) >= 4 and len(data) % 2 == 0:
|
||||
for i in range(0, len(data) - 3, 4):
|
||||
w1, w2 = data[i:i+2], data[i+2:i+4]
|
||||
try:
|
||||
floats_wordswap.append(struct.unpack(">f", w2 + w1)[0])
|
||||
except Exception:
|
||||
floats_wordswap.append(float("nan"))
|
||||
return {"id":uid,"fc":fc,"byte_count":bc,"registers_be":regs,"floats_wordswap":floats_wordswap,
|
||||
"crc_ok":(pcrc==ccrc),"crc_provided":f"{pcrc:04x}","crc_calc":f"{ccrc:04x}","hex":buf.hex()}
|
||||
if fc == 0x06:
|
||||
if len(buf) < 8: return {"error":"write echo too short","len":len(buf),"hex":buf.hex()}
|
||||
addr = (buf[2] << 8) | buf[3]; val = (buf[4] << 8) | buf[5]
|
||||
pcrc = (buf[7] << 8) | buf[6]; ccrc = crc16_modbus(buf[:6])
|
||||
return {"id":uid,"fc":fc,"addr":addr,"value":val,"crc_ok":(pcrc==ccrc),
|
||||
"crc_provided":f"{pcrc:04x}","crc_calc":f"{ccrc:04x}","hex":buf.hex()}
|
||||
return {"id":uid,"fc":fc,"hex":buf.hex(),"note":"unsupported fc"}
|
||||
|
||||
# ----- TCP accept -----
|
||||
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, "last": datetime.now(timezone.utc)}
|
||||
INBOX[key] = asyncio.Queue()
|
||||
print(f"[tcp] connected: {key}")
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(reader.read(READ_CHUNK), timeout=1.0)
|
||||
except asyncio.TimeoutError:
|
||||
if (datetime.now(timezone.utc) - META[key]["last"]).total_seconds() > IDLE_TIMEOUT:
|
||||
print(f"[tcp] idle timeout: {key}"); break
|
||||
continue
|
||||
except asyncio.IncompleteReadError:
|
||||
break
|
||||
if not chunk: break
|
||||
META[key]["last"] = datetime.now(timezone.utc)
|
||||
log_payload(key, chunk)
|
||||
try: INBOX[key].put_nowait(chunk)
|
||||
except Exception: pass
|
||||
finally:
|
||||
try: writer.close(); await writer.wait_closed()
|
||||
except Exception: pass
|
||||
CLIENTS.pop(key, None); INBOX.pop(key, None); META.pop(key, None)
|
||||
print(f"[tcp] disconnected: {key}")
|
||||
|
||||
async def run_tcp():
|
||||
kwargs = {}
|
||||
if os.name != "nt" and hasattr(socket, "SO_REUSEPORT"):
|
||||
kwargs["reuse_port"] = True
|
||||
server = await asyncio.start_server(handle_conn, TCP_HOST, TCP_PORT, **kwargs)
|
||||
addrs = ", ".join(str(s.getsockname()) for s in server.sockets)
|
||||
print(f"[tcp] listening on {addrs}")
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
# ----- FastAPI -----
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
try:
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
except Exception:
|
||||
CORSMiddleware = None
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
app = FastAPI(title="RTU-over-TCP Modbus Master (device connects here)")
|
||||
if CORSMiddleware:
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
@app.get("/clients")
|
||||
def list_clients():
|
||||
return {"count": len(META), "items": [{"key":k, "last":v["last"].isoformat()} for k,v in META.items()]}
|
||||
|
||||
def _resolve_target(target: str) -> str:
|
||||
# Accept patterns:
|
||||
# - "ip:port" exact
|
||||
# - "ip:*" pick latest connection for that IP
|
||||
# - "*" or "" pick latest connection regardless of IP (for DHCP devices)
|
||||
if target in CLIENTS:
|
||||
return target
|
||||
# wildcard or empty -> most recent
|
||||
if not target or str(target).strip() in ("*", "*:*"):
|
||||
if not META:
|
||||
raise HTTPException(404, "no active connections")
|
||||
return max(META.items(), key=lambda kv: kv[1]["last"])[0]
|
||||
if ":" not in target or target.endswith(":*"):
|
||||
ip = target.split(":")[0].strip()
|
||||
cand = [(k, v["last"]) for k, v in META.items() if k.split(":")[0] == ip]
|
||||
if not cand:
|
||||
raise HTTPException(404, f"no active connection for ip {ip}")
|
||||
cand.sort(key=lambda kv: kv[1], reverse=True)
|
||||
return cand[0][0]
|
||||
raise HTTPException(404, "target not connected")
|
||||
|
||||
|
||||
async def _send_and_wait(key: str, payload: bytes, timeout: float=3.0) -> bytes:
|
||||
w = CLIENTS.get(key)
|
||||
if not w: raise HTTPException(404, "target writer missing")
|
||||
q = INBOX.get(key)
|
||||
if q is None: raise HTTPException(500, "inbox missing")
|
||||
while not q.empty():
|
||||
try: q.get_nowait()
|
||||
except Exception: break
|
||||
w.write(payload); await w.drain()
|
||||
try:
|
||||
resp = await asyncio.wait_for(q.get(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(504, "device response timeout")
|
||||
return resp
|
||||
|
||||
class PushReq(BaseModel):
|
||||
target: str; data: str
|
||||
encoding: str = "hex"; append_newline: bool = False
|
||||
await_response: bool = True; response_timeout_ms: int = 3000
|
||||
|
||||
@app.post("/push_raw")
|
||||
async def push_raw(req: PushReq):
|
||||
key = _resolve_target(req.target)
|
||||
enc = (req.encoding or "hex").lower()
|
||||
if enc == "hex": payload = bytes.fromhex(clean_hex(req.data))
|
||||
elif enc == "text": payload = req.data.encode() + (b"\n" if req.append_newline else b"")
|
||||
elif enc == "base64": payload = base64.b64decode(req.data)
|
||||
else: raise HTTPException(400, "encoding must be hex|text|base64")
|
||||
if req.await_response:
|
||||
resp = await _send_and_wait(key, payload, timeout=max(0.001, req.response_timeout_ms/1000.0))
|
||||
return {"ok":True,"sent_hex":payload.hex(),"resp_hex":resp.hex(),"resp_preview":resp[:128].decode("utf-8","replace")}
|
||||
w = CLIENTS[key]; w.write(payload); await w.drain()
|
||||
return {"ok":True,"sent_hex":payload.hex(),"resp":None}
|
||||
|
||||
class MBReadReq(BaseModel):
|
||||
target: str = Field(..., description="ip:port")
|
||||
unit: int = Field(1, ge=0, le=247)
|
||||
addr: int = Field(..., ge=0, le=0xFFFF)
|
||||
qty: int = Field(..., ge=1, le=125)
|
||||
|
||||
class MBWriteReq(BaseModel):
|
||||
target: str; unit: int = Field(1, ge=0, le=247)
|
||||
addr: int = Field(..., ge=0, le=0xFFFF)
|
||||
value: int = Field(..., ge=0, le=0xFFFF)
|
||||
|
||||
@app.post("/modbus/read")
|
||||
async def mb_read(req: MBReadReq):
|
||||
key = _resolve_target(req.target)
|
||||
frame = build_read_holding(req.unit, req.addr, req.qty)
|
||||
parsed = parse_modbus_03_or_06(await _send_and_wait(key, frame, timeout=3.0))
|
||||
return {"ok": True, "target": key, "tx_hex": frame.hex(), "rx": parsed}
|
||||
|
||||
@app.post("/modbus/write")
|
||||
async def mb_write(req: MBWriteReq):
|
||||
key = _resolve_target(req.target)
|
||||
frame = build_write_single(req.unit, req.addr, req.value)
|
||||
parsed = parse_modbus_03_or_06(await _send_and_wait(key, frame, timeout=3.0))
|
||||
return {"ok": True, "target": key, "tx_hex": frame.hex(), "rx": parsed}
|
||||
|
||||
# ====== Bulk8 (addr=1 qty=30) with NEW mapping ======
|
||||
class MBReadBulk8Req(BaseModel):
|
||||
target: str = Field(..., description="ip:port")
|
||||
unit: int = Field(1, ge=0, le=247)
|
||||
|
||||
def _words(regs: List[int], a: int, n: int) -> List[int]:
|
||||
i0 = a - 1
|
||||
out = []
|
||||
for i in range(n):
|
||||
j = i0 + i
|
||||
out.append(regs[j] if 0 <= j < len(regs) else None)
|
||||
return out
|
||||
|
||||
def _words_hex(regs: List[int], a: int, n: int) -> str:
|
||||
ws = _words(regs, a, n)
|
||||
bytes_list = []
|
||||
for w in ws:
|
||||
if w is None: bytes_list += ["..",".."]
|
||||
else: bytes_list += [f"{(w>>8)&0xFF:02X}", f"{w&0xFF:02X}"]
|
||||
return " ".join(bytes_list)
|
||||
|
||||
def _triplet_bytes(regs: List[int], a: int) -> Optional[bytes]:
|
||||
ws = _words(regs, a, 3)
|
||||
if any(w is None for w in ws): return None
|
||||
b = bytes([(ws[0]>>8)&0xFF, ws[0]&0xFF, (ws[1]>>8)&0xFF, ws[1]&0xFF, (ws[2]>>8)&0xFF, ws[2]&0xFF])
|
||||
return b
|
||||
|
||||
def _plausible(v: float, kind: str) -> bool:
|
||||
if v is None or v != v: return False
|
||||
if kind == "temp": return -40.0 <= v <= 120.0
|
||||
if kind == "humi": return 0.0 <= v <= 100.0
|
||||
if kind == "batt": return 0.0 <= v <= 100.0
|
||||
if kind == "volt": return 0.0 <= v <= 60.0 # 태양광 전압 대략 범위(필요시 조정)
|
||||
if kind == "curr": return -20.0 <= v <= 20.0
|
||||
if kind == "gps": return True # 위경도는 후처리에서 포맷
|
||||
return True
|
||||
|
||||
def _try_ascii_float(b: bytes) -> Optional[float]:
|
||||
s = "".join(chr(x) for x in b if 32 <= x <= 126).strip()
|
||||
if not s: return None
|
||||
# 허용 문자만
|
||||
if not re.fullmatch(r"[0-9\.\-\+\sA-Za-z]*", s): return None
|
||||
# 숫자/기호만 추출
|
||||
m = re.search(r"[-+]?\d+(\.\d+)?", s)
|
||||
if not m: return None
|
||||
try: return float(m.group(0))
|
||||
except: return None
|
||||
|
||||
def _try_24_or_48bit_scaled(b: bytes, scale: float=100.0) -> Optional[float]:
|
||||
# 24비트 정수(상위 3바이트) 또는 48비트 정수→scale로 나눔
|
||||
if len(b) < 3: return None
|
||||
i24 = int.from_bytes(b[0:3], "big", signed=False)
|
||||
v24 = i24/scale
|
||||
# 48비트도 후보
|
||||
i48 = int.from_bytes(b[:6], "big", signed=False)
|
||||
v48 = i48/scale if i48 < 10**9 else None # 너무 크면 버림
|
||||
# 선택
|
||||
cand = [v for v in [v24, v48] if v is not None]
|
||||
return cand[0] if cand else None
|
||||
|
||||
def _try_float32_from_words(w1: int, w2: int, swap: bool) -> Optional[float]:
|
||||
try:
|
||||
if swap:
|
||||
b = bytes([(w2>>8)&0xFF, w2&0xFF, (w1>>8)&0xFF, w1&0xFF])
|
||||
else:
|
||||
b = bytes([(w1>>8)&0xFF, w1&0xFF, (w2>>8)&0xFF, w2&0xFF])
|
||||
return struct.unpack(">f", b)[0]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _pair_bytes(regs: List[int], a: int) -> Optional[bytes]:
|
||||
ws = _words(regs, a, 2)
|
||||
if any(w is None for w in ws): return None
|
||||
return bytes([(ws[0]>>8)&0xFF, ws[0]&0xFF, (ws[1]>>8)&0xFF, ws[1]&0xFF])
|
||||
|
||||
def _try_ascii_float4(b: bytes) -> Optional[float]:
|
||||
s = "".join(chr(x) for x in b if 32 <= x <= 126).strip()
|
||||
if not s: return None
|
||||
m = re.search(r"[-+]?\d+(\.\d+)?", s)
|
||||
if not m: return None
|
||||
try: return float(m.group(0))
|
||||
except: return None
|
||||
|
||||
def _try_int32_scaled(b: bytes, scale: float=100.0) -> Optional[float]:
|
||||
if len(b) < 4: return None
|
||||
i32 = int.from_bytes(b[:4], "big", signed=False)
|
||||
return i32/scale
|
||||
|
||||
def _decode_pair_numeric(regs: List[int], a: int, kind: str) -> Tuple[Optional[float], str]:
|
||||
b = _pair_bytes(regs, a)
|
||||
if not b: return (None, "no-bytes")
|
||||
# 1) ASCII
|
||||
v = _try_ascii_float4(b)
|
||||
if v is not None and _plausible(v, kind): return (v, "ascii4")
|
||||
# 2) float32 swap/be
|
||||
ws = _words(regs, a, 2)
|
||||
w1, w2 = ws[0], ws[1]
|
||||
for swap in (True, False):
|
||||
v = _try_float32_from_words(w1, w2, swap=swap)
|
||||
if v is not None and _plausible(v, kind):
|
||||
return (v, "f32-"+("swap" if swap else "be"))
|
||||
# 3) int32 /100 then /1000
|
||||
for sc, tag in [(100.0,"int32/100"), (1000.0,"int32/1000")]:
|
||||
v = _try_int32_scaled(b, scale=sc)
|
||||
if v is not None and _plausible(v, kind): return (v, tag)
|
||||
return (None, "unknown")
|
||||
|
||||
|
||||
def _decode_triplet_numeric(regs: List[int], a: int, kind: str) -> Tuple[Optional[float], str]:
|
||||
"""3워드 필드 디코드. 반환: (value, how)"""
|
||||
b = _triplet_bytes(regs, a)
|
||||
if not b: return (None, "no-bytes")
|
||||
# 1) ASCII 우선
|
||||
v = _try_ascii_float(b)
|
||||
if v is not None and _plausible(v, kind): return (v, "ascii")
|
||||
# 2) 24/48비트 정수 스케일(기본 /100)
|
||||
v = _try_24_or_48bit_scaled(b, scale=100.0)
|
||||
if v is not None and _plausible(v, kind): return (v, "int/100")
|
||||
# 3) 32비트 float (w1,w2), word-swap/normal 모두 시도
|
||||
ws = _words(regs, a, 3)
|
||||
w1, w2 = ws[0], ws[1]
|
||||
for swap in (True, False):
|
||||
v = _try_float32_from_words(w1, w2, swap=swap)
|
||||
if v is not None and _plausible(v, kind):
|
||||
return (v, "f32-"+("swap" if swap else "be"))
|
||||
# 실패
|
||||
return (None, "unknown")
|
||||
|
||||
def _decode_bulk_fields_v2(regs: List[int]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
|
||||
def add_triplet(addr: int, key: str, name: str, unit: str="", digits:int=2, kind:str="generic", clamp:Tuple[float,float]=None):
|
||||
v, how = _decode_triplet_numeric(regs, addr, kind)
|
||||
if clamp and v is not None:
|
||||
v = max(clamp[0], min(clamp[1], v))
|
||||
out.append({
|
||||
"addr": addr, "len":3, "key": key, "name": name, "type":"triplet",
|
||||
"value": v, "unit": unit, "digits": digits, "how": how,
|
||||
"raw_words": _words_hex(regs, addr, 3)
|
||||
})
|
||||
|
||||
def add_u16(addr: int, key: str, name: str):
|
||||
w = _words(regs, addr, 1)[0]
|
||||
out.append({"addr": addr, "len":1, "key": key, "name": name, "type":"u16",
|
||||
"value": w, "unit":"", "raw_words": _words_hex(regs, addr, 1)})
|
||||
|
||||
# 매핑 적용
|
||||
add_triplet(1, "temp", "온도", unit="℃", digits=2, kind="temp", clamp=(-50.0, 120.0))
|
||||
add_triplet(4, "humi", "습도", unit="%", digits=2, kind="humi", clamp=(0.0, 100.0))
|
||||
add_u16(7, "uv_state", "UV 상태")
|
||||
add_u16(8, "fan_state", "FAN 상태")
|
||||
add_u16(9, "lock", "잠김 상태")
|
||||
add_u16(10, "chg_ing", "충전 중 상태")
|
||||
add_u16(11, "chg_done", "충전 완료 상태")
|
||||
add_u16(12, "helmet", "안전모 유무 상태")
|
||||
add_triplet(13, "pv_volt", "태양광 전압", unit="V", digits=2, kind="volt", clamp=(0.0, 80.0))
|
||||
add_triplet(16, "pv_curr", "태양광 전류", unit="A", digits=2, kind="curr", clamp=(-50.0, 50.0))
|
||||
lat_v, lat_how = _decode_pair_numeric(regs, 19, "gps")
|
||||
out.append({"addr":19, "len":2, "key":"gps_lat", "name":"GPS 위도", "type":"pair", "value":lat_v, "unit":"", "digits":6, "how":lat_how, "raw_words": _words_hex(regs, 19, 2)})
|
||||
add_u16(21, "gps_stat_lat", "GPS 센서 상태(위도)")
|
||||
lon_v, lon_how = _decode_pair_numeric(regs, 22, "gps")
|
||||
out.append({"addr":22, "len":2, "key":"gps_lon", "name":"GPS 경도", "type":"pair", "value":lon_v, "unit":"", "digits":6, "how":lon_how, "raw_words": _words_hex(regs, 22, 2)})
|
||||
add_u16(24, "gps_stat_lon", "GPS 센서 상태(경도)")
|
||||
add_triplet(25, "batt_pct","배터리 잔량", unit="%", digits=1, kind="batt", clamp=(0.0, 100.0))
|
||||
|
||||
# 참고용 RAW 덤프(미포함 영역만)
|
||||
covered = set()
|
||||
for base in (1,4,13,16,25):
|
||||
covered.update([base, base+1, base+2])
|
||||
covered.update([19,20,21,22,23,24])
|
||||
covered.update([7,8,9,10,11,12])
|
||||
|
||||
for a in range(1, 31):
|
||||
if a in covered: continue
|
||||
out.append({"addr": a, "len":1, "key": f"raw_{a}", "name": f"RAW@{a}", "type":"u16",
|
||||
"value": _words(regs, a, 1)[0], "raw_words": _words_hex(regs, a, 1)})
|
||||
|
||||
out.sort(key=lambda x: x["addr"])
|
||||
return out
|
||||
|
||||
@app.post("/modbus/read_bulk8")
|
||||
async def mb_read_bulk8(req: MBReadBulk8Req):
|
||||
key = _resolve_target(req.target)
|
||||
start_addr = 0x0001
|
||||
qty = 30 # 슬라이드 8: 1~30
|
||||
frame = build_read_holding(req.unit, start_addr, qty)
|
||||
parsed = parse_modbus_03_or_06(await _send_and_wait(key, frame, timeout=3.0))
|
||||
if not parsed or "registers_be" not in parsed:
|
||||
raise HTTPException(502, "invalid device response")
|
||||
regs = parsed.get("registers_be", [])
|
||||
fields = _decode_bulk_fields_v2(regs)
|
||||
return {"ok": True, "target": key, "tx_hex": frame.hex(), "rx": parsed, "fields": fields}
|
||||
|
||||
|
||||
|
||||
# ===== Time-series endpoints =====
|
||||
@app.get("/timeseries/keys")
|
||||
def ts_keys():
|
||||
return {"items": [{"key": k, "count": len(v)} for k, v in TS_BUFFERS.items()]}
|
||||
|
||||
class TSSamplesReq(BaseModel):
|
||||
target: str
|
||||
limit: int = Field(100, ge=1, le=1000)
|
||||
|
||||
@app.post("/timeseries/get")
|
||||
def ts_get(req: TSSamplesReq):
|
||||
key = _resolve_target(req.target)
|
||||
dq = TS_BUFFERS.get(key)
|
||||
if not dq:
|
||||
return {"ok": True, "target": key, "count": 0, "samples": []}
|
||||
n = min(len(dq), req.limit)
|
||||
samples = list(dq)[-n:]
|
||||
return {"ok": True, "target": key, "count": n, "samples": samples}
|
||||
|
||||
# ----- UI -----
|
||||
@app.get("/ui", response_class=HTMLResponse)
|
||||
def ui():
|
||||
return """<!doctype html>
|
||||
<html lang='ko'><head><meta charset='utf-8'/><title>RTU-over-TCP Modbus Master</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}
|
||||
.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:14px;box-shadow:0 1px 2px rgba(0,0,0,.03);margin-bottom:12px}
|
||||
input{padding:6px 8px;border:1px solid #d1d5db;border-radius:8px;font-size:14px}
|
||||
button.btn{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border:1px solid #d1d5db;border-radius:8px;background:#111827;color:#fff;font-size:14px;cursor:pointer}
|
||||
button.btn.ghost{background:#fff;color:#111827}
|
||||
table{border-collapse:collapse;width:100%} th,td{border:1px solid #e5e7eb;padding:6px 8px;text-align:left;font-size:14px}
|
||||
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||||
.muted{color:#6b7280;font-size:12px}
|
||||
.pill{display:inline-block;padding:4px 8px;border-radius:999px;background:#eef2ff;color:#1e3a8a;font-weight:600}
|
||||
.controls .btn{margin:4px 4px 0 0}
|
||||
.dash{display:grid;grid-template-columns:repeat(3, minmax(180px, 1fr));gap:10px}
|
||||
.stat{background:#0f172a;color:#e5e7eb;border-radius:14px;padding:14px;box-shadow:0 2px 8px rgba(0,0,0,.12)}
|
||||
.stat .label{font-size:13px;color:#94a3b8;margin-bottom:6px}
|
||||
.stat .value{font-size:40px;line-height:1.1;font-weight:800;letter-spacing:-0.5px}
|
||||
.stat .unit{font-size:18px;margin-left:4px;font-weight:600;opacity:.9}
|
||||
.stat .sub{font-size:12px;color:#94a3b8;margin-top:6px}
|
||||
.kvs td:nth-child(1){width:70px;color:#64748b;font-weight:600}
|
||||
.kvs td:nth-child(2){width:220px}
|
||||
.kvs td:nth-child(3){width:180px;color:#111827;font-weight:700}
|
||||
.kvs td:nth-child(4){color:#6b7280;font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, 'Courier New', monospace}
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>RTU-over-TCP Modbus Master</h1>
|
||||
|
||||
<div class='card'>
|
||||
<div class='row'>
|
||||
<span class="pill">1) 연결 선택</span>
|
||||
<button class="btn ghost" id='refresh'>연결 목록 새로고침</button>
|
||||
<span class="muted">표에서 행 클릭 → 선택행 적용</span>
|
||||
</div>
|
||||
<table id='tbl'><thead><tr><th>key</th><th>last</th></tr></thead><tbody></tbody></table>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<input id='target' placeholder='ip 또는 ip:*' style="min-width:260px">
|
||||
<input id='unit' type='number' value='1' min='0' max='247' style="width:90px"><label class="row" style="gap:6px;margin-left:8px"><input id="followIp" type="checkbox"> <span>IP로 자동추적</span></label>
|
||||
<button class="btn" id='useSel'>선택행 적용</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='card'>
|
||||
<div class='row'><span class="pill">2) 단말 제어</span><span class="muted">작은 버튼</span></div>
|
||||
<div class="controls">
|
||||
<button class="btn" onclick="actionWrite(0x0016,1)">잠금해제</button>
|
||||
<button class="btn" onclick="actionWrite(0x0014,1)">UV ON</button>
|
||||
<button class="btn" onclick="actionWrite(0x0014,0)">UV OFF</button>
|
||||
<button class="btn" onclick="actionWrite(0x0015,1)">FAN ON</button>
|
||||
<button class="btn" onclick="actionWrite(0x0015,0)">FAN OFF</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='card'>
|
||||
<div class='row'>
|
||||
<span class="pill">3) 대시보드</span>
|
||||
<label class="row" style="gap:6px"><input id="auto" type="checkbox" checked> <span>자동 새로고침</span></label>
|
||||
<span class="muted">주기(초)</span><input id="period" type="number" value="2" min="1" max="60" style="width:80px">
|
||||
<button class="btn ghost" onclick="refreshDashboard()">즉시 갱신</button>
|
||||
</div>
|
||||
<div class="dash" style="margin-top:10px">
|
||||
<div class="stat"><div class="label">온도</div><div><span id="tempVal" class="value">—</span><span class="unit">℃</span></div><div id="tempSub" class="sub">대기중…</div></div>
|
||||
<div class="stat"><div class="label">습도</div><div><span id="humiVal" class="value">—</span><span class="unit">%</span></div><div id="humiSub" class="sub">대기중…</div></div>
|
||||
<div class="stat"><div class="label">배터리</div><div><span id="battVal" class="value">—</span><span class="unit">%</span></div><div id="battSub" class="sub">대기중…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='card'>
|
||||
<div class='row'>
|
||||
<span class="pill">4) 일괄 상태 (PPT 8p 최신 매핑)</span>
|
||||
<button class="btn ghost" onclick="refreshBulk8()">일괄 갱신</button>
|
||||
<span class="muted">/modbus/read_bulk8: addr=1, qty=30</span>
|
||||
</div>
|
||||
<table id="tbl8" class="kvs">
|
||||
<thead><tr><th>주소</th><th>항목</th><th>값</th><th>Raw Words</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class='card'><h2>결과</h2><pre id='out'>대기중…</pre></div>
|
||||
|
||||
<script>
|
||||
let selectedKey = ""; let timer = null;
|
||||
async function loadClients(){
|
||||
const res = await fetch('/clients'); const j = await res.json();
|
||||
const tbody = document.querySelector('#tbl tbody'); tbody.innerHTML='';
|
||||
for(const it of (j.items||[])){
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `<td>${it.key}</td><td>${it.last}</td>`;
|
||||
tr.onclick = () => { [...tbody.children].forEach(x => x.style.background=''); tr.style.background = '#f1f5f9'; selectedKey = it.key; const ip = it.key.split(':')[0]; if(document.getElementById('followIp').checked){ document.getElementById('target').value = ip + ':*'; } else { document.getElementById('target').value = it.key; } };
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
function parseNum(v){ const s=(v||'').toString(); return s.startsWith('0x')||s.startsWith('0X') ? parseInt(s,16) : parseInt(s,10); }
|
||||
function getTU(){
|
||||
const t = document.getElementById('target').value.trim() || selectedKey;
|
||||
const u = parseNum(document.getElementById('unit').value||'1');
|
||||
if(!t) throw new Error('target을 선택/입력하세요'); return {target:t, unit:u};
|
||||
}
|
||||
document.getElementById('refresh').onclick=loadClients;
|
||||
document.getElementById('useSel').onclick=()=>{ if(!selectedKey){ alert('표에서 연결을 선택하세요'); return; } const ip = selectedKey.split(':')[0]; document.getElementById('target').value = document.getElementById('followIp').checked ? (ip+':*') : selectedKey; };
|
||||
|
||||
async function actionWrite(addr, value){
|
||||
try{
|
||||
const {target, unit} = getTU();
|
||||
const res=await fetch('/modbus/write',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({target,unit,addr,value})});
|
||||
document.getElementById('out').textContent=await res.text();
|
||||
}catch(e){ alert(e.message); }
|
||||
}
|
||||
|
||||
function fmt(v, digits=1){ if(v===null||v===undefined||isNaN(v)) return '—'; return Number(v).toFixed(digits); }
|
||||
function nowStr(){ return new Date().toLocaleString(); }
|
||||
|
||||
function renderBulkTable(fields){
|
||||
const tbody = document.querySelector('#tbl8 tbody'); tbody.innerHTML = "";
|
||||
for(const f of fields.sort((a,b)=>a.addr-b.addr)){
|
||||
const valStr = (typeof f.value==='number') ? fmt(f.value, f.digits||2) + (f.unit||'') : '—';
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `<td>${f.addr}${f.len===3?'~'+(f.addr+2):''}</td><td>${f.name}</td><td>${valStr}</td><td>${f.raw_words}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBulk8(){
|
||||
try{
|
||||
const {target, unit} = getTU();
|
||||
const res=await fetch('/modbus/read_bulk8',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({target,unit})});
|
||||
const j = await res.json(); if(!j.ok) throw new Error('read_bulk8 failed');
|
||||
renderBulkTable(j.fields||[]);
|
||||
const find = (k)=> (j.fields||[]).find(x=>x.key===k);
|
||||
const t = find('temp'); const h = find('humi'); const b = find('batt_pct');
|
||||
if(t){ document.getElementById('tempVal').textContent = fmt(t.value, t.digits||1); document.getElementById('tempSub').textContent = "갱신: "+nowStr(); }
|
||||
if(h){ document.getElementById('humiVal').textContent = fmt(h.value, h.digits||1); document.getElementById('humiSub').textContent = "갱신: "+nowStr(); }
|
||||
if(b){ document.getElementById('battVal').textContent = fmt(b.value, b.digits||0); document.getElementById('battSub').textContent = "갱신: "+nowStr(); }
|
||||
document.getElementById('out').textContent = JSON.stringify(j, null, 2);
|
||||
}catch(e){
|
||||
document.getElementById('tempSub').textContent = "에러: " + e.message;
|
||||
document.getElementById('humiSub').textContent = "에러: " + e.message;
|
||||
document.getElementById('battSub').textContent = "에러: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDashboard(){ await refreshBulk8(); }
|
||||
function setupAuto(){ const auto=document.getElementById('auto'), period=document.getElementById('period'); if(timer){clearInterval(timer); timer=null;} if(auto.checked){ const ms=Math.max(1, parseInt(period.value||"2",10))*1000; timer=setInterval(refreshDashboard, ms);} }
|
||||
document.getElementById('auto').addEventListener('change', setupAuto);
|
||||
document.getElementById('period').addEventListener('change', setupAuto);
|
||||
|
||||
document.getElementById('followIp').checked = (localStorage.getItem('followIp')||'0')==='1';document.getElementById('followIp').addEventListener('change', e=>localStorage.setItem('followIp', e.target.checked?'1':'0'));loadClients(); setupAuto(); setTimeout(refreshDashboard, 200);
|
||||
</script>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
# ----- entrypoint -----
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _start_poller():
|
||||
asyncio.create_task(_poll_devices_task())
|
||||
|
||||
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())
|
4
socket/requirements.txt
Normal file
4
socket/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
python-multipart
|
||||
pymodbus>=3.5,<4
|
564
socket/tcp_server.py
Normal file
564
socket/tcp_server.py
Normal 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())
|
Reference in New Issue
Block a user