upload
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
#!/usr/bin/env python3
|
||||
"""NTRIP 캐스터 조사/검증 도구 — 마운트포인트가 '진짜 단일기준국'인지 판별한다.
|
||||
|
||||
VRS(가상 기지국)와 단일기준국(실제 상시관측소)은 같은 RTCM3 스트림으로 오지만
|
||||
메시지 구성이 다르다. 이 도구는 스트림을 직접 뜯어서 구분한다.
|
||||
|
||||
table 캐스터 소스테이블 출력 (인증 불필요)
|
||||
near 내 좌표에서 가까운 기준국 마운트포인트 정렬
|
||||
sniff 마운트포인트에 접속해 RTCM 메시지 종류/기지국 좌표/베이스라인을 관찰
|
||||
→ 기지국 좌표가 시간에 따라 움직이면 VRS, 고정이면 실제 기지국
|
||||
|
||||
사용 예:
|
||||
python3 ntrip_probe.py table gnssdata.or.kr 2101
|
||||
python3 ntrip_probe.py near gnssdata.or.kr 2101 37.5665 126.9780
|
||||
python3 ntrip_probe.py sniff RTS1.ngii.go.kr 2101 VRS-RTCM34 \
|
||||
--user ID --pass PW --gga-from-latlon 37.5665 126.9780 --secs 60
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import math
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
|
||||
# ── RTCM3 프레이밍 ──────────────────────────────────────────────────────────
|
||||
# 0xD3 | 6bit reserved + 10bit length | payload | 3byte CRC-24Q
|
||||
|
||||
_CRC24_TAB = []
|
||||
|
||||
|
||||
def _init_crc24():
|
||||
poly = 0x1864CFB
|
||||
for i in range(256):
|
||||
crc = i << 16
|
||||
for _ in range(8):
|
||||
crc <<= 1
|
||||
if crc & 0x1000000:
|
||||
crc ^= poly
|
||||
_CRC24_TAB.append(crc & 0xFFFFFF)
|
||||
|
||||
|
||||
_init_crc24()
|
||||
|
||||
|
||||
def crc24q(data: bytes) -> int:
|
||||
crc = 0
|
||||
for b in data:
|
||||
crc = ((crc << 8) & 0xFFFFFF) ^ _CRC24_TAB[((crc >> 16) ^ b) & 0xFF]
|
||||
return crc
|
||||
|
||||
|
||||
class Bits:
|
||||
"""MSB-first 비트 리더 (RTCM DF 필드용)."""
|
||||
|
||||
def __init__(self, buf: bytes):
|
||||
self.buf = buf
|
||||
self.pos = 0
|
||||
|
||||
def u(self, n: int) -> int:
|
||||
v = 0
|
||||
for _ in range(n):
|
||||
byte = self.buf[self.pos >> 3]
|
||||
v = (v << 1) | ((byte >> (7 - (self.pos & 7))) & 1)
|
||||
self.pos += 1
|
||||
return v
|
||||
|
||||
def s(self, n: int) -> int:
|
||||
v = self.u(n)
|
||||
return v - (1 << n) if v & (1 << (n - 1)) else v
|
||||
|
||||
|
||||
def parse_1005_1006(payload: bytes):
|
||||
"""1005/1006 → (station_id, X, Y, Z, antenna_height|None). 단위 m."""
|
||||
b = Bits(payload)
|
||||
msg = b.u(12)
|
||||
if msg not in (1005, 1006):
|
||||
return None
|
||||
sid = b.u(12)
|
||||
b.u(6) # ITRF realization year
|
||||
b.u(1); b.u(1); b.u(1) # GPS / GLONASS / Galileo indicator
|
||||
b.u(1) # reference station indicator
|
||||
x = b.s(38) * 1e-4
|
||||
b.u(1); b.u(1) # single receiver osc / reserved
|
||||
y = b.s(38) * 1e-4
|
||||
b.u(2) # quarter cycle indicator
|
||||
z = b.s(38) * 1e-4
|
||||
h = b.u(16) * 1e-4 if msg == 1006 else None
|
||||
return sid, x, y, z, h
|
||||
|
||||
|
||||
def ecef_to_lla(x, y, z):
|
||||
a, f = 6378137.0, 1 / 298.257223563
|
||||
e2 = f * (2 - f)
|
||||
lon = math.atan2(y, x)
|
||||
p = math.hypot(x, y)
|
||||
lat = math.atan2(z, p * (1 - e2))
|
||||
for _ in range(10):
|
||||
n = a / math.sqrt(1 - e2 * math.sin(lat) ** 2)
|
||||
alt = p / math.cos(lat) - n
|
||||
lat = math.atan2(z, p * (1 - e2 * n / (n + alt)))
|
||||
n = a / math.sqrt(1 - e2 * math.sin(lat) ** 2)
|
||||
return math.degrees(lat), math.degrees(lon), p / math.cos(lat) - n
|
||||
|
||||
|
||||
def haversine_km(lat1, lon1, lat2, lon2):
|
||||
r = 6371.0
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
h = (math.sin(dlat / 2) ** 2
|
||||
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2)
|
||||
return 2 * r * math.asin(math.sqrt(h))
|
||||
|
||||
|
||||
# 네트워크 RTK(=VRS/FKP/MAC) 임을 드러내는 메시지들
|
||||
NETWORK_MSGS = {
|
||||
1014: "Network Auxiliary Station Data (MAC)",
|
||||
1015: "GPS Ionospheric Correction Differences (MAC)",
|
||||
1016: "GPS Geometric Correction Differences (MAC)",
|
||||
1017: "GPS Combined Correction Differences (MAC)",
|
||||
1030: "GPS Network RTK Residual",
|
||||
1031: "GLONASS Network RTK Residual",
|
||||
1032: "Physical Reference Station Position (VRS)",
|
||||
1034: "GPS Network FKP Gradient",
|
||||
1035: "GLONASS Network FKP Gradient",
|
||||
}
|
||||
|
||||
|
||||
# ── NTRIP ───────────────────────────────────────────────────────────────────
|
||||
def ntrip_open(host, port, mount=None, user="", pw="", timeout=15):
|
||||
s = socket.create_connection((host, port), timeout=timeout)
|
||||
path = "/" + (mount or "")
|
||||
req = f"GET {path} HTTP/1.0\r\nUser-Agent: NTRIP ntrip_probe/1.0\r\nAccept: */*\r\n"
|
||||
if user or pw:
|
||||
auth = base64.b64encode(f"{user}:{pw}".encode()).decode()
|
||||
req += f"Authorization: Basic {auth}\r\n"
|
||||
req += "Connection: close\r\n\r\n"
|
||||
s.sendall(req.encode())
|
||||
return s
|
||||
|
||||
|
||||
def read_header(s):
|
||||
"""응답 헤더를 소진하고 (상태줄, 남은 바이트) 반환."""
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf and b"\n\n" not in buf:
|
||||
chunk = s.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
if buf.startswith(b"ICY 200 OK") and len(buf) > 12: # NTRIP1 짧은 응답
|
||||
break
|
||||
if len(buf) > 65536:
|
||||
break
|
||||
sep = b"\r\n\r\n" if b"\r\n\r\n" in buf else b"\n\n"
|
||||
head, _, rest = buf.partition(sep)
|
||||
status = head.split(b"\r\n")[0].decode("latin1", "replace")
|
||||
return status, head, rest
|
||||
|
||||
|
||||
def fetch_sourcetable(host, port):
|
||||
s = ntrip_open(host, port)
|
||||
data = b""
|
||||
try:
|
||||
while True:
|
||||
chunk = s.recv(8192)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
finally:
|
||||
s.close()
|
||||
return data.decode("latin1", "replace")
|
||||
|
||||
|
||||
def parse_str_rows(text, host=""):
|
||||
# 캐스터가 짧은 간격의 재요청을 조용히 끊는 일이 있다 — 빈 결과를 정상으로 오인하면 안 된다.
|
||||
if "STR;" not in text:
|
||||
print(f"# ⚠️ 소스테이블 응답에 STR 항목이 없음 ({len(text)} bytes)"
|
||||
+ (f" — {host} 재시도해볼 것" if host else ""), file=sys.stderr)
|
||||
if text[:200].strip():
|
||||
print("# 응답 앞부분: " + repr(text[:200]), file=sys.stderr)
|
||||
rows = []
|
||||
for line in text.splitlines():
|
||||
if not line.startswith("STR;"):
|
||||
continue
|
||||
f = line.split(";")
|
||||
if len(f) < 13:
|
||||
continue
|
||||
try:
|
||||
lat, lon = float(f[9]), float(f[10])
|
||||
except ValueError:
|
||||
lat = lon = None
|
||||
rows.append({
|
||||
"mount": f[1], "format": f[3], "details": f[4], "nav": f[6],
|
||||
"network": f[7], "lat": lat, "lon": lon,
|
||||
"nmea": f[11], "solution": f[12],
|
||||
"generator": f[13] if len(f) > 13 else "",
|
||||
"auth": f[15] if len(f) > 15 else "",
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def nmea_gga(lat, lon, alt=50.0):
|
||||
def dm(v, deg_w):
|
||||
d = int(abs(v))
|
||||
m = (abs(v) - d) * 60
|
||||
return f"{d:0{deg_w}d}{m:09.6f}"
|
||||
t = time.gmtime()
|
||||
body = (f"GPGGA,{t.tm_hour:02d}{t.tm_min:02d}{t.tm_sec:02d}.00,"
|
||||
f"{dm(lat,2)},{'N' if lat>=0 else 'S'},"
|
||||
f"{dm(lon,3)},{'E' if lon>=0 else 'W'},"
|
||||
f"1,10,1.0,{alt:.1f},M,20.0,M,,")
|
||||
ck = 0
|
||||
for c in body:
|
||||
ck ^= ord(c)
|
||||
return f"${body}*{ck:02X}\r\n".encode()
|
||||
|
||||
|
||||
# ── 서브커맨드 ──────────────────────────────────────────────────────────────
|
||||
def cmd_table(a):
|
||||
text = fetch_sourcetable(a.host, a.port)
|
||||
rows = parse_str_rows(text, a.host)
|
||||
print(f"# {a.host}:{a.port} — STR {len(rows)}개")
|
||||
print(f"{'MOUNTPOINT':<18} {'FORMAT':<9} {'NAV':<34} {'NET':<12} {'NMEA':<5} {'SOL':<4} {'종류'}")
|
||||
for r in rows:
|
||||
if a.filter and a.filter.upper() not in r["mount"].upper():
|
||||
continue
|
||||
kind = "네트워크(VRS/FKP/MAC)" if r["solution"] == "1" else "단일기준국"
|
||||
print(f"{r['mount']:<18} {r['format']:<9} {r['nav'][:34]:<34} "
|
||||
f"{r['network'][:12]:<12} {r['nmea']:<5} {r['solution']:<4} {kind}")
|
||||
net = sum(1 for r in rows if r["solution"] == "1")
|
||||
print(f"\n요약: 단일기준국 {len(rows)-net}개 / 네트워크 {net}개")
|
||||
print(" · SOL=0 → 실제 기준국 1곳의 관측치(단일기준국)")
|
||||
print(" · SOL=1 → 망 보간 해(VRS/FKP/MAC). NMEA=1 이면 GGA 업링크 필요")
|
||||
|
||||
|
||||
def cmd_near(a):
|
||||
rows = parse_str_rows(fetch_sourcetable(a.host, a.port), a.host)
|
||||
cand = []
|
||||
for r in rows:
|
||||
if r["lat"] is None or (r["lat"] == 0 and r["lon"] == 0):
|
||||
continue
|
||||
if a.format and a.format.upper() not in r["format"].upper().replace(" ", ""):
|
||||
continue
|
||||
cand.append((haversine_km(a.lat, a.lon, r["lat"], r["lon"]), r))
|
||||
cand.sort(key=lambda t: t[0])
|
||||
print(f"# 기준 위치 {a.lat:.5f}, {a.lon:.5f} — 가까운 순 {a.n}개"
|
||||
+ (f" (포맷 필터: {a.format})" if a.format else ""))
|
||||
print(f"{'거리km':>8} {'MOUNTPOINT':<18} {'FORMAT':<9} {'NAV':<34} {'SOL'}")
|
||||
for d, r in cand[:a.n]:
|
||||
print(f"{d:8.1f} {r['mount']:<18} {r['format']:<9} {r['nav'][:34]:<34} {r['solution']}")
|
||||
print("\n주의: 소스테이블 좌표는 반올림/오기입이 흔하다(0.1도 = 약 11km).")
|
||||
print(" 최종 확인은 sniff 로 1005/1006 기지국 실좌표를 봐야 한다.")
|
||||
|
||||
|
||||
def cmd_sniff(a):
|
||||
s = ntrip_open(a.host, a.port, a.mount, a.user, getattr(a, "pw"))
|
||||
status, head, rest = read_header(s)
|
||||
print(f"# 접속: {a.host}:{a.port}/{a.mount}")
|
||||
print(f"# 응답: {status}")
|
||||
if "200" not in status:
|
||||
print(head.decode("latin1", "replace"))
|
||||
return 1
|
||||
|
||||
gga = None
|
||||
if a.gga_from_latlon:
|
||||
gga = nmea_gga(a.gga_from_latlon[0], a.gga_from_latlon[1])
|
||||
s.sendall(gga)
|
||||
print(f"# GGA 업링크: {gga.decode().strip()}")
|
||||
|
||||
s.settimeout(5)
|
||||
buf = bytearray(rest)
|
||||
types = Counter()
|
||||
bases = {} # station_id -> 마지막 좌표
|
||||
base_moves = [] # (t, sid, 이동거리 m)
|
||||
total = 0
|
||||
t0 = time.time()
|
||||
last_gga = t0
|
||||
|
||||
while time.time() - t0 < a.secs:
|
||||
try:
|
||||
chunk = s.recv(4096)
|
||||
except socket.timeout:
|
||||
print(" (수신 없음 5초)")
|
||||
continue
|
||||
if not chunk:
|
||||
print("# 스트림 종료")
|
||||
break
|
||||
total += len(chunk)
|
||||
buf += chunk
|
||||
|
||||
i = 0
|
||||
while True:
|
||||
j = buf.find(b"\xd3", i)
|
||||
if j < 0 or len(buf) - j < 3:
|
||||
break
|
||||
# 길이 상위 바이트의 reserved 6bit 는 항상 0. 이걸 안 보면 페이로드 안의
|
||||
# 우연한 0xD3 을 프레임 시작으로 착각해 최대 1023B 동안 파서가 멈춘다.
|
||||
if buf[j + 1] & 0xFC:
|
||||
i = j + 1
|
||||
continue
|
||||
length = ((buf[j + 1] & 0x03) << 8) | buf[j + 2]
|
||||
frame_len = 3 + length + 3
|
||||
if len(buf) - j < frame_len:
|
||||
break
|
||||
frame = bytes(buf[j:j + frame_len])
|
||||
got = int.from_bytes(frame[-3:], "big")
|
||||
if crc24q(frame[:-3]) != got:
|
||||
i = j + 1 # 오정렬 → 다음 0xD3 부터
|
||||
continue
|
||||
payload = frame[3:3 + length]
|
||||
if length >= 2:
|
||||
mt = (payload[0] << 4) | (payload[1] >> 4)
|
||||
types[mt] += 1
|
||||
if mt in (1005, 1006):
|
||||
p = parse_1005_1006(payload)
|
||||
if p:
|
||||
sid, x, y, z, h = p
|
||||
prev = bases.get(sid)
|
||||
if prev and (abs(prev[0] - x) > 0.5 or abs(prev[1] - y) > 0.5
|
||||
or abs(prev[2] - z) > 0.5):
|
||||
d = math.dist(prev[:3], (x, y, z))
|
||||
base_moves.append((time.time() - t0, sid, d))
|
||||
print(f" ⚠️ 기지국 좌표 이동: id={sid} {d:.1f} m (t+{time.time()-t0:.0f}s)")
|
||||
bases[sid] = (x, y, z, h)
|
||||
i = j + frame_len
|
||||
del buf[:i]
|
||||
|
||||
if gga and time.time() - last_gga > 10:
|
||||
s.sendall(gga)
|
||||
last_gga = time.time()
|
||||
|
||||
s.close()
|
||||
|
||||
dur = time.time() - t0
|
||||
print(f"\n## 결과 ({dur:.0f}초, {total} bytes, {total/max(dur,1):.0f} B/s)")
|
||||
print("\n### RTCM 메시지 종류")
|
||||
for mt, c in sorted(types.items()):
|
||||
tag = f" ← {NETWORK_MSGS[mt]}" if mt in NETWORK_MSGS else ""
|
||||
print(f" {mt:<5} x{c:<6}{tag}")
|
||||
|
||||
print("\n### 기지국(1005/1006) 좌표")
|
||||
if not bases:
|
||||
print(" 없음 — 1005/1006 이 안 왔다. 관측 시간을 늘려보라(권장 60초 이상).")
|
||||
for sid, (x, y, z, h) in bases.items():
|
||||
lat, lon, alt = ecef_to_lla(x, y, z)
|
||||
line = f" id={sid} {lat:.7f}, {lon:.7f}, {alt:.2f}m"
|
||||
if h is not None:
|
||||
line += f" (안테나고 {h:.3f}m)"
|
||||
print(line)
|
||||
if a.gga_from_latlon:
|
||||
d = haversine_km(a.gga_from_latlon[0], a.gga_from_latlon[1], lat, lon)
|
||||
print(f" 베이스라인 {d:.2f} km")
|
||||
|
||||
print("\n### 판정")
|
||||
net_found = sorted(set(types) & set(NETWORK_MSGS))
|
||||
if net_found:
|
||||
print(f" ❌ 네트워크 RTK 스트림 — 네트워크 전용 메시지 {net_found} 존재")
|
||||
elif base_moves:
|
||||
print(f" ❌ 기지국 좌표가 {len(base_moves)}회 이동 — VRS(가상 기지국)")
|
||||
elif bases:
|
||||
print(" ✅ 단일기준국으로 보임 — 네트워크 메시지 없음 + 기지국 좌표 고정")
|
||||
else:
|
||||
print(" ? 판정 불가 — 1005/1006 미수신")
|
||||
print("\n ※ VRS 는 GGA 를 보낸 위치 근처에 가상 기지국을 만든다. 정지 상태로 짧게 보면")
|
||||
print(" 좌표가 고정으로 보일 수 있으므로, 네트워크 메시지 유무를 1차 근거로 삼을 것.")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p = sub.add_parser("table", help="소스테이블 출력")
|
||||
p.add_argument("host"); p.add_argument("port", type=int)
|
||||
p.add_argument("--filter", help="마운트포인트 이름 부분일치 필터")
|
||||
p.set_defaults(func=cmd_table)
|
||||
|
||||
p = sub.add_parser("near", help="내 위치에서 가까운 기준국 정렬")
|
||||
p.add_argument("host"); p.add_argument("port", type=int)
|
||||
p.add_argument("lat", type=float); p.add_argument("lon", type=float)
|
||||
p.add_argument("-n", type=int, default=10)
|
||||
p.add_argument("--format", help="포맷 필터 (예: RTCM3.2)")
|
||||
p.set_defaults(func=cmd_near)
|
||||
|
||||
p = sub.add_parser("sniff", help="스트림을 뜯어 VRS/단일기준국 판정")
|
||||
p.add_argument("host"); p.add_argument("port", type=int); p.add_argument("mount")
|
||||
p.add_argument("--user", default=""); p.add_argument("--pass", dest="pw", default="")
|
||||
p.add_argument("--gga-from-latlon", nargs=2, type=float, metavar=("LAT", "LON"),
|
||||
help="GGA 업링크 (VRS/nmea=1 마운트포인트에 필요)")
|
||||
p.add_argument("--secs", type=int, default=60)
|
||||
p.set_defaults(func=cmd_sniff)
|
||||
|
||||
a = ap.parse_args()
|
||||
sys.exit(a.func(a) or 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user