179 lines
7.7 KiB
Python
179 lines
7.7 KiB
Python
"""Проверка конфликтующих 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}")
|