Files
SDR-Rover/tools/hackrf_process_check.py

179 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Проверка конфликтующих 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}")