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