Загрузка данных
"""Switch Controller — управление переключением каналов по TCP.
Зависимости: pip install PyQt6
Необязательно: pip install pynput (глобальные горячие клавиши ← →)
"""
from __future__ import annotations
import asyncio
import html
import ipaddress
import json
import os
import re
import socket
import sys
import tempfile
import threading
import subprocess
import platform
import time
import webbrowser
from dataclasses import dataclass, field, replace
from datetime import datetime
from pathlib import Path
from PyQt6.QtCore import (
QEvent, QMimeData, QObject, QPoint, QPointF, QRect, QSize, Qt, QTimer, pyqtSignal,
)
from PyQt6.QtGui import (
QBrush, QColor, QDrag, QIntValidator, QKeySequence, QPainter, QPen, QPixmap, QPolygonF, QShortcut, QWheelEvent,
)
from PyQt6.QtNetwork import QAbstractSocket, QTcpSocket
from PyQt6.QtWidgets import (
QAbstractItemView, QHeaderView, QMenu, QProgressBar, QTableWidget, QTableWidgetItem,
)
from PyQt6.QtWidgets import (
QApplication, QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
QFrame, QGridLayout, QHBoxLayout, QLabel, QLayout, QLineEdit, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QScrollArea, QSizePolicy, QTextEdit, QVBoxLayout, QWidget, QWidgetItem
)
try:
from pynput import keyboard as pynput_keyboard
except ImportError:
pynput_keyboard = None
# ============================================================================
# КОНСТАНТЫ И НАСТРОЙКИ РЕЛИЗА
# ============================================================================
CONFIG_NAME = "switch_config_pro.json"
CONFIG_PATH = Path(os.path.expanduser("~")) / CONFIG_NAME
PASSWORD = "1337"
TEST_MODE = False
UI_SCALE = 0.80
CH_MIN, CH_MAX = 1, 60
CONNECT_TIMEOUT_MS = 3000
DEBOUNCE_TIMEOUT = 1.1 # Отсечка стрелок 1.1 сек
QWIDGETSIZE_MAX = 16777215
SCHEMES = {
"SH5expLB": 16, "SH5expVzor": 17, "SH6HB": 18, "SH6LB": 19, "Sparrow": 20,
"LNA8_0930_3060": 21, "SW8_1_01_8": 22, "VzorSH6_HX": 23, "SH5HB_C549": 24,
"SH5LMB_C549": 25, "SH5Vzor_C549": 26, "SH6M": 27, "SH7V": 28,
"SH4PCM_SH6_W2": 29, "SH4PCM_SH6_W1": 30, "SH4PCM_SH6_W3": 31,
"SH7_C_MB_VRNJ": 32, "SH7_C_LB_VRNJ": 33, "SH7_V_VRNJ": 34,
}
DEFAULT_PRESETS = {
"Дефолт": list(range(1, 61)),
"F25": [13, 19, 14, 20],
"F26": [1, 6, 12, 18],
"Test": [13, 19],
}
THEMES = {
"graphite": ("Графит", {
"BG": "#121418", "CARD": "#1a1d23", "INPUT": "#0f1115",
"BORDER": "#2a2e37", "BORDERHI": "#3b414d",
"BTN": "#232731", "BTNHI": "#2d323e",
"TEXT": "#e8eaef", "DIM": "#939aa8", "MUTED": "#5c6370",
"ACCENT": "#4f8cff", "ACCENTHI": "#6fa0ff", "ON_ACCENT": "#0b0d12",
"OK": "#3ddc97", "WARN": "#f5a524", "BAD": "#ff6b6b",
"WHITE_BTN": "#e8eaef", "WHITE_BTN_HI": "#ffffff", "WHITE_BTN_TXT": "#14161b", "WHITE_BTN_PRESS": "#c9ced8",
}),
"ash": ("Пепел", {
"BG": "#2a2b2a", "CARD": "#323332", "INPUT": "#232423",
"BORDER": "#454645", "BORDERHI": "#5a5b5a",
"BTN": "#3b3c3b", "BTNHI": "#474847",
"TEXT": "#ececec", "DIM": "#a4a5a4", "MUTED": "#757675",
"ACCENT": "#6ea8fe", "ACCENTHI": "#8dbcff", "ON_ACCENT": "#101210",
"OK": "#4cd98f", "WARN": "#f2b04a", "BAD": "#ff7676",
"WHITE_BTN": "#ececec", "WHITE_BTN_HI": "#ffffff", "WHITE_BTN_TXT": "#222322", "WHITE_BTN_PRESS": "#cdcecd",
}),
"charcoal": ("Уголь", {
"BG": "#0b0b0c", "CARD": "#141415", "INPUT": "#0a0a0b",
"BORDER": "#27272a", "BORDERHI": "#3a3a3f",
"BTN": "#1d1d20", "BTNHI": "#27272b",
"TEXT": "#ececee", "DIM": "#8e8e96", "MUTED": "#58585f",
"ACCENT": "#8b5cf6", "ACCENTHI": "#a78bfa", "ON_ACCENT": "#ffffff",
"OK": "#4ade80", "WARN": "#fb923c", "BAD": "#f87171",
"WHITE_BTN": "#ececee", "WHITE_BTN_HI": "#ffffff", "WHITE_BTN_TXT": "#0b0b0c", "WHITE_BTN_PRESS": "#c4c4c8",
}),
"light": ("Светлая", {
"BG": "#f0f0f0", "CARD": "#ffffff", "INPUT": "#f9f9f9",
"BORDER": "#cccccc", "BORDERHI": "#bbbbbb",
"BTN": "#e0e0e0", "BTNHI": "#d0d0d0",
"TEXT": "#111111", "DIM": "#666666", "MUTED": "#999999",
"ACCENT": "#0078d7", "ACCENTHI": "#005a9e", "ON_ACCENT": "#ffffff",
"OK": "#107c10", "WARN": "#d83b01", "BAD": "#a80000",
"WHITE_BTN": "#333333", "WHITE_BTN_HI": "#111111", "WHITE_BTN_TXT": "#ffffff", "WHITE_BTN_PRESS": "#000000",
}),
}
DEFAULT_THEME = "graphite"
QSS_TEMPLATE = """
QWidget { color: @TEXT; font-family: "Segoe UI", "Inter", "Helvetica Neue", sans-serif; font-size: 13px; }
QWidget#root, QDialog { background-color: @BG; }
QLabel { background: transparent; }
QLabel#cardTitle { color: @DIM; font-size: 11px; font-weight: 700; }
QLabel#dim { color: @DIM; }
QLabel#value { color: @TEXT; font-weight: 600; }
QLabel#hint { color: @WARN; }
QLabel#status { font-weight: 700; }
QFrame#card { background-color: @CARD; border: 1px solid @BORDER; border-radius: 10px; }
QFrame#chipBox { background-color: @INPUT; border: 1px solid @BORDER; border-radius: 6px; }
QLineEdit, QSpinBox, QComboBox {
background-color: @INPUT; border: 1px solid @BORDER; border-radius: 6px;
padding: 4px 8px; min-height: 18px; selection-background-color: @ACCENT; selection-color: @ON_ACCENT;
}
QLineEdit:focus, QSpinBox:focus, QComboBox:focus { border-color: @ACCENT; }
QLineEdit:disabled, QSpinBox:disabled, QComboBox:disabled { color: @MUTED; }
QLineEdit#big {
font-size: 22px; font-weight: 700; padding: 0px;
border: 1px solid @BORDER; background-color: @INPUT; border-radius: 6px;
}
QLineEdit#big:focus { border-color: @ACCENT; }
QFrame#tagField { background-color: @INPUT; border: 1px solid @BORDER; border-radius: 6px; }
QFrame#tagField[focused="true"] { border-color: @ACCENT; }
QLineEdit#tagInput, QLineEdit#tagInput:focus { background: transparent; border: none; padding: 2px 4px; min-height: 22px; }
QLabel#tag {
background-color: @BTN; border: 1px solid @BORDERHI; border-radius: 12px;
padding: 3px 11px; font-weight: 700; color: @TEXT;
}
QLabel#tag:hover { border-color: @BAD; color: @BAD; }
QFrame#dropLine { background-color: @ACCENT; border: none; }
QComboBox::drop-down { border: none; width: 22px; }
QComboBox::down-arrow { image: url("@ARROW"); width: 12px; height: 8px; margin-right: 8px; }
QComboBox QAbstractItemView {
background-color: @CARD; border: 1px solid @BORDER; outline: 0;
selection-background-color: @ACCENT; selection-color: @ON_ACCENT;
}
QPushButton {
background-color: @BTN; border: 1px solid @BORDER; border-radius: 6px;
padding: 4px 12px; font-weight: 600; min-height: 18px; font-size: 12px;
}
QPushButton:hover { background-color: @BTNHI; border-color: @BORDERHI; }
QPushButton:pressed { background-color: @CARD; }
QPushButton:disabled { color: @MUTED; background-color: @CARD; border-color: @BORDER; }
QPushButton[variant="white"] {
background-color: @WHITE_BTN; border-color: @WHITE_BTN; color: @WHITE_BTN_TXT;
}
QPushButton[variant="white"]:hover { background-color: @WHITE_BTN_HI; border-color: @WHITE_BTN_HI; }
QPushButton[variant="white"]:pressed { background-color: @WHITE_BTN_PRESS; border-color: @WHITE_BTN_PRESS; }
QPushButton[variant="white"]:disabled { background-color: @CARD; border-color: @BORDER; color: @MUTED; }
QPushButton[variant="danger"] { color: @BAD; }
QPushButton[variant="danger"]:disabled { color: @MUTED; }
QPushButton#nav { font-size: 14px; padding: 0; min-width: 30px; min-height: 30px; }
QPushButton#ghost { background: transparent; border: none; color: @DIM; padding: 2px 6px; min-height: 16px; font-size: 12px; }
QPushButton#ghost:hover { color: @TEXT; }
QPushButton#chip {
background-color: @BTN; border: 1px solid @BORDER; border-radius: 13px;
padding: 3px 12px; min-height: 18px; font-weight: 700;
}
QPushButton#chip:hover { border-color: @ACCENT; }
QPushButton#chip:checked { background-color: @WHITE_BTN; border-color: @WHITE_BTN; color: @WHITE_BTN_TXT; }
QPushButton#chip:disabled { color: @MUTED; }
QPushButton#chip:checked:disabled { background-color: @BORDERHI; border-color: @BORDERHI; color: @MUTED; }
QPushButton#chipHighlight {
background-color: @BORDERHI; border: 1px solid @BORDER; border-radius: 13px;
padding: 3px 12px; min-height: 18px; font-weight: 800; color: @TEXT;
}
QPushButton#chipHighlight:hover { border-color: @ACCENT; }
QPushButton#chipHighlight:checked { background-color: @WHITE_BTN; border-color: @WHITE_BTN; color: @WHITE_BTN_TXT; }
QPushButton#chipHighlight:disabled { color: @MUTED; }
QPushButton#chipHighlight:checked:disabled { background-color: @BORDERHI; border-color: @BORDERHI; color: @MUTED; }
QCheckBox { spacing: 8px; color: @TEXT; font-size: 12px; }
QCheckBox:disabled { color: @MUTED; }
QCheckBox::indicator { width: 16px; height: 16px; border: 1px solid @BORDERHI; border-radius: 4px; background: @INPUT; }
QCheckBox::indicator:checked { background: @ACCENT; border-color: @ACCENT; image: url("@CHECK"); }
QListWidget { background-color: @INPUT; border: 1px solid @BORDER; border-radius: 6px; padding: 4px; outline: 0; }
QListWidget::item { padding: 7px 8px; border-radius: 4px; }
QListWidget::item:selected { background-color: @WHITE_BTN; color: @WHITE_BTN_TXT; }
QListWidget::item:hover:!selected { background-color: @BTN; }
QTextEdit#log {
background-color: @INPUT; border: 1px solid @BORDER; border-radius: 8px; padding: 6px;
font-family: "Cascadia Code", "Consolas", "Menlo", monospace; font-size: 12px; color: @TEXT;
}
QScrollBar:vertical { background: transparent; width: 10px; margin: 0; }
QScrollBar::handle:vertical { background: @BORDER; border-radius: 5px; min-height: 24px; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
QScrollArea { border: none; background: transparent; }
QScrollArea > QWidget > QWidget { background: transparent; }
QTableWidget { background-color: @INPUT; border: 1px solid @BORDER; border-radius: 6px; outline: 0; }
QTableWidget::item { padding: 3px 8px; border: none; }
QTableWidget::item:selected { background-color: @WHITE_BTN; color: @WHITE_BTN_TXT; }
QHeaderView { background: transparent; }
QHeaderView::section {
background-color: @CARD; color: @DIM; border: none; border-bottom: 1px solid @BORDER;
border-right: 1px solid @BORDER; padding: 5px 8px; font-weight: 600;
}
QHeaderView::down-arrow { image: url("@ARROW"); width: 10px; height: 8px; }
QHeaderView::up-arrow { image: url("@ARROW_UP"); width: 10px; height: 8px; }
QTableCornerButton::section { background-color: @CARD; border: none; }
QProgressBar { background-color: @INPUT; border: 1px solid @BORDER; border-radius: 4px; max-height: 8px; }
QProgressBar::chunk { background-color: @ACCENT; border-radius: 3px; }
QMenu { background-color: @CARD; border: 1px solid @BORDER; padding: 4px; }
QMenu::item { padding: 6px 20px; border-radius: 4px; }
QMenu::item:selected { background-color: @WHITE_BTN; color: @WHITE_BTN_TXT; }
QMenu::item:disabled { color: @MUTED; }
QMenu::separator { height: 1px; background: @BORDER; margin: 4px 6px; }
QToolTip { background-color: @CARD; color: @TEXT; border: 1px solid @BORDER; padding: 4px; }
"""
def make_icons(palette: dict, theme: str) -> dict:
folder = Path(tempfile.gettempdir()) / "switch_controller_icons"
folder.mkdir(exist_ok=True)
arrow = QPixmap(12, 8)
arrow.fill(Qt.GlobalColor.transparent)
p = QPainter(arrow)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor(palette["DIM"]))
p.drawPolygon(QPolygonF([QPointF(1, 1), QPointF(11, 1), QPointF(6, 7)]))
p.end()
check = QPixmap(16, 16)
check.fill(Qt.GlobalColor.transparent)
p = QPainter(check)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.setPen(QPen(QColor(palette["ON_ACCENT"]), 2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
p.drawPolyline(QPolygonF([QPointF(4, 8.5), QPointF(7, 11.5), QPointF(12, 5)]))
p.end()
arrow_up = QPixmap(12, 8)
arrow_up.fill(Qt.GlobalColor.transparent)
p = QPainter(arrow_up)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor(palette["DIM"]))
p.drawPolygon(QPolygonF([QPointF(1, 7), QPointF(11, 7), QPointF(6, 1)]))
p.end()
arrow_path = folder / f"arrow_{theme}.png"
arrow_up_path = folder / f"arrow_up_{theme}.png"
check_path = folder / f"check_{theme}.png"
arrow.save(str(arrow_path))
check.save(str(check_path))
arrow_up.save(str(arrow_up_path))
return {"ARROW": arrow_path.as_posix(), "ARROW_UP": arrow_up_path.as_posix(), "CHECK": check_path.as_posix()}
def build_qss(palette: dict, icons: dict | None = None) -> str:
tokens = dict(palette)
tokens.update(icons or {"ARROW": "arrow.png", "ARROW_UP": "arrow_up.png", "CHECK": "check.png"})
qss = QSS_TEMPLATE
for key in sorted(tokens, key=len, reverse=True):
qss = qss.replace(f"@{key}", tokens[key])
return qss
def set_variant(widget: QWidget, variant: str) -> None:
widget.setProperty("variant", variant)
widget.style().unpolish(widget)
widget.style().polish(widget)
# ============================================================================
# Протокол устройства
# ============================================================================
CMD_SET_CHANNEL = 0x18
CMD_SET_SCHEME = 0x68
def build_set_packet(cmd: int, value: int) -> bytes:
now = datetime.now()
return bytes([0x06, 0x00, 0x00, 0x00, 0x08, now.hour, now.minute, 0x10, 0x01, cmd, value])
def build_uptime_packet() -> bytes:
now = datetime.now()
return bytes([0x05, 0x00, 0x00, 0x00, 0x08, now.hour, now.minute, 0xA0, 0x01, 0x01])
def parse_uptime(data: bytes) -> str | None:
return None
def hex_str(data: bytes) -> str:
return " ".join(f"{b:02X}" for b in data)
# ============================================================================
# Сетевой сканер (по мотивам Advanced IP Scanner)
# ============================================================================
MAC_RE = re.compile(r"(?<![0-9A-Fa-f])(?:[0-9A-Fa-f]{1,2}[-:]){5}[0-9A-Fa-f]{1,2}(?![0-9A-Fa-f])")
IP_RE = re.compile(r"(?<![\d.])(\d{1,3}(?:\.\d{1,3}){3})(?![\d.])")
RTT_RE = re.compile(r"(?:time|время)\s*([=<])\s*([\d.,]+)\s*(?:ms|мс)", re.I)
MAX_TARGETS = 65536
BUILTIN_OUI = {
"005056": "VMware", "000C29": "VMware", "000569": "VMware", "080027": "VirtualBox",
"00155D": "Microsoft (Hyper-V)", "525400": "QEMU/KVM",
"B827EB": "Raspberry Pi", "DCA632": "Raspberry Pi", "E45F01": "Raspberry Pi", "28CDC1": "Raspberry Pi",
}
def normalize_mac(text: str) -> str | None:
m = MAC_RE.search(text)
if not m:
return None
parts = re.split(r"[-:]", m.group(0))
mac = ":".join(p.zfill(2) for p in parts).upper()
return None if mac in ("00:00:00:00:00:00", "FF:FF:FF:FF:FF:FF") else mac
def oui_lookup(mac: str) -> str:
if not mac:
return ""
key = mac.replace(":", "").replace("-", "").upper()[:6]
name = BUILTIN_OUI.get(key)
if name:
return name
try:
if int(key[:2], 16) & 0x02:
return "Случайный MAC"
except ValueError:
pass
return ""
class _RangeError(Exception):
pass
def parse_targets(text: str, limit: int = MAX_TARGETS) -> tuple[list[str], str]:
ips: list[str] = []
seen: set[str] = set()
ipv4 = r"\d{1,3}(?:\.\d{1,3}){3}"
def addr(t: str) -> ipaddress.IPv4Address:
try:
return ipaddress.IPv4Address(t)
except ValueError:
raise _RangeError(f"Не удалось разобрать адрес: {t}")
def add(lo: int, hi: int) -> None:
if hi < lo:
lo, hi = hi, lo
if hi - lo + 1 > limit:
raise _RangeError(f"Слишком большой диапазон (максимум {limit} адресов).")
for n in range(lo, hi + 1):
ip = str(ipaddress.IPv4Address(n))
if ip not in seen:
seen.add(ip)
ips.append(ip)
if len(ips) > limit:
raise _RangeError(f"Слишком много адресов (максимум {limit}).")
tokens = [t.strip() for t in re.split(r"[,;\n]+", text) if t.strip()]
if not tokens:
return [], "Введите диапазон адресов."
try:
for tok in tokens:
if "/" in tok:
try:
net = ipaddress.IPv4Network(tok, strict=False)
except ValueError:
raise _RangeError(f"Не удалось разобрать сеть: {tok}")
lo, hi = int(net.network_address), int(net.broadcast_address)
if net.prefixlen < 31:
lo, hi = lo + 1, hi - 1
add(lo, hi)
continue
m = re.fullmatch(rf"({ipv4})\s*-\s*({ipv4})", tok)
if m:
add(int(addr(m.group(1))), int(addr(m.group(2))))
continue
m = re.fullmatch(rf"({ipv4})\s*-\s*(\d{{1,3}})", tok)
if m:
a = int(addr(m.group(1)))
last = int(m.group(2))
if last > 255:
raise _RangeError(f"В «{tok}» последнее число больше 255.")
add(a, (a & ~255) | last)
continue
add(int(addr(tok)), int(addr(tok)))
except _RangeError as e:
return [], str(e)
return ips, ""
def detect_local_range() -> str:
ip = ""
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.connect(("8.8.8.8", 80))
ip = sock.getsockname()[0]
except OSError:
try:
ip = socket.gethostbyname(socket.gethostname())
except OSError:
return ""
if not ip or ip.startswith("127."):
return ""
return f"{ip.rsplit('.', 1)[0]}.1-254"
def read_arp_table() -> dict[str, str]:
table: dict[str, str] = {}
try:
system = platform.system()
if system == "Linux":
with open("/proc/net/arp", "r") as f:
next(f, None)
for line in f:
p = line.split()
if len(p) >= 4 and p[2] != "0x0":
mac = normalize_mac(p[3])
if mac:
table[p[0]] = mac
return table
kwargs = {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0)} if system == "Windows" else {}
out = subprocess.check_output(["arp", "-a"], text=True, errors="ignore",
stderr=subprocess.DEVNULL, timeout=5, **kwargs)
for line in out.splitlines():
m = IP_RE.search(line)
mac = normalize_mac(line)
if m and mac:
table.setdefault(m.group(1), mac)
except Exception:
pass
return table
def send_wol(mac: str) -> str:
try:
raw = bytes.fromhex(mac.replace(":", "").replace("-", ""))
packet = b"\xff" * 6 + raw * 16
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.sendto(packet, ("255.255.255.255", 9))
return ""
except (OSError, ValueError) as e:
return str(e)
@dataclass
class HostResult:
ip: str
alive: bool = False
name: str = ""
mac: str = ""
rtt: float | None = None
port_open: bool = False
note: str = ""
class NetworkScanner(QObject):
host_result = pyqtSignal(object)
progress = pyqtSignal(int, int)
finished = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._loop = None
self._task = None
self._thread = None
self._running = False
@property
def running(self) -> bool:
return self._running
def start(self, ips: list[str], port: int) -> None:
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._run, args=(list(ips), port), daemon=True)
self._thread.start()
def stop(self) -> None:
self._running = False
loop, task = self._loop, self._task
if loop is not None and task is not None and loop.is_running():
loop.call_soon_threadsafe(task.cancel)
def wait(self, timeout: float = 3.0) -> None:
t = self._thread
if t is not None and t.is_alive() and t is not threading.current_thread():
t.join(timeout)
def _run(self, ips: list[str], port: int) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self._loop = loop
try:
self._task = loop.create_task(self._scan(ips, port))
loop.run_until_complete(self._task)
except asyncio.CancelledError:
pass
except Exception:
pass
finally:
try:
loop.run_until_complete(loop.shutdown_default_executor())
except Exception:
pass
loop.close()
self._loop = None
self._task = None
self._running = False
self.finished.emit()
async def _scan(self, ips: list[str], port: int) -> None:
total = len(ips)
done = 0
results: dict[str, HostResult] = {}
sem = asyncio.Semaphore(128)
async def probe(ip: str) -> None:
nonlocal done
async with sem:
if not self._running:
return
(alive, rtt), port_open = await asyncio.gather(self._ping(ip), self._tcp_open(ip, port))
r = HostResult(ip=ip, alive=alive or port_open, rtt=rtt, port_open=port_open,
note="" if alive else ("только порт" if port_open else ""))
results[ip] = r
done += 1
self.host_result.emit(replace(r))
self.progress.emit(done, total)
await asyncio.gather(*(probe(ip) for ip in ips))
if not self._running:
return
arp = await asyncio.to_thread(read_arp_table)
for ip, r in results.items():
mac = arp.get(ip)
if mac:
r.mac = mac
if not r.alive:
r.alive = True
r.note = "только ARP"
sem2 = asyncio.Semaphore(32)
async def finish(r: HostResult) -> None:
async with sem2:
try:
name = await asyncio.wait_for(asyncio.to_thread(self._get_hostname, r.ip), timeout=2.0)
except asyncio.TimeoutError:
name = None
r.name = name or ""
if self._running:
self.host_result.emit(replace(r))
await asyncio.gather(*(finish(r) for r in results.values() if r.alive))
@staticmethod
def _ping_command(ip: str) -> tuple[list[str], dict]:
system = platform.system()
if system == "Windows":
return ["ping", "-n", "1", "-w", "700", ip], {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0)}
if system == "Darwin":
return ["ping", "-c", "1", "-t", "1", ip], {}
return ["ping", "-c", "1", "-W", "1", ip], {}
@staticmethod
async def _kill(proc) -> None:
try:
if proc is not None and proc.returncode is None:
proc.kill()
await asyncio.wait_for(proc.wait(), timeout=1)
except (ProcessLookupError, asyncio.TimeoutError):
pass
async def _ping(self, ip: str) -> tuple[bool, float | None]:
cmd, kwargs = self._ping_command(ip)
proc = None
try:
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, **kwargs)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=3)
except asyncio.TimeoutError:
await self._kill(proc)
return False, None
except asyncio.CancelledError:
await self._kill(proc)
raise
except Exception:
return False, None
text = out.decode(errors="ignore")
if not re.search(r"ttl=", text, re.I):
return False, None
m = RTT_RE.search(text)
rtt = None
if m:
rtt = 0.5 if m.group(1) == "<" else float(m.group(2).replace(",", "."))
return True, rtt
@staticmethod
async def _tcp_open(ip: str, port: int) -> bool:
try:
_r, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=0.5)
writer.close()
await writer.wait_closed()
return True
except Exception:
return False
@staticmethod
def _get_hostname(ip: str) -> str | None:
try:
return socket.gethostbyaddr(ip)[0]
except Exception:
return None
# ============================================================================
# Каналы и конфиг
# ============================================================================
def parse_channels(text: str) -> tuple[list[int], list[str]]:
ok: list[int] = []
bad: list[str] = []
for tok in re.split(r"[,;\s]+", text.strip()):
if not tok:
continue
nums: range | list[int] | None
m = re.fullmatch(r"(\d+)-(\d+)", tok)
if m:
lo, hi = int(m[1]), int(m[2])
nums = range(lo, hi + 1) if lo <= hi else None
elif tok.isascii() and tok.isdigit():
nums = [int(tok)]
else:
nums = None
if nums is None or any(not CH_MIN <= n <= CH_MAX for n in nums):
bad.append(tok)
continue
ok.extend(nums)
return ok, bad
def sanitize_channels(values) -> list[int]:
result: list[int] = []
if not isinstance(values, (list, tuple)):
return result
for v in values:
if isinstance(v, bool) or not isinstance(v, int):
continue
if CH_MIN <= v <= CH_MAX and v not in result:
result.append(v)
return result
def clean_preset_name(name: str) -> str:
return re.sub(r"\s*\(\s*\d[\d,\s]*\)\s*$", "", name).strip()
@dataclass
class Config:
ip: str = "192.168.100.44"
port: int = 40250
scheme: str = "SH7_C_MB_VRNJ"
preset: str = "Дефолт"
global_hotkeys: bool = False
theme: str = DEFAULT_THEME
presets: dict = field(default_factory=lambda: {k: list(v) for k, v in DEFAULT_PRESETS.items()})
highlight_channels: dict = field(default_factory=dict)
authorized: bool = False
@classmethod
def load(cls) -> tuple["Config", str | None]:
cfg = cls()
path = next((p for p in (CONFIG_PATH, Path.cwd() / CONFIG_NAME) if p.is_file()), None)
if path is None:
return cfg, None
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as e:
return cfg, f"Не удалось прочитать {path.name}: {e}. Взяты настройки по умолчанию."
if not isinstance(raw, dict):
return cfg, f"{path.name} имеет неверный формат. Взяты настройки по умолчанию."
ip = str(raw.get("ip", "")).strip()
if ip:
cfg.ip = ip
try:
port = int(raw.get("port", cfg.port))
if 1 <= port <= 65535:
cfg.port = port
except (TypeError, ValueError):
pass
if raw.get("scheme") in SCHEMES:
cfg.scheme = raw["scheme"]
cfg.preset = str(raw.get("preset", "Дефолт"))
cfg.global_hotkeys = bool(raw.get("global_hotkeys", False))
theme = raw.get("theme")
if theme in THEMES:
cfg.theme = theme
elif raw.get("dark_theme") is False:
cfg.theme = "light"
cfg.authorized = bool(raw.get("authorized", False))
if isinstance(raw.get("presets"), dict):
cleaned: dict = {}
for name, chans in raw["presets"].items():
name = clean_preset_name(str(name))
chans = sanitize_channels(chans)
if name and chans:
cleaned[name] = chans
if cleaned:
cfg.presets = cleaned
if isinstance(raw.get("highlight_channels"), dict):
cfg.highlight_channels = {
str(k): sanitize_channels(v) for k, v in raw["highlight_channels"].items()
}
return cfg, None
def save(self) -> str | None:
data = {
"ip": self.ip, "port": self.port, "scheme": self.scheme, "preset": self.preset,
"global_hotkeys": self.global_hotkeys, "theme": self.theme,
"presets": self.presets, "highlight_channels": self.highlight_channels,
"authorized": self.authorized,
}
tmp = CONFIG_PATH.with_name(CONFIG_NAME + ".tmp")
try:
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=4), encoding="utf-8")
tmp.replace(CONFIG_PATH)
except OSError as e:
return str(e)
return None
# ============================================================================
# Сеть
# ============================================================================
def _error_text(sock: QTcpSocket) -> str:
E = QAbstractSocket.SocketError
known = {
E.ConnectionRefusedError: "Соединение отклонено",
E.RemoteHostClosedError: "Соединение закрыто устройством",
E.HostNotFoundError: "Хост не найден",
E.SocketTimeoutError: "Таймаут соединения",
E.NetworkError: "Сетевая ошибка",
}
return known.get(sock.error(), sock.errorString())
class DeviceClient(QObject):
state_changed = pyqtSignal(object)
error = pyqtSignal(str)
data = pyqtSignal(bytes)
def __init__(self, parent=None):
super().__init__(parent)
self._sock = QTcpSocket(self)
self._sock.setSocketOption(QAbstractSocket.SocketOption.LowDelayOption, 1)
self._sock.stateChanged.connect(lambda state: self.state_changed.emit(state))
self._sock.errorOccurred.connect(lambda _e: self.error.emit(_error_text(self._sock)))
self._sock.readyRead.connect(lambda: self.data.emit(bytes(self._sock.readAll())))
self._timeout = QTimer(self)
self._timeout.setSingleShot(True)
self._timeout.timeout.connect(self._on_timeout)
@property
def state(self):
return self._sock.state()
@property
def is_connected(self) -> bool:
return self.state == QAbstractSocket.SocketState.ConnectedState
def open(self, host: str, port: int) -> None:
self._timeout.start(CONNECT_TIMEOUT_MS)
self._sock.connectToHost(host, port)
def close(self) -> None:
self._timeout.stop()
if self.state != QAbstractSocket.SocketState.UnconnectedState:
self._sock.abort()
def send(self, packet: bytes) -> bool:
if not self.is_connected:
return False
return self._sock.write(packet) == len(packet)
def _on_timeout(self) -> None:
if not self.is_connected:
self._sock.abort()
self.error.emit("Таймаут подключения")
class GlobalHotkeys(QObject):
pressed = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._listener = None
@staticmethod
def available() -> bool:
return pynput_keyboard is not None
def start(self) -> None:
if self._listener is not None or not self.available():
return
def on_press(key):
if key == pynput_keyboard.Key.right:
self.pressed.emit("next")
elif key == pynput_keyboard.Key.left:
self.pressed.emit("prev")
self._listener = pynput_keyboard.Listener(on_press=on_press)
self._listener.daemon = True
self._listener.start()
def stop(self) -> None:
if self._listener is not None:
self._listener.stop()
self._listener = None
# ============================================================================
# Виджеты
# ============================================================================
class FlowLayout(QLayout):
def __init__(self, parent=None, hspacing: int = 6, vspacing: int = 6):
self._items: list = []
self._h, self._v = hspacing, vspacing
super().__init__(parent)
self.setContentsMargins(0, 0, 0, 0)
def addItem(self, item):
self._items.append(item)
self.invalidate()
def insertWidget(self, index: int, widget: QWidget) -> None:
self.addChildWidget(widget)
self._items.insert(index, QWidgetItem(widget))
widget.show()
self.invalidate()
def reorder(self, widgets: list) -> None:
ordered = []
for w in widgets:
for it in self._items:
if it.widget() is w:
ordered.append(it)
break
used = {id(it) for it in ordered}
self._items = ordered + [it for it in self._items if id(it) not in used]
self.invalidate()
def count(self):
return len(self._items)
def itemAt(self, index):
return self._items[index] if 0 <= index < len(self._items) else None
def takeAt(self, index):
if 0 <= index < len(self._items):
item = self._items.pop(index)
self.invalidate()
return item
return None
def expandingDirections(self):
return Qt.Orientation.Horizontal & Qt.Orientation.Vertical
def hasHeightForWidth(self):
return True
def heightForWidth(self, width):
return self._do_layout(QRect(0, 0, width, 0), True)
def setGeometry(self, rect):
super().setGeometry(rect)
self._do_layout(rect, False)
def sizeHint(self):
return self.minimumSize()
def minimumSize(self):
size = QSize()
for item in self._items:
size = size.expandedTo(item.minimumSize())
m = self.contentsMargins()
return size + QSize(m.left() + m.right(), m.top() + m.bottom())
def _do_layout(self, rect: QRect, test_only: bool) -> int:
m = self.contentsMargins()
r = rect.adjusted(m.left(), m.top(), -m.right(), -m.bottom())
x, y, line_h = r.x(), r.y(), 0
for item in self._items:
hint = item.sizeHint()
next_x = x + hint.width()
if next_x > r.right() + 1 and line_h > 0:
x = r.x()
y += line_h + self._v
next_x = x + hint.width()
line_h = 0
if not test_only:
item.setGeometry(QRect(QPoint(x, y), hint))
x = next_x + self._h
line_h = max(line_h, hint.height())
return y + line_h - rect.y() + m.bottom()
class ChipStrip(QWidget):
chipClicked = pyqtSignal(int)
def __init__(self, parent=None):
super().__init__(parent)
self._flow = FlowLayout(self)
self._group = QButtonGroup(self)
self._group.setExclusive(True)
self._buttons: list[QPushButton] = []
self._highlighted: set[int] = set()
def set_channels(self, channels: list[int], highlighted: set[int] | None = None) -> None:
self._highlighted = highlighted or set()
for b in self._buttons:
self._group.removeButton(b)
b.setParent(None)
b.deleteLater()
self._buttons.clear()
for i, ch in enumerate(channels):
b = QPushButton(str(ch))
if ch in self._highlighted:
b.setObjectName("chipHighlight")
else:
b.setObjectName("chip")
b.setCheckable(True)
b.setCursor(Qt.CursorShape.PointingHandCursor)
b.clicked.connect(lambda _=False, idx=i: self.chipClicked.emit(idx))
self._group.addButton(b)
self._flow.addWidget(b)
self._buttons.append(b)
self.updateGeometry()
def row_height(self) -> int:
h = max((b.sizeHint().height() for b in self._buttons), default=0)
return h or 30
def set_active(self, index: int) -> None:
self._group.setExclusive(False)
for i, b in enumerate(self._buttons):
b.setChecked(i == index)
self._group.setExclusive(True)
class ChipScroll(QScrollArea):
MAX_ROWS = 4
def __init__(self, strip: ChipStrip, parent=None):
super().__init__(parent)
self._strip = strip
self._last_hint = -1
self.setWidget(strip)
self.setWidgetResizable(True)
self.setFrameShape(QFrame.Shape.NoFrame)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
def minimumSizeHint(self) -> QSize:
return QSize(80, self._strip.row_height())
def sizeHint(self) -> QSize:
width = max(self.viewport().width(), 200)
row = self._strip.row_height()
cap = row * self.MAX_ROWS + 6 * (self.MAX_ROWS - 1)
content = self._strip.heightForWidth(width)
if content < 0:
content = row
return QSize(width, max(row, min(content, cap)))
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
h = self.sizeHint().height()
if h != self._last_hint:
self._last_hint = h
self.updateGeometry()
class IntField(QLineEdit):
def __init__(self, lo: int, hi: int, value: int, parent=None):
super().__init__(parent)
self._lo, self._hi, self._last = lo, hi, value
self.setValidator(QIntValidator(lo, hi, self))
self.setText(str(value))
self.editingFinished.connect(lambda: self.setText(str(self.value())))
def value(self) -> int:
try:
v = int(self.text())
except ValueError:
return self._last
self._last = min(max(v, self._lo), self._hi)
return self._last
def setValue(self, v: int) -> None:
self._last = min(max(int(v), self._lo), self._hi)
self.setText(str(self._last))
def wheelEvent(self, event: QWheelEvent) -> None:
delta = event.angleDelta().y()
if delta > 0:
self.setValue(self.value() + 1)
elif delta < 0:
self.setValue(self.value() - 1)
event.accept()
TAG_MIME = "application/x-switch-tag"
class TagChip(QLabel):
removeRequested = pyqtSignal(object)
dragRequested = pyqtSignal(object)
highlightToggled = pyqtSignal(object)
def __init__(self, ch: int, highlighted: bool = False):
super().__init__(str(ch))
self.setObjectName("tag")
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setToolTip("Двойной клик — убрать, перетащите — изменить порядок, ПКМ — подсветить")
self._press = None
self._highlighted = highlighted
self._update_style()
def set_highlighted(self, on: bool) -> None:
if on != self._highlighted:
self._highlighted = on
self._update_style()
def _update_style(self) -> None:
if self._highlighted:
self.setStyleSheet("background-color: #4f8cff; color: white; border: 1px solid #4f8cff;")
else:
self.setStyleSheet("")
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.RightButton:
self.highlightToggled.emit(self)
elif event.button() == Qt.MouseButton.LeftButton:
self._press = event.position().toPoint()
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self._press is not None and event.buttons() & Qt.MouseButton.LeftButton:
if (event.position().toPoint() - self._press).manhattanLength() >= QApplication.startDragDistance():
self._press = None
self.dragRequested.emit(self)
return
super().mouseMoveEvent(event)
def mouseDoubleClickEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._press = None
self.removeRequested.emit(self)
super().mouseDoubleClickEvent(event)
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._press = None
super().mouseReleaseEvent(event)
class _TagFrame(QFrame):
clicked = pyqtSignal()
dragMoved = pyqtSignal(QPoint)
dragLeft = pyqtSignal()
dropped = pyqtSignal(QPoint, int)
def __init__(self, parent=None):
super().__init__(parent)
self.setAcceptDrops(True)
def mousePressEvent(self, event):
self.clicked.emit()
super().mousePressEvent(event)
def dragEnterEvent(self, event):
if event.mimeData().hasFormat(TAG_MIME):
event.acceptProposedAction()
else:
event.ignore()
def dragMoveEvent(self, event):
if event.mimeData().hasFormat(TAG_MIME):
event.acceptProposedAction()
self.dragMoved.emit(event.position().toPoint())
def dragLeaveEvent(self, event):
self.dragLeft.emit()
def dropEvent(self, event):
if event.mimeData().hasFormat(TAG_MIME):
try:
src = int(bytes(event.mimeData().data(TAG_MIME)).decode())
except ValueError:
event.ignore()
return
event.acceptProposedAction()
self.dropped.emit(event.position().toPoint(), src)
class TagField(QWidget):
changed = pyqtSignal()
highlightChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._channels: list[int] = []
self._chips: list[TagChip] = []
self._highlighted: set[int] = set()
self.frame = _TagFrame()
self.frame.setObjectName("tagField")
self.frame.setMinimumHeight(54)
self._flow = FlowLayout(self.frame, 6, 6)
self._flow.setContentsMargins(8, 8, 8, 8)
self.input = QLineEdit()
self.input.setObjectName("tagInput")
self.input.setMinimumWidth(150)
self.input.setAcceptDrops(False)
self._flow.addWidget(self.input)
self._indicator = QFrame(self.frame)
self._indicator.setObjectName("dropLine")
self._indicator.hide()
self.frame.clicked.connect(self.input.setFocus)
self.frame.dragMoved.connect(self._on_drag_move)
self.frame.dragLeft.connect(self._indicator.hide)
self.frame.dropped.connect(self._on_drop)
self.input.returnPressed.connect(self._commit)
self.input.textEdited.connect(self._on_text)
self.input.installEventFilter(self)
self.hint = QLabel()
self.hint.setObjectName("hint")
self.hint.setWordWrap(True)
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(6)
lay.addWidget(self.frame)
lay.addWidget(self.hint)
self._update_placeholder()
def set_channels(self, channels: list[int], highlighted: set[int] | None = None) -> None:
for chip in self._chips:
chip.setParent(None)
chip.deleteLater()
self._chips.clear()
self._channels = []
self._highlighted = highlighted or set()
for ch in channels:
self._channels.append(ch)
self._add_chip(ch)
self.input.clear()
self.hint.clear()
self._update_placeholder()
def set_highlighted(self, highlighted: set[int]) -> None:
self._highlighted = highlighted
for chip in self._chips:
try:
ch = int(chip.text())
chip.set_highlighted(ch in highlighted)
except ValueError:
pass
def channels(self) -> list[int]:
return list(self._channels)
def highlighted(self) -> set[int]:
return set(self._highlighted)
def commit_pending(self) -> None:
self._commit()
def eventFilter(self, obj, event):
if obj is self.input:
t = event.type()
if t == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Backspace \
and not self.input.text() and self._channels:
self._remove(len(self._channels) - 1)
return True
if t == QEvent.Type.FocusIn:
self._set_focused(True)
elif t == QEvent.Type.FocusOut:
self._set_focused(False)
self._commit()
return super().eventFilter(obj, event)
def _set_focused(self, on: bool) -> None:
self.frame.setProperty("focused", on)
self.frame.style().unpolish(self.frame)
self.frame.style().polish(self.frame)
def _update_placeholder(self) -> None:
self.input.setPlaceholderText("" if self._channels else "Введите каналы (13 19 14 или 1-4) и нажмите Enter")
def _on_text(self, text: str) -> None:
if text and text[-1] in " ,;":
self._commit()
def _commit(self) -> None:
text = self.input.text()
if not text.strip():
self.input.clear()
return
ok, bad = parse_channels(text)
added, dupes = [], []
for n in ok:
if n in self._channels:
dupes.append(n)
else:
self._channels.append(n)
self._add_chip(n)
added.append(n)
notes = []
if bad:
notes.append(f"Не подходит: {', '.join(bad)} (допустимо {CH_MIN}–{CH_MAX})")
if dupes:
notes.append(f"Уже есть: {', '.join(map(str, dupes))}")
self.hint.setText(". ".join(notes))
self.input.setText(" ".join(bad))
if added:
self._update_placeholder()
self.changed.emit()
def _add_chip(self, ch: int) -> None:
chip = TagChip(ch, ch in self._highlighted)
chip.removeRequested.connect(self._on_chip_remove)
chip.dragRequested.connect(self._start_drag)
chip.highlightToggled.connect(self._on_highlight_toggle)
self._flow.insertWidget(len(self._chips), chip)
self._chips.append(chip)
def _on_highlight_toggle(self, chip) -> None:
try:
ch = int(chip.text())
except ValueError:
return
if ch in self._highlighted:
self._highlighted.remove(ch)
chip.set_highlighted(False)
else:
self._highlighted.add(ch)
chip.set_highlighted(True)
self.highlightChanged.emit()
def _on_chip_remove(self, chip) -> None:
if chip in self._chips:
self._remove(self._chips.index(chip))
def _remove(self, index: int) -> None:
if 0 <= index < len(self._channels):
del self._channels[index]
chip = self._chips.pop(index)
try:
ch = int(chip.text())
if ch in self._highlighted:
self._highlighted.discard(ch)
except ValueError:
pass
chip.setParent(None)
chip.deleteLater()
self._update_placeholder()
self.changed.emit()
self.input.setFocus()
def _start_drag(self, chip) -> None:
if chip not in self._chips:
return
mime = QMimeData()
mime.setData(TAG_MIME, str(self._chips.index(chip)).encode())
drag = QDrag(self)
drag.setMimeData(mime)
pixmap = chip.grab()
drag.setPixmap(pixmap)
drag.setHotSpot(QPoint(pixmap.width() // 2, pixmap.height() // 2))
drag.exec(Qt.DropAction.MoveAction)
self._indicator.hide()
def _target_index(self, pos: QPoint) -> int:
for i, chip in enumerate(self._chips):
g = chip.geometry()
if pos.y() < g.top():
return i
if pos.y() <= g.bottom() and pos.x() < g.center().x():
return i
return len(self._chips)
def _on_drag_move(self, pos: QPoint) -> None:
if not self._chips:
return
t = self._target_index(pos)
if t < len(self._chips):
g = self._chips[t].geometry()
x = g.left() - 4
else:
g = self._chips[-1].geometry()
x = g.right() + 3
self._indicator.setGeometry(x, g.top(), 2, g.height())
self._indicator.show()
self._indicator.raise_()
def _on_drop(self, pos: QPoint, src: int) -> None:
self._indicator.hide()
t = self._target_index(pos)
if not 0 <= src < len(self._chips):
return
if t > src:
t -= 1
if t == src:
return
self._channels.insert(t, self._channels.pop(src))
self._chips.insert(t, self._chips.pop(src))
self._flow.reorder(self._chips)
self.changed.emit()
# ============================================================================
# Диалог пароля
# ============================================================================
class AuthDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Вход")
self.setFixedSize(300, 140)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(15)
lbl = QLabel("Введите пароль для доступа:")
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(lbl)
self.pwd_input = QLineEdit()
self.pwd_input.setEchoMode(QLineEdit.EchoMode.Password)
self.pwd_input.setPlaceholderText("Пароль")
self.pwd_input.returnPressed.connect(self.try_auth)
layout.addWidget(self.pwd_input)
btn = QPushButton("Войти")
set_variant(btn, "white")
btn.clicked.connect(self.try_auth)
layout.addWidget(btn)
self.lbl_error = QLabel()
self.lbl_error.setStyleSheet("color: #ff6b6b;")
self.lbl_error.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.lbl_error)
def try_auth(self):
if self.pwd_input.text() == PASSWORD:
self.accept()
else:
self.lbl_error.setText("Неверный пароль")
self.pwd_input.clear()
# ============================================================================
# Диалог сканера
# ============================================================================
class _SortItem(QTableWidgetItem):
def __init__(self, text: str, key):
super().__init__(text)
self._key = key
def __lt__(self, other) -> bool:
try:
return self._key < other._key
except (AttributeError, TypeError):
return super().__lt__(other)
class ScannerDialog(QDialog):
COLS = ("Статус", "IP-адрес", "Имя", "Производитель", "MAC-адрес", "Отклик", "Порт")
COL_IP, COL_MAC = 1, 4
def __init__(self, parent=None, base_ip: str = "", port: int = 40250, palette: dict | None = None):
super().__init__(parent)
self.setWindowTitle("Сканер сети")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.resize(940, 580)
self._colors = palette or THEMES[DEFAULT_THEME][1]
self.selected_ip = ""
self._hosts: dict[str, HostResult] = {}
self._total = 0
self._dirty = False
self._started_at = 0.0
default_range = ""
try:
default_range = f"{str(ipaddress.IPv4Address(base_ip.strip())).rsplit('.', 1)[0]}.1-254"
except ValueError:
default_range = detect_local_range()
self.range_input = QLineEdit(default_range)
self.range_input.setPlaceholderText("192.168.100.1-254 или 192.168.100.0/24")
self.range_input.setToolTip("Примеры: 192.168.1.1-254 · 10.0.0.5 - 10.0.0.20 · 192.168.1.0/24 · несколько через запятую")
self.btn_mynet = QPushButton("Моя сеть")
self.btn_mynet.setToolTip("Подставить подсеть этого компьютера")
self.port_input = IntField(1, 65535, port)
self.port_input.setFixedWidth(80)
self.port_input.setToolTip("Порт устройства: узлы с открытым портом подсвечиваются в таблице")
self.btn_scan = QPushButton("Сканировать")
set_variant(self.btn_scan, "white")
self.btn_scan.setMinimumWidth(130)
top = QHBoxLayout()
top.setSpacing(8)
lbl_range = QLabel("Диапазон")
lbl_range.setObjectName("dim")
lbl_port = QLabel("Порт")
lbl_port.setObjectName("dim")
top.addWidget(lbl_range)
top.addWidget(self.range_input, 1)
top.addWidget(self.btn_mynet)
top.addWidget(lbl_port)
top.addWidget(self.port_input)
top.addWidget(self.btn_scan)
self.progress = QProgressBar()
self.progress.setTextVisible(False)
self.progress.setRange(0, 1)
self.progress.setValue(0)
self.lbl_error = QLabel()
self.lbl_error.setObjectName("hint")
self.table = QTableWidget(0, len(self.COLS))
self.table.setHorizontalHeaderLabels(list(self.COLS))
self.table.verticalHeader().setVisible(False)
self.table.verticalHeader().setDefaultSectionSize(26)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.table.setShowGrid(False)
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
header = self.table.horizontalHeader()
header.setHighlightSections(False)
header.setSortIndicatorShown(True)
for col, mode in enumerate((
QHeaderView.ResizeMode.ResizeToContents, QHeaderView.ResizeMode.ResizeToContents,
QHeaderView.ResizeMode.Stretch, QHeaderView.ResizeMode.Stretch,
QHeaderView.ResizeMode.ResizeToContents, QHeaderView.ResizeMode.ResizeToContents,
QHeaderView.ResizeMode.ResizeToContents,
)):
header.setSectionResizeMode(col, mode)
header.setSortIndicator(self.COL_IP, Qt.SortOrder.AscendingOrder)
self.table.setSortingEnabled(True)
self.chk_dead = QCheckBox("Показывать недоступные")
self.lbl_stats = QLabel("Укажите диапазон и нажмите «Сканировать».")
self.lbl_stats.setObjectName("dim")
self.btn_close = QPushButton("Закрыть")
bottom = QHBoxLayout()
bottom.setSpacing(8)
bottom.addWidget(self.chk_dead)
bottom.addStretch()
bottom.addWidget(self.btn_close)
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(10)
layout.addLayout(top)
layout.addWidget(self.progress)
layout.addWidget(self.lbl_error)
layout.addWidget(self.table, 1)
layout.addWidget(self.lbl_stats)
layout.addLayout(bottom)
self.scanner = NetworkScanner(self)
self.timer = QTimer(self)
self.timer.setInterval(250)
self.timer.timeout.connect(self._tick)
self.scanner.host_result.connect(self._on_host)
self.scanner.progress.connect(self._on_progress)
self.scanner.finished.connect(self._on_finished)
self.btn_scan.clicked.connect(self._toggle_scan)
self.range_input.returnPressed.connect(self._toggle_scan)
self.btn_mynet.clicked.connect(self._use_my_network)
self.btn_close.clicked.connect(self.reject)
self.chk_dead.toggled.connect(lambda _: self._render())
self.table.itemDoubleClicked.connect(lambda _item: self._pick())
self.table.customContextMenuRequested.connect(self._context_menu)
def keyPressEvent(self, event):
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
event.accept()
return
super().keyPressEvent(event)
def done(self, result: int) -> None:
self.timer.stop()
self.scanner.stop()
self.scanner.wait()
super().done(result)
def _use_my_network(self) -> None:
text = detect_local_range()
if text:
self.range_input.setText(text)
self.lbl_error.clear()
else:
self.lbl_error.setText("Не удалось определить локальную сеть.")
def _toggle_scan(self) -> None:
if self.scanner.running:
self.scanner.stop()
self.btn_scan.setEnabled(False)
self.btn_scan.setText("Остановка…")
return
ips, error = parse_targets(self.range_input.text())
self.lbl_error.setText(error)
if not ips:
return
port = self.port_input.value()
self._hosts.clear()
self._total = len(ips)
self._started_at = time.monotonic()
self.progress.setRange(0, self._total)
self.progress.setValue(0)
labels = list(self.COLS)
labels[6] = f"Порт {port}"
self.table.setHorizontalHeaderLabels(labels)
self.btn_scan.setText("Стоп")
set_variant(self.btn_scan, "danger")
for w in (self.range_input, self.btn_mynet, self.port_input):
w.setEnabled(False)
self._render()
self.timer.start()
self.scanner.start(ips, port)
def _on_host(self, host: HostResult) -> None:
self._hosts[host.ip] = host
self._dirty = True
def _on_progress(self, done: int, total: int) -> None:
self.progress.setValue(done)
def _on_finished(self) -> None:
self.timer.stop()
self._render()
self.btn_scan.setEnabled(True)
self.btn_scan.setText("Сканировать")
set_variant(self.btn_scan, "white")
for w in (self.range_input, self.btn_mynet, self.port_input):
w.setEnabled(True)
elapsed = time.monotonic() - self._started_at
alive = sum(1 for h in self._hosts.values() if h.alive)
self.lbl_stats.setText(f"Готово за {elapsed:.1f} с · в сети: {alive} · проверено адресов: {len(self._hosts)} из {self._total}")
def _tick(self) -> None:
if self._dirty:
self._render()
def _ip_at(self, row: int) -> str:
item = self.table.item(row, self.COL_IP)
return item.data(Qt.ItemDataRole.UserRole) if item is not None else ""
def _render(self) -> None:
self._dirty = False
show_dead = self.chk_dead.isChecked()
hosts = [h for h in self._hosts.values() if h.alive or show_dead]
selected = self._ip_at(self.table.currentRow()) if self.table.currentRow() >= 0 else ""
header = self.table.horizontalHeader()
col, order = header.sortIndicatorSection(), header.sortIndicatorOrder()
self.table.setSortingEnabled(False)
self.table.setRowCount(len(hosts))
for row, h in enumerate(hosts):
self._fill_row(row, h)
self.table.setSortingEnabled(True)
self.table.sortItems(col, order)
if selected:
for row in range(self.table.rowCount()):
if self._ip_at(row) == selected:
self.table.selectRow(row)
break
if self.scanner.running:
alive = sum(1 for h in self._hosts.values() if h.alive)
self.lbl_stats.setText(f"Сканирование… в сети: {alive} · проверено: {len(self._hosts)} из {self._total}")
def _fill_row(self, row: int, h: HostResult) -> None:
c = self._colors
inf = float("inf")
vendor = oui_lookup(h.mac) if h.mac else ""
if h.rtt is None:
rtt_text, rtt_key = "—", inf
else:
rtt_text, rtt_key = ("<1 мс" if h.rtt < 1 else f"{h.rtt:g} мс"), h.rtt
status = "● В сети" if h.alive else "○ Нет ответа"
if h.note:
status += f" ({h.note})"
cells = [
(status, 0 if h.alive else 1, c["OK"] if h.alive else c["MUTED"]),
(h.ip, int(ipaddress.IPv4Address(h.ip)), c["ACCENT"] if h.port_open else None),
(h.name or "—", h.name.lower() if h.name else "\uffff", None),
(vendor or "—", vendor.lower() if vendor else "\uffff", None),
(h.mac or "—", h.mac or "\uffff", None),
(rtt_text, rtt_key, None),
("✓ открыт" if h.port_open else "—", 0 if h.port_open else 1, c["ACCENT"] if h.port_open else None),
]
for col, (text, key, color) in enumerate(cells):
item = _SortItem(text, key)
if color:
item.setForeground(QBrush(QColor(color)))
elif not h.alive:
item.setForeground(QBrush(QColor(c["MUTED"])))
if col == self.COL_IP:
item.setData(Qt.ItemDataRole.UserRole, h.ip)
self.table.setItem(row, col, item)
def _pick(self) -> None:
row = self.table.currentRow()
ip = self._ip_at(row) if row >= 0 else ""
if ip:
self.selected_ip = ip
self.accept()
def _context_menu(self, pos) -> None:
row = self.table.rowAt(pos.y())
if row < 0:
return
self.table.selectRow(row)
ip = self._ip_at(row)
mac_text = self.table.item(row, self.COL_MAC).text()
mac = "" if mac_text == "—" else mac_text
clip = QApplication.clipboard()
menu = QMenu(self)
actions = [
(menu.addAction("Использовать этот IP"), self._pick),
(menu.addAction("Копировать IP"), lambda: clip.setText(ip)),
]
if mac:
actions.append((menu.addAction("Копировать MAC"), lambda: clip.setText(mac)))
menu.addSeparator()
actions.append((menu.addAction("Открыть в браузере"), lambda: webbrowser.open(f"http://{ip}")))
if platform.system() == "Windows":
actions.append((menu.addAction("Пинг в консоли"), lambda: subprocess.Popen(
["cmd", "/c", "start", f"Ping {ip}", "cmd", "/k", "ping", "-t", ip])))
if mac:
actions.append((menu.addAction("Разбудить (Wake-on-LAN)"), lambda: self._wake(mac)))
chosen = menu.exec(self.table.viewport().mapToGlobal(pos))
for action, fn in actions:
if action is chosen:
fn()
break
def _wake(self, mac: str) -> None:
err = send_wol(mac)
self.lbl_error.setText(f"Wake-on-LAN не отправлен: {err}" if err else "")
if not err:
self.lbl_stats.setText(f"Пакет пробуждения отправлен на {mac}.")
# ============================================================================
# Редактор пресетов
# ============================================================================
class PresetEditorDialog(QDialog):
def __init__(self, presets: dict, current: str, parent=None, highlight_channels: dict | None = None):
super().__init__(parent)
self.setWindowTitle("Редактор пресетов")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.resize(720, 500)
self._entries: list[list] = [[n, list(c)] for n, c in presets.items()]
self._highlight: dict = dict(highlight_channels or {})
title_l = QLabel("ПРЕСЕТЫ")
title_l.setObjectName("cardTitle")
self.list = QListWidget()
self.btn_new = QPushButton("Новый")
self.btn_dup = QPushButton("Копия")
self.btn_del = QPushButton("Удалить")
set_variant(self.btn_del, "danger")
self.btn_new.clicked.connect(self._new)
self.btn_dup.clicked.connect(self._duplicate)
self.btn_del.clicked.connect(self._delete)
btn_row = QHBoxLayout()
btn_row.setSpacing(6)
for b in (self.btn_new, self.btn_dup, self.btn_del):
btn_row.addWidget(b)
left = QVBoxLayout()
left.setSpacing(8)
left.addWidget(title_l)
left.addWidget(self.list, 1)
left.addLayout(btn_row)
left_w = QWidget()
left_w.setFixedWidth(250)
left_w.setLayout(left)
left.setContentsMargins(0, 0, 0, 0)
title_n = QLabel("НАЗВАНИЕ")
title_n.setObjectName("cardTitle")
self.name_edit = QLineEdit()
self.name_edit.setPlaceholderText("Например, F25")
title_c = QLabel("КАНАЛЫ · двойной клик — убрать, перетаскивание — порядок, ПКМ — подсветить")
title_c.setObjectName("cardTitle")
self.editor = TagField()
right = QVBoxLayout()
right.setContentsMargins(0, 0, 0, 0)
right.setSpacing(8)
right.addWidget(title_n)
right.addWidget(self.name_edit)
right.addSpacing(6)
right.addWidget(title_c)
right.addWidget(self.editor)
right.addStretch()
self.right_panel = QWidget()
self.right_panel.setLayout(right)
self.list.currentRowChanged.connect(self._on_row_changed)
self.name_edit.textEdited.connect(self._on_name_edited)
self.editor.changed.connect(self._on_channels_changed)
self.editor.highlightChanged.connect(self._on_highlight_changed)
body = QHBoxLayout()
body.setSpacing(16)
body.addWidget(left_w)
body.addWidget(self.right_panel, 1)
box = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
save_btn = box.button(QDialogButtonBox.StandardButton.Save)
save_btn.setText("Сохранить")
box.button(QDialogButtonBox.StandardButton.Cancel).setText("Отмена")
box.accepted.connect(self.accept)
box.rejected.connect(self.reject)
root = QVBoxLayout(self)
root.setContentsMargins(16, 16, 16, 16)
root.setSpacing(14)
root.addLayout(body, 1)
root.addWidget(box)
start = next((i for i, e in enumerate(self._entries) if e[0] == current), 0 if self._entries else -1)
self._refresh_list(start)
def presets(self) -> dict:
return {n.strip(): list(c) for n, c in self._entries}
def highlight_channels(self) -> dict:
return self._highlight
def selected_name(self) -> str:
row = self.list.currentRow()
return self._entries[row][0].strip() if 0 <= row < len(self._entries) else ""
def keyPressEvent(self, event):
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
event.accept()
return
super().keyPressEvent(event)
def accept(self) -> None:
self.editor.commit_pending()
problem = self._validate()
if problem:
row, message = problem
self.list.setCurrentRow(row)
QMessageBox.warning(self, "Проверьте пресет", message)
return
super().accept()
@staticmethod
def _item_text(entry: list) -> str:
name, chans = entry
shown = ", ".join(map(str, chans)) if chans else "пусто"
return f"{name.strip() or '(без названия)'} · {shown}"
def _refresh_list(self, select_row: int) -> None:
self.list.blockSignals(True)
self.list.clear()
for e in self._entries:
self.list.addItem(self._item_text(e))
if 0 <= select_row < len(self._entries):
self.list.setCurrentRow(select_row)
self.list.blockSignals(False)
self._on_row_changed(self.list.currentRow())
def _update_item(self, row: int) -> None:
item = self.list.item(row)
if item is not None:
item.setText(self._item_text(self._entries[row]))
def _on_row_changed(self, row: int) -> None:
has = 0 <= row < len(self._entries)
self.right_panel.setEnabled(has)
self.btn_dup.setEnabled(has)
self.btn_del.setEnabled(has)
if has:
name = self._entries[row][0].strip()
self.name_edit.setText(self._entries[row][0])
self.editor.set_channels(self._entries[row][1], set(self._highlight.get(name, [])))
else:
self.name_edit.clear()
self.editor.set_channels([])
def _on_name_edited(self, text: str) -> None:
row = self.list.currentRow()
if 0 <= row < len(self._entries):
old_name = self._entries[row][0].strip()
self._entries[row][0] = text
self._update_item(row)
new_name = text.strip()
if old_name in self._highlight and old_name != new_name:
self._highlight[new_name] = self._highlight.pop(old_name)
def _on_channels_changed(self) -> None:
row = self.list.currentRow()
if 0 <= row < len(self._entries):
self._entries[row][1] = self.editor.channels()
self._update_item(row)
def _on_highlight_changed(self) -> None:
row = self.list.currentRow()
if 0 <= row < len(self._entries):
name = self._entries[row][0].strip()
hl = self.editor.highlighted()
if hl:
self._highlight[name] = list(hl)
else:
self._highlight.pop(name, None)
def _unique_name(self, base: str) -> str:
names = {e[0].strip() for e in self._entries}
if base not in names:
return base
n = 2
while f"{base} {n}" in names:
n += 1
return f"{base} {n}"
def _new(self) -> None:
self._entries.append([self._unique_name("Новый пресет"), []])
self._refresh_list(len(self._entries) - 1)
self.name_edit.setFocus()
self.name_edit.selectAll()
def _duplicate(self) -> None:
row = self.list.currentRow()
if 0 <= row < len(self._entries):
name, chans = self._entries[row]
new_name = self._unique_name(f"{name.strip()} копия")
self._entries.append([new_name, list(chans)])
old_hl = self._highlight.get(name.strip())
if old_hl:
self._highlight[new_name] = list(old_hl)
self._refresh_list(len(self._entries) - 1)
def _delete(self) -> None:
row = self.list.currentRow()
if 0 <= row < len(self._entries):
name = self._entries[row][0].strip()
self._highlight.pop(name, None)
del self._entries[row]
self._refresh_list(min(row, len(self._entries) - 1))
def _validate(self) -> tuple[int, str] | None:
seen = set()
for i, (name, chans) in enumerate(self._entries):
n = name.strip()
if not n:
return i, "У пресета нет названия."
if n in seen:
return i, f"Название «{n}» встречается дважды."
if not chans:
return i, f"В пресете «{n}» нет ни одного канала."
seen.add(n)
return None
# ============================================================================
# Иконка замка (маленькая, залитая)
# ============================================================================
class LockIcon(QWidget):
"""Крошечный залитый замочек для нижней панели."""
clicked = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(10, 10) # Совсем крошечный
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setToolTip("Блокировка (3 клика)")
self._clicks = 0
self._last_click = 0.0
def paintEvent(self, event):
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor("#8b8b95")) # Залитый цвет (серый)
# Рисуем простой прямоугольник (тело) и полукруг (дужка), все слито воедино
p.drawRect(2, 5, 6, 4) # Тело
p.drawEllipse(3, 2, 4, 4) # Дужка (верхняя часть)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
now = time.monotonic()
if now - self._last_click > 1.0:
self._clicks = 0
self._last_click = now
self._clicks += 1
if self._clicks >= 3:
self._clicks = 0
self.clicked.emit()
# ============================================================================
# Главное окно
# ============================================================================
def make_card(title: str, right: QWidget | None = None) -> tuple[QFrame, QVBoxLayout]:
frame = QFrame()
frame.setObjectName("card")
frame.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
lay = QVBoxLayout(frame)
lay.setContentsMargins(14, 12, 14, 14)
lay.setSpacing(10)
head = QHBoxLayout()
lbl = QLabel(title)
lbl.setObjectName("cardTitle")
head.addWidget(lbl)
head.addStretch()
if right is not None:
head.addWidget(right)
lay.addLayout(head)
return frame, lay
class SwitchController(QWidget):
def __init__(self):
super().__init__()
S = QAbstractSocket.SocketState
self._S = S
self.cfg, cfg_warning = Config.load()
self.presets: dict = {k: list(v) for k, v in self.cfg.presets.items()}
self.highlight_channels: dict = dict(self.cfg.highlight_channels)
self.current_preset = self.cfg.preset if self.cfg.preset in self.presets else next(iter(self.presets), "")
self.device_channel: int | None = None
self.index = -1
self._uptime_pending = False
self._prev_state = S.UnconnectedState
self._save_warned = False
self._log_visible = True
self._channels_visible = True
self._last_step_time = 0.0
self._wide_width = 1000
self.client = DeviceClient(self)
self.hotkeys = GlobalHotkeys(self)
self._build_ui()
self._wire()
self._fill_preset_combo()
self._on_state(S.UnconnectedState)
if cfg_warning:
self.log(cfg_warning, "warn")
if self.cfg.global_hotkeys and GlobalHotkeys.available():
self.chk_global.setChecked(True)
self._set_log_visible(False)
self._refresh_theme_label()
def _build_ui(self) -> None:
self.setObjectName("root")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setWindowTitle("Switch Controller" + (" — тестовый режим" if TEST_MODE else ""))
self.resize(1000, 750)
self.lbl_status = QLabel()
self.lbl_status.setObjectName("status")
card_conn, lay = make_card("ПОДКЛЮЧЕНИЕ", self.lbl_status)
self.ip_input = QLineEdit(self.cfg.ip)
self.ip_input.setPlaceholderText("IP-адрес")
lay.addWidget(self.ip_input)
row_port = QHBoxLayout()
row_port.setSpacing(8)
self.port_input = IntField(1, 65535, self.cfg.port)
self.port_input.setFixedWidth(100)
self.port_input.setToolTip("Порт")
self.btn_connect = QPushButton()
row_port.addWidget(self.port_input)
row_port.addWidget(self.btn_connect, 1)
lay.addLayout(row_port)
card_ch, lay = make_card("КАНАЛ")
nav = QHBoxLayout()
nav.setSpacing(4)
self.btn_prev = QPushButton("◀")
self.btn_prev.setObjectName("nav")
self.btn_prev.setFixedHeight(36)
self.btn_prev.setToolTip("Предыдущий канал пресета (←)")
self.btn_next = QPushButton("▶")
self.btn_next.setObjectName("nav")
self.btn_next.setFixedHeight(36)
self.btn_next.setToolTip("Следующий канал пресета (→)")
self.spin = IntField(CH_MIN, CH_MAX, 13)
self.spin.setObjectName("big")
self.spin.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.spin.setFixedHeight(36)
nav.addWidget(self.btn_prev)
nav.addWidget(self.spin, 1)
nav.addWidget(self.btn_next)
lay.addLayout(nav)
self.btn_set_channel = QPushButton("Установить канал")
set_variant(self.btn_set_channel, "white")
lay.addWidget(self.btn_set_channel)
lbl_preset = QLabel("Пресет")
lbl_preset.setObjectName("dim")
lay.addWidget(lbl_preset)
prow = QHBoxLayout()
prow.setSpacing(8)
self.preset_combo = QComboBox()
self.btn_edit = QPushButton("Изменить")
prow.addWidget(self.preset_combo, 1)
prow.addWidget(self.btn_edit)
lay.addLayout(prow)
self.strip = ChipStrip()
self.strip_scroll = ChipScroll(self.strip)
lay.addWidget(self.strip_scroll)
self.lbl_empty = QLabel("Пресетов нет — создайте их в редакторе")
self.lbl_empty.setObjectName("dim")
lay.addWidget(self.lbl_empty)
self.chk_global = QCheckBox("Глобальные клавиши ← →")
self.chk_global.setToolTip(
"Переключать каналы стрелками, даже когда окно программы не в фокусе.\n"
"Пока окно активно, стрелки работают и без этой опции (кроме текстовых полей)."
)
if not GlobalHotkeys.available():
self.chk_global.setEnabled(False)
self.chk_global.setToolTip("Для этой возможности установите pynput: pip install pynput")
lay.addWidget(self.chk_global)
card_sch, lay = make_card("СХЕМА")
up_layout = QHBoxLayout()
up_layout.setSpacing(8)
lbl_up = QLabel("Аптайм")
lbl_up.setObjectName("dim")
self.lbl_uptime = QLabel("—")
self.lbl_uptime.setObjectName("value")
self.btn_uptime = QPushButton("⟳")
self.btn_uptime.setObjectName("nav")
self.btn_uptime.setToolTip("Запросить аптайм")
up_layout.addWidget(lbl_up)
up_layout.addWidget(self.lbl_uptime, 1)
up_layout.addWidget(self.btn_uptime)
lay.addLayout(up_layout)
app_layout = QHBoxLayout()
app_layout.setSpacing(8)
lbl_applied = QLabel("Применена")
lbl_applied.setObjectName("dim")
self.lbl_scheme_applied = QLabel("—")
self.lbl_scheme_applied.setObjectName("value")
app_layout.addWidget(lbl_applied)
app_layout.addWidget(self.lbl_scheme_applied, 1)
lay.addLayout(app_layout)
srow = QHBoxLayout()
srow.setSpacing(8)
self.scheme_combo = QComboBox()
self.scheme_combo.addItems(list(SCHEMES))
self.scheme_combo.setCurrentText(self.cfg.scheme)
self.btn_set_scheme = QPushButton("Установить")
srow.addWidget(self.scheme_combo, 1)
srow.addWidget(self.btn_set_scheme)
lay.addLayout(srow)
self.left_w = QWidget()
left = QVBoxLayout(self.left_w)
left.setContentsMargins(0, 0, 0, 0)
left.setSpacing(12)
left.addWidget(card_conn)
left.addWidget(card_ch)
left.addWidget(card_sch)
self.left_w.setFixedWidth(370)
left.addStretch()
self.right_w = QWidget()
right = QVBoxLayout(self.right_w)
right.setContentsMargins(0, 0, 0, 0)
right.setSpacing(0)
self.log_view = QTextEdit()
self.log_view.setObjectName("log")
self.log_view.setReadOnly(True)
self.log_view.document().setMaximumBlockCount(2000)
right.addWidget(self.log_view, 1)
self.bottom_bar = QWidget()
self.bottom_bar.setFixedHeight(30)
bottom_layout = QHBoxLayout(self.bottom_bar)
bottom_layout.setContentsMargins(10, 0, 10, 0)
self.btn_scan = QPushButton("Сканер сети")
self.btn_scan.setObjectName("ghost")
self.btn_scan.clicked.connect(self.open_scanner)
self.btn_toggle_log = QPushButton("Лог")
self.btn_toggle_log.setObjectName("ghost")
self.btn_toggle_log.clicked.connect(self._toggle_log)
self.btn_toggle_channels = QPushButton("Скрыть каналы")
self.btn_toggle_channels.setObjectName("ghost")
self.btn_toggle_channels.clicked.connect(self._toggle_channels)
self.btn_theme = QPushButton("Тема")
self.btn_theme.setObjectName("ghost")
self.btn_theme.clicked.connect(self._toggle_theme)
self.lock_icon = LockIcon()
self.lock_icon.clicked.connect(self.lock_program)
bottom_layout.addWidget(self.btn_scan)
bottom_layout.addWidget(self.btn_toggle_log)
bottom_layout.addWidget(self.btn_toggle_channels)
bottom_layout.addWidget(self.btn_theme)
bottom_layout.addStretch()
bottom_layout.addWidget(self.lock_icon)
self.content_layout = QHBoxLayout()
self.content_layout.setSpacing(14)
self.content_layout.addWidget(self.left_w)
self.content_layout.addWidget(self.right_w, 1)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(14, 14, 14, 14)
self.root_layout.setSpacing(10)
self.root_layout.addLayout(self.content_layout, 1)
self.root_layout.addWidget(self.bottom_bar)
def lock_program(self):
self.cfg.authorized = False
self.cfg.save()
QApplication.instance().quit()
def _toggle_log(self) -> None:
self._set_log_visible(not self._log_visible)
def _set_log_visible(self, visible: bool) -> None:
self._log_visible = visible
self.right_w.setVisible(visible)
self.btn_toggle_log.setText("<< Лог" if visible else "Лог >>")
if visible:
self.setMaximumWidth(QWIDGETSIZE_MAX)
self.resize(max(self._wide_width, self.minimumSizeHint().width()), self.height())
else:
self._wide_width = self.width()
self.layout().activate()
w = self.minimumSizeHint().width()
self.resize(w, self.height())
self.setMaximumWidth(w)
def _toggle_channels(self):
if self._channels_visible:
self.strip_scroll.setVisible(False)
self.lbl_empty.setVisible(False)
self.btn_toggle_channels.setText("Показать каналы")
self._channels_visible = False
else:
self.strip_scroll.setVisible(True)
self.lbl_empty.setVisible(not self.presets)
self.btn_toggle_channels.setText("Скрыть каналы")
self._channels_visible = True
def _pal(self) -> dict:
return THEMES.get(self.cfg.theme, THEMES[DEFAULT_THEME])[1]
def _refresh_theme_label(self) -> None:
name = THEMES.get(self.cfg.theme, THEMES[DEFAULT_THEME])[0]
self.btn_theme.setText(f"Тема: {name}")
def _toggle_theme(self) -> None:
keys = list(THEMES)
i = keys.index(self.cfg.theme) if self.cfg.theme in keys else 0
self.cfg.theme = keys[(i + 1) % len(keys)]
self._save()
self.apply_theme()
def apply_theme(self) -> None:
palette = self._pal()
QApplication.instance().setStyleSheet(build_qss(palette, make_icons(palette, self.cfg.theme)))
self._refresh_theme_label()
self._on_state(self.client.state)
def _wire(self) -> None:
self.btn_connect.clicked.connect(self.toggle_connection)
self.btn_prev.clicked.connect(lambda: self.step(-1))
self.btn_next.clicked.connect(lambda: self.step(+1))
self.btn_set_channel.clicked.connect(self.send_spin)
self.spin.returnPressed.connect(self.send_spin)
self.preset_combo.currentIndexChanged.connect(self._on_preset_selected)
self.btn_edit.clicked.connect(self.open_preset_editor)
self.strip.chipClicked.connect(self._go)
self.btn_set_scheme.clicked.connect(self.send_scheme)
self.btn_uptime.clicked.connect(self.request_uptime)
self.chk_global.toggled.connect(self._toggle_global)
self.client.state_changed.connect(self._on_state)
self.client.error.connect(lambda msg: self.log(msg, "err"))
self.client.data.connect(self._on_data)
self.hotkeys.pressed.connect(self._on_global_key)
for key, delta in (("Left", -1), ("Right", +1)):
sc = QShortcut(QKeySequence(key), self)
sc.setContext(Qt.ShortcutContext.WindowShortcut)
sc.activated.connect(lambda d=delta: self.step(d))
def open_scanner(self):
dlg = ScannerDialog(self, self.ip_input.text().strip(), self.port_input.value(), self._pal())
if dlg.exec() == QDialog.DialogCode.Accepted and dlg.selected_ip:
self.ip_input.setText(dlg.selected_ip)
self.log(f"Выбран IP {dlg.selected_ip}")
self._save()
def log(self, text: str, kind: str = "info") -> None:
palette = self._pal()
colors = {"info": palette["TEXT"], "tx": palette["ACCENT"], "rx": palette["OK"],
"warn": palette["WARN"], "err": palette["BAD"]}
ts = datetime.now().strftime("%H:%M:%S")
self.log_view.append(
f'<span style="color:{palette["MUTED"]}">[{ts}]</span> '
f'<span style="color:{colors.get(kind, colors["info"])}">{html.escape(text)}</span>'
)
bar = self.log_view.verticalScrollBar()
bar.setValue(bar.maximum())
def _save(self) -> None:
self.cfg.ip = self.ip_input.text().strip() or self.cfg.ip
self.cfg.port = self.port_input.value()
self.cfg.scheme = self.scheme_combo.currentText()
self.cfg.preset = self.current_preset
self.cfg.global_hotkeys = self.chk_global.isChecked()
self.cfg.presets = {k: list(v) for k, v in self.presets.items()}
self.cfg.highlight_channels = dict(self.highlight_channels)
err = self.cfg.save()
if err and not self._save_warned:
self._save_warned = True
self.log(f"Не удалось сохранить настройки: {err}", "warn")
def toggle_connection(self) -> None:
S = self._S
if self.client.state != S.UnconnectedState:
was_connecting = self.client.state != S.ConnectedState
self.client.close()
if was_connecting:
self.log("Подключение отменено", "warn")
return
host = self.ip_input.text().strip()
if not host:
self.log("Введите IP-адрес", "warn")
return
port = self.port_input.value()
self._save()
self.log(f"Подключение к {host}:{port}…")
self.client.open(host, port)
def _on_state(self, state) -> None:
S = self._S
prev, self._prev_state = self._prev_state, state
connected = state == S.ConnectedState
idle = state == S.UnconnectedState
palette = self._pal()
if connected:
text, color = "● Подключено", palette["OK"]
self.btn_connect.setText("Отключиться")
set_variant(self.btn_connect, "danger")
elif idle:
text, color = "● Не подключено", palette["BAD"]
self.btn_connect.setText("Подключиться")
set_variant(self.btn_connect, "")
else:
text, color = "● Подключение…", palette["WARN"]
self.btn_connect.setText("Отмена")
set_variant(self.btn_connect, "")
self.lbl_status.setText(text)
self.lbl_status.setStyleSheet(f"color: {color};")
self.ip_input.setEnabled(idle)
self.port_input.setEnabled(idle)
self.btn_scan.setEnabled(idle)
for w in (self.btn_prev, self.btn_next, self.btn_set_channel, self.strip,
self.btn_set_scheme, self.btn_uptime):
w.setEnabled(connected or TEST_MODE)
if connected and prev != S.ConnectedState:
self.log(f"Подключено к {self.ip_input.text().strip()}:{self.port_input.value()}", "rx")
QTimer.singleShot(200, self.send_scheme)
QTimer.singleShot(300, self.request_uptime)
elif idle and prev == S.ConnectedState:
self.log("Отключено", "warn")
self.lbl_scheme_applied.setText("—")
self.lbl_uptime.setText("—")
self._uptime_pending = False
self.device_channel = None
self._sync_chips()
def _on_data(self, data: bytes) -> None:
self.log(f"← {hex_str(data)}", "rx")
if self._uptime_pending:
self._uptime_pending = False
self.lbl_uptime.setText(parse_uptime(data) or "ответ в журнале")
def _send(self, packet: bytes, what: str) -> bool:
if not self.client.is_connected:
if TEST_MODE:
self.log(f"→ {what} [{hex_str(packet)}] (тест: не отправлено)", "warn")
return True
self.log("Нет подключения к устройству", "warn")
return False
if not self.client.send(packet):
self.log(f"Не удалось отправить: {what}", "err")
return False
self.log(f"→ {what} [{hex_str(packet)}]", "tx")
return True
def send_channel(self, ch: int) -> bool:
ok = self._send(build_set_packet(CMD_SET_CHANNEL, ch), f"канал {ch}")
if ok:
self.device_channel = ch
self.spin.setValue(ch)
self._sync_chips()
return ok
def send_spin(self) -> None:
self.send_channel(self.spin.value())
def send_scheme(self) -> None:
name = self.scheme_combo.currentText()
code = SCHEMES[name]
if self._send(build_set_packet(CMD_SET_SCHEME, code), f"схема {name} (код {code})"):
self.lbl_scheme_applied.setText(name)
self._save()
def request_uptime(self) -> None:
if self._send(build_uptime_packet(), "запрос аптайма"):
if self.client.is_connected:
self._uptime_pending = True
self.lbl_uptime.setText("запрошено…")
else:
self.lbl_uptime.setText("тест")
def _channels(self) -> list[int]:
return self.presets.get(self.current_preset, [])
def _fill_preset_combo(self) -> None:
self.preset_combo.blockSignals(True)
self.preset_combo.clear()
self.preset_combo.addItems(list(self.presets))
i = self.preset_combo.findText(self.current_preset)
if i >= 0:
self.preset_combo.setCurrentIndex(i)
self.preset_combo.blockSignals(False)
self._refresh_strip()
def _refresh_strip(self) -> None:
chs = self._channels()
hl = set(self.highlight_channels.get(self.current_preset, []))
self.strip.set_channels(chs, hl)
self.lbl_empty.setVisible(not self.presets and self._channels_visible)
self._sync_chips()
def _sync_chips(self) -> None:
chs = self._channels()
self.index = chs.index(self.device_channel) if self.device_channel in chs else -1
self.strip.set_active(self.index)
def _on_preset_selected(self, i: int) -> None:
if i < 0:
return
self.current_preset = self.preset_combo.itemText(i)
self._refresh_strip()
self._save()
self.log(f"Пресет: {self.current_preset}")
def step(self, delta: int) -> None:
now = time.monotonic()
if now - self._last_step_time < DEBOUNCE_TIMEOUT:
return
self._last_step_time = now
chs = self._channels()
if not chs:
return
if self.index < 0:
i = 0 if delta > 0 else len(chs) - 1
else:
i = (self.index + delta) % len(chs)
self._go(i)
def _go(self, i: int) -> None:
chs = self._channels()
if 0 <= i < len(chs):
self.send_channel(chs[i])
else:
self._sync_chips()
def open_preset_editor(self) -> None:
dlg = PresetEditorDialog(self.presets, self.current_preset, self, self.highlight_channels)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
self.presets = dlg.presets()
self.highlight_channels = dlg.highlight_channels()
chosen = dlg.selected_name()
self.current_preset = chosen if chosen in self.presets else next(iter(self.presets), "")
self._fill_preset_combo()
self._save()
self.log("Пресеты сохранены")
def _toggle_global(self, on: bool) -> None:
if on:
self.hotkeys.start()
else:
self.hotkeys.stop()
self._save()
def _on_global_key(self, which: str) -> None:
if QApplication.activeWindow() is not None:
return
self.step(-1 if which == "prev" else +1)
def closeEvent(self, event) -> None:
self._save()
self.hotkeys.stop()
self.client.close()
super().closeEvent(event)
def main() -> None:
os.environ.setdefault("QT_SCALE_FACTOR", str(UI_SCALE))
app = QApplication(sys.argv)
app.setStyle("Fusion")
cfg, _ = Config.load()
theme = cfg.theme if cfg.theme in THEMES else DEFAULT_THEME
palette = THEMES[theme][1]
app.setStyleSheet(build_qss(palette, make_icons(palette, theme)))
if not cfg.authorized:
dlg = AuthDialog()
if dlg.exec() != QDialog.DialogCode.Accepted:
sys.exit(0)
cfg.authorized = True
cfg.save()
window = SwitchController()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()