ipyrf 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ipyrf/__init__.py +5 -0
- ipyrf/__main__.py +4 -0
- ipyrf/cli.py +164 -0
- ipyrf/controllers.py +77 -0
- ipyrf/interactive.py +250 -0
- ipyrf/logger.py +106 -0
- ipyrf/tcp.py +216 -0
- ipyrf/token_bucket.py +24 -0
- ipyrf/udp.py +185 -0
- ipyrf/utils.py +72 -0
- ipyrf-0.1.0.dist-info/METADATA +240 -0
- ipyrf-0.1.0.dist-info/RECORD +16 -0
- ipyrf-0.1.0.dist-info/WHEEL +5 -0
- ipyrf-0.1.0.dist-info/entry_points.txt +2 -0
- ipyrf-0.1.0.dist-info/licenses/LICENSE +23 -0
- ipyrf-0.1.0.dist-info/top_level.txt +1 -0
ipyrf/__init__.py
ADDED
ipyrf/__main__.py
ADDED
ipyrf/cli.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .logger import Logger
|
|
8
|
+
from .utils import parse_bandwidth, parse_ip, tcp_congestion_control_info
|
|
9
|
+
from . import tcp, udp
|
|
10
|
+
from .interactive import InteractiveController
|
|
11
|
+
from .controllers import StaticPacingController
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
p = argparse.ArgumentParser(description="Minimal iperf3-like tool (JSON output)")
|
|
16
|
+
|
|
17
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
18
|
+
common.add_argument("--port", type=int, default=5201, help="Port number")
|
|
19
|
+
common.add_argument(
|
|
20
|
+
"--logfile", help="Write log messages to a file instead of stdout"
|
|
21
|
+
)
|
|
22
|
+
common.add_argument(
|
|
23
|
+
"--json_log",
|
|
24
|
+
help="Write the log messages as JSON.",
|
|
25
|
+
action="store_true",
|
|
26
|
+
default=False,
|
|
27
|
+
)
|
|
28
|
+
common.add_argument("--interval", type=float, default=1.0, help="Stats interval")
|
|
29
|
+
|
|
30
|
+
subp = p.add_subparsers(dest="protocol", required=True)
|
|
31
|
+
|
|
32
|
+
tcp_parser = subp.add_parser("tcp", help="TCP mode")
|
|
33
|
+
tcp_sub = tcp_parser.add_subparsers(dest="role", required=True)
|
|
34
|
+
|
|
35
|
+
congestion_control = tcp_congestion_control_info()
|
|
36
|
+
common_tcp = argparse.ArgumentParser(add_help=False)
|
|
37
|
+
common_tcp.add_argument(
|
|
38
|
+
"--congestion-control",
|
|
39
|
+
choices=congestion_control.get("allowed", []),
|
|
40
|
+
default=None,
|
|
41
|
+
help=(
|
|
42
|
+
argparse.SUPPRESS
|
|
43
|
+
if congestion_control == {}
|
|
44
|
+
else (
|
|
45
|
+
"TCP: set congestion control algorithm "
|
|
46
|
+
f"(default: system default '{congestion_control.get('current')}')"
|
|
47
|
+
)
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
tcp_srv = tcp_sub.add_parser(
|
|
52
|
+
"server", parents=[common, common_tcp], help="Run a TCP server"
|
|
53
|
+
)
|
|
54
|
+
tcp_srv.add_argument(
|
|
55
|
+
"address", metavar="ADDRESS", type=parse_ip, help="Listen address"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
tcp_cli = tcp_sub.add_parser(
|
|
59
|
+
"client", parents=[common, common_tcp], help="Run a TCP client"
|
|
60
|
+
)
|
|
61
|
+
tcp_cli.add_argument(
|
|
62
|
+
"--bandwidth", type=parse_bandwidth, help="Target bandwidth, e.g., 50M"
|
|
63
|
+
)
|
|
64
|
+
tcp_cli.add_argument("address", metavar="ADDRESS", help="Server address to connect")
|
|
65
|
+
tcp_cli.add_argument(
|
|
66
|
+
"--set-mss", dest="set_mss", type=int, help="TCP: set TCP_MAXSEG (approx MSS)"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Time and interactive mode are mutually exclusive
|
|
70
|
+
tcp_time_group = tcp_cli.add_mutually_exclusive_group()
|
|
71
|
+
tcp_time_group.add_argument(
|
|
72
|
+
"--time", type=int, default=10, help="Test duration in seconds"
|
|
73
|
+
)
|
|
74
|
+
tcp_time_group.add_argument(
|
|
75
|
+
"--interactive", action="store_true", help="Run client in interactive mode"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
udp_parser = subp.add_parser("udp", help="UDP mode")
|
|
79
|
+
udp_sub = udp_parser.add_subparsers(dest="role", required=True)
|
|
80
|
+
|
|
81
|
+
udp_srv = udp_sub.add_parser("server", parents=[common], help="Run a UDP server")
|
|
82
|
+
udp_srv.add_argument(
|
|
83
|
+
"address", metavar="ADDRESS", type=parse_ip, help="Listen address"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
udp_cli = udp_sub.add_parser("client", parents=[common], help="Run a UDP client")
|
|
87
|
+
udp_cli.add_argument("address", metavar="ADDRESS", help="Server address to connect")
|
|
88
|
+
udp_cli.add_argument(
|
|
89
|
+
"--bandwidth", type=parse_bandwidth, help="Target bandwidth, e.g., 50M"
|
|
90
|
+
)
|
|
91
|
+
udp_cli.add_argument(
|
|
92
|
+
"-l", dest="length", type=int, default=1200, help="UDP payload length"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Time and interactive mode are mutually exclusive
|
|
96
|
+
udp_time_group = udp_cli.add_mutually_exclusive_group()
|
|
97
|
+
udp_time_group.add_argument(
|
|
98
|
+
"--time", type=int, default=10, help="Test duration in seconds"
|
|
99
|
+
)
|
|
100
|
+
udp_time_group.add_argument(
|
|
101
|
+
"--interactive", action="store_true", help="Run client in interactive mode"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
args = p.parse_args()
|
|
105
|
+
|
|
106
|
+
if args.role not in ("server", "client"):
|
|
107
|
+
raise ValueError(f"Invalid role: {args.role}. Must be 'server' or 'client'.")
|
|
108
|
+
|
|
109
|
+
log = Logger(args.json_log, args.protocol, args.role, args.logfile)
|
|
110
|
+
|
|
111
|
+
controller = None
|
|
112
|
+
if args.protocol == "udp":
|
|
113
|
+
if args.role == "server":
|
|
114
|
+
udp.server(log, args.address, args.port, args.interval)
|
|
115
|
+
else:
|
|
116
|
+
bw = (
|
|
117
|
+
args.bandwidth or parse_bandwidth("50M")
|
|
118
|
+
if args.interactive
|
|
119
|
+
else (args.bandwidth or 1e9)
|
|
120
|
+
)
|
|
121
|
+
if args.interactive:
|
|
122
|
+
controller = InteractiveController(bw, args.length, args.interval)
|
|
123
|
+
else:
|
|
124
|
+
controller = StaticPacingController(
|
|
125
|
+
bw, max(args.length, udp.UDP_HDR.size), args.time, args.interval
|
|
126
|
+
)
|
|
127
|
+
udp.client(
|
|
128
|
+
log,
|
|
129
|
+
args.address,
|
|
130
|
+
args.port,
|
|
131
|
+
args.length,
|
|
132
|
+
controller,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
else:
|
|
136
|
+
if args.role == "server":
|
|
137
|
+
tcp.server(
|
|
138
|
+
log, args.address, args.port, args.interval, args.congestion_control
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
if args.interactive:
|
|
142
|
+
quantum = args.set_mss if args.set_mss else 1200
|
|
143
|
+
# If no bandwidth provided, controller will act as unlimited until adjusted
|
|
144
|
+
controller = InteractiveController(
|
|
145
|
+
args.bandwidth, quantum, args.interval
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
controller = StaticPacingController(
|
|
149
|
+
args.bandwidth,
|
|
150
|
+
args.set_mss if args.set_mss else 1200,
|
|
151
|
+
args.time,
|
|
152
|
+
args.interval,
|
|
153
|
+
)
|
|
154
|
+
tcp.client(
|
|
155
|
+
log,
|
|
156
|
+
args.address,
|
|
157
|
+
args.port,
|
|
158
|
+
args.congestion_control,
|
|
159
|
+
args.set_mss,
|
|
160
|
+
controller,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if controller is not None:
|
|
164
|
+
controller.stop()
|
ipyrf/controllers.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import time
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
from .token_bucket import TokenBucket
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BasePacingController:
|
|
9
|
+
def __init__(self, interval_seconds: float = 1.0):
|
|
10
|
+
self.interval_seconds = interval_seconds
|
|
11
|
+
|
|
12
|
+
def is_pacing(self) -> bool:
|
|
13
|
+
return False
|
|
14
|
+
|
|
15
|
+
def maybe_sleep(self, n_bytes: int):
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
def get_update_fields(self) -> Dict[str, float]:
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
def should_stop(self) -> bool:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
def stop_reason(self) -> str:
|
|
25
|
+
return "unknown"
|
|
26
|
+
|
|
27
|
+
def start(self):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
def stop(self):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class StaticPacingController(BasePacingController):
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
bandwidth_bps: Optional[float],
|
|
38
|
+
quantum_bytes: int,
|
|
39
|
+
duration_seconds: float,
|
|
40
|
+
interval_seconds: float,
|
|
41
|
+
):
|
|
42
|
+
super().__init__(interval_seconds=interval_seconds)
|
|
43
|
+
self.bandwidth_bps = bandwidth_bps
|
|
44
|
+
self.duration_seconds = duration_seconds
|
|
45
|
+
self.start_time = None
|
|
46
|
+
self.tb: Optional[TokenBucket] = None
|
|
47
|
+
if bandwidth_bps is not None:
|
|
48
|
+
self.tb = TokenBucket(bandwidth_bps, quantum_bytes)
|
|
49
|
+
|
|
50
|
+
def is_pacing(self) -> bool:
|
|
51
|
+
return self.tb is not None
|
|
52
|
+
|
|
53
|
+
def maybe_sleep(self, n_bytes: int):
|
|
54
|
+
if self.tb is None:
|
|
55
|
+
return
|
|
56
|
+
while True:
|
|
57
|
+
sleep_time = self.tb.take(n_bytes)
|
|
58
|
+
if sleep_time <= 0:
|
|
59
|
+
break
|
|
60
|
+
time.sleep(sleep_time)
|
|
61
|
+
|
|
62
|
+
def get_update_fields(self) -> Dict[str, float]:
|
|
63
|
+
if self.bandwidth_bps is None:
|
|
64
|
+
return {}
|
|
65
|
+
return {"target_bandwidth_bps": float(self.bandwidth_bps)}
|
|
66
|
+
|
|
67
|
+
def start(self):
|
|
68
|
+
"""Call this when the test starts to begin duration tracking."""
|
|
69
|
+
self.start_time = time.time()
|
|
70
|
+
|
|
71
|
+
def should_stop(self) -> bool:
|
|
72
|
+
"""Check if the test should stop based on duration."""
|
|
73
|
+
assert self.start_time is not None
|
|
74
|
+
return (time.time() - self.start_time) >= self.duration_seconds
|
|
75
|
+
|
|
76
|
+
def stop_reason(self) -> str:
|
|
77
|
+
return "duration"
|
ipyrf/interactive.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
import threading
|
|
7
|
+
import termios
|
|
8
|
+
import fcntl
|
|
9
|
+
import select
|
|
10
|
+
|
|
11
|
+
from .utils import human_bps
|
|
12
|
+
from .token_bucket import TokenBucket
|
|
13
|
+
from .controllers import BasePacingController
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DynamicTokenBucket(TokenBucket):
|
|
17
|
+
"""Token bucket that allows updating the target rate at runtime."""
|
|
18
|
+
|
|
19
|
+
def set_rate_bps(self, new_rate_bps: float, quantum_bytes: int):
|
|
20
|
+
# Convert bits/sec -> bytes/sec as used internally by TokenBucket
|
|
21
|
+
self.rate_bps = max(1e-6, new_rate_bps / 8.0)
|
|
22
|
+
self.capacity = max(quantum_bytes * 2, int(self.rate_bps))
|
|
23
|
+
self.tokens = min(self.tokens, self.capacity)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class KeyReader:
|
|
27
|
+
"""Non-blocking key reader that recognizes arrow keys and a few chars.
|
|
28
|
+
|
|
29
|
+
Reads from /dev/tty (the controlling terminal), so stdout can be redirected.
|
|
30
|
+
Prevents terminal echo so arrow keys won't shift the cursor (no stray spaces).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self):
|
|
34
|
+
self.tty_path = "/dev/tty"
|
|
35
|
+
self.fd = None
|
|
36
|
+
self.orig_attrs = None
|
|
37
|
+
self.orig_flags = None
|
|
38
|
+
|
|
39
|
+
def __enter__(self):
|
|
40
|
+
try:
|
|
41
|
+
self.fd = os.open(self.tty_path, os.O_RDONLY | os.O_NONBLOCK)
|
|
42
|
+
except OSError:
|
|
43
|
+
self.fd = sys.stdin.fileno()
|
|
44
|
+
|
|
45
|
+
self.orig_attrs = termios.tcgetattr(self.fd)
|
|
46
|
+
attrs = termios.tcgetattr(self.fd)
|
|
47
|
+
lflag = attrs[3]
|
|
48
|
+
attrs[3] = lflag & ~(termios.ECHO | termios.ICANON | termios.IEXTEN)
|
|
49
|
+
cc = list(attrs[6])
|
|
50
|
+
cc[termios.VMIN] = 0
|
|
51
|
+
cc[termios.VTIME] = 0
|
|
52
|
+
attrs[6] = cc
|
|
53
|
+
termios.tcsetattr(self.fd, termios.TCSADRAIN, attrs)
|
|
54
|
+
|
|
55
|
+
self.orig_flags = fcntl.fcntl(self.fd, fcntl.F_GETFL)
|
|
56
|
+
fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_flags | os.O_NONBLOCK)
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def __exit__(self, exc_type, exc, tb):
|
|
60
|
+
try:
|
|
61
|
+
if self.orig_attrs is not None:
|
|
62
|
+
termios.tcsetattr(self.fd, termios.TCSADRAIN, self.orig_attrs)
|
|
63
|
+
if self.orig_flags is not None:
|
|
64
|
+
fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_flags)
|
|
65
|
+
if self.fd not in (None, sys.stdin.fileno()):
|
|
66
|
+
os.close(self.fd)
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def read_keys(self, stop_event: threading.Event, on_key):
|
|
71
|
+
buf = bytearray()
|
|
72
|
+
allowed_chars = {"q", "Q", "0", "u", "U"}
|
|
73
|
+
while not stop_event.is_set():
|
|
74
|
+
try:
|
|
75
|
+
rlist, _, _ = select.select([self.fd], [], [], 0.05)
|
|
76
|
+
except Exception:
|
|
77
|
+
rlist = []
|
|
78
|
+
if not rlist:
|
|
79
|
+
continue
|
|
80
|
+
try:
|
|
81
|
+
chunk = os.read(self.fd, 16)
|
|
82
|
+
except BlockingIOError:
|
|
83
|
+
continue
|
|
84
|
+
except Exception:
|
|
85
|
+
break
|
|
86
|
+
if not chunk:
|
|
87
|
+
continue
|
|
88
|
+
buf.extend(chunk)
|
|
89
|
+
while True:
|
|
90
|
+
if not buf:
|
|
91
|
+
break
|
|
92
|
+
c = buf[0]
|
|
93
|
+
if c == 0x1B: # ESC
|
|
94
|
+
if len(buf) >= 3 and buf[1] == 0x5B:
|
|
95
|
+
code = buf[2]
|
|
96
|
+
if code in (0x41, 0x42, 0x43, 0x44): # A,B,C,D
|
|
97
|
+
del buf[:3]
|
|
98
|
+
mapping = {
|
|
99
|
+
0x41: "UP",
|
|
100
|
+
0x42: "DOWN",
|
|
101
|
+
0x43: "RIGHT",
|
|
102
|
+
0x44: "LEFT",
|
|
103
|
+
}
|
|
104
|
+
on_key(mapping[code])
|
|
105
|
+
continue
|
|
106
|
+
if len(buf) < 3:
|
|
107
|
+
break
|
|
108
|
+
del buf[0]
|
|
109
|
+
continue
|
|
110
|
+
else:
|
|
111
|
+
ch = chr(c)
|
|
112
|
+
del buf[0]
|
|
113
|
+
if ch in allowed_chars:
|
|
114
|
+
on_key(ch)
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class InteractiveController(BasePacingController):
|
|
119
|
+
def __init__(
|
|
120
|
+
self, initial_bps: float | None, quantum_bytes: int, interval: float = 1.0
|
|
121
|
+
):
|
|
122
|
+
super().__init__(interval_seconds=interval)
|
|
123
|
+
self.lock = threading.Lock()
|
|
124
|
+
self.stop_event = threading.Event()
|
|
125
|
+
self.quantum = quantum_bytes
|
|
126
|
+
self.pacing = initial_bps is not None
|
|
127
|
+
self.target_bps = (
|
|
128
|
+
float(initial_bps) if initial_bps is not None else float("inf")
|
|
129
|
+
)
|
|
130
|
+
self.tb: DynamicTokenBucket | None = (
|
|
131
|
+
DynamicTokenBucket(self.target_bps, self.quantum) if self.pacing else None
|
|
132
|
+
)
|
|
133
|
+
self.keyloop_thread = start_interactive_keys(self, initial_bps)
|
|
134
|
+
|
|
135
|
+
def is_pacing(self) -> bool:
|
|
136
|
+
with self.lock:
|
|
137
|
+
return self.pacing and self.target_bps != float("inf")
|
|
138
|
+
|
|
139
|
+
def maybe_sleep(self, n_bytes: int):
|
|
140
|
+
tb = None
|
|
141
|
+
with self.lock:
|
|
142
|
+
tb = self.tb
|
|
143
|
+
if tb is None:
|
|
144
|
+
return
|
|
145
|
+
while True:
|
|
146
|
+
sleep_time = tb.take(n_bytes)
|
|
147
|
+
if sleep_time <= 0:
|
|
148
|
+
break
|
|
149
|
+
time.sleep(sleep_time)
|
|
150
|
+
|
|
151
|
+
def get_update_fields(self):
|
|
152
|
+
with self.lock:
|
|
153
|
+
if self.pacing and self.target_bps != float("inf"):
|
|
154
|
+
return {"target_bandwidth_bps": self.target_bps}
|
|
155
|
+
return {}
|
|
156
|
+
|
|
157
|
+
def should_stop(self) -> bool:
|
|
158
|
+
return self.stop_event.is_set()
|
|
159
|
+
|
|
160
|
+
def stop_reason(self) -> str:
|
|
161
|
+
return "user-stop"
|
|
162
|
+
|
|
163
|
+
def reset(self, initial_bps: float | None):
|
|
164
|
+
with self.lock:
|
|
165
|
+
if initial_bps is None:
|
|
166
|
+
self.target_bps = float("inf")
|
|
167
|
+
self.pacing = False
|
|
168
|
+
self.tb = None
|
|
169
|
+
else:
|
|
170
|
+
self.target_bps = float(initial_bps)
|
|
171
|
+
self.pacing = True
|
|
172
|
+
if self.tb is None:
|
|
173
|
+
self.tb = DynamicTokenBucket(self.target_bps, self.quantum)
|
|
174
|
+
else:
|
|
175
|
+
self.tb.set_rate_bps(self.target_bps, self.quantum)
|
|
176
|
+
|
|
177
|
+
def unlimited(self):
|
|
178
|
+
with self.lock:
|
|
179
|
+
self.pacing = False
|
|
180
|
+
self.target_bps = float("inf")
|
|
181
|
+
self.tb = None
|
|
182
|
+
|
|
183
|
+
def bump(self, delta_bps: float = 0.0, scale: float = 1.0):
|
|
184
|
+
with self.lock:
|
|
185
|
+
if not self.pacing or self.target_bps == float("inf"):
|
|
186
|
+
self.pacing = True
|
|
187
|
+
if not (self.target_bps != float("inf")):
|
|
188
|
+
self.target_bps = 50e6
|
|
189
|
+
if self.tb is None:
|
|
190
|
+
self.tb = DynamicTokenBucket(self.target_bps, self.quantum)
|
|
191
|
+
if scale != 1.0:
|
|
192
|
+
self.target_bps = max(1e3, self.target_bps * scale)
|
|
193
|
+
else:
|
|
194
|
+
self.target_bps = max(1e3, self.target_bps + delta_bps)
|
|
195
|
+
self.tb.set_rate_bps(self.target_bps, self.quantum)
|
|
196
|
+
return self.target_bps
|
|
197
|
+
|
|
198
|
+
def request_stop(self):
|
|
199
|
+
self.stop_event.set()
|
|
200
|
+
|
|
201
|
+
def stop(self):
|
|
202
|
+
if self.keyloop_thread is not None:
|
|
203
|
+
self.keyloop_thread.join()
|
|
204
|
+
self.keyloop_thread = None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def start_interactive_keys(
|
|
208
|
+
controller: InteractiveController, initial_bps: float | None
|
|
209
|
+
):
|
|
210
|
+
def on_key(k: str):
|
|
211
|
+
if k == "RIGHT":
|
|
212
|
+
new = controller.bump(delta_bps=1e6)
|
|
213
|
+
print(f"[interactive] target = {human_bps(new)}", file=sys.stderr)
|
|
214
|
+
elif k == "LEFT":
|
|
215
|
+
new = controller.bump(delta_bps=-1e6)
|
|
216
|
+
print(f"[interactive] target = {human_bps(new)}", file=sys.stderr)
|
|
217
|
+
elif k == "UP":
|
|
218
|
+
new = controller.bump(scale=1.10)
|
|
219
|
+
print(f"[interactive] target = {human_bps(new)}", file=sys.stderr)
|
|
220
|
+
elif k == "DOWN":
|
|
221
|
+
new = controller.bump(scale=0.90)
|
|
222
|
+
print(f"[interactive] target = {human_bps(new)}", file=sys.stderr)
|
|
223
|
+
elif k == "0":
|
|
224
|
+
controller.reset(initial_bps)
|
|
225
|
+
print(
|
|
226
|
+
f"[interactive] reset -> {human_bps(initial_bps if initial_bps is not None else float('inf'))}",
|
|
227
|
+
file=sys.stderr,
|
|
228
|
+
)
|
|
229
|
+
elif k in ("u", "U"):
|
|
230
|
+
controller.unlimited()
|
|
231
|
+
print("[interactive] pacing: unlimited", file=sys.stderr)
|
|
232
|
+
elif k in ("q", "Q"):
|
|
233
|
+
controller.request_stop()
|
|
234
|
+
|
|
235
|
+
print(
|
|
236
|
+
"[interactive] Controls: ← -1 Mbps, -> +1 Mbps, ↓ -10%, ↑ +10%, 0 reset, u unlimited, q quit, Ctrl+C exit",
|
|
237
|
+
file=sys.stderr,
|
|
238
|
+
)
|
|
239
|
+
print(
|
|
240
|
+
f"[interactive] starting at {human_bps(initial_bps if initial_bps is not None else float('inf'))}",
|
|
241
|
+
file=sys.stderr,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def _keyloop():
|
|
245
|
+
with KeyReader() as kr:
|
|
246
|
+
kr.read_keys(controller.stop_event, on_key)
|
|
247
|
+
|
|
248
|
+
keyloop_thread = threading.Thread(target=_keyloop, daemon=True)
|
|
249
|
+
keyloop_thread.start()
|
|
250
|
+
return keyloop_thread
|
ipyrf/logger.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .utils import human_readable_bytes
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
log_types = ["start", "test", "update", "summary"]
|
|
9
|
+
log_directions = ["tx", "rx"]
|
|
10
|
+
log_modes = ["tcp", "udp"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Logger:
|
|
14
|
+
def __init__(
|
|
15
|
+
self, json_log: bool, mode: str, role: str, logfile: str | None = None
|
|
16
|
+
):
|
|
17
|
+
self.json_log = json_log
|
|
18
|
+
assert mode in log_modes
|
|
19
|
+
self.mode = mode
|
|
20
|
+
self.direction = "tx" if role == "client" else "rx"
|
|
21
|
+
assert self.direction in log_directions
|
|
22
|
+
self.test_start_time = None
|
|
23
|
+
self.logfile = logfile
|
|
24
|
+
|
|
25
|
+
def start(self, ip: str, port: int):
|
|
26
|
+
self._log(
|
|
27
|
+
log_type="start",
|
|
28
|
+
mode=self.mode,
|
|
29
|
+
direction=self.direction,
|
|
30
|
+
address=f"{ip}:{port}",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def test(self, peer_ip: str, peer_port: int, start_ts: float):
|
|
34
|
+
self._log(
|
|
35
|
+
log_type="test",
|
|
36
|
+
peer=f"{peer_ip}:{peer_port}",
|
|
37
|
+
ts=start_ts,
|
|
38
|
+
)
|
|
39
|
+
self.test_start_time = start_ts
|
|
40
|
+
|
|
41
|
+
def update(self, start_ts: float, end_ts: float, bytes: int, **obj):
|
|
42
|
+
if self.test_start_time is None:
|
|
43
|
+
raise RuntimeError("test() must be called before update()")
|
|
44
|
+
end_ts -= self.test_start_time
|
|
45
|
+
start_ts -= self.test_start_time
|
|
46
|
+
delta_t = end_ts - start_ts
|
|
47
|
+
|
|
48
|
+
bps = (bytes * 8.0) / delta_t if delta_t > 0 else 0.0
|
|
49
|
+
self._log(
|
|
50
|
+
log_type="update",
|
|
51
|
+
start=start_ts,
|
|
52
|
+
end=end_ts,
|
|
53
|
+
bytes=bytes,
|
|
54
|
+
bits_per_second=bps,
|
|
55
|
+
**obj,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def summary(self, **obj):
|
|
59
|
+
self._log(log_type="summary", **obj)
|
|
60
|
+
|
|
61
|
+
def write(self, message: str):
|
|
62
|
+
if self.logfile:
|
|
63
|
+
with open(self.logfile, "a", buffering=1) as f:
|
|
64
|
+
f.write(message + "\n")
|
|
65
|
+
else:
|
|
66
|
+
print(message)
|
|
67
|
+
sys.stdout.flush()
|
|
68
|
+
|
|
69
|
+
def _log(self, log_type, **obj):
|
|
70
|
+
if self.json_log:
|
|
71
|
+
obj["type"] = log_type
|
|
72
|
+
obj["mode"] = self.mode
|
|
73
|
+
obj["direction"] = self.direction
|
|
74
|
+
self.write(json.dumps(obj, separators=(",", ":")) + "\n")
|
|
75
|
+
return
|
|
76
|
+
assert log_type in log_types
|
|
77
|
+
if log_type == "start":
|
|
78
|
+
self.write(
|
|
79
|
+
f"▶ {self.mode.upper()} {self.direction.upper()} — {obj['address']}"
|
|
80
|
+
)
|
|
81
|
+
elif log_type == "test":
|
|
82
|
+
self.write(f"▶ TEST peer={obj['peer']} ts={obj['ts']}")
|
|
83
|
+
elif log_type == "update":
|
|
84
|
+
message = (
|
|
85
|
+
f"⏱ {obj['start']:.2f}-{obj['end']:.2f} sec"
|
|
86
|
+
f" | {human_readable_bytes(obj['bytes'])}"
|
|
87
|
+
)
|
|
88
|
+
if "target_bandwidth_bps" in obj:
|
|
89
|
+
message += f" | {obj['bits_per_second'] / 1e6:.2f}/{obj['target_bandwidth_bps'] / 1e6:.2f} Mbps"
|
|
90
|
+
else:
|
|
91
|
+
message += f" | {obj['bits_per_second'] / 1e6:.2f} Mbps"
|
|
92
|
+
if "lost_packets" in obj and "packets" in obj:
|
|
93
|
+
message += f" | {obj['lost_packets']}/{obj['packets']} lost ({obj['lost_percent']:.1f}%)"
|
|
94
|
+
elif "packets" in obj:
|
|
95
|
+
message += f" | {obj['packets']} pkts"
|
|
96
|
+
self.write(message)
|
|
97
|
+
elif log_type == "summary":
|
|
98
|
+
self.write(
|
|
99
|
+
"\n━ SUMMARY ━\n"
|
|
100
|
+
f" {self.mode.upper()} {self.direction.upper()}\n"
|
|
101
|
+
f" {obj.get('sender', '')} → {obj.get('receiver', '')}\n"
|
|
102
|
+
f" duration : {obj['seconds']:.2f} sec\n"
|
|
103
|
+
f" data : {human_readable_bytes(obj['bytes'])}\n"
|
|
104
|
+
f" rate : {obj['bits_per_second'] / 1e6:.2f} Mbps\n"
|
|
105
|
+
f" reason : {obj.get('stop_reason', '')}\n"
|
|
106
|
+
)
|