#!/usr/bin/env python3
"""GUI 客户测 UDP：关节 FSNH / FNIM v2 240 / FNIM v3 384 / Pico 全身。1Hz 原地刷新，不滚屏。

  python tools/udp_receiver.py --port 15001
  python tools/udp_receiver.py --seconds 5 -q
  python tools/udp_receiver.py --self-check
"""

from __future__ import annotations

import argparse
import shutil
import socket
import struct
import sys
import time
from collections import defaultdict
from datetime import datetime

MAGIC_FSNH = 0x464E4853
FSNH_VERSION = 4
PACKET_SIZE_WRIST = 612
PACKET_SIZE_FOREARM = 640
HEADER_SIZE = 24
JOINTS_WRIST = 21
JOINTS_FOREARM = 22
JOINT_SIZE = 28
QUAT_ORDER_WXYZ = 0x77787A79
STATUS_NEW = 0x01
STATUS_FOREARM_LIVE = 0x02

STATUS_DERIVED = 0x04
STATUS_KNOWN = STATUS_NEW | STATUS_FOREARM_LIVE | STATUS_DERIVED

MAGIC_FNIM = 0x464E494D
FNIM_VERSION = 2
FNIM_SIZE = 240
FNIM_SLOTS = 6
FNIM_SLOT_SIZE = 32
FNIM_IMU_OFF = 216
FNIM_V3_VERSION = 3
FNIM_V3_SIZE = 384
FNIM_V3_SLOT_SIZE = 56
FNIM_V3_IMU_OFF = 360

PRINT_PERIOD = 1.0
MOCAP_SIZE = 1860
MOCAP_JOINTS = 64
MOCAP_ID = 0x07
MOCAP_POSE0 = 14  # AA55 + id + length + timestamp_ns
_HDR = struct.Struct("<I BBBB I Q I")
_FNIM_HDR = struct.Struct("<I B B H I Q I")
_FNIM_SLOT = struct.Struct("<3f 4f B 3x")
_FNIM_V3_SLOT = struct.Struct("<3f 4f 3f 3f B 3x")
_FNIM_IMU = struct.Struct("<6f")
_POSE = struct.Struct("<3f 4f")

NAMES_WRIST = (
    "palm",
    "th_mcp", "th_pip", "th_dip", "th_tip",
    "ix_mcp", "ix_pip", "ix_dip", "ix_tip",
    "md_mcp", "md_pip", "md_dip", "md_tip",
    "rg_mcp", "rg_pip", "rg_dip", "rg_tip",
    "pk_mcp", "pk_pip", "pk_dip", "pk_tip",
)
NAMES_FOREARM = ("forearm", "wrist") + NAMES_WRIST[1:]
NAMES_COIL = ("thumb", "index", "middle", "ring", "pinky", "forearm")
NAMES_MOCAP = (
    "Head", "Neck", "Chest", "Spine1", "Spine2", "Waist",
    "LeftCollar", "LeftShoulder", "LeftElbow", "LeftWrist", "LeftHand",
    "LeftHip", "LeftKnee", "LeftAnkle", "LeftFoot",
    "LeftThumbNear", "LeftThumbMid", "LeftThumbFar", "LeftThumbEnd",
    "LeftIndexNear", "LeftIndexMid", "LeftIndexFar", "LeftIndexEnd",
    "LeftMiddleNear", "LeftMiddleMid", "LeftMiddleFar", "LeftMiddleEnd",
    "LeftRingNear", "LeftRingMid", "LeftRingFar", "LeftRingEnd",
    "LeftPinkyNear", "LeftPinkyMid", "LeftPinkyFar", "LeftPinkyEnd",
    "RightCollar", "RightShoulder", "RightElbow", "RightWrist", "RightHand",
    "RightHip", "RightKnee", "RightAnkle", "RightFoot",
    "RightThumbNear", "RightThumbMid", "RightThumbFar", "RightThumbEnd",
    "RightIndexNear", "RightIndexMid", "RightIndexFar", "RightIndexEnd",
    "RightMiddleNear", "RightMiddleMid", "RightMiddleFar", "RightMiddleEnd",
    "RightRingNear", "RightRingMid", "RightRingFar", "RightRingEnd",
    "RightPinkyNear", "RightPinkyMid", "RightPinkyFar", "RightPinkyEnd",
)

_last_print = 0.0
_hz_t0 = 0.0
_block_rows = 0
_pkt = [None, None, None]
_meta = [None, None, None]
_n = [0, 0, 0]
_rx_t = [0.0, 0.0, 0.0]
_coil_pkt = [None, None, None]
_coil_n = [0, 0, 0]
_coil_t = [0.0, 0.0, 0.0]
_kind = ""
_body_pkt = None
_body_n = 0
_body_t = 0.0


def classify_fsnh(data: bytes):
    ln = len(data)
    if ln == PACKET_SIZE_WRIST:
        n, root = JOINTS_WRIST, "wrist"
    elif ln == PACKET_SIZE_FOREARM:
        n, root = JOINTS_FOREARM, "forearm"
    else:
        return None
    magic, version, hand, joint_count, status, _fi, _ts, quat = _HDR.unpack_from(data, 0)
    if magic != MAGIC_FSNH or version != FSNH_VERSION:
        return None
    if quat != QUAT_ORDER_WXYZ or joint_count != n or hand not in (1, 2):
        return None
    if status & ~STATUS_KNOWN:
        return None
    return n, root, hand


def classify_fnim(data: bytes):
    ln = len(data)
    if ln == FNIM_SIZE:
        expect = FNIM_VERSION
    elif ln == FNIM_V3_SIZE:
        expect = FNIM_V3_VERSION
    else:
        return None
    magic, version, flags, reserved, _fn, _ts, quat = _FNIM_HDR.unpack_from(data, 0)
    if magic != MAGIC_FNIM or version != expect:
        return None
    if reserved != 0 or quat != QUAT_ORDER_WXYZ or flags not in (1, 2):
        return None
    return version, flags


def classify_mocap(data: bytes) -> bool:
    if len(data) != MOCAP_SIZE:
        return False
    if data[0] != 0xAA or data[1] != 0x55:
        return False
    pkt_id = struct.unpack_from("<H", data, 2)[0]
    return pkt_id == MOCAP_ID


def stream_label(data: bytes) -> str | None:
    fsnh = classify_fsnh(data)
    if fsnh is not None:
        return "joints Left" if fsnh[2] == 1 else "joints Right"
    fnim = classify_fnim(data)
    if fnim is not None:
        return "coil Left" if fnim[1] == 1 else "coil Right"
    if classify_mocap(data):
        return "pico"
    return None


def interval_stats(times: list[float]) -> dict:
    n = len(times)
    if n < 2:
        return {"n": n, "span": 0.0, "hz": 0.0, "dts": []}
    span = times[-1] - times[0]
    dts = [(times[i] - times[i - 1]) * 1000.0 for i in range(1, n)]
    hz = (n - 1) / span if span > 0 else 0.0
    return {"n": n, "span": span, "hz": hz, "dts": dts}


def _pct(sorted_xs: list[float], p: float) -> float:
    n = len(sorted_xs)
    if n == 0:
        return 0.0
    i = min(n - 1, max(0, int((n - 1) * p)))
    return sorted_xs[i]


def _dt_hist(dts: list[float]) -> str:
    edges = (6.0, 7.5, 9.0, 10.0, 12.0)
    labels = ("<6", "6-7.5", "7.5-9", "9-10", "10-12", ">12")
    counts = [0] * len(labels)
    for d in dts:
        i = 0
        while i < len(edges) and d >= edges[i]:
            i += 1
        counts[i] += 1
    return "  ".join(f"{lab}:{c}" for lab, c in zip(labels, counts))


def print_interval_stats(rx_times: dict[str, list[float]], dump_dt: bool) -> None:
    if not rx_times:
        return
    print("120 Hz → 8.333 ms")
    for label in sorted(rx_times):
        st = interval_stats(rx_times[label])
        n, span, hz, dts = st["n"], st["span"], st["hz"], st["dts"]
        if n < 2:
            print(f"{label}: {n} frames")
            continue
        s = sorted(dts)
        mean = sum(dts) / len(dts)
        print(
            f"{label}: {n} frames  {span:.3f}s  {hz:.2f} Hz\n"
            f"  dt ms  mean={mean:.3f}  p50={_pct(s, 0.5):.3f}  "
            f"p95={_pct(s, 0.95):.3f}  min={s[0]:.3f}  max={s[-1]:.3f}\n"
            f"  hist   {_dt_hist(dts)}"
        )
        if dump_dt:
            for i, dt in enumerate(dts, 1):
                print(f"  {i:5d}  {dt:.3f} ms")


def _names(n: int) -> tuple[str, ...]:
    return NAMES_FOREARM if n == JOINTS_FOREARM else NAMES_WRIST


def _pose(data: bytes, base: int, j: int) -> tuple:
    return _POSE.unpack_from(data, base + j * JOINT_SIZE)


def _coil_slot(data: bytes, j: int) -> tuple:
    if len(data) == FNIM_V3_SIZE:
        return _FNIM_V3_SLOT.unpack_from(data, HEADER_SIZE + j * FNIM_V3_SLOT_SIZE)
    return _FNIM_SLOT.unpack_from(data, HEADER_SIZE + j * FNIM_SLOT_SIZE)


def _coil_imu(data: bytes) -> tuple:
    off = FNIM_V3_IMU_OFF if len(data) == FNIM_V3_SIZE else FNIM_IMU_OFF
    return _FNIM_IMU.unpack_from(data, off)


def _fmt_pose(p: tuple) -> str:
    x, y, z, qw, qx, qy, qz = p[:7]
    return f"({x:8.4f},{y:8.4f},{z:8.4f})  {qw:6.3f} {qx:6.3f} {qy:6.3f} {qz:6.3f}"


def _fmt_xyz(a: float, b: float, c: float) -> str:
    return f"({a:8.4f},{b:8.4f},{c:8.4f})"


def _term_cols() -> int:
    try:
        return shutil.get_terminal_size().columns
    except OSError:
        return 100


def _emit_block(lines: list[str]) -> None:
    global _block_rows
    if not sys.stdout.isatty():
        print("\n".join(lines))
        return
    cols = _term_cols()
    if _block_rows:
        sys.stdout.write(f"\033[{_block_rows}A")
    n = max(len(lines), _block_rows)
    for i in range(n):
        s = lines[i] if i < len(lines) else ""
        if len(s) > cols:
            s = s[: cols - 1]
        sys.stdout.write("\033[2K\r" + s.ljust(cols) + "\n")
    sys.stdout.flush()
    _block_rows = n


def _live_hands(pkts: list, times: list, now: float) -> list[int]:
    live = []
    for h in (1, 2):
        if pkts[h] is None or now - times[h] > 2.0:
            continue
        live.append(h)
    return live


def _render(now: float, hz: list[float]) -> list[str]:
    live = []
    for h in (1, 2):
        if _pkt[h] is None or _meta[h] is None or now - _rx_t[h] > 2.0:
            continue
        live.append(h)
    if not live:
        return []
    lines = [f"{datetime.now().strftime('%H:%M:%S')}  joints"]
    for h in live:
        n, root, base = _meta[h]
        names = _names(n)
        side = "Left" if h == 1 else "Right"
        hz_s = f"{hz[h]:.0f}Hz" if hz[h] > 0 else "…Hz"
        lines.append(f"  --- {side} {root} {hz_s} ---")
        lines.append(f" {'#':>2}  {'name':<7}  pos (m)                     wxyz")
        for j in range(n):
            name = names[j] if j < len(names) else f"j{j}"
            lines.append(f" {j:2d}  {name:<7}  {_fmt_pose(_pose(_pkt[h], base, j))}")
    return lines


def _render_coil(now: float, hz: list[float]) -> list[str]:
    live = _live_hands(_coil_pkt, _coil_t, now)
    if not live:
        return []
    lines = [f"{datetime.now().strftime('%H:%M:%S')}  coil"]
    for h in live:
        side = "Left" if h == 1 else "Right"
        hz_s = f"{hz[h]:.0f}Hz" if hz[h] > 0 else "…Hz"
        lines.append(f"  --- {side} {hz_s} ---")
        lines.append(f" {'#':>2}  {'name':<7}  pos (mm)                    wxyz           v")
        for j, name in enumerate(NAMES_COIL):
            slot = _coil_slot(_coil_pkt[h], j)
            if len(slot) == 14:
                x, y, z, qw, qx, qy, qz, cax, cay, caz, cgx, cgy, cgz, valid = slot
                pose = _fmt_pose((x, y, z, qw, qx, qy, qz))
                lines.append(f" {j:2d}  {name:<7}  {pose}  {valid}")
                lines.append(f" {'':>2}  {'imu':<7}  {_fmt_xyz(cax, cay, caz)} m/s2  {_fmt_xyz(cgx, cgy, cgz)} rad/s")
            else:
                x, y, z, qw, qx, qy, qz, valid = slot
                pose = _fmt_pose((x, y, z, qw, qx, qy, qz))
                lines.append(f" {j:2d}  {name:<7}  {pose}  {valid}")
        ax, ay, az, gx, gy, gz = _coil_imu(_coil_pkt[h])
        lines.append(f" {'':>2}  {'accel':<7}  {_fmt_xyz(ax, ay, az)} m/s2")
        lines.append(f" {'':>2}  {'gyro':<7}  {_fmt_xyz(gx, gy, gz)} rad/s")
    return lines


def _btn_names(bits: int, primary: str, secondary: str) -> str:
    names = []
    if bits & 0x02:
        names.append("stick")
    if bits & 0x04:
        names.append(primary)
    if bits & 0x08:
        names.append(secondary)
    if bits & 0x10:
        names.append("trig")
    if bits & 0x20:
        names.append("grip")
    return ",".join(names) if names else "-"


def _mocap_hands(data: bytes) -> tuple[int, float, float, int, float, float]:
    off = MOCAP_POSE0 + MOCAP_JOINTS * 28  # 1806
    return struct.unpack_from("<BffBff", data, off)


def _mocap_battery(data: bytes) -> tuple[int, ...]:
    off = MOCAP_POSE0 + MOCAP_JOINTS * 28 + 18  # 1824
    return struct.unpack_from("<8B", data, off)


def _render_body(now: float, hz: float) -> list[str]:
    if _body_pkt is None or now - _body_t > 2.0:
        return []
    hz_s = f"{hz:.0f}Hz" if hz > 0 else "…Hz"
    lines = [
        f"{datetime.now().strftime('%H:%M:%S')}  pico  {hz_s}",
        f" {'#':>2}  {'name':<16}  pos (m)                     wxyz",
    ]
    for j in range(MOCAP_JOINTS):
        name = NAMES_MOCAP[j]
        p = _pose(_body_pkt, MOCAP_POSE0, j)
        lines.append(f" {j:2d}  {name:<16}  {_fmt_pose(p)}")
    lb, lx, ly, rb, rx, ry = _mocap_hands(_body_pkt)
    lines.append(f"  L {_btn_names(lb, 'X', 'Y'):<16}  joy {lx:+.2f} {ly:+.2f}")
    lines.append(f"  R {_btn_names(rb, 'A', 'B'):<16}  joy {rx:+.2f} {ry:+.2f}")
    head, t1, t2, t3, t4, t5, bl, br = _mocap_battery(_body_pkt)
    lines.append(
        f"  bat head={head:<3}  t1={t1:<3} t2={t2:<3} t3={t3:<3} t4={t4:<3} t5={t5:<3}  L={bl:<3} R={br}"
    )
    return lines


def handle_packet(data: bytes, quiet: bool, force_print: bool = False) -> tuple[int, int]:
    global _last_print, _hz_t0, _kind, _body_pkt, _body_n, _body_t
    fsnh = classify_fsnh(data)
    fnim = classify_fnim(data)
    body = classify_mocap(data)
    if fsnh is None and fnim is None and not body:
        return 0, 1
    now = time.monotonic()
    if _hz_t0 == 0.0:
        _hz_t0 = now
    if fsnh is not None:
        _kind = "hand"
        n, root, hand = fsnh
        _pkt[hand] = data
        _meta[hand] = (n, root, HEADER_SIZE)
        _n[hand] += 1
        _rx_t[hand] = now
    elif fnim is not None:
        _kind = "coil"
        flags = fnim[1]
        _coil_pkt[flags] = data
        _coil_n[flags] += 1
        _coil_t[flags] = now
    else:
        _kind = "body"
        _body_pkt = data
        _body_n += 1
        _body_t = now
    if quiet:
        return 1, 0
    if not force_print and (now - _last_print) < PRINT_PERIOD:
        return 1, 0
    dt = now - _hz_t0
    hz = [0.0, 0.0, 0.0]
    coil_hz = [0.0, 0.0, 0.0]
    body_hz = 0.0
    if dt > 0.2:
        hz[1] = _n[1] / dt
        hz[2] = _n[2] / dt
        coil_hz[1] = _coil_n[1] / dt
        coil_hz[2] = _coil_n[2] / dt
        body_hz = _body_n / dt
    _n[1] = _n[2] = 0
    _coil_n[1] = _coil_n[2] = 0
    _body_n = 0
    _hz_t0 = now
    _last_print = now
    if _kind == "coil":
        lines = _render_coil(now, coil_hz)
    elif _kind == "body":
        lines = _render_body(now, body_hz)
    else:
        lines = _render(now, hz)
    if lines:
        _emit_block(lines)
    return 1, 0


def _pack_fsnh(n: int, hand: int, status: int | None = None) -> bytes:
    if status is None:
        status = STATUS_NEW if n == JOINTS_WRIST else (STATUS_NEW | STATUS_FOREARM_LIVE)
    hdr = _HDR.pack(MAGIC_FSNH, FSNH_VERSION, hand, n, status, 1, 0, QUAT_ORDER_WXYZ)
    rest = struct.pack("<3f 4f", 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0)
    one = struct.pack("<3f 4f", 0.19, -0.11, -0.03, 1.0, 0.0, 0.0, 0.0)
    tip_i = 4 if n == JOINTS_WRIST else 5
    joints = b"".join(one if j == tip_i else rest for j in range(n))
    return hdr + joints


def _pack_fnim(hand: int) -> bytes:
    hdr = _FNIM_HDR.pack(MAGIC_FNIM, FNIM_VERSION, hand, 0, 1, 0, QUAT_ORDER_WXYZ)
    slots = []
    for j in range(FNIM_SLOTS):
        valid = 0 if j == 5 else 1
        x = 10.0 * (j + 1)
        slots.append(_FNIM_SLOT.pack(x, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, valid))
    imu = _FNIM_IMU.pack(0.1, 0.2, 9.8, 0.01, 0.02, 0.03)
    return hdr + b"".join(slots) + imu


def _pack_fnim_v3(hand: int) -> bytes:
    hdr = _FNIM_HDR.pack(MAGIC_FNIM, FNIM_V3_VERSION, hand, 0, 1, 0, QUAT_ORDER_WXYZ)
    slots = []
    for j in range(FNIM_SLOTS):
        valid = 0 if j == 5 else 1
        x = 10.0 * (j + 1)
        ax = ay = az = 0.0
        gx = gy = gz = 0.0
        if j == 0:
            ax, ay, az = 1.5, -0.25, 9.81
            gx, gy, gz = 0.11, 0.22, 0.33
        slots.append(
            _FNIM_V3_SLOT.pack(x, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, ax, ay, az, gx, gy, gz, valid)
        )
    imu = _FNIM_IMU.pack(0.1, 0.2, 9.8, 0.01, 0.02, 0.03)
    return hdr + b"".join(slots) + imu


def _pack_mocap() -> bytes:
    buf = bytearray(MOCAP_SIZE)
    buf[0], buf[1] = 0xAA, 0x55
    struct.pack_into("<H", buf, 2, MOCAP_ID)
    struct.pack_into("<H", buf, 4, 1850)
    _POSE.pack_into(buf, MOCAP_POSE0, 1.0, 2.0, 3.0, 1.0, 0.0, 0.0, 0.0)
    return bytes(buf)


def self_check() -> None:
    assert len(NAMES_MOCAP) == MOCAP_JOINTS
    w = _pack_fsnh(JOINTS_WRIST, 1)
    f = _pack_fsnh(JOINTS_FOREARM, 2)
    assert len(w) == PACKET_SIZE_WRIST and len(f) == PACKET_SIZE_FOREARM
    assert classify_fsnh(w)[:2] == (21, "wrist")
    assert classify_fsnh(f)[:2] == (22, "forearm")
    assert handle_packet(w, True) == (1, 0)
    assert handle_packet(f, True) == (1, 0)
    assert classify_fsnh(w[:100]) is None
    # 插值帧（status 位 2）必须照收，未定义位仍须拒收。
    d = _pack_fsnh(JOINTS_WRIST, 1, STATUS_NEW | STATUS_DERIVED)
    assert classify_fsnh(d)[:2] == (21, "wrist")
    assert classify_fsnh(_pack_fsnh(JOINTS_WRIST, 1, STATUS_NEW | 0x08)) is None
    x, y, z = _pose(f, HEADER_SIZE, 5)[:3]
    assert abs(x - 0.19) < 1e-5
    c = _pack_fnim(1)
    assert len(c) == FNIM_SIZE
    assert classify_fnim(c) == (2, 1)
    assert classify_fsnh(c) is None
    assert handle_packet(c, True) == (1, 0)
    sx, sy, sz, _qw, _qx, _qy, _qz, valid = _coil_slot(c, 0)
    assert abs(sx - 10.0) < 1e-5 and valid == 1
    assert _coil_slot(c, 5)[7] == 0
    ax, ay, az, gx, gy, gz = _coil_imu(c)
    assert (abs(ax - 0.1) < 1e-5 and abs(az - 9.8) < 1e-5 and abs(gz - 0.03) < 1e-5)
    v3 = _pack_fnim_v3(2)
    assert len(v3) == FNIM_V3_SIZE
    assert classify_fnim(v3) == (3, 2)
    assert handle_packet(v3, True) == (1, 0)
    s0 = _coil_slot(v3, 0)
    assert abs(s0[0] - 10.0) < 1e-5 and s0[13] == 1
    assert abs(s0[7] - 1.5) < 1e-5 and abs(s0[9] - 9.81) < 1e-5
    assert _coil_slot(v3, 5)[13] == 0
    tax, tay, taz, tgx, tgy, tgz = _coil_imu(v3)
    assert (abs(tax - 0.1) < 1e-5 and abs(taz - 9.8) < 1e-5 and abs(tgz - 0.03) < 1e-5)
    m = _pack_mocap()
    assert classify_mocap(m) and classify_fsnh(m) is None and classify_fnim(m) is None
    assert handle_packet(m, True) == (1, 0)
    hx, hy, hz = _pose(m, MOCAP_POSE0, 0)[:3]
    assert (hx, hy, hz) == (1.0, 2.0, 3.0)
    assert _mocap_hands(m) == (0, 0.0, 0.0, 0, 0.0, 0.0)
    assert _mocap_battery(m) == (0, 0, 0, 0, 0, 0, 0, 0)
    assert stream_label(w) == "joints Left"
    assert stream_label(f) == "joints Right"
    assert stream_label(c) == "coil Left"
    assert stream_label(v3) == "coil Right"
    assert stream_label(m) == "pico"
    hz120 = [i / 120.0 for i in range(121)]
    st = interval_stats(hz120)
    assert st["n"] == 121 and abs(st["hz"] - 120.0) < 0.01
    assert abs(sum(st["dts"]) / len(st["dts"]) - 1000.0 / 120.0) < 0.01
    print("udp_receiver self-check ok (FSNH 612/640 + FNIM 240/384 + Pico 1860)")


def main() -> int:
    parser = argparse.ArgumentParser(
        description="UDP receiver: FSNH joints / FNIM 240/384 / Pico body (GUI test)"
    )
    parser.add_argument("--port", type=int, default=15001)
    parser.add_argument("--bind", type=str, default="0.0.0.0")
    parser.add_argument("-q", "--quiet", action="store_true", help="count only, no live table")
    parser.add_argument("-n", type=int, default=0, help="Stop after N packets (0=forever)")
    parser.add_argument(
        "--seconds",
        type=float,
        default=0.0,
        help="Stop N seconds after the first packet (0=forever)",
    )
    parser.add_argument(
        "--dump-dt",
        action="store_true",
        help="Print every inter-arrival interval in ms",
    )
    parser.add_argument("--self-check", action="store_true")
    args = parser.parse_args()
    if args.self_check:
        self_check()
        return 0

    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((args.bind, args.port))
    sock.settimeout(1.0)
    tty = sys.stdout.isatty()
    collect = f"  collect {args.seconds:g}s after first packet" if args.seconds > 0 else ""
    print(
        f"Listening {args.bind}:{args.port}  "
        f"FSNH 612/640 / FNIM 240/384 / Pico 1860  (1 Hz in-place){collect}"
    )
    if tty:
        sys.stdout.write("\033[?25l\033[?7l")
        sys.stdout.flush()

    count = 0
    errors = 0
    t_first = 0.0
    rx_times: dict[str, list[float]] = defaultdict(list)
    try:
        while args.n == 0 or count < args.n:
            try:
                data = sock.recvfrom(2048)[0]
            except socket.timeout:
                if args.seconds > 0 and t_first > 0.0 and (time.monotonic() - t_first) >= args.seconds:
                    break
                continue
            now = time.monotonic()
            label = stream_label(data)
            ok, _err = handle_packet(data, args.quiet, force_print=(count == 0))
            if not ok:
                errors += 1
                continue
            count += 1
            if label is not None:
                rx_times[label].append(now)
            if t_first == 0.0:
                t_first = now
            if args.seconds > 0 and (now - t_first) >= args.seconds:
                break
    except KeyboardInterrupt:
        print()
    finally:
        if tty:
            sys.stdout.write("\033[?7h\033[?25h")
            sys.stdout.flush()

    print(f"\nReceived: {count} packets, {errors} errors")
    print_interval_stats(rx_times, args.dump_dt)
    return 0 if errors == 0 or count > 0 else 1


if __name__ == "__main__":
    sys.exit(main())
