From 31ca8bacd4ba95c2d3204332ba0a6378e718da85 Mon Sep 17 00:00:00 2001 From: drho1y Date: Wed, 15 Jul 2026 23:45:56 +0300 Subject: [PATCH] Update: actual control program --- backend_control/gui.py | 282 +++++++++++++++++++---------------------- 1 file changed, 131 insertions(+), 151 deletions(-) diff --git a/backend_control/gui.py b/backend_control/gui.py index a5fee27..b915a04 100644 --- a/backend_control/gui.py +++ b/backend_control/gui.py @@ -1,18 +1,21 @@ import customtkinter as ctk import paho.mqtt.client as mqtt -# ================= НАСТРОЙКИ MQTT ================= -MQTT_BROKER = "192.168.31.225" +# ================= НАСТРОЙКИ ================= +MQTT_BROKER = "192.168.0.200" MQTT_PORT = 1883 MQTT_USER = "test" MQTT_PASSWORD = "1234" +# Количество каналов сервоприводов (UI сгенерируется автоматически) +MAX_SERVOS = 4 + # Цвета для индикаторов COLOR_OK = "#28a745" COLOR_ERR = "#dc3545" COLOR_OFF = "#555555" COLOR_ACTIVE = "#00d2ff" -COLOR_PENDING = "#ffaa00" # Оранжевый для ожидания +COLOR_PENDING = "#ffaa00" class MotorSCADA: def __init__(self): @@ -21,13 +24,13 @@ class MotorSCADA: self.root = ctk.CTk() self.root.title("🚀 Motor & Servo Control SCADA") - self.root.geometry("1100x800") - self.root.minsize(900, 650) + self.root.geometry("1100x850") + self.root.minsize(1000, 750) # Флаги ожидания подтверждения self.driver_pending = False self.tmc_pending = False - self.servo_pending = False + self.servo_pending = {i: False for i in range(MAX_SERVOS)} self.setup_gui() self.setup_mqtt() @@ -47,7 +50,7 @@ class MotorSCADA: self.tabview.pack(fill="both", expand=True, padx=20, pady=10) self.tab_motor = self.tabview.add("Шаговый двигатель (TMC2209)") - self.tab_servo = self.tabview.add("Сервопривод") + self.tab_servo = self.tabview.add(f"Сервоприводы (0-{MAX_SERVOS-1})") self.create_motor_tab(self.tab_motor) self.create_servo_tab(self.tab_servo) @@ -101,7 +104,7 @@ class MotorSCADA: # Microsteps ctk.CTkLabel(frame, text="Микрошаги:").pack(anchor="w", padx=20, pady=(15,0)) self.opt_msteps = ctk.CTkOptionMenu(frame, values=["1", "2", "4", "8", "16", "32", "64", "128", "256"], command=self.on_msteps_change) - self.opt_msteps.set("1") + self.opt_msteps.set("16") self.opt_msteps.pack(anchor="w", padx=20, pady=5) # Reset @@ -112,7 +115,6 @@ class MotorSCADA: frame.grid(row=0, column=1, sticky="nsew", padx=10) ctk.CTkLabel(frame, text="🔌 Режимы и Включение", font=ctk.CTkFont(size=18, weight="bold")).pack(pady=(10, 20)) - # ИСПРАВЛЕНО: распаковка кортежа self.sw_driver, self.led_driver_fb = self.create_switch_with_feedback(frame, "Аппаратное вкл. (Driver EN)", self.on_driver_change) self.sw_tmc_enable, self.led_tmc_fb = self.create_switch_with_feedback(frame, "Программное вкл. (TMC Chip)", self.on_tmc_enable_change) @@ -144,68 +146,56 @@ class MotorSCADA: self.led_stealth_act = self.create_led_row(stat_frame, "StealthChop активен:") self.led_standstill = self.create_led_row(stat_frame, "Остановка (Standstill):") - # ================= ВКЛАДКА СЕРВОПРИВОДА ================= + # ================= ВКЛАДКА СЕРВОПРИВОДОВ ================= def create_servo_tab(self, parent): grid = ctk.CTkFrame(parent, fg_color="transparent") - grid.pack(fill="both", expand=True) - grid.grid_columnconfigure((0, 1), weight=1, uniform="col") - grid.grid_rowconfigure(0, weight=1) - - # --- Колонка 1: Управление --- - frame_ctrl = ctk.CTkFrame(grid) - frame_ctrl.grid(row=0, column=0, sticky="nsew", padx=(0, 10)) - ctk.CTkLabel(frame_ctrl, text="⚙️ Управление серво", font=ctk.CTkFont(size=18, weight="bold")).pack(pady=(10, 20)) - - # Угол - ctk.CTkLabel(frame_ctrl, text="Угол (0-180°):").pack(anchor="w", padx=20) - ang_frame = ctk.CTkFrame(frame_ctrl, fg_color="transparent") - ang_frame.pack(fill="x", padx=20, pady=5) - self.sld_servo_ang = ctk.CTkSlider(ang_frame, from_=0, to=180, command=self.on_servo_ang_slider) - self.sld_servo_ang.pack(side="left", fill="x", expand=True, padx=(0, 10)) - self.ent_servo_ang = ctk.CTkEntry(ang_frame, width=70, justify="right") - self.ent_servo_ang.insert(0, "90") - self.ent_servo_ang.pack(side="left", padx=(0, 10)) - self.ent_servo_ang.bind("", self.on_servo_ang_entry) - self.ent_servo_ang.bind("", self.on_servo_ang_entry) - self.lbl_servo_ang_val = ctk.CTkLabel(ang_frame, text="90", width=40) - self.lbl_servo_ang_val.pack(side="right") - - # Импульс - ctk.CTkLabel(frame_ctrl, text="Импульс (500-2500 мкс):").pack(anchor="w", padx=20, pady=(15,0)) - pls_frame = ctk.CTkFrame(frame_ctrl, fg_color="transparent") - pls_frame.pack(fill="x", padx=20, pady=5) - self.sld_servo_pls = ctk.CTkSlider(pls_frame, from_=500, to=2500, command=self.on_servo_pls_slider) - self.sld_servo_pls.pack(side="left", fill="x", expand=True, padx=(0, 10)) - self.ent_servo_pls = ctk.CTkEntry(pls_frame, width=70, justify="right") - self.ent_servo_pls.insert(0, "1500") - self.ent_servo_pls.pack(side="left", padx=(0, 10)) - self.ent_servo_pls.bind("", self.on_servo_pls_entry) - self.ent_servo_pls.bind("", self.on_servo_pls_entry) - self.lbl_servo_pls_val = ctk.CTkLabel(pls_frame, text="1500", width=40) - self.lbl_servo_pls_val.pack(side="right") - - # Detach - ctk.CTkButton(frame_ctrl, text="Отсоединить сигнал (Detach)", fg_color="#dc3545", hover_color="#b02a37", command=self.on_servo_detach).pack(pady=30) - - # --- Колонка 2: Статус и Телеметрия --- - frame_stat = ctk.CTkFrame(grid) - frame_stat.grid(row=0, column=1, sticky="nsew", padx=(10, 0)) - ctk.CTkLabel(frame_stat, text="🔌 Статус и Телеметрия", font=ctk.CTkFont(size=18, weight="bold")).pack(pady=(10, 20)) - - # ИСПРАВЛЕНО: распаковка кортежа - self.sw_servo_enable, self.led_servo_fb = self.create_switch_with_feedback(frame_stat, "Включить сервопривод", self.on_servo_enable_change) - - ctk.CTkFrame(frame_stat, height=2, fg_color="#4a4a6a").pack(fill="x", padx=20, pady=20) - - # Телеметрия - tel_frame = ctk.CTkFrame(frame_stat) - tel_frame.pack(fill="x", padx=10, pady=5) - self.lbl_fb_servo_ang = self.create_telemetry_row(tel_frame, "Текущий угол (Факт):") + grid.pack(fill="both", expand=True, padx=10, pady=10) - ctk.CTkLabel(frame_stat, text="🚨 Статус", font=ctk.CTkFont(size=16, weight="bold")).pack(pady=(15, 5)) - stat_frame = ctk.CTkFrame(frame_stat) - stat_frame.pack(fill="x", padx=10, pady=5) - self.led_servo_status = self.create_led_row(stat_frame, "Серво активно:") + # Настраиваем сетку 2x2 для карточек сервоприводов + grid.grid_columnconfigure((0, 1), weight=1, uniform="col") + grid.grid_rowconfigure((0, 1), weight=1, uniform="row") + + self.servo_ui = {} + for i in range(MAX_SERVOS): + row = i // 2 + col = i % 2 + frame = ctk.CTkFrame(grid) + frame.grid(row=row, column=col, sticky="nsew", padx=10, pady=10) + self.servo_ui[i] = self.create_servo_card(frame, i) + + def create_servo_card(self, parent, channel): + """Создает карточку управления для одного канала сервопривода""" + ui_elements = {} + ctk.CTkLabel(parent, text=f"🦾 Сервопривод #{channel}", font=ctk.CTkFont(size=16, weight="bold")).pack(pady=(10, 10)) + + # Угол + ctk.CTkLabel(parent, text="Угол (0-180°):").pack(anchor="w", padx=20) + ang_frame = ctk.CTkFrame(parent, fg_color="transparent") + ang_frame.pack(fill="x", padx=20, pady=5) + + sld = ctk.CTkSlider(ang_frame, from_=0, to=180, command=lambda val, ch=channel: self.on_servo_ang_slider(ch, val)) + sld.pack(side="left", fill="x", expand=True, padx=(0, 10)) + sld.set(90) + + ent = ctk.CTkEntry(ang_frame, width=60, justify="right") + ent.insert(0, "90") + ent.pack(side="left", padx=(0, 10)) + ent.bind("", lambda event, ch=channel: self.on_servo_ang_entry(ch, event)) + ent.bind("", lambda event, ch=channel: self.on_servo_ang_entry(ch, event)) + + lbl_val = ctk.CTkLabel(ang_frame, text="90", width=40) + lbl_val.pack(side="right") + + ui_elements['slider_ang'] = sld + ui_elements['entry_ang'] = ent + ui_elements['lbl_ang_val'] = lbl_val + + # Включение с обратной связью + sw, led_fb = self.create_switch_with_feedback(parent, "Включить серво", lambda ch=channel: self.on_servo_enable_change(ch)) + ui_elements['switch_en'] = sw + ui_elements['led_fb'] = led_fb + + return ui_elements # ================= ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ GUI ================= def create_switch(self, parent, text, command): @@ -259,8 +249,9 @@ class MotorSCADA: def on_mqtt_connect(self, client, userdata, flags, reason_code, properties): if reason_code == 0: self.root.after(0, self.update_status, True) + # Подписываемся на все топики обратной связи client.subscribe("motor/feedback/#") - client.subscribe("servo/feedback/#") + client.subscribe("servo/+/feedback/#") # Динамический канал для серво else: print(f"Ошибка подключения MQTT. Код: {reason_code}") self.root.after(0, self.update_status, False) @@ -274,56 +265,62 @@ class MotorSCADA: self.root.after(0, self.process_feedback, topic, val) def process_feedback(self, topic, val): - # --- ШАГОВЫЙ ДВИГАТЕЛЬ --- - if topic == "motor/feedback/rpm": self.lbl_fb_rpm.configure(text=val) - elif topic == "motor/feedback/totalsteps": self.lbl_fb_steps.configure(text=val) - elif topic == "motor/feedback/is_run": - is_running = val == "true" - self.lbl_fb_run.configure(text="Вращается" if is_running else "Остановлен", text_color=COLOR_OK if is_running else COLOR_ERR) - elif topic == "motor/feedback/tmc/current_percent": - self.lbl_cur_val.configure(text=val) - self.sld_current.set(int(val)) - elif topic == "motor/feedback/tmc/microsteps": self.opt_msteps.set(val) - elif topic == "motor/feedback/tmc/sg_result": self.lbl_fb_sg.configure(text=val) - elif topic == "motor/feedback/tmc/interstep_duration": self.lbl_fb_interstep.configure(text=val) - elif topic == "motor/feedback/tmc/status/current_scaling": self.lbl_fb_cscale.configure(text=val) - elif topic == "motor/feedback/tmc/status/over_temp": self.update_led(self.led_over_temp, val, is_error=True) - elif topic == "motor/feedback/tmc/status/short_to_ground": self.update_led(self.led_short_gnd, val, is_error=True) - elif topic == "motor/feedback/tmc/status/open_load": self.update_led(self.led_open_load, val, is_error=True) - elif topic == "motor/feedback/tmc/status/stealth_chop_active": self.update_led(self.led_stealth_act, val, is_error=False) - elif topic == "motor/feedback/tmc/status/standstill": self.update_led(self.led_standstill, val, is_error=False) + parts = topic.split('/') - # ИСПРАВЛЕНО: обращение к self.sw_driver напрямую, а не через [0] - elif topic == "motor/feedback/driver/status": - is_on = val == "on" - self.driver_pending = False - if self.sw_driver.get() != is_on: - if is_on: self.sw_driver.select() - else: self.sw_driver.deselect() - self.led_driver_fb.configure(text_color=COLOR_OK if is_on else COLOR_OFF) - - # ИСПРАВЛЕНО: обращение к self.sw_tmc_enable напрямую - elif topic == "motor/feedback/tmc/status": - is_on = val == "on" - self.tmc_pending = False - if self.sw_tmc_enable.get() != is_on: - if is_on: self.sw_tmc_enable.select() - else: self.sw_tmc_enable.deselect() - self.led_tmc_fb.configure(text_color=COLOR_OK if is_on else COLOR_OFF) + # --- ШАГОВЫЙ ДВИГАТЕЛЬ --- + if parts[0] == 'motor' and parts[1] == 'feedback': + if topic == "motor/feedback/rpm": self.lbl_fb_rpm.configure(text=val) + elif topic == "motor/feedback/totalsteps": self.lbl_fb_steps.configure(text=val) + elif topic == "motor/feedback/is_run": + is_running = val == "true" + self.lbl_fb_run.configure(text="Вращается" if is_running else "Остановлен", text_color=COLOR_OK if is_running else COLOR_ERR) + elif topic == "motor/feedback/tmc/current_percent": + self.lbl_cur_val.configure(text=val) + self.sld_current.set(int(val)) + elif topic == "motor/feedback/tmc/microsteps": self.opt_msteps.set(val) + elif topic == "motor/feedback/tmc/sg_result": self.lbl_fb_sg.configure(text=val) + elif topic == "motor/feedback/tmc/interstep_duration": self.lbl_fb_interstep.configure(text=val) + elif topic == "motor/feedback/tmc/status/current_scaling": self.lbl_fb_cscale.configure(text=val) + elif topic == "motor/feedback/tmc/status/over_temp": self.update_led(self.led_over_temp, val, is_error=True) + elif topic == "motor/feedback/tmc/status/short_to_ground": self.update_led(self.led_short_gnd, val, is_error=True) + elif topic == "motor/feedback/tmc/status/open_load": self.update_led(self.led_open_load, val, is_error=True) + elif topic == "motor/feedback/tmc/status/stealth_chop_active": self.update_led(self.led_stealth_act, val, is_error=False) + elif topic == "motor/feedback/tmc/status/standstill": self.update_led(self.led_standstill, val, is_error=False) + elif topic == "motor/feedback/driver/status": + is_on = val == "on" + self.driver_pending = False + if self.sw_driver.get() != is_on: + if is_on: self.sw_driver.select() + else: self.sw_driver.deselect() + self.led_driver_fb.configure(text_color=COLOR_OK if is_on else COLOR_OFF) + elif topic == "motor/feedback/tmc/status": + is_on = val == "on" + self.tmc_pending = False + if self.sw_tmc_enable.get() != is_on: + if is_on: self.sw_tmc_enable.select() + else: self.sw_tmc_enable.deselect() + self.led_tmc_fb.configure(text_color=COLOR_OK if is_on else COLOR_OFF) - # --- СЕРВОПРИВОД --- - elif topic == "servo/feedback/angle": - self.lbl_fb_servo_ang.configure(text=val) - - # ИСПРАВЛЕНО: обращение к self.sw_servo_enable напрямую - elif topic == "servo/feedback/status": - is_on = val == "on" - self.servo_pending = False - if self.sw_servo_enable.get() != is_on: - if is_on: self.sw_servo_enable.select() - else: self.sw_servo_enable.deselect() - self.led_servo_fb.configure(text_color=COLOR_OK if is_on else COLOR_OFF) - self.update_led(self.led_servo_status, val, is_error=False) + # --- СЕРВОПРИВОДЫ --- + elif parts[0] == 'servo' and parts[2] == 'feedback': + try: + channel = int(parts[1]) + if channel in self.servo_ui: + param = parts[3] + ui = self.servo_ui[channel] + + if param == 'angle': + ui['lbl_fb_ang'].configure(text=val) + elif param == 'status': + is_on = val == "on" + self.servo_pending[channel] = False + if ui['switch_en'].get() != is_on: + if is_on: ui['switch_en'].select() + else: ui['switch_en'].deselect() + ui['led_fb'].configure(text_color=COLOR_OK if is_on else COLOR_OFF) + self.update_led(ui['led_status'], val, is_error=False) + except (IndexError, ValueError): + pass # Игнорируем некорректные или лишние топики # ================= ОБРАБОТЧИКИ СОБЫТИЙ ================= def publish(self, topic, payload): @@ -366,13 +363,11 @@ class MotorSCADA: def on_msteps_change(self, choice): self.publish("motor/control/tmc/microsteps", int(choice)) def on_reset_steps(self): self.publish("motor/control/totalsteps/reset", "1") - # ИСПРАВЛЕНО: обращение к self.sw_driver напрямую def on_driver_change(self): is_on = self.sw_driver.get() self.driver_pending = True; self.led_driver_fb.configure(text_color=COLOR_PENDING) self.publish("motor/control/driver", "on" if is_on else "off") - # ИСПРАВЛЕНО: обращение к self.sw_tmc_enable напрямую def on_tmc_enable_change(self): is_on = self.sw_tmc_enable.get() self.tmc_pending = True; self.led_tmc_fb.configure(text_color=COLOR_PENDING) @@ -381,43 +376,28 @@ class MotorSCADA: def on_stealth_change(self): self.publish("motor/control/tmc/stealthchop", "on" if self.sw_stealth.get() else "off") def on_cool_change(self): self.publish("motor/control/tmc/coolstep", "on" if self.sw_cool.get() else "off") - # --- Сервопривод --- - def on_servo_ang_slider(self, value): + # --- Сервоприводы --- + def on_servo_ang_slider(self, channel, value): int_val = int(value) - self.ent_servo_ang.delete(0, ctk.END); self.ent_servo_ang.insert(0, str(int_val)) - self.lbl_servo_ang_val.configure(text=str(int_val)) - self.publish("servo/control/angle", int_val) + ui = self.servo_ui[channel] + ui['entry_ang'].delete(0, ctk.END); ui['entry_ang'].insert(0, str(int_val)) + ui['lbl_ang_val'].configure(text=str(int_val)) + self.publish(f"servo/control/{channel}/angle", int_val) - def on_servo_ang_entry(self, event=None): + def on_servo_ang_entry(self, channel, event=None): + ui = self.servo_ui[channel] try: - int_val = max(0, min(180, int(self.ent_servo_ang.get()))) - self.sld_servo_ang.set(int_val); self.lbl_servo_ang_val.configure(text=str(int_val)) - self.publish("servo/control/angle", int_val) + int_val = max(0, min(180, int(ui['entry_ang'].get()))) + ui['slider_ang'].set(int_val); ui['lbl_ang_val'].configure(text=str(int_val)) + self.publish(f"servo/control/{channel}/angle", int_val) except ValueError: - self.ent_servo_ang.delete(0, ctk.END); self.ent_servo_ang.insert(0, str(int(self.sld_servo_ang.get()))) + ui['entry_ang'].delete(0, ctk.END); ui['entry_ang'].insert(0, str(int(ui['slider_ang'].get()))) - def on_servo_pls_slider(self, value): - int_val = int(value) - self.ent_servo_pls.delete(0, ctk.END); self.ent_servo_pls.insert(0, str(int_val)) - self.lbl_servo_pls_val.configure(text=str(int_val)) - self.publish("servo/control/pulse", int_val) - - def on_servo_pls_entry(self, event=None): - try: - int_val = max(500, min(2500, int(self.ent_servo_pls.get()))) - self.sld_servo_pls.set(int_val); self.lbl_servo_pls_val.configure(text=str(int_val)) - self.publish("servo/control/pulse", int_val) - except ValueError: - self.ent_servo_pls.delete(0, ctk.END); self.ent_servo_pls.insert(0, str(int(self.sld_servo_pls.get()))) - - def on_servo_detach(self): - self.publish("servo/control/detach", "1") - - # ИСПРАВЛЕНО: обращение к self.sw_servo_enable напрямую - def on_servo_enable_change(self): - is_on = self.sw_servo_enable.get() - self.servo_pending = True; self.led_servo_fb.configure(text_color=COLOR_PENDING) - self.publish("servo/control/enable", "on" if is_on else "off") + def on_servo_enable_change(self, channel): + ui = self.servo_ui[channel] + is_on = ui['switch_en'].get() + self.servo_pending[channel] = True; ui['led_fb'].configure(text_color=COLOR_PENDING) + self.publish(f"servo/control/{channel}/enable", "on" if is_on else "off") def run(self): self.root.mainloop()