ndVpi 1.100.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.
ndvpi/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """ndVpi — фоновая оптимизация сети и сканер Wi-Fi."""
2
+ from .version import __version__
3
+ from .wifi import WiFiScanner
4
+ from .network_info import NetworkInfo
5
+ from .optimizer import NetworkOptimizer
6
+ from .network_config import NetworkConfig
7
+ from .monitor import NetworkMonitor
8
+ from .diagnostics import NetworkDiagnostics
9
+ from .history import SessionHistory
10
+
11
+ __all__ = [
12
+ "__version__",
13
+ "WiFiScanner",
14
+ "NetworkInfo",
15
+ "NetworkOptimizer",
16
+ "NetworkConfig",
17
+ "NetworkMonitor",
18
+ "NetworkDiagnostics",
19
+ "SessionHistory",
20
+ ]
21
+
22
+ try:
23
+ from . import collquan_ext # noqa: F401
24
+ except Exception:
25
+ pass
ndvpi/charm_enhance.py ADDED
@@ -0,0 +1,279 @@
1
+ """Мягкая интеграция с CharmPkk: красиво, если есть; обычно, если нет."""
2
+ import sys
3
+
4
+ _charm = None
5
+ _c = None
6
+ _available = False
7
+
8
+ try:
9
+ from charmpkk import console as _c
10
+ _charm = __import__("charmpkk")
11
+ _available = True
12
+ except Exception:
13
+ _available = False
14
+
15
+
16
+ def has_charm():
17
+ return _available
18
+
19
+
20
+ def console():
21
+ return _c
22
+
23
+
24
+ def theme(name="tokyo_night"):
25
+ if not _available:
26
+ return None
27
+ return _c.get_theme(name)
28
+
29
+
30
+ # ---------- Цветной вывод ----------
31
+
32
+ def _fg(color):
33
+ if not _available or not color:
34
+ return None
35
+ if isinstance(color, str) and color.startswith("#"):
36
+ return _c.fg_hex(color)
37
+ return color
38
+
39
+
40
+ def print_msg(text, color=None, style=None):
41
+ if _available:
42
+ print(_c.colorize(text, fg=_fg(color), style=style))
43
+ else:
44
+ print(text)
45
+
46
+
47
+ def banner(title, subtitle=None, color="#7aa2ff"):
48
+ if _available:
49
+ return _c.banner(title, subtitle, fg=_fg(color))
50
+ if subtitle:
51
+ return f"\n=== {title} ===\n{subtitle}\n"
52
+ return f"\n=== {title} ===\n"
53
+
54
+
55
+ def box(content, title=None, color="#7aa2ff"):
56
+ if _available:
57
+ return _c.box(content, title=title, border_fg=_fg(color))
58
+ lines = content if isinstance(content, list) else [content]
59
+ header = f"--- {title} ---" if title else "-" * 20
60
+ return "\n".join([header] + [str(l) for l in lines] + ["-" * 20])
61
+
62
+
63
+ def table(headers, rows, header_color="#7aa2ff"):
64
+ if _available:
65
+ t = _c.Table(headers=headers, rows=rows, header_fg=_fg(header_color))
66
+ return t.render()
67
+ all_rows = [list(headers)] + [list(r) for r in rows]
68
+ n_cols = len(headers)
69
+ widths = [max(len(str(r[i])) if i < len(r) else 0 for r in all_rows)
70
+ for i in range(n_cols)]
71
+ lines = []
72
+ for i, row in enumerate(all_rows):
73
+ cells = []
74
+ for j in range(n_cols):
75
+ v = str(row[j]) if j < len(row) else ""
76
+ cells.append(v.ljust(widths[j]))
77
+ lines.append(" | ".join(cells))
78
+ if i == 0:
79
+ lines.append("-+-".join("-" * w for w in widths))
80
+ return "\n".join(lines)
81
+
82
+
83
+ def key_value(data, key_color="#8b93a7"):
84
+ if _available:
85
+ return _c.key_value(data, key_fg=_fg(key_color))
86
+ max_k = max((len(str(k)) for k in data), default=0)
87
+ return "\n".join(f" {str(k).ljust(max_k)}: {v}" for k, v in data.items())
88
+
89
+
90
+ # ---------- Сообщения: печать (возвращают None) ----------
91
+
92
+ def info(msg, color="#3b82f6"):
93
+ if _available:
94
+ print(_c.info(msg))
95
+ else:
96
+ print(f"[i] {msg}")
97
+
98
+
99
+ def success(msg, color="#22c55e"):
100
+ if _available:
101
+ print(_c.success(msg))
102
+ else:
103
+ print(f"[+] {msg}")
104
+
105
+
106
+ def warning(msg, color="#f59e0b"):
107
+ if _available:
108
+ print(_c.warning(msg))
109
+ else:
110
+ print(f"[!] {msg}")
111
+
112
+
113
+ def error(msg, color="#ef4444"):
114
+ if _available:
115
+ print(_c.error(msg))
116
+ else:
117
+ print(f"[x] {msg}")
118
+
119
+
120
+ # ---------- Сообщения: рендер (возвращают строку) ----------
121
+ # Используйте эти версии, когда нужно встроить сообщение в составной текст
122
+ # (например, в "\n".join(...)), а не печатать его сразу.
123
+
124
+ def render_info(msg, color="#3b82f6"):
125
+ if _available:
126
+ return _c.info(msg)
127
+ return f"[i] {msg}"
128
+
129
+
130
+ def render_success(msg, color="#22c55e"):
131
+ if _available:
132
+ return _c.success(msg)
133
+ return f"[+] {msg}"
134
+
135
+
136
+ def render_warning(msg, color="#f59e0b"):
137
+ if _available:
138
+ return _c.warning(msg)
139
+ return f"[!] {msg}"
140
+
141
+
142
+ def render_error(msg, color="#ef4444"):
143
+ if _available:
144
+ return _c.error(msg)
145
+ return f"[x] {msg}"
146
+
147
+
148
+ # ---------- Интерактив ----------
149
+
150
+ def ask(prompt, default=None):
151
+ if _available:
152
+ return _c.ask(prompt, default=default)
153
+ hint = f" [{default}]" if default is not None else ""
154
+ try:
155
+ v = input(f"? {prompt}{hint}: ").strip()
156
+ return v or default
157
+ except (EOFError, KeyboardInterrupt):
158
+ return default
159
+
160
+
161
+ def confirm(prompt, default=False):
162
+ if _available:
163
+ return _c.confirm(prompt, default=default)
164
+ d = "Y/n" if default else "y/N"
165
+ try:
166
+ v = input(f"? {prompt} ({d}): ").strip().lower()
167
+ except (EOFError, KeyboardInterrupt):
168
+ return default
169
+ return v.startswith("y") if v else default
170
+
171
+
172
+ def select(title, options, color="#7aa2ff"):
173
+ if not options:
174
+ return None
175
+ if _available:
176
+ return _c.select(title, options, fg_cursor=_fg(color), fg_title=_fg(color))
177
+ print(title)
178
+ for i, opt in enumerate(options, 1):
179
+ print(f" {i}. {opt}")
180
+ try:
181
+ idx = int(input("> ").strip()) - 1
182
+ if 0 <= idx < len(options):
183
+ return options[idx]
184
+ except (ValueError, EOFError, KeyboardInterrupt):
185
+ return None
186
+
187
+
188
+ # ---------- Прогресс и спиннеры ----------
189
+
190
+ class _NullProgress:
191
+ def __enter__(self):
192
+ return self
193
+
194
+ def __exit__(self, *exc):
195
+ print()
196
+
197
+ def update(self, current):
198
+ sys.stdout.write(f"\r {current}%")
199
+ sys.stdout.flush()
200
+
201
+ def finish(self, msg=None):
202
+ if msg:
203
+ print(msg)
204
+
205
+
206
+ def progress(total=100, label="", color="#7aa2ff"):
207
+ if _available:
208
+ return _c.FancyProgressBar(total=total, label=label, fg=_fg(color))
209
+ return _NullProgress()
210
+
211
+
212
+ class _NullSpinner:
213
+ def __enter__(self):
214
+ print()
215
+ return self
216
+
217
+ def __exit__(self, *exc):
218
+ print()
219
+
220
+ def stop(self, final=None):
221
+ if final:
222
+ print(final)
223
+
224
+
225
+ def spinner(text="Loading", color="#7aa2ff"):
226
+ if _available:
227
+ return _c.Spinner(text, fg=_fg(color))
228
+ print(text + "...")
229
+ return _NullSpinner()
230
+
231
+
232
+ # ---------- Live Dashboard ----------
233
+
234
+ class LiveDashboard:
235
+ """Живой дашборд. С CharmPkk использует Live, без него — ANSI-перерисовку."""
236
+
237
+ def __init__(self):
238
+ self._live = None
239
+ self._running = False
240
+ self._last_lines = 0
241
+
242
+ def __enter__(self):
243
+ if _available:
244
+ self._live = _c.Live()
245
+ self._live.__enter__()
246
+ self._running = True
247
+ return self
248
+
249
+ def update(self, content):
250
+ if not self._running:
251
+ return
252
+
253
+ if _available and self._live:
254
+ self._live.update(content)
255
+ else:
256
+ lines = content.split("\n")
257
+ if self._last_lines > 0:
258
+ sys.stdout.write(f"\033[{self._last_lines}A")
259
+
260
+ max_lines = max(self._last_lines, len(lines))
261
+ for i in range(max_lines):
262
+ sys.stdout.write("\033[2K")
263
+ if i < len(lines):
264
+ sys.stdout.write(lines[i])
265
+ sys.stdout.write("\n")
266
+
267
+ if max_lines > len(lines):
268
+ sys.stdout.write(f"\033[{max_lines - len(lines)}A")
269
+
270
+ self._last_lines = len(lines)
271
+ sys.stdout.flush()
272
+
273
+ def __exit__(self, *exc):
274
+ self._running = False
275
+ if _available and self._live:
276
+ self._live.__exit__(*exc)
277
+ else:
278
+ sys.stdout.write("\n")
279
+ return False
ndvpi/cli.py ADDED
@@ -0,0 +1,15 @@
1
+ """CLI для ndVpi."""
2
+ import sys
3
+
4
+
5
+ def main():
6
+ from .menu import run
7
+ try:
8
+ return run()
9
+ except KeyboardInterrupt:
10
+ print()
11
+ return 130
12
+
13
+
14
+ if __name__ == "__main__":
15
+ sys.exit(main())
ndvpi/collquan_ext.py ADDED
@@ -0,0 +1,151 @@
1
+ """Интеграция ndVpi с Collquan (CharmPkk)."""
2
+ try:
3
+ from charmpkk.terminals import CollquanShell
4
+ except Exception:
5
+ CollquanShell = None
6
+
7
+ from .wifi import WiFiScanner
8
+ from .network_info import NetworkInfo
9
+ from .optimizer import NetworkOptimizer
10
+ from .network_config import NetworkConfig, _is_admin
11
+ from .monitor import NetworkMonitor
12
+ from .diagnostics import NetworkDiagnostics
13
+ from .history import SessionHistory
14
+ from pathlib import Path
15
+
16
+ _optimizer = NetworkOptimizer()
17
+ _config = NetworkConfig()
18
+ _history = SessionHistory() # Глобальная история для Collquan
19
+
20
+
21
+ def _cmd_scan(args):
22
+ data = WiFiScanner().current_network()
23
+ if not data:
24
+ print("Нет данных о Wi-Fi.")
25
+ return
26
+ for k, v in data.items():
27
+ print(f"{k}: {v}")
28
+
29
+
30
+ def _cmd_info(args):
31
+ info = NetworkInfo()
32
+ print(f"IP: {info.local_ip()}")
33
+ print(f"Шлюз: {info.gateway()}")
34
+ print(f"DNS: {', '.join(info.dns_servers()) or '—'}")
35
+ print(f"Ping: {info.ping() or '—'} ms")
36
+
37
+
38
+ def _cmd_opt(args):
39
+ if args and args[0] == "start":
40
+ _optimizer.start()
41
+ print("Оптимизатор запущен.")
42
+ elif args and args[0] == "stop":
43
+ _optimizer.stop()
44
+ print("Оптимизатор остановлен.")
45
+ else:
46
+ print("Использование: ndopt start|stop")
47
+
48
+
49
+ def _cmd_status(args):
50
+ s = _optimizer.status()
51
+ print(f"Запущен: {s['running']}")
52
+ print(f"DNS-кэш: {s['dns_cache_size']} записей (TTL {s['dns_ttl']}s)")
53
+
54
+
55
+ def _cmd_dns(args):
56
+ if not args or args[0] == "show":
57
+ servers = _config.get_dns()
58
+ print("DNS:", ", ".join(servers) if servers else "автоматически")
59
+ return
60
+ if args[0] == "set":
61
+ if len(args) < 2:
62
+ print("Использование: nddns set <ip1> [ip2]...")
63
+ return
64
+ ok, msg = _config.set_dns(args[1:])
65
+ print(("[+] " if ok else "[x] ") + msg)
66
+ return
67
+ if args[0] == "reset":
68
+ ok, msg = _config.reset_dns()
69
+ print(("[+] " if ok else "[x] ") + msg)
70
+ return
71
+ print("Использование: nddns [show|set <ip>...|reset]")
72
+
73
+
74
+ def _cmd_pass(args):
75
+ if len(args) < 2:
76
+ print("Использование: ndpass <ssid> <new_password>")
77
+ return
78
+ ssid, pwd = args[0], args[1]
79
+ print(f"[!] Внимание: смена пароля для '{ssid}' может разорвать соединение.")
80
+ if input("Продолжить? [y/N] ").strip().lower() != "y":
81
+ print("Отменено.")
82
+ return
83
+ ok, msg = _config.change_wifi_password(ssid, pwd)
84
+ print(("[+] " if ok else "[x] ") + msg)
85
+
86
+
87
+ def _cmd_adapters(args):
88
+ if not args or args[0] == "list":
89
+ for a in _config.list_adapters():
90
+ print(f" {a.get('name', '?'):<30} {a}")
91
+ return
92
+ if args[0] in ("enable", "disable") and len(args) >= 2:
93
+ ok, msg = _config.set_adapter_state(
94
+ args[1], enabled=(args[0] == "enable"))
95
+ print(("[+] " if ok else "[x] ") + msg)
96
+ return
97
+ print("Использование: ndadapters [list|enable|disable <name>]")
98
+
99
+
100
+ def _cmd_mtu(args):
101
+ if not args:
102
+ print("Использование: ndmtu <interface> [new_mtu]")
103
+ return
104
+ iface = args[0]
105
+ if len(args) == 1:
106
+ mtu = _config.get_mtu(iface)
107
+ print(f"MTU {iface} = {mtu if mtu is not None else '—'}")
108
+ return
109
+ ok, msg = _config.set_mtu(iface, args[1])
110
+ print(("[+] " if ok else "[x] ") + msg)
111
+
112
+
113
+ def _cmd_admin(args):
114
+ print("Администратор:", "да" if _is_admin() else "нет")
115
+
116
+
117
+ def _cmd_live(args):
118
+ print("Запуск мониторинга в реальном времени (Ctrl+C для выхода)...")
119
+ monitor = NetworkMonitor(history=_history)
120
+ monitor.run()
121
+
122
+
123
+ def _cmd_diag(args):
124
+ print("Запуск диагностики сети...\n")
125
+ diag = NetworkDiagnostics()
126
+ print(diag.get_report())
127
+
128
+
129
+ def _cmd_export(args):
130
+ if not _history.records:
131
+ print("История пуста.")
132
+ return
133
+ reports_dir = Path.home() / ".ndvpi" / "reports"
134
+ p_json = _history.export_json(reports_dir / "collquan_session.json")
135
+ p_csv = _history.export_csv(reports_dir / "collquan_session.csv")
136
+ print(f"Экспортировано:\n {p_json}\n {p_csv}")
137
+
138
+
139
+ if CollquanShell is not None:
140
+ CollquanShell.register("ndscan", "Сканировать Wi-Fi")(_cmd_scan)
141
+ CollquanShell.register("ndinfo", "Информация о сети")(_cmd_info)
142
+ CollquanShell.register("ndopt", "Оптимизатор: start/stop")(_cmd_opt)
143
+ CollquanShell.register("ndstatus", "Статус оптимизатора")(_cmd_status)
144
+ CollquanShell.register("nddns", "DNS: show/set/reset")(_cmd_dns)
145
+ CollquanShell.register("ndpass", "Сменить пароль Wi-Fi: <ssid> <pass>")(_cmd_pass)
146
+ CollquanShell.register("ndadapters", "Адаптеры: list/enable/disable")(_cmd_adapters)
147
+ CollquanShell.register("ndmtu", "MTU: <iface> [new_mtu]")(_cmd_mtu)
148
+ CollquanShell.register("ndadmin", "Проверить права администратора")(_cmd_admin)
149
+ CollquanShell.register("ndlive", "Live-монитор сети")(_cmd_live)
150
+ CollquanShell.register("nddiag", "Диагностика сети")(_cmd_diag)
151
+ CollquanShell.register("ndexport", "Экспорт истории в JSON/CSV")(_cmd_export)
ndvpi/diagnostics.py ADDED
@@ -0,0 +1,126 @@
1
+ """Диагностика сети: выявление проблем и рекомендации."""
2
+ import socket
3
+ from .network_info import NetworkInfo
4
+ from .wifi import WiFiScanner
5
+ from .network_config import NetworkConfig
6
+ from . import charm_enhance as ui
7
+
8
+
9
+ class CheckResult:
10
+ def __init__(self, name, ok, details, recommendation=""):
11
+ self.name = name
12
+ self.ok = ok
13
+ self.details = details
14
+ self.recommendation = recommendation
15
+
16
+ def __str__(self):
17
+ return f"{'[OK]' if self.ok else '[FAIL]'} {self.name}: {self.details}"
18
+
19
+
20
+ class NetworkDiagnostics:
21
+ def __init__(self):
22
+ self.info = NetworkInfo()
23
+ self.wifi = WiFiScanner()
24
+ self.config = NetworkConfig()
25
+
26
+ def run_checks(self):
27
+ results = []
28
+
29
+ # 1. Адаптеры
30
+ adapters = self.config.list_adapters()
31
+ enabled = [a for a in adapters
32
+ if a.get("state") in ("Connected", "Подключено", "up", "Подключен")]
33
+ if not adapters:
34
+ results.append(CheckResult("Адаптеры", False,
35
+ "Не найдены сетевые адаптеры",
36
+ "Проверьте драйверы сетевой карты"))
37
+ elif not enabled:
38
+ results.append(CheckResult("Адаптеры", False,
39
+ "Все адаптеры отключены",
40
+ "Включите сетевой адаптер в настройках ОС"))
41
+ else:
42
+ results.append(CheckResult("Адаптеры", True,
43
+ f"Активен: {enabled[0].get('name', 'Неизвестно')}"))
44
+
45
+ # 2. Wi-Fi
46
+ wifi_data = self.wifi.current_network()
47
+ if wifi_data:
48
+ results.append(CheckResult("Wi-Fi", True,
49
+ f"Подключено к {wifi_data.get('ssid', '?')}"))
50
+ else:
51
+ results.append(CheckResult("Wi-Fi", True, "Wi-Fi не активен (возможно Ethernet)"))
52
+
53
+ # 3. Шлюз
54
+ gw = self.info.gateway()
55
+ if not gw:
56
+ results.append(CheckResult("Шлюз", False,
57
+ "Шлюз не найден",
58
+ "Проверьте настройки DHCP или задайте IP вручную"))
59
+ else:
60
+ gw_ping = self.info.ping(gw, timeout=1)
61
+ if gw_ping is not None:
62
+ results.append(CheckResult("Шлюз", True,
63
+ f"{gw} доступен ({gw_ping:.0f} ms)"))
64
+ else:
65
+ results.append(CheckResult("Шлюз", False,
66
+ f"{gw} не отвечает",
67
+ "Проблема с роутером или кабелем. Перезагрузите роутер."))
68
+
69
+ # 4. DNS
70
+ try:
71
+ socket.gethostbyname("google.com")
72
+ results.append(CheckResult("DNS", True, "Разрешение имён работает"))
73
+ except socket.gaierror:
74
+ results.append(CheckResult("DNS", False,
75
+ "Не удаётся разрешить домены",
76
+ "Смените DNS на 1.1.1.1 (Cloudflare) или 8.8.8.8 (Google)"))
77
+
78
+ # 5. Пинг/Интернет
79
+ ping_stats = self.info.ping_series("8.8.8.8", count=3, timeout=1)
80
+ if not ping_stats:
81
+ results.append(CheckResult("Интернет", False,
82
+ "Нет доступа в интернет",
83
+ "Проверьте подключение к провайдеру."))
84
+ else:
85
+ if ping_stats["loss"] > 0:
86
+ results.append(CheckResult("Потери пакетов", False,
87
+ f"Потери: {ping_stats['loss']:.1f}%",
88
+ "Возможны помехи Wi-Fi или перегрузка канала."))
89
+ elif ping_stats["avg"] > 150:
90
+ results.append(CheckResult("Пинг", False,
91
+ f"Высокий пинг: {ping_stats['avg']:.0f} ms",
92
+ "Попробуйте запустить оптимизатор (TCP_NODELAY) или сменить DNS."))
93
+ else:
94
+ results.append(CheckResult("Интернет", True,
95
+ f"Стабильно ({ping_stats['avg']:.0f} ms)"))
96
+
97
+ return results
98
+
99
+ def get_report(self):
100
+ """Возвращает готовый к печати отчёт (строкой)."""
101
+ results = self.run_checks()
102
+ fails = [r for r in results if not r.ok]
103
+
104
+ if ui.has_charm():
105
+ rows = []
106
+ for r in results:
107
+ status = "✔ OK" if r.ok else "✖ ОШИБКА"
108
+ rows.append([status, r.name, r.details or "", r.recommendation or ""])
109
+
110
+ out = [ui.table(["Статус", "Проверка", "Детали", "Рекомендация"], rows)]
111
+ out.append("")
112
+ if fails:
113
+ out.append(ui.render_warning(
114
+ "Обнаружены проблемы. Ознакомьтесь с рекомендациями выше."))
115
+ else:
116
+ out.append(ui.render_success("Сеть работает стабильно."))
117
+ return "\n".join(out)
118
+
119
+ # Plain-режим
120
+ out = ["=== Диагностика сети ==="]
121
+ for r in results:
122
+ status = "[+]" if r.ok else "[x]"
123
+ out.append(f"{status} {r.name}: {r.details}")
124
+ if not r.ok and r.recommendation:
125
+ out.append(f" -> Рекомендация: {r.recommendation}")
126
+ return "\n".join(out)
ndvpi/history.py ADDED
@@ -0,0 +1,92 @@
1
+ """История метрик сети за сессию и экспорт в JSON/CSV."""
2
+ import json
3
+ import csv
4
+ import time
5
+ from pathlib import Path
6
+
7
+ class SessionHistory:
8
+ """Сборщик истории пингов и скоростей."""
9
+
10
+ def __init__(self):
11
+ self.records = [] # Список dict: {"ts": float, "type": str, "data": dict}
12
+
13
+ def add_ping(self, host, stats):
14
+ if stats:
15
+ self.records.append({
16
+ "ts": time.time(),
17
+ "type": "ping",
18
+ "host": host,
19
+ "data": stats
20
+ })
21
+
22
+ def add_speed(self, download_mbps, upload_mbps):
23
+ self.records.append({
24
+ "ts": time.time(),
25
+ "type": "speed",
26
+ "data": {"download": download_mbps, "upload": upload_mbps}
27
+ })
28
+
29
+ def clear(self):
30
+ self.records.clear()
31
+
32
+ def summary(self):
33
+ """Возвращает усреднённые значения за сессию."""
34
+ pings = [r for r in self.records if r["type"] == "ping"]
35
+ speeds = [r for r in self.records if r["type"] == "speed"]
36
+
37
+ result = {"samples": len(self.records)}
38
+ if pings:
39
+ avg_ping = sum(p["data"]["avg"] for p in pings) / len(pings)
40
+ avg_loss = sum(p["data"]["loss"] for p in pings) / len(pings)
41
+ avg_jitter = sum(p["data"]["jitter"] for p in pings) / len(pings)
42
+ result.update({
43
+ "avg_ping_ms": round(avg_ping, 2),
44
+ "avg_loss_pct": round(avg_loss, 2),
45
+ "avg_jitter_ms": round(avg_jitter, 2)
46
+ })
47
+
48
+ if speeds:
49
+ avg_down = sum(s["data"]["download"] for s in speeds) / len(speeds)
50
+ avg_up = sum(s["data"]["upload"] for s in speeds) / len(speeds)
51
+ result.update({
52
+ "avg_download_mbps": round(avg_down, 2),
53
+ "avg_upload_mbps": round(avg_up, 2)
54
+ })
55
+
56
+ return result
57
+
58
+ def export_json(self, filepath):
59
+ path = Path(filepath)
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ with open(path, "w", encoding="utf-8") as f:
62
+ json.dump(self.records, f, indent=2, ensure_ascii=False)
63
+ return str(path)
64
+
65
+ def export_csv(self, filepath):
66
+ path = Path(filepath)
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+
69
+ with open(path, "w", encoding="utf-8", newline="") as f:
70
+ writer = csv.writer(f)
71
+ writer.writerow(["Timestamp", "Type", "Min/Avg/Download", "Max/Upload", "Loss/Jitter"])
72
+
73
+ for r in self.records:
74
+ if r["type"] == "ping":
75
+ d = r["data"]
76
+ writer.writerow([
77
+ time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(r["ts"])),
78
+ "Ping",
79
+ f"{d['avg']:.1f} ms",
80
+ f"{d['max']:.1f} ms",
81
+ f"{d['loss']:.1f}% / {d['jitter']:.1f} ms"
82
+ ])
83
+ elif r["type"] == "speed":
84
+ d = r["data"]
85
+ writer.writerow([
86
+ time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(r["ts"])),
87
+ "Speed",
88
+ f"{d['download']:.1f} Mbps",
89
+ f"{d['upload']:.1f} Mbps",
90
+ "-"
91
+ ])
92
+ return str(path)