/
DimerTrue
/
ecg_gui
Обзор
Документация
Войти
/
DimerTrue
/
ecg_gui
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
ui/device_window.py
1 824 строки
69 KB
dimer
fix window position
05 авг 2026, 12:39
05 авг 2026, 12:39
10da83c
Код
Авторство
О чём код?
# device_window.py from core.app_logger import AppLogger logger = AppLogger.get_logger(__name__) import tkinter as tk from tkinter import ttk, messagebox import queue import threading from typing import Optional, Dict, Any, List, Tuple from dataclasses import dataclass from enum import Enum from models.device_state import ( DeviceState, DeviceMessage, WriteDataCommand, WriteCommandType, STATUS_CORRECT, ConnectionStatus, ) from models.plot_models import PlotSample from models.data_history import DataHistory from models.common import MessageType from config.theme import Theme from config.write_data_config import VALID_BAUDRATES, WRITE_PARAMS from ui.plot_window import PlotWindow from ui.tooltips import ToolTip from config import ui_config # TODO: рефакторинг и мб переделать на grid # TODO: сделать кастомный messagebox для русификации??? class DeviceWindow: """ Окно отображения данных для одного конкретного устройства. """ def __init__( self, root: tk.Tk, parent_app, read_data_queue: queue.Queue, write_data_queue: queue.Queue, message_queue: queue.Queue, stop_event: threading.Event, device_id: int, com_port: str, ): self.root = root self.read_data_queue = read_data_queue self.write_data_queue = write_data_queue self.message_queue = message_queue self.stop_event = stop_event self.device_id = device_id self.com_port = com_port self.parent_app = parent_app self.data_history = DataHistory() self.plot_window: Optional[PlotWindow] = None self.latest_point: Optional[PlotSample] = None # Хранилище ссылок на виджеты для динамического обновления self._widgets: Dict[str, Any] = {} self._status_widgets: Dict[str, tk.Label] = {} self._error_widgets: Dict[str, tk.Label] = {} # Словарь для вводимых значений. Необходим для отслеживания изменения значений ввода self._values: Dict[str, str] = {} # Словарь для отслеживания "грязных" (измененных пользователем) полей self._dirty_fields: Dict[str, bool] = { "speed_entry": False, "id_spinbox": False, # "umin_entry": False, # "imax_entry": False, # "umax_entry": False, } self._clean_timers: Dict[str, str] = {} # Флаг для корректного обновления вводимых полей self.has_messages = False self.status = "" self.is_connected = True self.connect_status = ConnectionStatus.CONNECTED logger.info( f"Открыто окно мониторинга для устройства ID {self.device_id} на порту {self.com_port}" ) self._setup_ui() self.update_display() self._animate_status_bar() # --------------------------------------------------------------------------- # Инициализация UI # --------------------------------------------------------------------------- def _setup_ui(self): """Основная инициализация интерфейса""" colors = Theme.get_colors() self.root.title(f"{ui_config.TOPAZ_VERSION} - Устройство ID {self.device_id}") self.root.geometry( f"{ui_config.DEVICE_WINDOW_WIDHT}x{ui_config.DEVICE_WINDOW_HEIGHT}" ) self.root.resizable(False, False) self.root.configure(bg=colors["bg"]) self._build_menu() self._build_main_layout() self._configure_styles() self.set_theme(Theme.current) # TODO: сделать кастомное меню def _build_menu(self): """Создание главного меню""" main_menu = tk.Menu(self.root) self.root.config(menu=main_menu) option_menu = tk.Menu( main_menu, tearoff=0, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE) ) themes_menu = tk.Menu( option_menu, tearoff=0, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ) themes_menu.add_command( label="Светлая", command=lambda: self.parent_app.set_theme( "light" ), # Вызываем родительскую функцию ) themes_menu.add_command( label="Темная", command=lambda: self.parent_app.set_theme("dark") ) option_menu.add_cascade( label="Тема", menu=themes_menu, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ) option_menu.add_separator() option_menu.add_command( label="Выход", command=self.stop_app, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ) help_menu = tk.Menu( main_menu, tearoff=0, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE) ) help_menu.add_command(label="О программе", command=self.show_about_dialog) main_menu.add_cascade( label="Опции", menu=option_menu, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ) main_menu.add_cascade( label="Справка", menu=help_menu, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ) def show_about_dialog(self): """Кастомное окно 'О программе' для корректного отображения на Linux""" about_win = tk.Toplevel(self.root) about_win.withdraw() about_win.title("О программе") about_win.resizable(False, False) frame = tk.Frame(about_win, padx=10, pady=10) frame.pack(fill="both", expand=True) title_label = tk.Label( frame, text=ui_config.TOPAZ_VERSION, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE, "bold"), ) title_label.pack(anchor="w", pady=(0, 10)) desc_label = tk.Label( frame, text=ui_config.SOFTWARE_DESC, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), justify="left", wraplength=350, ) desc_label.pack(anchor="w", pady=(0, 10)) bottom_frame = tk.Frame(frame) bottom_frame.pack(fill="x", side="bottom", pady=(10, 0)) copy_label = tk.Label( bottom_frame, text=ui_config.COPYRIGHT_TEXT, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE - 2), foreground="gray", ) copy_label.pack(side="left", anchor="sw") btn_close = ttk.Button( bottom_frame, text="OK", command=about_win.destroy, width=10, ) btn_close.pack(side="right", anchor="se") about_win.update_idletasks() win_w = about_win.winfo_reqwidth() win_h = about_win.winfo_reqheight() parent_x = self.root.winfo_x() parent_y = self.root.winfo_y() parent_w = self.root.winfo_width() parent_h = self.root.winfo_height() x = parent_x + (parent_w - win_w) // 2 y = parent_y + (parent_h - win_h) // 2 about_win.geometry(f"{win_w}x{win_h}+{x}+{y}") about_win.deiconify() about_win.transient(self.root) about_win.grab_set() about_win.focus_set() about_win.protocol("WM_DELETE_WINDOW", about_win.destroy) def _build_main_layout(self): """Создание основной структуры layout""" colors = Theme.get_colors() main_container = tk.Frame(self.root, bg=colors["bg"]) main_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) columns_container = tk.Frame(main_container, bg=colors["bg"]) columns_container.pack(fill=tk.BOTH, expand=True) columns_container.columnconfigure(0, weight=1) columns_container.columnconfigure(1, weight=1) columns_container.rowconfigure(0, weight=1) left_panel = tk.Frame(columns_container, bg=colors["bg"]) left_panel.grid(row=0, column=0, sticky="nsew", padx=(0, 5)) self._widgets["left_panel"] = left_panel right_panel = tk.Frame(columns_container, bg=colors["bg"]) right_panel.grid(row=0, column=1, sticky="nsew", padx=(5, 0)) self._build_left_panel(left_panel) self._build_right_panel(right_panel) self._build_errors_panel(main_container) self._build_status_bar() # --------------------------------------------------------------------------- # Построение панелей # --------------------------------------------------------------------------- def _build_main_table(self, parent): """Создание главной таблицы параметров""" colors = Theme.get_colors() frame = tk.LabelFrame( parent, text="Главная", font=(ui_config.MAIN_FONT, ui_config.MAIN_TEXT_SIZE, "bold"), bg=colors["bg"], fg=colors["title_fg"], ) frame.pack(fill=tk.X, pady=(0, 10)) table_frame = tk.Frame(frame, bg=colors["bg"]) table_frame.pack(fill=tk.X, padx=10, pady=5) # Настраиваем веса колонок, чтобы значения растягивались равномерно for i in range(4): table_frame.columnconfigure(i, weight=1) headers = ["Параметр", "ЭХГ", "АКБ", "Выход"] for col, header in enumerate(headers): # Определяем выравнивание в зависимости от колонки if col == 0: # Левая колонка: текст слева, виджет прижат влево anchor_mode = "w" sticky_mode = "w" padx_el = 3 else: # Остальные колонки: текст по центру, виджет растянут anchor_mode = "center" sticky_mode = "nsew" padx_el = 5 tk.Label( table_frame, text=header, bg=colors["bg"], fg=colors["title_fg"], font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE, "bold"), anchor=anchor_mode, borderwidth=0, highlightthickness=0, ).grid( row=0, column=col, padx=padx_el, pady=5, sticky=sticky_mode, ) parameters = [ ("Ток, А", "current_ecg", "current_akb", "current_out"), ("Напр, В", "voltage_ecg", "voltage_akb", "voltage_out"), ("Мощн, Вт", "power_ecg", "power_akb", "power_out"), ] for row, (label, key_ecg, key_akb, key_out) in enumerate(parameters, 1): # Лейбл параметра tk.Label( table_frame, text=label, bg=colors["bg"], fg=colors["fg"], font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), anchor="w", ).grid(row=row, column=0, padx=3, pady=5, sticky="w") # Значения for key, col_idx in [(key_ecg, 1), (key_akb, 2), (key_out, 3)]: lbl = tk.Label( table_frame, text="0.00", bg=colors["bg"], fg=colors["value_fg"], font=(ui_config.MAIN_FONT, ui_config.BIG_VALUE_TEXT_SIZE, "bold"), anchor="center", ) lbl.grid(row=row, column=col_idx, padx=5, pady=5, sticky="nsew") self._widgets[key] = lbl def _build_left_panel(self, parent): """Построение левой панели""" self._build_main_table(parent) self._build_power_block(parent) self._build_status_panel(parent) self._build_rs485_block(parent) spacer = tk.Frame(parent, bg=Theme.get_colors()["bg"]) spacer.pack(fill=tk.BOTH, expand=True) def _build_right_panel(self, parent): """Построение правой панели""" self._build_fuel_block(parent) self._build_temperature_block(parent) self._build_control_buttons(parent) self._build_air_block(parent) self._build_battery_block(parent) spacer = tk.Frame(parent, bg=Theme.get_colors()["bg"]) spacer.pack(fill=tk.BOTH, expand=True) def _build_simple_row(self, parent, label_text, key, unit_text, default_val): """Вспомогательный метод для создания одной строки: Лейбл - Значение Ед.изм""" colors = Theme.get_colors() row_frame = tk.Frame(parent, bg=colors["bg"]) row_frame.pack(fill=tk.X, pady=2) # Настраиваем колонки: лейбл растягивается, значения прижаты вправо row_frame.columnconfigure( 0, weight=1 ) # Лейбл занимает всё доступное пространство row_frame.columnconfigure(1, weight=0) # Значения не растягиваются # Лейбл слева tk.Label( row_frame, text=label_text, bg=colors["bg"], fg=colors["fg"], font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), anchor="w", ).grid(row=0, column=0, sticky="w", padx=(0, 5)) # Фрейм для значения и единиц val_frame = tk.Frame(row_frame, bg=colors["bg"]) val_frame.grid(row=0, column=1, sticky="e") # Значение val_label = tk.Label( val_frame, text=str(default_val), bg=colors["bg"], fg=colors["value_fg"], font=(ui_config.MAIN_FONT, ui_config.VALUE_TEXT_SIZE, "bold"), anchor="e", ) val_label.pack(side=tk.LEFT) self._widgets[key] = val_label # Единицы с минимальным отступом tk.Label( val_frame, text=unit_text, bg=colors["bg"], fg=colors["fg"], font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), anchor="w", ).pack( side=tk.LEFT, padx=(1, 0) ) # Минимальный отступ 1 пиксель def _build_fuel_block(self, parent): """Топливо""" colors = Theme.get_colors() frame = tk.LabelFrame( parent, text="Топливо", font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE, "bold"), bg=colors["bg"], fg=colors["title_fg"], ) frame.pack(fill=tk.X, pady=(0, 10), padx=2) inner = tk.Frame(frame, bg=colors["bg"]) inner.pack(fill=tk.X, padx=5, pady=5) self._build_simple_row(inner, "Давление", "pressure", "мБар", 0) self._build_simple_row(inner, "Расход", "flow", "мл/мин", 0) def _build_temperature_block(self, parent): """Температуры""" colors = Theme.get_colors() frame = tk.LabelFrame( parent, text="Температуры, °C", font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE, "bold"), bg=colors["bg"], fg=colors["title_fg"], ) frame.pack(fill=tk.X, pady=(0, 10), padx=2) inner = tk.Frame(frame, bg=colors["bg"]) inner.pack(fill=tk.X, padx=5, pady=5) self._build_simple_row(inner, "ТОТЭ", "tote", "°C", 0) self._build_simple_row(inner, "Плата", "board", "°C", 0) def _build_air_block(self, parent): """Воздух""" colors = Theme.get_colors() frame = tk.LabelFrame( parent, text="Воздух", font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE, "bold"), bg=colors["bg"], fg=colors["title_fg"], ) frame.pack(fill=tk.X, pady=(0, 10), padx=2) inner = tk.Frame(frame, bg=colors["bg"]) inner.pack(fill=tk.X, padx=5, pady=5) self._build_simple_row(inner, "Давление", "air_pressure", "мм.рт.ст.", 0) self._build_simple_row(inner, "Влажность", "air_humidity", "%", 0) def _build_battery_block(self, parent): """Блок АКБ""" colors = Theme.get_colors() frame = tk.LabelFrame( parent, text="Блок АКБ", font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE, "bold"), bg=colors["bg"], fg=colors["title_fg"], ) frame.pack(fill=tk.X, pady=(0, 10), padx=2) inner = tk.Frame(frame, bg=colors["bg"]) inner.pack(fill=tk.X, padx=5, pady=5) self._build_simple_row(inner, "Температура", "bat_temp", "°C", 0.0) self._build_simple_row(inner, "Заряд АКБ", "bat_charge", "%", 0) def _build_power_block(self, parent): """Создание блока выдачи мощности""" colors = Theme.get_colors() frame = tk.LabelFrame( parent, text="Блок выдачи мощности", font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE, "bold"), bg=colors["bg"], fg=colors["title_fg"], ) frame.pack(fill=tk.X, pady=(0, 10)) params = [ ( "Imax, А", "imax_entry", WriteCommandType.WRITE_CURRENT_MAX, WRITE_PARAMS.get(WriteCommandType.WRITE_CURRENT_MAX).min_value[0], WRITE_PARAMS.get(WriteCommandType.WRITE_CURRENT_MAX).max_value[0], ), ( "Umin, В", "umin_entry", WriteCommandType.WRITE_VOLTAGE_MIN, WRITE_PARAMS.get(WriteCommandType.WRITE_VOLTAGE_MIN).min_value[0], WRITE_PARAMS.get(WriteCommandType.WRITE_VOLTAGE_MIN).max_value[0], ), ( "Umax, В", "umax_entry", WriteCommandType.WRITE_VOLTAGE_MAX, WRITE_PARAMS.get(WriteCommandType.WRITE_VOLTAGE_MAX).min_value[0], WRITE_PARAMS.get(WriteCommandType.WRITE_VOLTAGE_MAX).max_value[0], ), ] for i, (label, key, command, min_value, max_value) in enumerate(params): row_frame = tk.Frame(frame, bg=colors["bg"]) row_frame.pack(fill=tk.X, padx=10, pady=5) tk.Label( row_frame, text=label, bg=colors["bg"], fg=colors["fg"], width=8, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ).pack(side=tk.LEFT) entry = tk.Entry( row_frame, width=10, justify="center", bg=colors["entry_bg"], fg=colors["entry_fg"], disabledbackground=colors["entry_bg"], disabledforeground=colors["entry_fg"], font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ) entry.insert(0, "0") entry.pack(side=tk.LEFT, padx=5) self._widgets[key] = entry btn = tk.Button( row_frame, text="Уст", width=5, command=lambda key=key, cmd=command: self._set_power_params(key, cmd), bg=colors["button_bg"], fg=colors["button_fg"], activebackground=colors["button_active_bg"], activeforeground=colors["button_fg"], font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), cursor="hand2", relief=tk.FLAT, ) btn.pack(side=tk.LEFT, padx=5) self._widgets[f"btn_{key}"] = btn self._values[key] = "0.0" ToolTip( widget=entry, text=f"Введите значение в диапазоне от {min_value} до {max_value}", delay=ui_config.TOOLTIP_DELAY, follow_mouse=True, ) def _build_status_panel(self, parent): """Создание панели статуса и кнопки графиков""" colors = Theme.get_colors() frame = tk.Frame(parent, bg=colors["bg"]) frame.pack(fill=tk.X, pady=(0, 10)) # Настраиваем сетку: # Колонка 0 (Статус) растягивается (weight=1) # Колонка 1 (Кнопка) имеет фиксированный размер frame.columnconfigure(0, weight=1) frame.rowconfigure(0, minsize=60) # Блок Статуса status_frame = tk.LabelFrame( frame, text="Статус ЭУ", font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE, "bold"), bg=colors["bg"], fg=colors["title_fg"], padx=10, pady=5, ) # sticky="nsew" заставляет блок растянуться на всю доступную высоту и ширину ячейки status_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 10)) # Внутренняя сетка для статусов status_inner = tk.Frame(status_frame, bg=colors["bg"]) status_inner.pack(fill=tk.X) # Статус ЭХГ tk.Label( status_inner, text="ЭХГ", bg=colors["bg"], fg=colors["fg"], font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ).grid(row=0, column=0, padx=1, sticky="e") lbl = tk.Label( status_inner, text=DeviceState.device_status, bg=colors["bg"], fg=colors["fg"], font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE, "bold"), width=12, ) lbl.grid(row=0, column=1, padx=1, sticky="w") self._status_widgets["ecg_status"] = lbl # Состояние выхода tk.Label( status_inner, text="Выход ЭУ", bg=colors["bg"], fg=colors["fg"], font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ).grid(row=0, column=2, padx=1, sticky="e") lbl = tk.Label( status_inner, text=DeviceState.device_power_state, bg=colors["bg"], fg=colors["fg"], font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE, "bold"), width=5, ) lbl.grid(row=0, column=3, padx=1, sticky="w") self._status_widgets["output_status"] = lbl status_inner.grid_columnconfigure(0, weight=1) status_inner.grid_columnconfigure(1, weight=1) status_inner.grid_columnconfigure(2, weight=1) status_inner.grid_columnconfigure(3, weight=1) # Кнопка графиков btn_plot = tk.Button( frame, text="Графики", command=self.open_plot_window, font=(ui_config.MAIN_FONT, ui_config.MAIN_TEXT_SIZE, "bold"), bg=colors["button_plot_bg"], fg=colors["button_plot_fg"], activebackground=colors["button_active_plot_bg"], activeforeground=colors["button_plot_fg"], padx=20, pady=0, cursor="hand2", relief=tk.FLAT, ) btn_plot.grid(row=0, column=1, sticky="ns") self._widgets["btn_plot"] = btn_plot self.btn_plot = btn_plot def _build_control_buttons(self, parent): """Создание кнопок управления ЭХГ""" colors = Theme.get_colors() frame = tk.Frame(parent, bg=colors["bg"]) frame.pack(fill=tk.X, pady=(0, 10)) frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) btn_power_on = tk.Button( frame, text="Вкл ЭХГ", command=lambda: self._set_status_write_params( power_off=False, power_on=True ), bg=colors["button_on_bg"], fg=colors["button_on_fg"], activebackground=colors["button_active_on_bg"], activeforeground=colors["button_on_fg"], font=(ui_config.MAIN_FONT, ui_config.MAIN_TEXT_SIZE, "bold"), width=12, height=2, cursor="hand2", relief=tk.FLAT, ) btn_power_on.grid(row=0, column=0, padx=5, pady=5, sticky="ew") ToolTip( widget=btn_power_on, text=f"Включение режима генерации", delay=ui_config.TOOLTIP_DELAY, follow_mouse=True, ) self._widgets["btn_power_on"] = btn_power_on self.btn_on = btn_power_on btn_power_off = tk.Button( frame, text="Выкл ЭХГ", command=lambda: self._set_status_write_params( power_off=True, power_on=False ), bg=colors["button_off_bg"], fg=colors["button_off_fg"], activebackground=colors["button_active_off_bg"], activeforeground=colors["button_off_fg"], font=(ui_config.MAIN_FONT, ui_config.MAIN_TEXT_SIZE, "bold"), width=12, height=2, cursor="hand2", relief=tk.FLAT, ) btn_power_off.grid(row=0, column=1, padx=5, pady=5, sticky="ew") ToolTip( widget=btn_power_off, text=f"Выключение устройства (для повторного включения необходимо подать питание заново)", delay=ui_config.TOOLTIP_DELAY, follow_mouse=True, ) self._widgets["btn_power_off"] = btn_power_off self.btn_off = btn_power_off def _build_errors_panel(self, parent): """Создание панели ошибок""" error_configs = DeviceState.get_errors_config() colors = Theme.get_colors() errors_frame = tk.LabelFrame( parent, text="Ошибки", font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE, "bold"), bg=colors["bg"], fg=colors["title_fg"], ) errors_frame.pack(fill=tk.X, pady=(5, 5)) errors_container = tk.Frame(errors_frame, bg=colors["bg"]) errors_container.pack(fill=tk.X, padx=10, pady=5) inactive_color = colors["disabled_fg"] # Настраиваем равномерное распределение колонок total_errors = len(error_configs) if total_errors > 0: for i in range(total_errors): errors_container.columnconfigure(i, weight=1) for idx, (error_name, error_desc) in enumerate(error_configs): label = tk.Label( errors_container, text=error_name, bg=colors["bg"], fg=inactive_color, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE, "bold"), anchor="center", padx=2, pady=2, ) # sticky="nsew" заставляет лейбл растянуться на всю ячейку label.grid(row=0, column=idx, sticky="nsew") self._error_widgets[error_name] = label ToolTip( self._error_widgets[error_name], error_desc, delay=ui_config.TOOLTIP_DELAY, ) def _build_status_bar(self): """Создание статус бара""" colors = Theme.get_colors() # Создаем контейнер для статус-бара, чтобы прижать его к низу status_container = tk.Frame(self.root, bg=colors["bg"]) status_container.pack(fill=tk.X, side=tk.BOTTOM) # Создаем верхнюю рамку self.status_bar_border = tk.Frame( status_container, bg=colors["bar_border"], height=1, ) self.status_bar_border.pack(fill=tk.X) # Основной фрейм статус-бара self.bar_frame = tk.Frame(status_container, bg=colors["bar_bg"]) self.bar_frame.pack(fill=tk.X) # Настраиваем колонки self.bar_frame.columnconfigure(0, weight=0) self.bar_frame.columnconfigure(1, weight=1) # Правый лейбл - статус подключения (будет мигать) self.status_label = tk.Label( self.bar_frame, text=self.connect_status, anchor="w", relief="flat", # Убираем стандартную рамку borderwidth=0, # Убираем толщину границы highlightthickness=0, # Убираем подсветку фокуса bg=colors["bar_bg"], fg=colors["bar_fg"], font=(ui_config.MAIN_FONT, ui_config.MAIN_TEXT_SIZE), padx=5, pady=0, ) self.status_label.grid(row=0, column=0, sticky="w") # Левый лейбл - основная информация (не мигает) self.bar_label = tk.Label( self.bar_frame, text=f"| COM: {self.com_port} | SN:{ui_config.SN_GENERATOR} | FW:v{ui_config.FIRMWARE_SERIAL} | {ui_config.GAS}", anchor="w", relief="flat", # Убираем стандартную рамку borderwidth=0, # Убираем толщину границы highlightthickness=0, # Убираем подсветку фокуса bg=colors["bar_bg"], fg=colors["bar_fg"], font=(ui_config.MAIN_FONT, ui_config.MAIN_TEXT_SIZE), padx=5, pady=0, ) self.bar_label.grid(row=0, column=1, sticky="w") def _build_baudrate_combobox(self, parent): """ Создает или пересоздает Combobox скорости Костыльный метод для измненения цвета виджета ttk """ # Если виджет уже существует, удаляем его if "baudrate_entry" in self._widgets: old_widget = self._widgets["baudrate_entry"] old_widget.grid_forget() old_widget.destroy() # Создаем новый виджет baudrate_entry = ttk.Combobox( parent, values=list(VALID_BAUDRATES.keys()), state="readonly", font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ) # Восстанавливаем значение baudrate_entry.set(self.baudrate) # Размещаем в сетке (те же координаты, что и были) baudrate_entry.grid(row=1, column=0, sticky="ew", padx=3, pady=(0, 3)) # Восстанавливаем привязки событий baudrate_entry.bind("<<ComboboxSelected>>", self._on_select_baudrate) # Обновляем ссылку в словаре виджетов self._widgets["baudrate_entry"] = baudrate_entry # Пересоздаем Tooltip ToolTip( widget=baudrate_entry, text=f"Выберите скорость подключения", delay=ui_config.TOOLTIP_DELAY, follow_mouse=True, ) return baudrate_entry def _build_rs485_block(self, parent): """Создание блока RS485""" colors = Theme.get_colors() frame = tk.LabelFrame( parent, text="RS485", font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE, "bold"), bg=colors["bg"], fg=colors["title_fg"], ) frame.pack(fill=tk.X, pady=(0, 10)) self._widgets["rs485_frame"] = frame self._build_rs485_widgets(frame) def _build_rs485_widgets(self, parent): """Создание блока RS485""" colors = Theme.get_colors() # inner = self._widgets["rs485_inner_frame"] inner = tk.Frame(parent, bg=colors["bg"]) inner.pack(fill=tk.X, padx=10, pady=5) self._widgets["rs485_inner_frame"] = inner self._widgets["speed_label"] = tk.Label( inner, text="Скорость", bg=colors["bg"], fg=colors["fg"], font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ).pack(side=tk.LEFT) # Создаем комбобокс self._widgets["speed_entry"] = ttk.Combobox( inner, values=list(VALID_BAUDRATES.keys()), width=8, state="readonly", font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ) self._widgets["speed_entry"].set(19200) self._widgets["speed_entry"].pack(side=tk.LEFT, padx=5) self._values["speed_entry"] = "0" # Привязка события выбора из списка self._widgets["speed_entry"].bind( "<<ComboboxSelected>>", lambda e: self._mark_dirty("speed_entry") ) tp_speed = ToolTip( widget=self._widgets["speed_entry"], text=f"Выберите скорость передачи данных в бод", delay=ui_config.TOOLTIP_DELAY, follow_mouse=True, ) if not hasattr(self, "_tooltips"): self._tooltips = {} self._tooltips["speed_entry"] = tp_speed self._widgets["id_label"] = tk.Label( inner, text="ID", bg=colors["bg"], fg=colors["fg"], font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ).pack(side=tk.LEFT, padx=(10, 0)) self.id_var = tk.StringVar(value=str(self.device_id)) self._widgets["id_spinbox"] = tk.Spinbox( inner, bg=colors["spinbox_bg"], fg=colors["spinbox_fg"], disabledbackground=colors["spinbox_bg"], disabledforeground=colors["spinbox_fg"], from_=1, to=127, width=5, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), textvariable=self.id_var, ) self._widgets["id_spinbox"].delete(0, tk.END) self._widgets["id_spinbox"].insert(0, str(self.device_id)) self._widgets["id_spinbox"].pack(side=tk.LEFT, padx=5) self._values["id_spinbox"] = "0" self.id_var.trace_add("write", lambda *args: self._mark_dirty("id_spinbox")) min_id = WRITE_PARAMS.get(WriteCommandType.WRITE_RS485).min_value[1] max_id = WRITE_PARAMS.get(WriteCommandType.WRITE_RS485).max_value[1] self._tooltips["id_spinbox"] = ToolTip( widget=self._widgets["id_spinbox"], text=f"Выберите адрес от {min_id} до {max_id}", delay=ui_config.TOOLTIP_DELAY, follow_mouse=True, ) btn = tk.Button( inner, text="Уст", width=5, command=self._set_rs485_params, bg=colors["button_bg"], fg=colors["button_fg"], activebackground=colors["button_active_bg"], activeforeground=colors["button_fg"], font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), cursor="hand2", relief=tk.FLAT, ) btn.pack(side=tk.LEFT, padx=10) self._widgets["btn_set_rs485"] = btn self._configure_styles() def _rebuild_rs485_block(self, parent): """Пересоздание блока RS485 для применения новой темы""" if "rs485_frame" in self._widgets: frame = self._widgets["rs485_frame"] # Удаляем связанные Tooltips # Ключи виджетов, у которых есть тултипы в этом блоке tooltip_keys = ["speed_entry", "id_spinbox"] for key in tooltip_keys: if hasattr(self, "_tooltips") and key in self._tooltips: tp = self._tooltips[key] tp.unbind_events() tp.hide_tip() # Скрываем окно, если оно открыто del self._tooltips[key] # Удаляем объект из памяти for child in frame.winfo_children(): child.destroy() # Очищаем ссылки в словарях виджетов keys_to_remove = [ "rs485_inner_frame", "speed_entry", "id_spinbox", "btn_set_rs485", "speed_label", "id_label", ] for key in keys_to_remove: if key in self._widgets: del self._widgets[key] self._build_rs485_widgets(frame) # --------------------------------------------------------------------------- # Обновление данных # --------------------------------------------------------------------------- def update_display(self): """Обновление отображаемых данных""" if not self.root.winfo_exists(): return if self.connect_status == ConnectionStatus.EXIT_REQUIRED: return new_state = None # Читаем очередь данных от ModbusWorker while not self.read_data_queue.empty(): try: item = self.read_data_queue.get_nowait() if isinstance(item, DeviceState): new_state = item except queue.Empty: break message = None message_type = None message_pool = [] # Читаем и обрабатываем очередь сообщений от ModbusWorker while not self.message_queue.empty(): try: item = self.message_queue.get_nowait() if isinstance(item, DeviceMessage): message_pool.append(item) except queue.Empty: break if message_pool: # Выбираем наиболее приоритетное сообщения, игнорируя остальные priority_message = self._get_priority_message(message_pool) message = priority_message.message message_type = priority_message.message_type self._process_message(message, message_type) # Если было получено сообщение о выключении генератора, # то принудительно очищаем старые данные во избежание ложного переключения состояния if self.connect_status == ConnectionStatus.REBOOT_REQUIRED: new_state = None if new_state is not None: # Если в состоянии REBOOT_REQUIRED, # то получение данных означает, что устройство перезагрузилось и работает # Это необходимо, для отображения диагностического сообщения if self.connect_status == ConnectionStatus.REBOOT_REQUIRED: self.set_interface_state(True) self.is_connected = True self.connect_status = ConnectionStatus.CONNECTED logger.info( f"Устройство {self.device_id}: данные получены после перезагрузки, " f"возврат в обычный режим" ) # Обновляем историю для графиков plot_sample = PlotSample( timestamp=new_state.timestamp, temp_tubes=new_state.temp_tubes, ecg_power=new_state.power_ecg, load_power=new_state.power_load, ) self.latest_point = plot_sample self.data_history.add_sample(plot_sample) # Обновляем UI self._update_ui_from_state(new_state) # Обновляем графики if self.plot_window and self.plot_window.root.winfo_exists(): self.plot_window.add_point(plot_sample) if not self.stop_event.is_set(): self.root.after(50, self.update_display) def _update_ui_from_state(self, state: DeviceState): """Распределение данных из State по виджетам""" # Главная таблица self._safe_update("current_ecg", f"{state.current_ecg:.2f}") self._safe_update("voltage_ecg", f"{state.voltage_ecg:.2f}") self._safe_update("power_ecg", f"{state.power_ecg:.2f}") self._safe_update("current_akb", f"{state.current_battery:.2f}") self._safe_update("voltage_akb", f"{state.voltage_battery:.2f}") self._safe_update("power_akb", f"{state.power_battery:.2f}") self._safe_update("current_out", f"{state.current_load:.2f}") self._safe_update("voltage_out", f"{state.voltage_load:.2f}") self._safe_update("power_out", f"{state.power_load:.2f}") self._update_entry("imax_entry", f"{state.current_output_max:.3f}") self._update_entry("umin_entry", f"{state.voltage_output_min:.3f}") self._update_entry("umax_entry", f"{state.voltage_output_max:.3f}") self._values["imax_entry"] = f"{state.current_output_max:.3f}" self._values["umin_entry"] = f"{state.voltage_output_min:.3f}" self._values["umax_entry"] = f"{state.voltage_output_max:.3f}" # Топливо self._safe_update("pressure", f"{state.pressure_fuel:.0f}") self._safe_update("flow", f"{state.flow_fuel:.1f}") # Температуры tote_temp = state.temp_tubes if int(tote_temp) == -8888: tote_temp = "ОБРЫВ" else: tote_temp = f"{tote_temp:.1f}" self._safe_update("tote", tote_temp) self._safe_update("board", f"{state.temp_pcb:.1f}") # Воздух self._safe_update("air_pressure", f"{state.pressure_atm:.1f}") self._safe_update("air_humidity", f"{state.humidity_air:.1f}") # АКБ self._safe_update("bat_temp", f"{state.temp_battery:.1f}") self._safe_update("bat_charge", f"{state.charge_battery:.1f}") # Статус и ошибки self._update_status(state.status_read) self._update_errors(state.error_bits) # RS485 # Необходимо, чтобы запись скорости и id была возможно только в определенном статусе self.status = DeviceState.get_status(state.status_read) self._update_rs485(str(state.baudrate), str(state.slave_id)) self._values["speed_entry"] = f"{state.baudrate}" self._values["id_spinbox"] = f"{state.slave_id}" # Нижнее поле self._update_status_bar( self.com_port, state.generator_serial, state.firmware_serial_raw ) def _safe_update(self, key: str, value: str): if key not in self._widgets: return try: self._widgets[key].config(text=value) except: pass def _update_entry(self, key: str, value: str): if key not in self._widgets: return widget = self._widgets[key] if not widget or self._is_widget_focused(widget): return try: if isinstance(widget, tk.Entry): widget.delete(0, tk.END) widget.insert(0, value) except Exception as e: logger.exception( f"Ошибка обновления виджета {key} в окне устройства {self.device_id}" ) def _update_rs485(self, baudrate: str, slave_id: str): """Обновляет отображение параметров RS485 в интерфейсе""" speed_combo = self._widgets.get("speed_entry") id_spin = self._widgets.get("id_spinbox") # Обновляем скорость, только если пользователь её не менял if ( speed_combo and not self._dirty_fields.get("speed_entry") and not self._is_widget_focused(speed_combo) ): speed_combo.set(baudrate) # Обновляем ID, только если пользователь его не менял if ( id_spin and not self._dirty_fields.get("id_spinbox") and not self._is_widget_focused(id_spin) ): if self.id_var.get() != str(slave_id): self.id_var.set(str(slave_id)) def _update_status(self, status_read_value: int): status = DeviceState.get_status(status_read_value) status_label = self._status_widgets["ecg_status"] status_label.configure(text=status) power_state = DeviceState.get_power_state(status_read_value) power_label = self._status_widgets["output_status"] power_label.configure(text=power_state) def _update_errors(self, errors_val: int): """ Обновление списка ошибок """ colors = Theme.get_colors() # Получаем список активных ошибок active_errors = DeviceState.decode_errors(errors_val) # Обновляем все виджеты ошибок for error_name, label in self._error_widgets.items(): if error_name in active_errors: label.configure(fg=colors["error_fg"]) else: label.configure(fg=colors["disabled_fg"]) def _animate_status_bar(self): """Независимый цикл анимации статус-бара""" if not self.root.winfo_exists(): return colors = Theme.get_colors() widget = self.status_label if not self.is_connected: if ( widget["fg"] == colors["bar_fg"] or widget["fg"] == colors["disabled_fg"] ): widget.configure(text=self.connect_status, fg=colors["error_fg"]) else: widget.configure(text=self.connect_status, fg=colors["disabled_fg"]) else: widget.configure(text=self.connect_status, fg=colors["bar_fg"]) # Перезапускаем этот же метод через 500 мс self.root.after(500, self._animate_status_bar) def _get_priority_message(self, message_pool): """Метод для получения наиболее приоритетного сообщения""" priority_message = None device_off = False for message in message_pool: message_type = message.message_type if message_type == MessageType.SAVE_RS485: priority_message = message break if message_type == MessageType.SAVE_STATUS_OFF: priority_message = message device_off = True if not device_off: priority_message = message return priority_message def _process_message(self, message, message_type): # Потеря связи if message_type == MessageType.DISCONNECT: # Если уже в состоянии потери, то выходим, чтобы не спамить messagebox if self.connect_status != ConnectionStatus.DISCONNECTED: self.root.after_idle( lambda: messagebox.showinfo("COM-порт", message, parent=self.root) ) self.parent_app.set_interface_state(False) self.parent_app.set_connection_command(False) self.parent_app.set_connect_status(ConnectionStatus.DISCONNECTED) logger.error( f"Получен сигнал DISCONNECT для устройства {self.device_id}: {message}" ) # Удачное переподключение elif message_type == MessageType.RECONNECT: self.root.after_idle( lambda: messagebox.showinfo("COM-порт", message, parent=self.root) ) self.parent_app.set_connection_command(True) self.parent_app.set_connect_status(ConnectionStatus.CONNECTED) self.parent_app.set_interface_state(True) logger.warning(f"{self.device_id}: {message}") # Смена ID или скорости, необходимо перезапустить программу elif message_type == MessageType.SAVE_RS485: self.root.after_idle( lambda: messagebox.showinfo("Запись данных", message, parent=self.root) ) self.parent_app.set_interface_state(False) self.parent_app.set_connection_command(False) self.parent_app.set_connect_status(ConnectionStatus.EXIT_REQUIRED) logger.error( f"Получен сигнал SAVE_RS485 для устройства {self.device_id}: {message}" ) elif message_type == MessageType.SAVE_STATUS_OFF: # Если уже в состоянии выключения, то выходим, чтобы не спамить messagebox if self.connect_status == ConnectionStatus.REBOOT_REQUIRED: return self.root.after_idle( lambda: messagebox.showinfo("Запись данных", message, parent=self.root) ) self.set_interface_state(False) self.is_connected = False self.connect_status = ConnectionStatus.REBOOT_REQUIRED logger.error( f"Получен сигнал SAVE_STATUS_OFF для устройства {self.device_id}: {message}" ) else: self.root.after_idle( lambda: messagebox.showinfo("Запись данных", message, parent=self.root) ) self.has_messages = True def _update_status_bar(self, com_port, generator_serial, firmware_serial_raw): gas, firmware = DeviceState.get_serial_and_gase(firmware_serial_raw) self.bar_label.configure( text=f"| COM: {com_port} | SN:{generator_serial} | FW:v{firmware} | {gas}" ) # --------------------------------------------------------------------------- # Обработчики действий # --------------------------------------------------------------------------- def _set_power_params(self, key: str, command_type: WriteCommandType): """На параметры напряжения/тока выхода""" widget = self._widgets.get(key) if not widget: return # Получаем текущее значение параметра current_value = self._values.get(key) if not current_value: return # Получаем сырое значение поля raw_value_str = widget.get().replace(",", ".") try: new_val = float(raw_value_str) except ValueError: self.root.after_idle( lambda: messagebox.showerror( "Ошибка ввода", "Некорректное значение в поле! Проверьте, что вводите только числовые значения.", parent=self.root, ) ) return try: old_val = float(current_value) except ValueError: old_val = None if new_val == old_val: self.root.after_idle( lambda: messagebox.showinfo( "Информация", "Нельзя отправить старое значение!", parent=self.root, ) ) logger.info(f"Попытка отправки неизмененного значения для {key}") return power_param = WRITE_PARAMS.get(command_type) if not power_param: return power_param = WRITE_PARAMS.get(command_type) if not power_param: return # Валидация register_address = power_param.address[0] is_valid, message, clean_value = WriteDataCommand.validate( raw_value_str, register_address ) if not is_valid: self.root.after_idle( lambda: messagebox.showerror( "Ошибка ввода", f"{message}", parent=self.root ) ) return data_command = WriteDataCommand( slave_id=self.device_id, command_type=command_type, values=[clean_value], ) try: self.write_data_queue.put(data_command, timeout=0.1) except queue.Full: logger.error(f"Очередь записи переполнена!") def _set_rs485_params(self): """ Отправляет команду на смену скорости и id Проверяет прежде валидность значений """ if self.status != STATUS_CORRECT: logger.info( f"Отказ в смене RS485: неверный статус устройства '{self.status}'" ) self.root.after_idle( lambda: messagebox.showerror( "Ошибка ввода", f"Изменить параметры RS485 можно только лишь в статусе ЭХГ '{STATUS_CORRECT}'!", parent=self.root, ) ) return raw_speed = self._widgets["speed_entry"].get() raw_device_id = self._widgets["id_spinbox"].get() # Получаем текущее значение параметра current_baud_value = self._values.get("speed_entry") current_id_value = self._values.get("id_spinbox") if not current_baud_value or not current_id_value: return # Валидация скорости rs485_param = WRITE_PARAMS.get(WriteCommandType.WRITE_RS485) if not rs485_param: self._dirty_fields["speed_entry"] = False return speed_register_address = rs485_param.address[0] is_valid, message, clean_speed = WriteDataCommand.validate( raw_speed, speed_register_address ) # TODO: Временная мера, пока не поменяется прошивка if str(clean_speed) not in list(VALID_BAUDRATES.keys()): self.root.after_idle( lambda: messagebox.showerror( "Ошибка ввода", f"Некорректное значение скорости!", parent=self.root ) ) return if not is_valid: self.root.after_idle( lambda: messagebox.showerror( "Ошибка ввода", f"{message}", parent=self.root ) ) return # Валидация id id_register_address = rs485_param.address[1] is_valid, message, clean_id = WriteDataCommand.validate( raw_device_id, id_register_address ) if not is_valid: self.root.after_idle( lambda: messagebox.showerror( "Ошибка ввода", f"{message}", parent=self.root ) ) self._dirty_fields["id_spinbox"] = False return try: is_new_baud = float(raw_speed) == float(current_baud_value) is_new_id = int(raw_device_id) == int(current_id_value) if is_new_baud and is_new_id: self.root.after_idle( lambda: messagebox.showerror( "Ошибка ввода", "Нельзя отправить старое значение!", parent=self.root, ) ) return except ValueError: # Если не удалось распарсить числа — просто выходим return # Проверяем: изменилось ли значение перед отправкой if is_new_baud and is_new_id: self.root.after_idle( lambda: messagebox.showerror( "Ошибка ввода", f"Нельзя отправить старое значение!", parent=self.root, ) ) return # Отправка данных в очередь data_command = WriteDataCommand( slave_id=self.device_id, command_type=WriteCommandType.WRITE_RS485, values=[str(clean_speed), clean_id], ) try: self.write_data_queue.put(data_command, timeout=0.1) except queue.Full: logger.error(f"Очередь записи переполнена!") def _set_status_write_params(self, power_off, power_on): """Отправляет команды на вкл/выкл ЭХГ""" charge_dc_dc = False data_command = WriteDataCommand( slave_id=self.device_id, command_type=WriteCommandType.WRITE_STATUS, values=[charge_dc_dc, power_off, power_on], ) try: self.write_data_queue.put(data_command, timeout=0.1) except queue.Full: logger.error(f"Очередь записи переполнена!.") # ------------------------------------------------ # Управление темой # ------------------------------------------------ def _configure_styles(self): """Настройка стилей ttk""" style = ttk.Style() style.theme_use("clam") colors = Theme.get_colors() # Глобальный стиль для всех TCombobox style.configure( "TCombobox", font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), background=colors["combobox_bg"], foreground=colors["combobox_fg"], fieldbackground=colors["combobox_bg"], arrowcolor=colors["fg"], selectbackground=colors["combobox_bg"], selectforeground=colors["combobox_fg"], bordercolor=colors["border"], lightcolor=colors["combobox_bg"], darkcolor=colors["combobox_bg"], ) style.map( "TCombobox", background=[ ("active", colors["combobox_bg"]), ("readonly", colors["combobox_bg"]), ], foreground=[ ("active", colors["combobox_fg"]), ("readonly", colors["combobox_fg"]), ], fieldbackground=[ ("readonly", colors["combobox_bg"]), ("focus", colors["combobox_bg"]), ], ) self.root.option_add( "*TCombobox*Listbox.font", (ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE) ) self.root.option_add("*TCombobox*Listbox.background", colors["combobox_bg"]) self.root.option_add("*TCombobox*Listbox.foreground", colors["combobox_fg"]) self.root.option_add( "*TCombobox*Listbox.selectBackground", colors["combobox_active_bg"] ) self.root.option_add( "*TCombobox*Listbox.selectForeground", colors["combobox_active_fg"] ) def set_theme(self, theme_name: str): """Смена темы оформления""" Theme.current = theme_name self._configure_styles() if "rs485_frame" in self._widgets: self._rebuild_rs485_block(self._widgets["left_panel"]) self._apply_theme_recursive(self.root, Theme.get_colors()) if self.plot_window and self.plot_window.root.winfo_exists(): self.plot_window.apply_theme() self.parent_app._save_settings() def _apply_theme_recursive(self, widget, colors): """Рекурсивное применение цветов""" # Принудительно пропускаем обработку виджета из ttk # Почему-то проверка в блоке try-except не срабатывает if isinstance(widget, ttk.Combobox): for child in widget.winfo_children(): self._apply_theme_recursive(child, colors) return try: # Обновляем фон самого окна (Tk или Toplevel) if isinstance(widget, (tk.Tk, tk.Toplevel)): widget.configure(bg=colors["bg"]) # Обновляем рамку нижней панели if widget == self.status_bar_border: widget.configure(bg=colors["bar_border"]) # Обновляем фреймы и рамки elif isinstance(widget, tk.Frame): if widget == self.bar_frame: widget.configure(bg=colors["bar_bg"]) else: widget.configure(bg=colors["bg"]) elif isinstance(widget, tk.LabelFrame): widget.configure(bg=colors["bg"]) widget.configure(fg=colors["title_fg"]) elif isinstance(widget, tk.Label): if widget == self.bar_label or widget == self.status_label: widget.configure(bg=colors["bar_bg"], fg=colors["bar_fg"]) else: widget.configure(bg=colors["bg"], fg=colors["fg"]) # Кнопки elif isinstance(widget, tk.Button): # Для кнопки "Графики" и других стандартных кнопок if widget == self._widgets["btn_plot"]: widget.configure( bg=colors["button_plot_bg"], fg=colors["button_plot_fg"], activebackground=colors["button_active_plot_bg"], activeforeground=colors["button_plot_fg"], ) elif widget == self._widgets["btn_power_on"]: widget.configure( bg=colors["button_on_bg"], fg=colors["button_on_fg"], activebackground=colors["button_active_on_bg"], activeforeground=colors["button_on_fg"], ) elif widget == self._widgets["btn_power_off"]: widget.configure( bg=colors["button_off_bg"], fg=colors["button_off_fg"], activebackground=colors["button_active_off_bg"], activeforeground=colors["button_off_fg"], ) else: widget.configure( bg=colors["button_bg"], fg=colors["button_fg"], activebackground=colors["button_active_bg"], activeforeground=colors["button_fg"], ) # Поля ввода elif isinstance(widget, tk.Entry): widget.configure( bg=colors["entry_bg"], fg=colors["entry_fg"], disabledbackground=colors["entry_bg"], disabledforeground=colors["entry_fg"], insertbackground=colors["fg"], ) elif isinstance(widget, tk.Spinbox): widget.configure( bg=colors["spinbox_bg"], fg=colors["spinbox_fg"], disabledbackground=colors["spinbox_bg"], disabledforeground=colors["spinbox_fg"], buttonbackground=colors["spinbox_bg"], activebackground=colors["spinbox_bg"], readonlybackground=colors["spinbox_bg"], ) elif isinstance(widget, ttk.Combobox): pass except Exception as e: logger.exception( f"Ошибка применения темы для {widget} ({type(widget).__name__})" ) # Рекурсивный вызов для всех дочерних элементов for child in widget.winfo_children(): self._apply_theme_recursive(child, colors) # --------------------------------------------------------------------------- # Управление окнами # --------------------------------------------------------------------------- def set_interface_state(self, enabled: bool): """Включает или выключает все интерактивные элементы управления""" state_val = tk.NORMAL if enabled else tk.DISABLED for key, widget in self._widgets.items(): try: # Проверяем тип виджета, так как state настраивается по-разному if isinstance(widget, ttk.Combobox): widget.configure(state="readonly" if enabled else "disabled") elif isinstance(widget, (tk.Button, tk.Entry, tk.Spinbox)): widget.configure(state=state_val) except Exception as e: logger.exception(f"Ошибка переключения {type(widget).__name__}:") def open_plot_window(self): """Открытие окна графиков""" if self.plot_window is None or not self.plot_window.root.winfo_exists(): logger.info(f"Открытие окна графиков для устройства {self.device_id}") top = tk.Toplevel(self.root) top.withdraw() self.plot_window = PlotWindow( root=top, stop_event=self.stop_event, id=self.device_id, history=self.data_history, ) # Центрируем окно ровно посередине главного приложения top.update_idletasks() win_w = top.winfo_reqwidth() win_h = top.winfo_reqheight() parent_x = self.root.winfo_x() parent_y = self.root.winfo_y() parent_w = self.root.winfo_width() parent_h = self.root.winfo_height() x = parent_x + (parent_w - win_w) // 2 y = parent_y + (parent_h - win_h) // 2 top.geometry(f"{win_w}x{win_h}+{x}+{y}") top.deiconify() else: self.plot_window.root.lift() self.plot_window.root.focus_force() def _is_widget_focused(self, widget): """Безопасная проверка, находится ли виджет в фокусе""" if not widget: return False try: return self.root.focus_get() == widget except Exception: return False def _mark_dirty(self, key: str): """Помечает поле как измененное пользователем""" self._dirty_fields[key] = True # Запускаем таймер на 5 секунд (5000 мс) self._schedule_clean_field(key, delay_ms=5000) def _schedule_clean_field(self, key: str, delay_ms: int = 5000): """ Планирует автоматический сброс 'грязного' флага через заданное время. Если пользователь продолжит ввод, таймер перезапустится. """ # Отменяем предыдущий таймер для этого поля, если он был if hasattr(self, "_clean_timers") and key in self._clean_timers: self.root.after_cancel(self._clean_timers[key]) # Запускаем новый таймер timer_id = self.root.after(delay_ms, lambda: self._clean_field(key)) # Сохраняем ID таймера if not hasattr(self, "_clean_timers"): self._clean_timers = {} self._clean_timers[key] = timer_id def _clean_field(self, key: str): """Сбрасывает флаг изменения поля""" if key in self._dirty_fields: self._dirty_fields[key] = False def stop_app(self): """ Остановка работы всего приложения """ # Используем родительский метод self.parent_app.exit()