bianque 0.0.1__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.
Files changed (42) hide show
  1. bianque/__init__.py +12 -0
  2. bianque/__main__.py +4 -0
  3. bianque/app.py +31 -0
  4. bianque/core/__init__.py +27 -0
  5. bianque/core/audio.py +93 -0
  6. bianque/core/battery.py +101 -0
  7. bianque/core/disk.py +94 -0
  8. bianque/core/memory.py +112 -0
  9. bianque/core/models.py +89 -0
  10. bianque/core/network.py +126 -0
  11. bianque/core/report.py +158 -0
  12. bianque/core/stress.py +128 -0
  13. bianque/core/system.py +276 -0
  14. bianque/core/thermal.py +138 -0
  15. bianque/py.typed +0 -0
  16. bianque/ui/__init__.py +0 -0
  17. bianque/ui/main_window.py +105 -0
  18. bianque/ui/pages/__init__.py +0 -0
  19. bianque/ui/pages/base.py +62 -0
  20. bianque/ui/pages/battery_page.py +90 -0
  21. bianque/ui/pages/camera_page.py +111 -0
  22. bianque/ui/pages/disk_page.py +92 -0
  23. bianque/ui/pages/keyboard_page.py +154 -0
  24. bianque/ui/pages/memory_page.py +94 -0
  25. bianque/ui/pages/microphone_page.py +164 -0
  26. bianque/ui/pages/mouse_page.py +102 -0
  27. bianque/ui/pages/network_page.py +85 -0
  28. bianque/ui/pages/overview_page.py +153 -0
  29. bianque/ui/pages/report_page.py +88 -0
  30. bianque/ui/pages/screen_page.py +141 -0
  31. bianque/ui/pages/speaker_page.py +90 -0
  32. bianque/ui/pages/thermal_page.py +151 -0
  33. bianque/ui/theme.py +149 -0
  34. bianque/ui/widgets/__init__.py +0 -0
  35. bianque/ui/widgets/common.py +110 -0
  36. bianque/ui/worker.py +52 -0
  37. bianque-0.0.1.dist-info/METADATA +133 -0
  38. bianque-0.0.1.dist-info/RECORD +42 -0
  39. bianque-0.0.1.dist-info/WHEEL +5 -0
  40. bianque-0.0.1.dist-info/entry_points.txt +5 -0
  41. bianque-0.0.1.dist-info/licenses/LICENSE +674 -0
  42. bianque-0.0.1.dist-info/top_level.txt +1 -0
bianque/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """BianQue(扁鹊) - cross-platform hardware inspection & burn-in workflow.
2
+
3
+ 扁鹊,战国名医,望闻问切。这工具给新机器做一次"体检":
4
+ 屏幕、摄像头、麦克风、扬声器、键盘、鼠标、散热、电池、磁盘、内存、网络
5
+ 从头到尾查一遍,最后生成一份可存档的验机报告。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __version__ = "0.0.1"
11
+
12
+ __all__ = ["__version__"]
bianque/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from bianque.app import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
bianque/app.py ADDED
@@ -0,0 +1,31 @@
1
+ """Application entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def main() -> int:
9
+ """Create the Qt application and show the main window."""
10
+ from PySide6.QtWidgets import QApplication
11
+
12
+ from bianque import __version__
13
+ from bianque.ui.main_window import MainWindow
14
+
15
+ app = QApplication(sys.argv)
16
+ app.setApplicationName("BianQue")
17
+ app.setApplicationDisplayName("BianQue 扁鹊 · 验机工具")
18
+ app.setApplicationVersion(__version__)
19
+ app.setOrganizationName("cycleuser")
20
+
21
+ from bianque.ui.theme import apply_theme
22
+
23
+ apply_theme(app)
24
+
25
+ window = MainWindow()
26
+ window.show()
27
+ return app.exec()
28
+
29
+
30
+ if __name__ == "__main__":
31
+ raise SystemExit(main())
@@ -0,0 +1,27 @@
1
+ """BianQue core: hardware inspection logic (no UI)."""
2
+
3
+ from bianque.core import (
4
+ audio,
5
+ battery,
6
+ disk,
7
+ memory,
8
+ models,
9
+ network,
10
+ report,
11
+ stress,
12
+ system,
13
+ thermal,
14
+ )
15
+
16
+ __all__ = [
17
+ "audio",
18
+ "battery",
19
+ "disk",
20
+ "memory",
21
+ "models",
22
+ "network",
23
+ "report",
24
+ "stress",
25
+ "system",
26
+ "thermal",
27
+ ]
bianque/core/audio.py ADDED
@@ -0,0 +1,93 @@
1
+ """Audio helpers: test-tone synthesis and device enumeration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import wave
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ SAMPLE_RATE = 44100
11
+
12
+
13
+ def generate_tone(
14
+ frequency: float = 440.0,
15
+ duration: float = 1.0,
16
+ sample_rate: int = SAMPLE_RATE,
17
+ channels: int = 2,
18
+ amplitude: float = 0.5,
19
+ fade: float = 0.02,
20
+ ) -> np.ndarray:
21
+ """Synthesize a stereo sine tone with short fade in/out to avoid clicks.
22
+
23
+ Returns a float32 array of shape ``(n_samples, channels)`` in [-1, 1].
24
+ """
25
+ n = int(sample_rate * duration)
26
+ t = np.arange(n) / sample_rate
27
+ wave_mono = amplitude * np.sin(2 * np.pi * frequency * t)
28
+ if fade > 0:
29
+ fade_n = int(sample_rate * fade)
30
+ if fade_n > 0 and fade_n * 2 < n:
31
+ env = np.ones(n, dtype=np.float64)
32
+ env[:fade_n] = np.linspace(0.0, 1.0, fade_n)
33
+ env[-fade_n:] = np.linspace(1.0, 0.0, fade_n)
34
+ wave_mono = wave_mono * env
35
+ stereo = np.stack([wave_mono] * channels, axis=1)
36
+ return stereo.astype(np.float32) # type: ignore[no-any-return]
37
+
38
+
39
+ def generate_sweep(
40
+ duration: float = 2.0,
41
+ sample_rate: int = SAMPLE_RATE,
42
+ channels: int = 2,
43
+ amplitude: float = 0.4,
44
+ f0: float = 100.0,
45
+ f1: float = 8000.0,
46
+ ) -> np.ndarray:
47
+ """Generate a log frequency sweep from ``f0`` to ``f1`` Hz."""
48
+ n = int(sample_rate * duration)
49
+ t = np.arange(n) / sample_rate
50
+ phase = 2 * np.pi * f0 * duration * (np.exp(t / duration * np.log(f1 / f0)) - 1) / np.log(f1 / f0)
51
+ mono = amplitude * np.sin(phase)
52
+ return np.stack([mono] * channels, axis=1).astype(np.float32) # type: ignore[no-any-return]
53
+
54
+
55
+ def _float_to_pcm16(samples: np.ndarray) -> bytes:
56
+ pcm = np.clip(samples, -1.0, 1.0)
57
+ pcm = (pcm * 32767.0).astype(np.int16)
58
+ return pcm.tobytes()
59
+
60
+
61
+ def write_wav(path: str, samples: np.ndarray, sample_rate: int = SAMPLE_RATE) -> None:
62
+ """Write a float32 stereo sample array to a 16-bit PCM WAV file."""
63
+ pcm = _float_to_pcm16(samples)
64
+ channels = samples.shape[1] if samples.ndim > 1 else 1
65
+ with wave.open(path, "wb") as wf:
66
+ wf.setnchannels(channels)
67
+ wf.setsampwidth(2)
68
+ wf.setframerate(sample_rate)
69
+ wf.writeframes(pcm)
70
+
71
+
72
+ def list_audio_devices() -> dict[str, Any]:
73
+ """Enumerate input/output devices via sounddevice (best-effort)."""
74
+ result: dict[str, Any] = {"inputs": [], "outputs": [], "available": False}
75
+ try:
76
+ import sounddevice as sd # type: ignore
77
+
78
+ devices = sd.query_devices()
79
+ result["available"] = True
80
+ for idx, dev in enumerate(devices):
81
+ entry = {
82
+ "index": idx,
83
+ "name": dev.get("name"),
84
+ "channels": max(dev.get("max_input_channels", 0), dev.get("max_output_channels", 0)),
85
+ "default_samplerate": dev.get("default_samplerate"),
86
+ }
87
+ if dev.get("max_input_channels", 0) > 0:
88
+ result["inputs"].append(entry)
89
+ if dev.get("max_output_channels", 0) > 0:
90
+ result["outputs"].append(entry)
91
+ except (ImportError, OSError):
92
+ pass
93
+ return result
@@ -0,0 +1,101 @@
1
+ """Battery health probing (percent, cycle count, condition)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Any
7
+
8
+ import psutil
9
+
10
+ from bianque.core.system import _run, _system_profiler_json
11
+
12
+ IS_MAC = sys.platform == "darwin"
13
+ IS_WIN = sys.platform == "win32"
14
+ IS_LINUX = sys.platform.startswith("linux")
15
+
16
+
17
+ def get_battery_info() -> dict[str, Any]:
18
+ info: dict[str, Any] = {
19
+ "present": False,
20
+ "percent": None,
21
+ "power_plugged": None,
22
+ "secsleft": None,
23
+ "cycle_count": None,
24
+ "condition": None,
25
+ "design_capacity": None,
26
+ "full_capacity": None,
27
+ }
28
+ try:
29
+ batt = psutil.sensors_battery()
30
+ if batt is not None:
31
+ info["present"] = True
32
+ info["percent"] = batt.percent
33
+ info["power_plugged"] = batt.power_plugged
34
+ info["secsleft"] = batt.secsleft
35
+ except (NotImplementedError, OSError):
36
+ pass
37
+
38
+ extra: dict[str, Any] = {}
39
+ if IS_MAC:
40
+ extra = _mac_battery()
41
+ elif IS_WIN:
42
+ extra = _win_battery()
43
+ elif IS_LINUX:
44
+ extra = _linux_battery()
45
+ info.update(extra)
46
+ return info
47
+
48
+
49
+ def _mac_battery() -> dict[str, Any]:
50
+ power = _system_profiler_json("SPPowerDataType")
51
+ items = power.get("SPPowerDataType") or []
52
+ if not items:
53
+ return {}
54
+ batt = items[0]
55
+ return {
56
+ "cycle_count": batt.get("sppower_battery_cycle_count") or batt.get("cycle_count"),
57
+ "condition": batt.get("sppower_battery_condition") or batt.get("condition"),
58
+ "max_capacity_percent": batt.get("sppower_battery_max_capacity")
59
+ or batt.get("maximum_capacity"),
60
+ }
61
+
62
+
63
+ def _win_battery() -> dict[str, Any]:
64
+ out, _, code = _run(
65
+ ["wmic", "path", "Win32_Battery", "get", "EstimatedChargeRemaining,DesignCapacity,FullChargeCapacity", "/value"],
66
+ timeout=20.0,
67
+ )
68
+ if code != 0:
69
+ return {}
70
+ result: dict[str, Any] = {}
71
+ for line in out.splitlines():
72
+ line = line.strip()
73
+ if "=" not in line:
74
+ continue
75
+ key, _, val = line.partition("=")
76
+ key, val = key.strip().lower(), val.strip()
77
+ if not val or val == "0":
78
+ continue
79
+ if key == "estimatedchargeremaining":
80
+ result["percent"] = int(val)
81
+ elif key == "designcapacity":
82
+ result["design_capacity"] = int(val)
83
+ elif key == "fullchargecapacity":
84
+ result["full_capacity"] = int(val)
85
+ return result
86
+
87
+
88
+ def _linux_battery() -> dict[str, Any]:
89
+ out, _, code = _run(["upower", "-d"], timeout=15.0)
90
+ if code != 0:
91
+ return {}
92
+ result: dict[str, Any] = {}
93
+ for line in out.splitlines():
94
+ line = line.strip()
95
+ if line.startswith("energy-full-design"):
96
+ result["design_capacity"] = line.split(":", 1)[1].strip()
97
+ elif line.startswith("energy-full"):
98
+ result["full_capacity"] = line.split(":", 1)[1].strip()
99
+ elif line.startswith("capacity"):
100
+ result["percent"] = line.split(":", 1)[1].strip()
101
+ return result
bianque/core/disk.py ADDED
@@ -0,0 +1,94 @@
1
+ """Disk information and sequential read/write benchmark."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ import time
8
+ from dataclasses import dataclass
9
+
10
+ from bianque.core import system
11
+
12
+ BLOCK_SIZE = 1024 * 1024 # 1 MiB
13
+
14
+
15
+ @dataclass
16
+ class DiskResult:
17
+ path: str
18
+ size_mb: float
19
+ write_speed_mbps: float
20
+ read_speed_mbps: float
21
+ error: str | None = None
22
+
23
+ def to_dict(self) -> dict[str, float | str | None]:
24
+ return {
25
+ "path": self.path,
26
+ "size_mb": self.size_mb,
27
+ "write_speed_mbps": self.write_speed_mbps,
28
+ "read_speed_mbps": self.read_speed_mbps,
29
+ "error": self.error,
30
+ }
31
+
32
+
33
+ def get_disk_info() -> list[dict]:
34
+ return system.get_disk_partitions()
35
+
36
+
37
+ def run_disk_benchmark(
38
+ path: str | None = None,
39
+ size_mb: float = 128.0,
40
+ block_size: int = BLOCK_SIZE,
41
+ ) -> DiskResult:
42
+ """Measure sequential write/read throughput on a temporary file.
43
+
44
+ The file is created under ``path`` (or the system temp dir), written once,
45
+ fsync'd, read back, then removed. Returns :class:`DiskResult`.
46
+ """
47
+ if path is None:
48
+ path = tempfile.gettempdir()
49
+ path = os.path.abspath(path)
50
+
51
+ try:
52
+ os.makedirs(path, exist_ok=True)
53
+ fd, tmp = tempfile.mkstemp(prefix="bianque_bench_", dir=path)
54
+ os.close(fd)
55
+
56
+ total_bytes = int(size_mb * 1024 * 1024)
57
+ block = os.urandom(block_size)
58
+
59
+ # --- write ---
60
+ start = time.perf_counter()
61
+ with open(tmp, "wb") as fh:
62
+ remaining = total_bytes
63
+ while remaining > 0:
64
+ chunk = block if remaining >= block_size else block[:remaining]
65
+ fh.write(chunk)
66
+ remaining -= len(chunk)
67
+ fh.flush()
68
+ os.fsync(fh.fileno())
69
+ write_elapsed = time.perf_counter() - start
70
+
71
+ # --- read ---
72
+ start = time.perf_counter()
73
+ with open(tmp, "rb") as fh:
74
+ while fh.read(block_size):
75
+ pass
76
+ read_elapsed = time.perf_counter() - start
77
+
78
+ write_mbps = (total_bytes / 1024 / 1024) / write_elapsed if write_elapsed else 0.0
79
+ read_mbps = (total_bytes / 1024 / 1024) / read_elapsed if read_elapsed else 0.0
80
+
81
+ return DiskResult(
82
+ path=path,
83
+ size_mb=size_mb,
84
+ write_speed_mbps=round(write_mbps, 2),
85
+ read_speed_mbps=round(read_mbps, 2),
86
+ )
87
+ except OSError as exc:
88
+ return DiskResult(path=path, size_mb=size_mb, write_speed_mbps=0.0, read_speed_mbps=0.0, error=str(exc))
89
+ finally:
90
+ try:
91
+ if "tmp" in locals():
92
+ os.remove(tmp)
93
+ except OSError:
94
+ pass
bianque/core/memory.py ADDED
@@ -0,0 +1,112 @@
1
+ """Memory information and a write/read self-test."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import time
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import psutil
11
+
12
+ from bianque.core.system import _run, _system_profiler_json
13
+
14
+ IS_MAC = sys.platform == "darwin"
15
+ IS_WIN = sys.platform == "win32"
16
+
17
+
18
+ @dataclass
19
+ class MemoryTestResult:
20
+ size_mb: float
21
+ ok: bool
22
+ write_speed_mbps: float
23
+ read_speed_mbps: float
24
+ error: str | None = None
25
+
26
+ def to_dict(self) -> dict[str, Any]:
27
+ return {
28
+ "size_mb": self.size_mb,
29
+ "ok": self.ok,
30
+ "write_speed_mbps": self.write_speed_mbps,
31
+ "read_speed_mbps": self.read_speed_mbps,
32
+ "error": self.error,
33
+ }
34
+
35
+
36
+ def get_memory_details() -> dict[str, Any]:
37
+ """Virtual memory plus physical DIMM details where available."""
38
+ vm = psutil.virtual_memory()
39
+ info: dict[str, Any] = {
40
+ "total_bytes": vm.total,
41
+ "available_bytes": vm.available,
42
+ "percent": vm.percent,
43
+ "dimm_count": None,
44
+ "dimm_speed": None,
45
+ "dimm_type": None,
46
+ }
47
+ if IS_MAC:
48
+ info.update(_mac_dimm())
49
+ elif IS_WIN:
50
+ info["dimm_speed"] = _win_dimm_speed()
51
+ return info
52
+
53
+
54
+ def run_memory_self_test(size_mb: float = 256.0) -> MemoryTestResult:
55
+ """Allocate ``size_mb``, write a pattern, then read-verify it."""
56
+ size = int(size_mb * 1024 * 1024)
57
+ pattern = bytes((0x5A, 0xA5, 0x3C, 0xC3)) * (1024 * 1024 // 4)
58
+ try:
59
+ buf = bytearray(size)
60
+ except (MemoryError, OverflowError) as exc:
61
+ return MemoryTestResult(size_mb=size_mb, ok=False, write_speed_mbps=0.0, read_speed_mbps=0.0, error=str(exc))
62
+
63
+ start = time.perf_counter()
64
+ for i in range(0, size, len(pattern)):
65
+ buf[i : i + len(pattern)] = pattern
66
+ write_elapsed = time.perf_counter() - start
67
+
68
+ start = time.perf_counter()
69
+ ok = True
70
+ for i in range(0, size, len(pattern)):
71
+ if buf[i : i + len(pattern)] != pattern:
72
+ ok = False
73
+ break
74
+ read_elapsed = time.perf_counter() - start
75
+
76
+ write_mbps = (size / 1024 / 1024) / write_elapsed if write_elapsed else 0.0
77
+ read_mbps = (size / 1024 / 1024) / read_elapsed if read_elapsed else 0.0
78
+ return MemoryTestResult(
79
+ size_mb=size_mb,
80
+ ok=ok,
81
+ write_speed_mbps=round(write_mbps, 2),
82
+ read_speed_mbps=round(read_mbps, 2),
83
+ )
84
+
85
+
86
+ def _mac_dimm() -> dict[str, Any]:
87
+ mem = _system_profiler_json("SPMemoryDataType")
88
+ items = mem.get("SPMemoryDataType") or []
89
+ if not items:
90
+ return {}
91
+ i0 = items[0]
92
+ return {
93
+ "dimm_count": i0.get("dimm_number") or i0.get("dimm_manufacturer"),
94
+ "dimm_speed": i0.get("dimm_speed"),
95
+ "dimm_type": i0.get("dimm_type"),
96
+ }
97
+
98
+
99
+ def _win_dimm_speed() -> Any:
100
+ out, _, code = _run(
101
+ ["wmic", "memorychip", "get", "Speed,Capacity,Manufacturer", "/value"],
102
+ timeout=20.0,
103
+ )
104
+ if code != 0:
105
+ return None
106
+ speeds = []
107
+ for line in out.splitlines():
108
+ if "=" in line:
109
+ key, _, val = line.partition("=")
110
+ if key.strip().lower() == "speed" and val.strip().isdigit():
111
+ speeds.append(val.strip())
112
+ return speeds[0] if speeds else None
bianque/core/models.py ADDED
@@ -0,0 +1,89 @@
1
+ """Shared data models for BianQue checks and reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+
10
+ class Status:
11
+ """Unified check result status."""
12
+
13
+ PASS = "pass"
14
+ FAIL = "fail"
15
+ UNKNOWN = "unknown"
16
+ SKIPPED = "skipped"
17
+
18
+ @classmethod
19
+ def all(cls) -> list[str]:
20
+ return [cls.PASS, cls.FAIL, cls.UNKNOWN, cls.SKIPPED]
21
+
22
+
23
+ @dataclass
24
+ class CheckResult:
25
+ """A single check item result.
26
+
27
+ Attributes:
28
+ key: Stable identifier such as ``screen.dead_pixel``.
29
+ name: Human-readable display name.
30
+ status: One of :class:`Status`.
31
+ message: Human-readable outcome summary.
32
+ data: Structured payload (metrics, raw numbers).
33
+ timestamp: Epoch seconds when the check was produced.
34
+ """
35
+
36
+ key: str
37
+ name: str
38
+ status: str = Status.UNKNOWN
39
+ message: str = ""
40
+ data: dict[str, Any] = field(default_factory=dict)
41
+ timestamp: float = field(default_factory=time.time)
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ return {
45
+ "key": self.key,
46
+ "name": self.name,
47
+ "status": self.status,
48
+ "message": self.message,
49
+ "data": self.data,
50
+ "timestamp": self.timestamp,
51
+ }
52
+
53
+ @classmethod
54
+ def from_dict(cls, raw: dict[str, Any]) -> CheckResult:
55
+ return cls(
56
+ key=str(raw.get("key", "")),
57
+ name=str(raw.get("name", "")),
58
+ status=str(raw.get("status", Status.UNKNOWN)),
59
+ message=str(raw.get("message", "")),
60
+ data=dict(raw.get("data", {})),
61
+ timestamp=float(raw.get("timestamp", time.time())),
62
+ )
63
+
64
+
65
+ @dataclass
66
+ class Report:
67
+ """Aggregated inspection report."""
68
+
69
+ results: list[CheckResult] = field(default_factory=list)
70
+ generated_at: float = field(default_factory=time.time)
71
+ host: str = ""
72
+ platform: str = ""
73
+
74
+ def add(self, result: CheckResult) -> None:
75
+ self.results.append(result)
76
+
77
+ def count_by_status(self) -> dict[str, int]:
78
+ counts: dict[str, int] = dict.fromkeys(Status.all(), 0)
79
+ for r in self.results:
80
+ counts[r.status] = counts.get(r.status, 0) + 1
81
+ return counts
82
+
83
+ def to_dict(self) -> dict[str, Any]:
84
+ return {
85
+ "generated_at": self.generated_at,
86
+ "host": self.host,
87
+ "platform": self.platform,
88
+ "results": [r.to_dict() for r in self.results],
89
+ }
@@ -0,0 +1,126 @@
1
+ """Network interface information and ICMP latency probing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import socket
7
+ import sys
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ import psutil
12
+
13
+ from bianque.core.system import _run
14
+
15
+ IS_WIN = sys.platform == "win32"
16
+
17
+ _TIME_RE = re.compile(r"time[=<]\s*([0-9.]+)\s*ms", re.IGNORECASE)
18
+ _TRANSMITTED_RE = re.compile(r"(\d+)\s+packets?\s+transmitted", re.IGNORECASE)
19
+ _RECEIVED_RE = re.compile(r"(\d+)\s+packets?\s+received", re.IGNORECASE)
20
+ _LOSS_RE = re.compile(r"([0-9.]+)%\s+packet\s+loss", re.IGNORECASE)
21
+ _ROUNDTRIP_RE = re.compile(
22
+ r"round-trip[^=]*=\s*([0-9.]+)/([0-9.]+)/([0-9.]+)", re.IGNORECASE
23
+ )
24
+
25
+
26
+ @dataclass
27
+ class PingResult:
28
+ host: str
29
+ transmitted: int = 0
30
+ received: int = 0
31
+ loss_percent: float = 0.0
32
+ min_ms: float | None = None
33
+ avg_ms: float | None = None
34
+ max_ms: float | None = None
35
+ error: str | None = None
36
+ times_ms: list[float] = field(default_factory=list)
37
+
38
+ def to_dict(self) -> dict[str, Any]:
39
+ return {
40
+ "host": self.host,
41
+ "transmitted": self.transmitted,
42
+ "received": self.received,
43
+ "loss_percent": self.loss_percent,
44
+ "min_ms": self.min_ms,
45
+ "avg_ms": self.avg_ms,
46
+ "max_ms": self.max_ms,
47
+ "error": self.error,
48
+ }
49
+
50
+
51
+ def get_network_info() -> dict[str, Any]:
52
+ addrs = psutil.net_if_addrs()
53
+ stats = psutil.net_if_stats()
54
+ counters = psutil.net_io_counters(pernic=True)
55
+
56
+ interfaces = []
57
+ for name, addr_list in addrs.items():
58
+ ips = [a.address for a in addr_list if a.family == socket.AF_INET]
59
+ macs = [
60
+ a.address
61
+ for a in addr_list
62
+ if a.family not in (socket.AF_INET, socket.AF_INET6)
63
+ ]
64
+ stat = stats.get(name)
65
+ counter = counters.get(name)
66
+ interfaces.append(
67
+ {
68
+ "name": name,
69
+ "ips": ips,
70
+ "mac": macs[0] if macs else None,
71
+ "up": stat.isup if stat else None,
72
+ "speed_mbps": stat.speed if stat else None,
73
+ "bytes_sent": counter.bytes_sent if counter else None,
74
+ "bytes_recv": counter.bytes_recv if counter else None,
75
+ }
76
+ )
77
+ return {"interfaces": interfaces}
78
+
79
+
80
+ def ping(host: str = "8.8.8.8", count: int = 4, timeout: float = 10.0) -> PingResult:
81
+ """Ping ``host`` ``count`` times and parse round-trip latency."""
82
+ if IS_WIN:
83
+ cmd = ["ping", "-n", str(count), "-w", str(int(timeout * 1000)), host]
84
+ else:
85
+ cmd = ["ping", "-c", str(count), "-W", str(int(timeout)), host]
86
+ out, err, code = _run(cmd, timeout=timeout + 5.0)
87
+ result = PingResult(host=host)
88
+
89
+ if code != 0:
90
+ result.error = (err or out).strip()[:200] or "ping failed"
91
+ return result
92
+
93
+ times = [float(m) for m in _TIME_RE.findall(out)]
94
+ result.times_ms = times
95
+
96
+ transmitted = _extract_int(_TRANSMITTED_RE, out, count)
97
+ received = _extract_int(_RECEIVED_RE, out, len(times))
98
+ loss = _extract_float(_LOSS_RE, out, None)
99
+ result.transmitted = transmitted
100
+ result.received = received
101
+ if loss is not None:
102
+ result.loss_percent = loss
103
+ elif transmitted > 0:
104
+ result.loss_percent = round((transmitted - received) / transmitted * 100, 1)
105
+
106
+ if times:
107
+ result.min_ms = round(min(times), 2)
108
+ result.avg_ms = round(sum(times) / len(times), 2)
109
+ result.max_ms = round(max(times), 2)
110
+ else:
111
+ m = _ROUNDTRIP_RE.search(out)
112
+ if m:
113
+ result.min_ms = round(float(m.group(1)), 2)
114
+ result.avg_ms = round(float(m.group(2)), 2)
115
+ result.max_ms = round(float(m.group(3)), 2)
116
+ return result
117
+
118
+
119
+ def _extract_int(pattern: re.Pattern[str], text: str, default: int) -> int:
120
+ m = pattern.search(text)
121
+ return int(m.group(1)) if m else default
122
+
123
+
124
+ def _extract_float(pattern: re.Pattern[str], text: str, default: float | None) -> float | None:
125
+ m = pattern.search(text)
126
+ return float(m.group(1)) if m else default