From a77da7554bdcfd8fd9fc9f4edc77e61485c3b876 Mon Sep 17 00:00:00 2001 From: IdkHowToCode123 Date: Mon, 23 Mar 2026 14:39:35 -0400 Subject: [PATCH 1/2] Syntax --- main.py | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 main.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..dbcbb76 --- /dev/null +++ b/main.py @@ -0,0 +1,192 @@ +import dearpygui.dearpygui as dpg +import can +import threading +import queue + + +dpg.create_context() +bwid = 50 + +# --- THEMES --- +with dpg.theme() as green_led: + with dpg.theme_component(dpg.mvAll): + dpg.add_theme_color(dpg.mvThemeCol_Button, (34, 139, 34, 255)) + dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, 50) + +with dpg.theme() as red_led: + with dpg.theme_component(dpg.mvAll): + dpg.add_theme_color(dpg.mvThemeCol_Button, (200, 0, 0, 255)) + dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, 50) + +# --- UI LAYOUT --- +with dpg.window(label="Telemetry Master", tag="Primary Window"): + with dpg.group(horizontal=True): + with dpg.group(width=250): + with dpg.child_window(height=400, border=True): + dpg.add_text("Spark Maxes", color=(255, 255, 255)) + dpg.add_separator() + + with dpg.table(header_row=False, borders_innerH=False, borders_innerV=False): + for i in range(4): dpg.add_table_column() + + # DRIVE Section + with dpg.table_row(): dpg.add_text("DRIVE", color=(0, 255, 255)) + with dpg.table_row(): + # We give these specific tags so we can find them later + for i in range(1, 5): + tag_id = f"btn_sp{i}" #REPLACE THIS FOR LOOP WITH EXACT CAN ID VALUES LATER + dpg.add_button(label=f"SP{i}", width=bwid, height=bwid, tag=tag_id) + dpg.bind_item_theme(tag_id, green_led) + + # ARM Section + with dpg.table_row(): dpg.add_text("ARM", color=(0, 255, 255)) + with dpg.table_row(): + for i in range(5, 8): #REPLACE THIS FOR LOOP WITH EXACT CAN ID VALUES LATER + tag_id = f"btn_sp{i}" + dpg.add_button(label=f"SP{i}", width=bwid, height=bwid, tag=tag_id) + dpg.bind_item_theme(tag_id, green_led) + + #GRIP Section + with dpg.table_row(): dpg.add_text("GRIP", color=(0, 255, 255)) + with dpg.table_row(): + for i in range(9, 11): #REPLACE THIS FOR LOOP WITH EXACT CAN ID VALUES LATER + tag_id = f"btn_sp{i}" + dpg.add_button(label=f"SP{i}", width=bwid, height=bwid, tag=tag_id) + dpg.bind_item_theme(tag_id, green_led) + + #SCIENCE SECTION + with dpg.table_row(): dpg.add_text("DRIVE", color=(0, 255, 255)) + with dpg.table_row(): + #REPLACE THIS ID WITH EXACT CAN ID VALUES LATER + tag_id="btn_sp19" + dpg.add_button(label="btn_sp19", width=bwid, height=bwid, tag=tag_id) + dpg.bind_item_theme(tag_id, green_led) + + with dpg.child_window(height=-1, border=True): + dpg.add_text("Science Module", color=(255, 255, 255)) + dpg.add_separator() + + with dpg.child_window(width=-1, border=True): + dpg.add_text("EBOX DATA", color=(0, 255, 255)) + dpg.add_separator() + +# --- CAN Bit Extractor --- +def extract_bits (bit, sbit_loc, bit_mask): + + return (bit >> sbit_loc) & bit_mask + +# --- THE LOGIC --- + +#FROM C Header, pass this to return as needed +''' + BITS 29 - 24 is type + '' 16 - 23 is manufacturer + '' 10-15 is class + '' 6 - 9 is index + and 0 - 5 is device id +''' +TYPE_POS = 24 +MANU_POS = 16 +CLASS_POS = 10 +INDEX_POS = 6 +DEVID_POS = 0 + +def get_telemetry_info(arb_id): + return { + "type": (arb_id >> TYPE_POS) & 0x1F, #5 bits + "manu": (arb_id >> MANU_POS) & 0xFF, #8 bits + "class": (arb_id >> CLASS_POS) & 0x3F, #6 bits + "index": (arb_id >> INDEX_POS) & 0x0F, #4 bits + "dev_id": (arb_id >> DEVID_POS) & 0x3F #6 bits + } + +#_ _ _ _ _ | _ _ _ _ _ _ _ | _ _ _ _ _ _ | _ _ _ _ | _ _ _ _ _ _ | +#type Menu Class Index Device ID + +#1. Create a queue + +telemetry_queue = queue.Queue() + +def can_worker(): + + try: + # Constants from C header - change when DBC is implemented + ''' + + + Creates a filter that ONLY lets through "Status 1" (where faults live) + This ignores the "Heartbeat" and "Encoder" data entirely at the hardware level. + + This ID represents: Type(02) + Mfr(05) + API(061) + DeviceID(00) + Binary: 0000 0010 + 0000 0101 + 0011 1101 + 0000 0000 + HEX: 0 2 0 5 3 D 0 0x + ''' + target_id = 0x02053D00 + + + #from CAN, we only want + filters = [ + { + "can_id": target_id, + "can_mask": 0x1FFFFFC0, # Check Type/Mfr/API, ignore Device ID + "extended": True} + ] + + bus = can.interface.Bus(channel='can0', bustype='socketcan', can_filters=filters) + except: + print("BUS NOT FOUND! (did you run sudo ip link...)?") + bus = None + while True: + # This BLOCKS. It stays here until a message arrives, instead of always polling + msg = bus.recv(timeout=1.0) + if msg: + info = get_telemetry_info(msg.arbitration_id) + #TELEMETRY: + if msg.arbitration_id == 0x02080546: #iD for Voltage / Current + print (f"VolCurr = {msg.data} from device {info["dev_id"]}") + if info["type"]== 2: + if info["manu"] == 8: + #for all sparkmaxes, 0x0208 or above + if info["index"] == 3: #temperature, for example + print(f"TEMP: {msg.arbitration_id} from device {info["dev_id"]}") + + #telemetry_queue.put((device_id, data)) # - gets passed into update_telemetry_ui + + + # 2. Start the thread before the UI loop +thread = threading.Thread(target=can_worker, daemon=True) +thread.start() + +# 3. Update the UI function to just check the Queue +def update_telemetry_ui(): + # Process everything currently in the queue + while not telemetry_queue.empty(): + try: + device_id, data = telemetry_queue.get_nowait() + target_tag = f"btn_sp{device_id}" #Going to have to change all the button values to the individual device IDs. + + if dpg.does_item_exist(target_tag): + if data > 0: #Data only passed if there is an issue + dpg.bind_item_theme(target_tag, red_led) + dpg.configure_item(target_tag, label=f"ID:{device_id}\nERR") + else: + dpg.bind_item_theme(target_tag, green_led) + dpg.configure_item(target_tag, label=f"SP{device_id}") + except queue.Empty: + break + + + + +# --- RENDER LOOP --- +dpg.create_viewport(title='YURS Telemetry', width=1200, height=800) +dpg.setup_dearpygui() +dpg.show_viewport() +dpg.set_primary_window("Primary Window", True) + +# Manual Render Loop (Crucial for live telemetry) +while dpg.is_dearpygui_running(): + update_telemetry_ui + dpg.render_dearpygui_frame() + +dpg.destroy_context() \ No newline at end of file From bac799c15a124f23c811d51bdf60abfa05ac36d1 Mon Sep 17 00:00:00 2001 From: IdkHowToCode123 Date: Mon, 30 Mar 2026 11:06:54 -0400 Subject: [PATCH 2/2] Changed to PySide6 --- Mac-CANTest.py | 49 +++++++ main.py | 347 ++++++++++++++++++++++------------------------- requirements.txt | 6 + 3 files changed, 217 insertions(+), 185 deletions(-) create mode 100644 Mac-CANTest.py create mode 100644 requirements.txt diff --git a/Mac-CANTest.py b/Mac-CANTest.py new file mode 100644 index 0000000..c39839e --- /dev/null +++ b/Mac-CANTest.py @@ -0,0 +1,49 @@ +import can +import time +import random + +def generate_fake_can(): + # Use 'virtual' for macOS compatibility + try: + bus = can.interface.Bus(channel='224.0.0.1', interface='udp_multicast') + except: + # If vcan0 isn't defined in your configs, this usually just works + bus = can.interface.Bus(channel='224.0.0.1', interface='udp_multicast') + + print("Bus started. Sending fake Rover telemetry...") + + # Constants from your whiteboard + TYPE = 2 + MANU = 8 + DEV_ID = 6 # Targeting Motor 6 as per your UI code + + count = 0 + while True: + # 1. Generate Heartbeat (Class 63, Index 0) + # ID Construction: (Type << 24) | (Manu << 16) | (Class << 10) | (Index << 6) | DevID + heartbeat_id = (TYPE << 24) | (MANU << 16) | (63 << 10) | (0 << 6) | DEV_ID + msg_hb = can.Message(arbitration_id=heartbeat_id, data=[1], is_extended_id=True) + bus.send(msg_hb) + #print(msg_hb) + + #another heartbeat + heartbeat_id = (TYPE << 24) | (MANU << 16) | (63 << 10) | (0 << 6) | 9 + msg_hb = can.Message(arbitration_id=heartbeat_id, data=[1], is_extended_id=True) + bus.send(msg_hb) + # 2. Generate Temp (Class 1, Index 3) + temp_id = (TYPE << 24) | (MANU << 16) | (1 << 10) | (3 << 6) | DEV_ID + fake_temp = int(40 + 10 * random.random()) # 40-50 degrees + msg_temp = can.Message(arbitration_id=temp_id, data=[fake_temp], is_extended_id=True) + bus.send(msg_temp) + + # 3. Generate Volt/Curr (Class 1, Index 4) + power_id = (TYPE << 24) | (MANU << 16) | (1 << 10) | (4 << 6) | DEV_ID + fake_volt = int(20 + random.random() * 4) # 20-24V + fake_curr = int(random.random() * 50) # 0-50A + msg_power = can.Message(arbitration_id=power_id, data=[fake_volt, fake_curr], is_extended_id=True) + bus.send(msg_power) + + time.sleep(0.1) # Send at 10Hz + +if __name__ == "__main__": + generate_fake_can() \ No newline at end of file diff --git a/main.py b/main.py index dbcbb76..7e4d7fd 100644 --- a/main.py +++ b/main.py @@ -1,192 +1,169 @@ -import dearpygui.dearpygui as dpg +import sys import can import threading -import queue - - -dpg.create_context() -bwid = 50 - -# --- THEMES --- -with dpg.theme() as green_led: - with dpg.theme_component(dpg.mvAll): - dpg.add_theme_color(dpg.mvThemeCol_Button, (34, 139, 34, 255)) - dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, 50) - -with dpg.theme() as red_led: - with dpg.theme_component(dpg.mvAll): - dpg.add_theme_color(dpg.mvThemeCol_Button, (200, 0, 0, 255)) - dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, 50) - -# --- UI LAYOUT --- -with dpg.window(label="Telemetry Master", tag="Primary Window"): - with dpg.group(horizontal=True): - with dpg.group(width=250): - with dpg.child_window(height=400, border=True): - dpg.add_text("Spark Maxes", color=(255, 255, 255)) - dpg.add_separator() - - with dpg.table(header_row=False, borders_innerH=False, borders_innerV=False): - for i in range(4): dpg.add_table_column() - - # DRIVE Section - with dpg.table_row(): dpg.add_text("DRIVE", color=(0, 255, 255)) - with dpg.table_row(): - # We give these specific tags so we can find them later - for i in range(1, 5): - tag_id = f"btn_sp{i}" #REPLACE THIS FOR LOOP WITH EXACT CAN ID VALUES LATER - dpg.add_button(label=f"SP{i}", width=bwid, height=bwid, tag=tag_id) - dpg.bind_item_theme(tag_id, green_led) - - # ARM Section - with dpg.table_row(): dpg.add_text("ARM", color=(0, 255, 255)) - with dpg.table_row(): - for i in range(5, 8): #REPLACE THIS FOR LOOP WITH EXACT CAN ID VALUES LATER - tag_id = f"btn_sp{i}" - dpg.add_button(label=f"SP{i}", width=bwid, height=bwid, tag=tag_id) - dpg.bind_item_theme(tag_id, green_led) - - #GRIP Section - with dpg.table_row(): dpg.add_text("GRIP", color=(0, 255, 255)) - with dpg.table_row(): - for i in range(9, 11): #REPLACE THIS FOR LOOP WITH EXACT CAN ID VALUES LATER - tag_id = f"btn_sp{i}" - dpg.add_button(label=f"SP{i}", width=bwid, height=bwid, tag=tag_id) - dpg.bind_item_theme(tag_id, green_led) - - #SCIENCE SECTION - with dpg.table_row(): dpg.add_text("DRIVE", color=(0, 255, 255)) - with dpg.table_row(): - #REPLACE THIS ID WITH EXACT CAN ID VALUES LATER - tag_id="btn_sp19" - dpg.add_button(label="btn_sp19", width=bwid, height=bwid, tag=tag_id) - dpg.bind_item_theme(tag_id, green_led) - - with dpg.child_window(height=-1, border=True): - dpg.add_text("Science Module", color=(255, 255, 255)) - dpg.add_separator() - - with dpg.child_window(width=-1, border=True): - dpg.add_text("EBOX DATA", color=(0, 255, 255)) - dpg.add_separator() - -# --- CAN Bit Extractor --- -def extract_bits (bit, sbit_loc, bit_mask): - - return (bit >> sbit_loc) & bit_mask - -# --- THE LOGIC --- - -#FROM C Header, pass this to return as needed -''' - BITS 29 - 24 is type - '' 16 - 23 is manufacturer - '' 10-15 is class - '' 6 - 9 is index - and 0 - 5 is device id -''' -TYPE_POS = 24 -MANU_POS = 16 -CLASS_POS = 10 -INDEX_POS = 6 -DEVID_POS = 0 - -def get_telemetry_info(arb_id): - return { - "type": (arb_id >> TYPE_POS) & 0x1F, #5 bits - "manu": (arb_id >> MANU_POS) & 0xFF, #8 bits - "class": (arb_id >> CLASS_POS) & 0x3F, #6 bits - "index": (arb_id >> INDEX_POS) & 0x0F, #4 bits - "dev_id": (arb_id >> DEVID_POS) & 0x3F #6 bits - } - -#_ _ _ _ _ | _ _ _ _ _ _ _ | _ _ _ _ _ _ | _ _ _ _ | _ _ _ _ _ _ | -#type Menu Class Index Device ID - -#1. Create a queue - -telemetry_queue = queue.Queue() - -def can_worker(): +#import struct #ONLY IF FLOATS ARE IN THE PAYLOAD +from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QPushButton, QGridLayout) +from PySide6.QtCore import Qt, Signal, QObject +from PySide6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis +from PySide6.QtGui import QColor # <--- The missing piece - try: - # Constants from C header - change when DBC is implemented - ''' +class CommWorker(QObject): + telemetry_received = Signal(int, dict, list) - - Creates a filter that ONLY lets through "Status 1" (where faults live) - This ignores the "Heartbeat" and "Encoder" data entirely at the hardware level. - - This ID represents: Type(02) + Mfr(05) + API(061) + DeviceID(00) - Binary: 0000 0010 + 0000 0101 + 0011 1101 + 0000 0000 - HEX: 0 2 0 5 3 D 0 0x - ''' - target_id = 0x02053D00 - - - #from CAN, we only want - filters = [ - { - "can_id": target_id, - "can_mask": 0x1FFFFFC0, # Check Type/Mfr/API, ignore Device ID - "extended": True} - ] - - bus = can.interface.Bus(channel='can0', bustype='socketcan', can_filters=filters) - except: - print("BUS NOT FOUND! (did you run sudo ip link...)?") - bus = None - while True: - # This BLOCKS. It stays here until a message arrives, instead of always polling - msg = bus.recv(timeout=1.0) - if msg: - info = get_telemetry_info(msg.arbitration_id) - #TELEMETRY: - if msg.arbitration_id == 0x02080546: #iD for Voltage / Current - print (f"VolCurr = {msg.data} from device {info["dev_id"]}") - if info["type"]== 2: - if info["manu"] == 8: - #for all sparkmaxes, 0x0208 or above - if info["index"] == 3: #temperature, for example - print(f"TEMP: {msg.arbitration_id} from device {info["dev_id"]}") - - #telemetry_queue.put((device_id, data)) # - gets passed into update_telemetry_ui - - - # 2. Start the thread before the UI loop -thread = threading.Thread(target=can_worker, daemon=True) -thread.start() - -# 3. Update the UI function to just check the Queue -def update_telemetry_ui(): - # Process everything currently in the queue - while not telemetry_queue.empty(): + def run(self): try: - device_id, data = telemetry_queue.get_nowait() - target_tag = f"btn_sp{device_id}" #Going to have to change all the button values to the individual device IDs. + # Filter logic: + # We want Class 1 (Telemetry) and Class 63 (Heartbeat). + # To keep it simple, we filter for Type=2, Manu=8. + # Mask 0x1F000000 checks Type, 0x00FF0000 checks Manu. + target_id = (2 << 24) | (8 << 16) + mask = 0x1FFF0000 - if dpg.does_item_exist(target_tag): - if data > 0: #Data only passed if there is an issue - dpg.bind_item_theme(target_tag, red_led) - dpg.configure_item(target_tag, label=f"ID:{device_id}\nERR") - else: - dpg.bind_item_theme(target_tag, green_led) - dpg.configure_item(target_tag, label=f"SP{device_id}") - except queue.Empty: - break - - + filters = [{"can_id": target_id, "can_mask": mask, "extended": True}] + #On mac, need to use UDP (dependency is removed from requirements.txt) + bus = can.interface.Bus(channel='224.0.0.1', interface='udp_multicast',filter=filters) + except Exception as e: + print(f"CAN Bus Error: {e}") + return + + while True: + msg = bus.recv(timeout=1.0) + print(msg) + if msg: + # Bit mapping from your whiteboard + info = { + "dev_id": (msg.arbitration_id >> 0) & 0x3F, + "index": (msg.arbitration_id >> 6) & 0x0F, + "class": (msg.arbitration_id >> 10) & 0x3F, + "manu": (msg.arbitration_id >> 16) & 0xFF, + "type": (msg.arbitration_id >> 24) & 0x07, + } + self.telemetry_received.emit(msg.arbitration_id, info, list(msg.data)) + +class RoverDash(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("Sparky - Health Monitor") + self.resize(1024, 600) + self.setStyleSheet("background-color: #0f0f0f; color: white;") + + central_widget = QWidget() + self.setCentralWidget(central_widget) + main_layout = QHBoxLayout(central_widget) + + # --- LEFT SIDE: MOTOR STATUS --- + left_column = QVBoxLayout() + self.motor_grid = QGridLayout() + self.motor_buttons = {} + + for i in range(11): #replace this with the exact dev ids once you can get them + dev_id = i + 1 + btn = QPushButton(f"M{dev_id}") + btn.setFixedSize(60, 60) + btn.setStyleSheet(self.get_style("red")) # Default to red until heartbeat + self.motor_grid.addWidget(btn, i // 4, i % 4) + self.motor_buttons[dev_id] = btn + + btn12 = QPushButton("0x12") #e.g. button with dev id of 12 + btn12.setFixedSize(60,60) + btn12.setStyleSheet(self.get_style("red")) # Default to red until heartbeat + self.motor_grid.addWidget(btn12) + self.motor_buttons[12]=btn12 + + left_column.addLayout(self.motor_grid) + main_layout.addLayout(left_column, 1) + + # --- RIGHT SIDE: GRAPHS --- + graph_column = QVBoxLayout() + self.chart_grid = QGridLayout() + self.series_temp = QLineSeries() + self.chart_temp_view = self.create_graph("Temp (°C)", self.series_temp) + self.chart_grid.addWidget(self.chart_temp_view) + + self.series_current = QLineSeries() #cv represents current - voltage + self.series_current.setName("Current (A)") + self.series_current.setColor(QColor("#00e5ff")) # Cyan-ish for current + + self.series_voltage = QLineSeries() + self.series_voltage.setName("Voltage (V))") + self.series_voltage.setColor(QColor("#dc300e")) # red-ish for voltage + + self.chart_currVol_view = self.create_graph("Current & Voltage", [self.series_voltage, self.series_current]) + self.chart_grid.addWidget(self.chart_currVol_view) + + graph_column.addLayout(self.chart_grid) + main_layout.addLayout(graph_column, 2) + + self.data_count = 0 + + # --- WORKER THREAD --- + self.worker = CommWorker() + self.worker_thread = threading.Thread(target=self.worker.run, daemon=True) + self.worker.telemetry_received.connect(self.process_can_data) + self.worker_thread.start() + + def get_style(self, color): + hex_color = "#00ff88" if color == "green" else "#ff3333" + return f"background-color: {hex_color}; color: black; border-radius: 30px; font-weight: bold; border: 1px solid white;" + + def create_graph(self, title, series_input): + chart = QChart() + chart.setTitle(title) + chart.setTheme(QChart.ChartThemeDark) + + # Ensure we are working with a list even if one series is passed + series_list = series_input if isinstance(series_input, list) else [series_input] + + axis_x = QValueAxis() + axis_x.setRange(0, 100) + chart.addAxis(axis_x, Qt.AlignBottom) + + axis_y = QValueAxis() + axis_y.setRange(0, 100) # Adjust based on your battery/motor limits + chart.addAxis(axis_y, Qt.AlignLeft) + + for s in series_list: + chart.addSeries(s) + s.attachAxis(axis_x) + s.attachAxis(axis_y) + + view = QChartView(chart) + view.setRenderHint(view.renderHints().Antialiasing) + return view - -# --- RENDER LOOP --- -dpg.create_viewport(title='YURS Telemetry', width=1200, height=800) -dpg.setup_dearpygui() -dpg.show_viewport() -dpg.set_primary_window("Primary Window", True) - -# Manual Render Loop (Crucial for live telemetry) -while dpg.is_dearpygui_running(): - update_telemetry_ui - dpg.render_dearpygui_frame() - -dpg.destroy_context() \ No newline at end of file + def process_can_data(self, arb_id, info, data): + dev_id = info['dev_id'] + idx = info['index'] + cls = info['class'] + + # 1. Update Motor Status (Heartbeat) + if cls == 63: #heartbeat + if dev_id in self.motor_buttons: + self.motor_buttons[dev_id].setStyleSheet(self.get_style("green")) + + # 2. Update Graphs + if cls == 1: + if idx == 3 and dev_id == 6 and len(data) >= 1: # Temperature + # If data is a float, use: struct.unpack('=1: #current Voltage + vol=data[0] + amp=data[1] + self.series_current.append(self.data_count, vol) + self.series_voltage.append(self.data_count, amp) + + #Auto-scroll & Increment X-Axis + self.data_count += 1 + if self.data_count > 100: + new_min, new_max = self.data_count - 100, self.data_count + self.chart_temp_view.chart().axisX().setRange(new_min, new_max) + self.chart_currVol_view.chart().axisX().setRange(new_min, new_max) + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = RoverDash() + window.show() + sys.exit(app.exec()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6f07918 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +packaging==26.0 +PySide6==6.11.0 +PySide6_Addons==6.11.0 +PySide6_Essentials==6.11.0 +python-can==4.6.1 +shiboken6==6.11.0