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.
- bianque/__init__.py +12 -0
- bianque/__main__.py +4 -0
- bianque/app.py +31 -0
- bianque/core/__init__.py +27 -0
- bianque/core/audio.py +93 -0
- bianque/core/battery.py +101 -0
- bianque/core/disk.py +94 -0
- bianque/core/memory.py +112 -0
- bianque/core/models.py +89 -0
- bianque/core/network.py +126 -0
- bianque/core/report.py +158 -0
- bianque/core/stress.py +128 -0
- bianque/core/system.py +276 -0
- bianque/core/thermal.py +138 -0
- bianque/py.typed +0 -0
- bianque/ui/__init__.py +0 -0
- bianque/ui/main_window.py +105 -0
- bianque/ui/pages/__init__.py +0 -0
- bianque/ui/pages/base.py +62 -0
- bianque/ui/pages/battery_page.py +90 -0
- bianque/ui/pages/camera_page.py +111 -0
- bianque/ui/pages/disk_page.py +92 -0
- bianque/ui/pages/keyboard_page.py +154 -0
- bianque/ui/pages/memory_page.py +94 -0
- bianque/ui/pages/microphone_page.py +164 -0
- bianque/ui/pages/mouse_page.py +102 -0
- bianque/ui/pages/network_page.py +85 -0
- bianque/ui/pages/overview_page.py +153 -0
- bianque/ui/pages/report_page.py +88 -0
- bianque/ui/pages/screen_page.py +141 -0
- bianque/ui/pages/speaker_page.py +90 -0
- bianque/ui/pages/thermal_page.py +151 -0
- bianque/ui/theme.py +149 -0
- bianque/ui/widgets/__init__.py +0 -0
- bianque/ui/widgets/common.py +110 -0
- bianque/ui/worker.py +52 -0
- bianque-0.0.1.dist-info/METADATA +133 -0
- bianque-0.0.1.dist-info/RECORD +42 -0
- bianque-0.0.1.dist-info/WHEEL +5 -0
- bianque-0.0.1.dist-info/entry_points.txt +5 -0
- bianque-0.0.1.dist-info/licenses/LICENSE +674 -0
- 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
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())
|
bianque/core/__init__.py
ADDED
|
@@ -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
|
bianque/core/battery.py
ADDED
|
@@ -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
|
+
}
|
bianque/core/network.py
ADDED
|
@@ -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
|