Lab056: compare 20 and 500 ksymbol/s in controlled RF session
This commit is contained in:
178
tools/hackrf_process_check.py
Normal file
178
tools/hackrf_process_check.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Проверка конфликтующих SDR-процессов без обращения к оборудованию."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import Enum
|
||||
import importlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import PureWindowsPath
|
||||
import subprocess
|
||||
from typing import Callable, Iterable
|
||||
|
||||
|
||||
class ProcessState(str, Enum):
|
||||
CLEAR = "CLEAR"
|
||||
BUSY = "BUSY"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessIdentity:
|
||||
pid: int
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessScan:
|
||||
state: ProcessState
|
||||
mechanism: str
|
||||
matches: tuple[ProcessIdentity, ...] = ()
|
||||
inspected: int = 0
|
||||
errors: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessCheck:
|
||||
state: ProcessState
|
||||
attempts: tuple[ProcessScan, ...]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
# Эти программы способны открыть SDR. Их наличие консервативно даёт BUSY,
|
||||
# даже если конкретный экземпляр пока не открыл устройство. Это проверка имён,
|
||||
# а не доказательство отсутствия произвольного переименованного USB-клиента.
|
||||
SDR_PROCESS_NAMES = frozenset({
|
||||
"gqrx", "sdrangel", "sdrsharp", "sdrpp", "sdr++", "cubicsdr", "hdsdr",
|
||||
"sdrconnect", "gnuradio-companion", "osmocom_fft", "osmocom_siggen",
|
||||
"soapysdrserver", "soapyremote", "soapy_sdr_server", "urh",
|
||||
})
|
||||
|
||||
|
||||
def is_conflicting(name: str) -> bool:
|
||||
normalized = PureWindowsPath(name).name.casefold()
|
||||
if normalized.endswith(".exe"):
|
||||
normalized = normalized[:-4]
|
||||
return normalized.startswith("hackrf_") or normalized in SDR_PROCESS_NAMES
|
||||
|
||||
|
||||
def classify_snapshot(mechanism: str, rows: Iterable[ProcessIdentity],
|
||||
errors: Iterable[str] = ()) -> ProcessScan:
|
||||
"""Пустое, неполное или неразобранное перечисление не означает CLEAR."""
|
||||
failures = list(errors)
|
||||
matches = []
|
||||
count = 0
|
||||
try:
|
||||
for row in rows:
|
||||
if (not isinstance(row, ProcessIdentity) or type(row.pid) is not int or row.pid < 0
|
||||
or not isinstance(row.name, str) or not row.name.strip()):
|
||||
failures.append("Некорректная запись процесса")
|
||||
continue
|
||||
count += 1
|
||||
if is_conflicting(row.name):
|
||||
matches.append(row)
|
||||
except Exception as error:
|
||||
failures.append(f"{type(error).__name__}: {error}")
|
||||
if count == 0:
|
||||
failures.append("Не получено ни одного процесса")
|
||||
state = ProcessState.BUSY if matches else (ProcessState.UNKNOWN if failures else ProcessState.CLEAR)
|
||||
return ProcessScan(state, mechanism, tuple(matches), count, tuple(failures))
|
||||
|
||||
|
||||
def scan_psutil() -> ProcessScan:
|
||||
"""NoSuchProcess — обычное завершение; отказ доступа сохраняет UNKNOWN."""
|
||||
rows, errors = [], []
|
||||
try:
|
||||
psutil = importlib.import_module("psutil")
|
||||
clear_cache = getattr(psutil.process_iter, "cache_clear", None)
|
||||
if clear_cache is not None:
|
||||
clear_cache()
|
||||
for process in psutil.process_iter():
|
||||
try:
|
||||
rows.append(ProcessIdentity(process.pid, process.name()))
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
except Exception as error:
|
||||
errors.append(f"pid={process.pid}: {type(error).__name__}: {error}")
|
||||
except Exception as error:
|
||||
errors.append(f"{type(error).__name__}: {error}")
|
||||
return classify_snapshot("psutil", rows, errors)
|
||||
|
||||
|
||||
def run_command(command: list[str]) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(command, capture_output=True, text=True, encoding="utf-8",
|
||||
errors="strict", timeout=10,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
|
||||
|
||||
def scan_powershell(runner: Callable = run_command) -> ProcessScan:
|
||||
"""Get-Process завершает перечисление целиком или возвращает ошибку."""
|
||||
script = ("$ErrorActionPreference='Stop'; "
|
||||
"[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new(); "
|
||||
"try { $items=@(Get-Process -ErrorAction Stop | "
|
||||
"Select-Object Id,ProcessName); "
|
||||
"ConvertTo-Json -InputObject $items -Compress } "
|
||||
"catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }")
|
||||
rows, errors = [], []
|
||||
try:
|
||||
result = runner(["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script])
|
||||
if result.returncode != 0 or result.stderr.strip():
|
||||
raise RuntimeError(f"exit={result.returncode}: {result.stderr.strip()}")
|
||||
data = json.loads(result.stdout)
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("Ожидался полный JSON-массив процессов")
|
||||
for row in data:
|
||||
try:
|
||||
rows.append(ProcessIdentity(row["Id"], row["ProcessName"]))
|
||||
except Exception as error:
|
||||
errors.append(f"{type(error).__name__}: {error}")
|
||||
except Exception as error:
|
||||
errors.append(f"{type(error).__name__}: {error}")
|
||||
return classify_snapshot("powershell_get_process", rows, errors)
|
||||
|
||||
|
||||
def scan_tasklist(runner: Callable = run_command) -> ProcessScan:
|
||||
"""Резервный полный CSV; текст ошибки или пустой вывод не равен CLEAR."""
|
||||
rows, errors = [], []
|
||||
try:
|
||||
result = runner(["tasklist.exe", "/FO", "CSV", "/NH"])
|
||||
if result.returncode != 0 or result.stderr.strip():
|
||||
raise RuntimeError(f"exit={result.returncode}: {result.stderr.strip()}")
|
||||
for row in csv.reader(io.StringIO(result.stdout), strict=True):
|
||||
try:
|
||||
if len(row) != 5:
|
||||
raise ValueError("Некорректная строка tasklist CSV")
|
||||
rows.append(ProcessIdentity(int(row[1]), row[0]))
|
||||
except Exception as error:
|
||||
errors.append(f"{type(error).__name__}: {error}")
|
||||
except Exception as error:
|
||||
errors.append(f"{type(error).__name__}: {error}")
|
||||
return classify_snapshot("tasklist", rows, errors)
|
||||
|
||||
|
||||
def check_processes(providers: Iterable[Callable[[], ProcessScan]] | None = None) -> ProcessCheck:
|
||||
"""UNKNOWN разрешает другой источник; только полноценный CLEAR открывает gate."""
|
||||
attempts = []
|
||||
sources = (scan_psutil, scan_powershell, scan_tasklist) if providers is None else providers
|
||||
for provider in sources:
|
||||
try:
|
||||
scan = provider()
|
||||
if not isinstance(scan, ProcessScan) or not isinstance(scan.state, ProcessState):
|
||||
raise TypeError("Некорректный ответ проверки процессов")
|
||||
except Exception as error:
|
||||
scan = ProcessScan(ProcessState.UNKNOWN, getattr(provider, "__name__", "provider"),
|
||||
errors=(f"{type(error).__name__}: {error}",))
|
||||
attempts.append(scan)
|
||||
if scan.state in (ProcessState.CLEAR, ProcessState.BUSY):
|
||||
return ProcessCheck(scan.state, tuple(attempts))
|
||||
return ProcessCheck(ProcessState.UNKNOWN, tuple(attempts))
|
||||
|
||||
|
||||
def require_clear(result: ProcessCheck) -> None:
|
||||
if not isinstance(result, ProcessCheck) or result.state is not ProcessState.CLEAR:
|
||||
detail = result.to_dict() if isinstance(result, ProcessCheck) else repr(result)
|
||||
raise RuntimeError(f"Проверка процессов не дала CLEAR: {detail}")
|
||||
Reference in New Issue
Block a user