diff --git a/src/dataflux/callbacks/menu.py b/src/dataflux/callbacks/menu.py index b0d3aa6..42d40e9 100644 --- a/src/dataflux/callbacks/menu.py +++ b/src/dataflux/callbacks/menu.py @@ -2,6 +2,7 @@ # Copyright (C) 2026 Association Exergie # SPDX-License-Identifier: GPL-3.0-or-later +from ast import arg from threading import Thread import dearpygui.dearpygui as dpg from dataflux.state import AppState @@ -16,6 +17,7 @@ from dataflux.tags import ( GRAPH_X_AXIS_TENG, GRAPH_X_AXIS_VBAT, WINDOW_FILE_DIALOG_AUTOSAVE_BUFFERS, + WINDOW_FILE_DIALOG_LOAD_LAP, WINDOW_LORA_CONNECTION_MENU, WINDOW_FILE_DIALOG_DUMP_BUFFERS, WINDOW_SERIAL_CONNECTION_MENU, @@ -28,11 +30,8 @@ def open_lora_connection_window(sender, app_data, user_data: AppState) -> None: def open_serial_connection_window(sender, app_data, user_data: AppState) -> None: - print("Handling serial window open callback") dataflux.ui.routines.windows.update_window_serial_connection_menu_combo(user_data) - print("Combo updated") dpg.show_item(WINDOW_SERIAL_CONNECTION_MENU) - print("Window shown") def menu_io_disconnect_lora(sender, app_data, user_data: AppState) -> None: @@ -49,6 +48,10 @@ def menu_file_dump_buffers(sender, app_data, user_data: AppState) -> None: dpg.show_item(WINDOW_FILE_DIALOG_DUMP_BUFFERS) +def menu_file_load_lap(sender, app_data, user_data: AppState) -> None: + dpg.show_item(WINDOW_FILE_DIALOG_LOAD_LAP) + + def menu_file_quit(sender, app_data, user_data) -> None: dpg.stop_dearpygui() @@ -82,6 +85,15 @@ def window_file_dialog_autosave_buffers_ok( user_data.autosave_buffer_thread.start() +def window_file_dialog_load_lap_ok(sender, app_data, user_data: AppState) -> None: + user_data.lap_loader_thread = Thread( + target=dataflux.services.telemetry.lap_load_worker, + args=(user_data, app_data["file_path_name"]), + daemon=True, + ) + user_data.lap_loader_thread.start() + + def menu_window_select(sender, app_data, user_data: str) -> None: dataflux.ui.routines.windows.toggle_window(user_data) diff --git a/src/dataflux/services/serial/__init__.py b/src/dataflux/services/serial/__init__.py index 84e752d..02ff347 100644 --- a/src/dataflux/services/serial/__init__.py +++ b/src/dataflux/services/serial/__init__.py @@ -72,14 +72,20 @@ def connect_serial(state: AppState, device: str) -> None: def disconnect_lora(state: AppState) -> None: if state.lora_port is not None: state.lora_thread_running = False - state.lora_port.close() + try: + state.lora_port.close() + except OSError: + pass state.lora_port = None def disconnect_serial(state: AppState) -> None: if state.serial_port is not None: state.serial_thread_running = False - state.serial_port.close() + try: + state.serial_port.close() + except OSError: + pass state.serial_port = None @@ -105,7 +111,14 @@ def serial_worker(state: AppState) -> None: if port.port is not None and not os.path.exists(port.port): break - line = port.readline() + try: + line = port.readline() + except TypeError: + break + except serial.SerialException: + break + except OSError: + break if line: text = line.decode("utf-8", errors="replace") @@ -155,8 +168,7 @@ def lora_reader_worker(state: AppState) -> None: state.packet_queue.put(parsed) state.lora_status_queue.put(0.1) - except Exception as e: - print(f"Serial parser error: {e}") + except Exception: break disconnect_lora(state) dataflux.ui.routines.update_global_connection_status(state) diff --git a/src/dataflux/services/telemetry/__init__.py b/src/dataflux/services/telemetry/__init__.py index 6bbbacd..85df113 100644 --- a/src/dataflux/services/telemetry/__init__.py +++ b/src/dataflux/services/telemetry/__init__.py @@ -184,6 +184,40 @@ def autosave_worker(state: AppState, path: str) -> None: time.sleep(30) +def lap_load_worker(state: AppState, path: str) -> None: + state.lap_recap_buffers.timestamp.clear() + state.lap_recap_buffers.speed.clear() + state.lap_recap_buffers.vbat.clear() + state.lap_recap_buffers.teng.clear() + state.lap_recap_buffers.lat.clear() + state.lap_recap_buffers.lng.clear() + + load_path = Path(path) + + try: + with load_path.open("r", newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + + for row in reader: + state.lap_recap_buffers.timestamp.append(int(row["timestamp"])) + state.lap_recap_buffers.speed.append(float(row["speed"])) + state.lap_recap_buffers.vbat.append(float(row["vbat"])) + state.lap_recap_buffers.teng.append(float(row["teng"])) + state.lap_recap_buffers.lat.append(float(row["lat"])) + state.lap_recap_buffers.lng.append(float(row["lng"])) + + except FileNotFoundError: + pass + except KeyError: + pass + except ValueError: + pass + else: + state.lap_recap_updated = True + + state.lap_loader_thread = None + + def closest_idx(values: list[int], target: int) -> int: if not values: raise ValueError("Cannot find closest index in an empty list") diff --git a/src/dataflux/state.py b/src/dataflux/state.py index fb0773d..df87d4b 100644 --- a/src/dataflux/state.py +++ b/src/dataflux/state.py @@ -68,6 +68,9 @@ class AppState: live_buffers_updated: bool = False live_buffer_len: int = 30 + lap_recap_buffers: Buffers = field(default_factory=Buffers) + lap_recap_updated: bool = False + lap_lock: Lock = field(default_factory=Lock) new_laps: Queue = field(default_factory=Queue) laps: list[LapInfo] = field(default_factory=list) @@ -77,4 +80,6 @@ class AppState: autosave_enabled: bool = False autosave_path: Path | None = None + lap_loader_thread: Thread | None = None + lock: Lock = field(default_factory=Lock) diff --git a/src/dataflux/tags.py b/src/dataflux/tags.py index fd1c90b..f20afc5 100644 --- a/src/dataflux/tags.py +++ b/src/dataflux/tags.py @@ -8,12 +8,14 @@ MENU_IO_DISCONNECT_LORA: str = "menu_io_disconnect_lora" MENU_IO_DISCONNECT_SERIAL: str = "menu_io_disconnect_serial" MENU_FILE_DUMP_BUFFERS: str = "menu_file_dump_buffers" MENU_FILE_AUTOSAVE_BUFFERS: str = "menu_file_autosave_buffers" +MENU_FILE_LOAD_LAP: str = "menu_file_load_lap" WINDOW_LORA_CONNECTION_MENU: str = "window_lora_connection_menu" WINDOW_SERIAL_CONNECTION_MENU: str = "window_serial_connection_menu" WINDOW_LORA_CONNECTION_MENU_COMBO: str = "window_lora_connection_menu_combo" WINDOW_SERIAL_CONNECTION_MENU_COMBO: str = "window_serial_connection_menu_combo" WINDOW_FILE_DIALOG_DUMP_BUFFERS: str = "window_file_dialog_dump_buffers" WINDOW_FILE_DIALOG_AUTOSAVE_BUFFERS: str = "window_file_dialog_autosave_buffers" +WINDOW_FILE_DIALOG_LOAD_LAP: str = "window_file_dialog_load_lap" CHILD_WINDOW_SERIAL_CONSOLE: str = "child_window_serial_console" @@ -49,10 +51,22 @@ GRAPH_X_AXIS_SPEED: str = "graph_x_axis_speed" GRAPH_Y_AXIS_SPEED: str = "graph_y_axis_speed" GRAPH_SERIES_SPEED: str = "graph_series_speed" +GRAPH_X_AXIS_SPEED_LR: str = "graph_x_axis_speed_lr" +GRAPH_Y_AXIS_SPEED_LR: str = "graph_y_axis_speed_lr" +GRAPH_SERIES_SPEED_LR: str = "graph_series_speed_lr" + GRAPH_X_AXIS_VBAT: str = "graph_x_axis_vbat" GRAPH_Y_AXIS_VBAT: str = "graph_y_axis_vbat" GRAPH_SERIES_VBAT: str = "graph_series_vbat" +GRAPH_X_AXIS_VBAT_LR: str = "graph_x_axis_vbat_lr" +GRAPH_Y_AXIS_VBAT_LR: str = "graph_y_axis_vbat_lr" +GRAPH_SERIES_VBAT_LR: str = "graph_series_vbat_lr" + GRAPH_X_AXIS_TENG: str = "graph_x_axis_teng" GRAPH_Y_AXIS_TENG: str = "graph_y_axis_teng" GRAPH_SERIES_TENG: str = "graph_series_teng" + +GRAPH_X_AXIS_TENG_LR: str = "graph_x_axis_teng_lr" +GRAPH_Y_AXIS_TENG_LR: str = "graph_y_axis_teng_lr" +GRAPH_SERIES_TENG_LR: str = "graph_series_teng_lr" diff --git a/src/dataflux/ui/routines/windows.py b/src/dataflux/ui/routines/windows.py index 2aeace8..87e405a 100644 --- a/src/dataflux/ui/routines/windows.py +++ b/src/dataflux/ui/routines/windows.py @@ -24,6 +24,7 @@ def update_window_lora_connection_menu_combo(state: AppState) -> None: if port_name in ports: ports.remove(port_name) dpg.configure_item(WINDOW_LORA_CONNECTION_MENU_COMBO, items=ports) + dpg.set_value(WINDOW_LORA_CONNECTION_MENU_COMBO, "") def update_window_serial_connection_menu_combo(state: AppState) -> None: @@ -34,6 +35,7 @@ def update_window_serial_connection_menu_combo(state: AppState) -> None: if port_name in ports: ports.remove(port_name) dpg.configure_item(WINDOW_SERIAL_CONNECTION_MENU_COMBO, items=ports) + dpg.set_value(WINDOW_SERIAL_CONNECTION_MENU_COMBO, "") def hide_all_but(tag: str) -> None: diff --git a/src/dataflux/ui/windows.py b/src/dataflux/ui/windows.py index 1de48f7..33e9d53 100644 --- a/src/dataflux/ui/windows.py +++ b/src/dataflux/ui/windows.py @@ -2,6 +2,7 @@ # Copyright (C) 2026 Association Exergie # SPDX-License-Identifier: GPL-3.0-or-later +from operator import call import dearpygui.dearpygui as dpg import dataflux.callbacks.menu import dataflux.callbacks.serial @@ -11,14 +12,23 @@ from dataflux.tags import ( BUTTON_SERIAL_CONSOLE_SEND, CHILD_WINDOW_SERIAL_CONSOLE, GRAPH_SERIES_SPEED, + GRAPH_SERIES_SPEED_LR, GRAPH_SERIES_TENG, + GRAPH_SERIES_TENG_LR, GRAPH_SERIES_VBAT, + GRAPH_SERIES_VBAT_LR, GRAPH_X_AXIS_SPEED, + GRAPH_X_AXIS_SPEED_LR, GRAPH_X_AXIS_TENG, + GRAPH_X_AXIS_TENG_LR, GRAPH_X_AXIS_VBAT, + GRAPH_X_AXIS_VBAT_LR, GRAPH_Y_AXIS_SPEED, + GRAPH_Y_AXIS_SPEED_LR, GRAPH_Y_AXIS_TENG, + GRAPH_Y_AXIS_TENG_LR, GRAPH_Y_AXIS_VBAT, + GRAPH_Y_AXIS_VBAT_LR, INPUT_SERIAL_CONSOLE, LIVE_DATA_TENG_VALUE, LIVE_DATA_UTC_TIME_VALUE, @@ -26,6 +36,7 @@ from dataflux.tags import ( LIVE_DATA_VEHICLE_TIME_VALUE, LIVE_DATA_SPEED_VALUE, MENU_FILE_AUTOSAVE_BUFFERS, + MENU_FILE_LOAD_LAP, MENU_IO_CONNECT_LORA, MENU_IO_DISCONNECT_LORA, MENU_FILE_DUMP_BUFFERS, @@ -45,6 +56,7 @@ from dataflux.tags import ( THEME_STATUS_CONNECTED_BRIGHT, THEME_STATUS_DISCONNECTED, WINDOW_FILE_DIALOG_AUTOSAVE_BUFFERS, + WINDOW_FILE_DIALOG_LOAD_LAP, WINDOW_LORA_CONNECTION_MENU, WINDOW_LORA_CONNECTION_MENU_COMBO, WINDOW_FILE_DIALOG_DUMP_BUFFERS, @@ -85,6 +97,12 @@ def build_windows(state: AppState) -> None: callback=dataflux.callbacks.menu.menu_file_autosave_buffers, user_data=state, ) + dpg.add_menu_item( + label="Load Lap", + enabled=True, + tag=MENU_FILE_LOAD_LAP, + callback=dataflux.callbacks.menu.menu_file_load_lap, + ) dpg.add_menu_item( label="Quit", callback=dataflux.callbacks.menu.menu_file_quit ) @@ -300,6 +318,53 @@ def build_windows(state: AppState) -> None: dpg.add_text("Lap Recap") dpg.add_separator() + with dpg.plot(label="Speed", height=250, width=-1, no_inputs=True): + dpg.add_plot_legend() + dpg.add_plot_axis( + dpg.mvXAxis, label="Time", tag=GRAPH_X_AXIS_SPEED_LR + ) + y_axis_speed = dpg.add_plot_axis( + dpg.mvYAxis, label="Speed", tag=GRAPH_Y_AXIS_SPEED_LR + ) + dpg.set_axis_limits(GRAPH_Y_AXIS_SPEED_LR, ymin=0, ymax=50) + dpg.set_axis_limits(GRAPH_X_AXIS_SPEED_LR, ymin=-30, ymax=0) + dpg.add_line_series( + [], [], parent=y_axis_speed, tag=GRAPH_SERIES_SPEED_LR + ) + with dpg.plot( + label="Battery Voltage", + height=250, + width=-1, + no_inputs=True, + ): + dpg.add_plot_legend() + dpg.add_plot_axis( + dpg.mvXAxis, label="Time", tag=GRAPH_X_AXIS_VBAT_LR + ) + y_axis_vbat = dpg.add_plot_axis( + dpg.mvYAxis, label="Voltage", tag=GRAPH_Y_AXIS_VBAT_LR + ) + dpg.set_axis_limits(GRAPH_Y_AXIS_VBAT_LR, ymin=0, ymax=20) + dpg.set_axis_limits(GRAPH_X_AXIS_VBAT_LR, ymin=-30, ymax=0) + dpg.add_line_series( + [], [], parent=y_axis_vbat, tag=GRAPH_SERIES_VBAT_LR + ) + with dpg.plot( + label="Engine Temp", height=250, width=-1, no_inputs=True + ): + dpg.add_plot_legend() + dpg.add_plot_axis( + dpg.mvXAxis, label="Time", tag=GRAPH_X_AXIS_TENG_LR + ) + y_axis_teng = dpg.add_plot_axis( + dpg.mvYAxis, label="Temperature", tag=GRAPH_Y_AXIS_TENG_LR + ) + dpg.set_axis_limits(GRAPH_Y_AXIS_TENG_LR, ymin=0, ymax=120) + dpg.set_axis_limits(GRAPH_X_AXIS_TENG_LR, ymin=-30, ymax=0) + dpg.add_line_series( + [], [], parent=y_axis_teng, tag=GRAPH_SERIES_TENG_LR + ) + with dpg.group(tag=PAGE_SERIAL_CONSOLE, show=False): with dpg.child_window( tag=CHILD_WINDOW_SERIAL_CONSOLE, @@ -452,3 +517,15 @@ def build_windows(state: AppState) -> None: user_data=state, ): pass + + with dpg.file_dialog( + directory_selector=False, + show=False, + tag=WINDOW_FILE_DIALOG_LOAD_LAP, + width=700, + height=400, + modal=True, + callback=dataflux.callbacks.menu.window_file_dialog_load_lap_ok, + user_data=state, + ): + dpg.add_file_extension(".csv") diff --git a/src/dataflux/ui/worker.py b/src/dataflux/ui/worker.py index 2d82cb1..940bcf3 100644 --- a/src/dataflux/ui/worker.py +++ b/src/dataflux/ui/worker.py @@ -12,13 +12,20 @@ import serial.tools.list_ports from dataflux.state import AppState from dataflux.tags import ( GRAPH_SERIES_SPEED, + GRAPH_SERIES_SPEED_LR, GRAPH_SERIES_TENG, + GRAPH_SERIES_TENG_LR, GRAPH_SERIES_VBAT, + GRAPH_SERIES_VBAT_LR, + GRAPH_X_AXIS_SPEED_LR, + GRAPH_X_AXIS_TENG_LR, + GRAPH_X_AXIS_VBAT_LR, LIVE_DATA_TENG_VALUE, LIVE_DATA_UTC_TIME_VALUE, LIVE_DATA_VBAT_VALUE, LIVE_DATA_VEHICLE_TIME_VALUE, LIVE_DATA_SPEED_VALUE, + MENU_FILE_AUTOSAVE_BUFFERS, ) from dataflux.ui.routines.serial import append_text_to_console @@ -37,10 +44,11 @@ def ui_worker(state: AppState): last_teng: str = "" no_data_written = False while state.running: - # if state.autosave_enabled: - # dpg.set_value(MENU_FILE_AUTOSAVE_BUFFERS, True) - # else: - # dpg.set_value(MENU_FILE_AUTOSAVE_BUFFERS, False) + if state.autosave_enabled: + dpg.set_value(MENU_FILE_AUTOSAVE_BUFFERS, True) + else: + dpg.set_value(MENU_FILE_AUTOSAVE_BUFFERS, False) + now = datetime.now(timezone.utc) formatted = now.strftime("%H:%M:%S") @@ -56,6 +64,33 @@ def ui_worker(state: AppState): else: append_text_to_console(text) + if state.lap_recap_updated: + state.lap_recap_updated = False + timestamps = state.lap_recap_buffers.timestamp + + if timestamps: + t0 = timestamps[0] + x_common = [(t - t0) / 100.0 for t in timestamps] + else: + x_common = [] + dpg.set_value( + GRAPH_SERIES_SPEED_LR, + [x_common, state.lap_recap_buffers.speed], + ) + dpg.set_value( + GRAPH_SERIES_VBAT_LR, + [x_common, state.lap_recap_buffers.vbat], + ) + dpg.set_value( + GRAPH_SERIES_TENG_LR, + [x_common, state.lap_recap_buffers.teng], + ) + axis_min = x_common[0] + axis_max = x_common[-1] + dpg.set_axis_limits(GRAPH_X_AXIS_SPEED_LR, ymin=axis_min, ymax=axis_max) + dpg.set_axis_limits(GRAPH_X_AXIS_VBAT_LR, ymin=axis_min, ymax=axis_max) + dpg.set_axis_limits(GRAPH_X_AXIS_TENG_LR, ymin=axis_min, ymax=axis_max) + if state.lora_thread_running and state.telemetry_valid: x_common: list[float] | None = None speed_y: list[float] | None = None