Compare commits

...

15 Commits

30 changed files with 1599 additions and 376 deletions

3
.gitignore vendored
View File

@@ -8,3 +8,6 @@ wheels/
# Virtual environments # Virtual environments
.venv .venv
# Build generated files
*.spec

8
Makefile Normal file
View File

@@ -0,0 +1,8 @@
APP_NAME = DataFlux
ENTRY = main.py
build:
uv run pyinstaller --onedir --windowed --name $(APP_NAME) --paths src --add-data "assets/fonts:assets/fonts" --add-data "assets/images:assets/images" $(ENTRY)
clean:
rm -rf build dist *.spec

Binary file not shown.

BIN
assets/images/map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 MiB

139
main2.py
View File

@@ -1,139 +0,0 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
import dearpygui.dearpygui as dpg
import time
import threading
from serial import Serial
import serial.tools.list_ports
running = True
serial_connected = False
def quit_app(sender, app_data, user_data):
global running
running = False
dpg.destroy_context()
def serial_worker():
global serial_connected
global serial_port
global running
while running and serial_connected:
if not serial_port.is_open:
serial_connected = False
print(serial_port.read())
serial_connected = False
serial_port.close()
dpg.enable_item("menu_file_connect")
dpg.disable_item("menu_file_disconnect")
serial_port: Serial | None = None
serial_thread: threading.Thread | None = None
def telemetry_worker():
speed_data_x = [-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,0]
speed_data_y = [5,6,7,8,9,10,9,8,7,6,5]
while running:
time.sleep(0.1)
y_val = speed_data_y.pop(0)
speed_data_y.append(y_val)
dpg.set_value("speed_series", [speed_data_x, speed_data_y])
dpg.set_axis_limits("x_axis_speed", speed_data_x[0], speed_data_x[-1])
def show_page(sender, app_data, user_data):
pages = ["page_live_data", "page_lap_recap"]
for page in pages:
dpg.hide_item(page)
dpg.show_item(user_data)
def show_connection_menu(sender, app_data, user_data):
ports = serial.tools.list_ports.comports()
port_names = []
for port in ports:
if port.vid is not None and port.pid is not None:
port_names.append(port.device)
dpg.configure_item("serial_combo", items=port_names)
dpg.show_item("connection_menu")
def disconnect_serial(sender, app_data, user_data):
global serial_connected
serial_connected = False
def connect_to_serial(sender, app_data, user_data):
global serial_port
global serial_connected
global serial_thread
port = dpg.get_value("serial_combo")
print("Connecting to " + port)
serial_port = Serial(port=port, baudrate=115200)
serial_connected = True
dpg.enable_item("menu_file_disconnect")
dpg.disable_item("menu_file_connect")
serial_thread = threading.Thread(target=serial_worker, daemon=True)
serial_thread.start()
# dpg.create_context()
# dpg.create_viewport(title='DataFlux', width=600, height=600)
#
# with dpg.font_registry():
# app_font = dpg.add_font("./Inter-Regular.ttf", 18)
#
# dpg.bind_font(app_font)
# with dpg.window(label='DataFlux',tag="main_window", no_collapse=True):
# with dpg.menu_bar():
# with dpg.menu(label='File'):
# dpg.add_menu_item(label="Connect", callback=show_connection_menu, enabled=True, tag="menu_file_connect")
# dpg.add_menu_item(label="Disonnect", callback=disconnect_serial, enabled=False, tag="menu_file_disconnect")
# dpg.add_menu_item(label="Quit", callback=quit_app)
# with dpg.menu(label='Window'):
# dpg.add_menu_item(label="Live Data", callback=show_page, user_data="page_live_data")
# dpg.add_menu_item(label="Lap Recap", callback=show_page, user_data="page_lap_recap")
#
# with dpg.child_window(tag="content_area", autosize_x=True, autosize_y=True, border=False):
# with dpg.group(tag="page_live_data", show=True):
# dpg.add_text("Live Data")
# dpg.add_separator()
# with dpg.group(horizontal=True):
# with dpg.child_window(tag="realtime_stats", width=250, autosize_y=True, border=True):
# dpg.add_text("Speed: 25kmh")
#
# with dpg.child_window(tag="data_graphs", autosize_x=True, autosize_y=True, border=True):
# 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="x_axis_speed")
# y_axis = dpg.add_plot_axis(dpg.mvYAxis, label="Speed", tag="y_axis_speed")
# dpg.set_axis_limits("y_axis_speed", 0, 50)
# dpg.add_line_series([], [], parent=y_axis, tag="speed_series")
#
# with dpg.group(tag="page_lap_recap", show=False):
# dpg.add_text("Lap Recap")
# dpg.add_separator()
#
# with dpg.window(label="Connection Menu", tag="connection_menu", show=False, modal=True, no_collapse=True, width=300):
# dpg.add_combo([], tag="serial_combo")
# dpg.add_button(label="Connect", callback=connect_to_serial)
worker = threading.Thread(target=telemetry_worker, daemon=True)
worker.start()
# dpg.setup_dearpygui()
# dpg.show_viewport()
#
#
# vp_w = dpg.get_viewport_client_width()
# vp_h = dpg.get_viewport_client_height()
# dpg.configure_item("main_window", pos=(0, 0), width=vp_w, height=vp_h)
# dpg.set_primary_window("main_window", True)
#
# dpg.start_dearpygui()
#
# running = False
# dpg.destroy_context()

140
main3.py
View File

@@ -1,140 +0,0 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
import dearpygui.dearpygui as dpg
import time
import threading
from serial import Serial
import serial.tools.list_ports
running = True
serial_connected = False
def quit_app(sender, app_data, user_data):
global running
running = False
dpg.destroy_context()
def serial_worker():
global serial_connected
global serial_port
global running
while running and serial_connected:
if not serial_port.is_open:
serial_connected = False
print(serial_port.read())
serial_connected = False
serial_port.close()
dpg.enable_item("menu_file_connect")
dpg.disable_item("menu_file_disconnect")
serial_port: Serial | None = None
serial_thread: threading.Thread | None = None
def telemetry_worker():
speed_data_x = [-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,0]
speed_data_y = [5,6,7,8,9,10,9,8,7,6,5]
while running:
time.sleep(0.1)
y_val = speed_data_y.pop(0)
speed_data_y.append(y_val)
dpg.set_value("speed_series", [speed_data_x, speed_data_y])
dpg.set_axis_limits("x_axis_speed", speed_data_x[0], speed_data_x[-1])
def show_page(sender, app_data, user_data):
pages = ["page_live_data", "page_lap_recap"]
for page in pages:
dpg.hide_item(page)
dpg.show_item(user_data)
def show_connection_menu(sender, app_data, user_data):
ports = serial.tools.list_ports.comports()
port_names = []
for port in ports:
if port.vid is not None and port.pid is not None:
port_names.append(port.device)
dpg.configure_item("serial_combo", items=port_names)
dpg.show_item("connection_menu")
def disconnect_serial(sender, app_data, user_data):
global serial_connected
serial_connected = False
def connect_to_serial(sender, app_data, user_data):
global serial_port
global serial_connected
global serial_thread
port = dpg.get_value("serial_combo")
print("Connecting to " + port)
serial_port = Serial(port=port, baudrate=115200)
serial_connected = True
dpg.enable_item("menu_file_disconnect")
dpg.disable_item("menu_file_connect")
serial_thread = threading.Thread(target=serial_worker, daemon=True)
serial_thread.start()
dpg.create_context()
dpg.create_viewport(title='DataFlux', width=600, height=600)
with dpg.font_registry():
app_font = dpg.add_font("./Inter-Regular.ttf", 18)
dpg.bind_font(app_font)
with dpg.window(label='DataFlux',tag="main_window", no_collapse=True):
with dpg.menu_bar():
with dpg.menu(label='File'):
dpg.add_menu_item(label="Connect", callback=show_connection_menu, enabled=True, tag="menu_file_connect")
dpg.add_menu_item(label="Disonnect", callback=disconnect_serial, enabled=False, tag="menu_file_disconnect")
dpg.add_menu_item(label="Quit", callback=quit_app)
with dpg.menu(label='Window'):
dpg.add_menu_item(label="Live Data", callback=show_page, user_data="page_live_data")
dpg.add_menu_item(label="Lap Recap", callback=show_page, user_data="page_lap_recap")
with dpg.child_window(tag="content_area", autosize_x=True, autosize_y=True, border=False):
with dpg.group(tag="page_live_data", show=True):
dpg.add_text("Live Data")
dpg.add_separator()
with dpg.group(horizontal=True):
with dpg.child_window(tag="realtime_stats", width=250, autosize_y=True, border=True):
dpg.add_text("Speed: 25kmh")
with dpg.child_window(tag="data_graphs", autosize_x=True, autosize_y=True, border=True):
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="x_axis_speed")
y_axis = dpg.add_plot_axis(dpg.mvYAxis, label="Speed", tag="y_axis_speed")
dpg.set_axis_limits("y_axis_speed", 0, 50)
dpg.add_line_series([], [], parent=y_axis, tag="speed_series")
with dpg.group(tag="page_lap_recap", show=False):
dpg.add_text("Lap Recap")
dpg.add_separator()
with dpg.window(label="Connection Menu", tag="connection_menu", show=False, modal=True, no_collapse=True, width=300):
dpg.add_combo([], tag="serial_combo")
dpg.add_button(label="Connect", callback=connect_to_serial)
worker = threading.Thread(target=telemetry_worker, daemon=True)
worker.start()
dpg.setup_dearpygui()
dpg.show_viewport()
vp_w = dpg.get_viewport_client_width()
vp_h = dpg.get_viewport_client_height()
dpg.configure_item("main_window", pos=(0, 0), width=vp_w, height=vp_h)
dpg.set_primary_window("main_window", True)
dpg.start_dearpygui()
running = False
dpg.destroy_context()

View File

@@ -6,6 +6,7 @@ readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"dearpygui>=2.2", "dearpygui>=2.2",
"pyinstaller>=6.20.0",
"pyserial>=3.5", "pyserial>=3.5",
] ]

Binary file not shown.

View File

@@ -0,0 +1,3 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later

View File

@@ -2,21 +2,73 @@
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com> # Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime
from pathlib import Path
import sys
from threading import Thread
import dearpygui.dearpygui as dpg import dearpygui.dearpygui as dpg
from dataflux.state import AppState from dataflux.state import AppState
import dataflux.config
from dataflux.tags import TEXT_SERIAL_CONSOLE
import dataflux.ui.windows import dataflux.ui.windows
import dataflux.ui.worker
import dataflux.services.telemetry
def _asset_path(relative_path: str) -> str:
path = Path(relative_path)
candidates: list[Path] = []
bundle_dir = getattr(sys, "_MEIPASS", None)
if bundle_dir is not None:
candidates.append(Path(bundle_dir) / path)
candidates.extend(
(
Path.cwd() / path,
Path(__file__).resolve().parents[2] / path,
)
)
for candidate in candidates:
if candidate.exists():
return str(candidate)
searched = ", ".join(str(candidate) for candidate in candidates)
raise FileNotFoundError(f"Missing asset {relative_path!r}. Searched: {searched}")
def run() -> None: def run() -> None:
state: AppState = AppState() state: AppState = AppState()
state.start_time = datetime.now()
# Create application context and viewport # Create application context and viewport
dpg.create_context() dpg.create_context()
dpg.create_viewport(title='DataFlux', width=600, height=600)
map_path = _asset_path("assets/images/map.png")
map_image = dpg.load_image(map_path)
if map_image is None:
raise RuntimeError(f"Failed to load map image: {map_path}")
width, height, channels, data = map_image
dataflux.config.MAP_IMAGE_WIDTH = width
dataflux.config.MAP_IMAGE_HEIGHT = height
with dpg.texture_registry(show=False):
dpg.add_static_texture(
width=width, height=height, default_value=data, tag="texture_tab"
)
dpg.create_viewport(title="DataFlux", width=600, height=600)
# Add Inter font to registry and bind as main app font # Add Inter font to registry and bind as main app font
with dpg.font_registry(): with dpg.font_registry():
app_font = dpg.add_font("./Inter-Regular.ttf", 18) app_font = dpg.add_font(_asset_path("assets/fonts/Inter-Regular.ttf"), 18 * 2)
mono_font = dpg.add_font(
_asset_path("assets/fonts/JetBrainsMono-Regular.ttf"),
size=36,
label="mono_font",
)
dpg.bind_font(app_font) dpg.bind_font(app_font)
dataflux.ui.windows.build_windows(state) dataflux.ui.windows.build_windows(state)
@@ -24,17 +76,29 @@ def run() -> None:
dpg.setup_dearpygui() dpg.setup_dearpygui()
dpg.show_viewport() dpg.show_viewport()
vp_w = dpg.get_viewport_client_width() vp_w = dpg.get_viewport_client_width()
vp_h = dpg.get_viewport_client_height() vp_h = dpg.get_viewport_client_height()
dpg.configure_item("main_window", pos=(0, 0), width=vp_w, height=vp_h) dpg.configure_item("main_window", pos=(0, 0), width=vp_w, height=vp_h)
dpg.set_primary_window("main_window", True) dpg.set_primary_window("main_window", True)
dpg.bind_item_font(TEXT_SERIAL_CONSOLE, mono_font)
state.ui_worker_thread = Thread(
target=dataflux.ui.worker.ui_worker, args=(state,), daemon=True
)
state.ui_worker_thread.start()
state.telemetry_thread_running = True
state.telemetry_thread = Thread(
target=dataflux.services.telemetry.telemetry_worker, args=(state,), daemon=True
)
state.telemetry_thread.start()
state.ports_thread_running = True
state.ports_thread = Thread(
target=dataflux.ui.worker.ports_worker, args=(state,), daemon=True
)
state.ports_thread.start()
dpg.start_dearpygui() dpg.start_dearpygui()
dpg.destroy_context() dpg.destroy_context()

View File

@@ -0,0 +1,5 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later

View File

@@ -1,19 +1,106 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev> # Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com> # Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from ast import arg
from threading import Thread
import dearpygui.dearpygui as dpg import dearpygui.dearpygui as dpg
from dataflux.state import AppState
from dataflux.ui.routines import update_global_connection_status from dataflux.ui.routines import update_global_connection_status
import dataflux.ui.routines.windows import dataflux.ui.routines.windows
import dataflux.ui.routines.status import dataflux.ui.routines.status
import dataflux.services.serial import dataflux.services.serial
import dataflux.services.telemetry
from dataflux.tags import WINDOW_CONNECTION_MENU from dataflux.tags import (
GRAPH_X_AXIS_SPEED,
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,
)
def open_connection_window(sender, app_data, user_data) -> None:
dataflux.ui.routines.windows.update_window_connection_menu_combo()
dpg.show_item(WINDOW_CONNECTION_MENU)
def menu_file_disconnect(sender, app_data, user_data) -> None: def open_lora_connection_window(sender, app_data, user_data: AppState) -> None:
dataflux.ui.routines.windows.update_window_lora_connection_menu_combo(user_data)
dpg.show_item(WINDOW_LORA_CONNECTION_MENU)
def open_serial_connection_window(sender, app_data, user_data: AppState) -> None:
dataflux.ui.routines.windows.update_window_serial_connection_menu_combo(user_data)
dpg.show_item(WINDOW_SERIAL_CONNECTION_MENU)
def menu_io_disconnect_lora(sender, app_data, user_data: AppState) -> None:
dataflux.services.serial.disconnect_lora(user_data)
update_global_connection_status(user_data)
def menu_io_disconnect_serial(sender, app_data, user_data: AppState) -> None:
dataflux.services.serial.disconnect_serial(user_data) dataflux.services.serial.disconnect_serial(user_data)
update_global_connection_status(user_data) update_global_connection_status(user_data)
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()
def window_file_dialog_dump_buffers_ok(sender, app_data, user_data: AppState) -> None:
user_data.buffer_dump_thread = Thread(
target=dataflux.services.telemetry.buffer_dump,
args=(user_data, app_data["file_path_name"]),
daemon=True,
)
user_data.buffer_dump_thread.start()
def menu_file_autosave_buffers(sender, app_state, user_data: AppState) -> None:
if user_data.autosave_enabled:
user_data.autosave_enabled = False
user_data.autosave_buffer_thread = None
else:
dpg.show_item(WINDOW_FILE_DIALOG_AUTOSAVE_BUFFERS)
def window_file_dialog_autosave_buffers_ok(
sender, app_data, user_data: AppState
) -> None:
user_data.autosave_enabled = True
user_data.autosave_buffer_thread = Thread(
target=dataflux.services.telemetry.autosave_worker,
args=(user_data, app_data["file_path_name"]),
daemon=True,
)
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)
def menu_data_timeframe(sender, app_data, user_data: tuple[AppState, int]) -> None:
app_state, timeframe = user_data
app_state.live_buffer_len = timeframe
dpg.set_axis_limits(GRAPH_X_AXIS_SPEED, ymin=-(timeframe), ymax=0)
dpg.set_axis_limits(GRAPH_X_AXIS_VBAT, ymin=-(timeframe), ymax=0)
dpg.set_axis_limits(GRAPH_X_AXIS_TENG, ymin=-(timeframe), ymax=0)

View File

@@ -7,12 +7,31 @@ import dataflux.services.serial
import dataflux.ui.routines import dataflux.ui.routines
from dataflux.state import AppState from dataflux.state import AppState
from dataflux.tags import WINDOW_CONNECTION_MENU, WINDOW_CONNECTION_MENU_COMBO from dataflux.tags import (
INPUT_SERIAL_CONSOLE,
WINDOW_LORA_CONNECTION_MENU,
WINDOW_LORA_CONNECTION_MENU_COMBO,
WINDOW_SERIAL_CONNECTION_MENU,
WINDOW_SERIAL_CONNECTION_MENU_COMBO,
)
def connection_window_connect_lora(sender, app_data, user_data: AppState) -> None:
device = dpg.get_value(WINDOW_LORA_CONNECTION_MENU_COMBO)
dataflux.services.serial.connect_lora(user_data, device)
dataflux.ui.routines.update_global_connection_status(user_data)
dpg.hide_item(WINDOW_LORA_CONNECTION_MENU)
def connection_window_connect_serial(sender, app_data, user_data: AppState) -> None: def connection_window_connect_serial(sender, app_data, user_data: AppState) -> None:
device = dpg.get_value(WINDOW_CONNECTION_MENU_COMBO) device = dpg.get_value(WINDOW_SERIAL_CONNECTION_MENU_COMBO)
dataflux.services.serial.connect_serial(user_data, device) dataflux.services.serial.connect_serial(user_data, device)
dataflux.ui.routines.update_global_connection_status(user_data) dataflux.ui.routines.update_global_connection_status(user_data)
dpg.hide_item(WINDOW_CONNECTION_MENU) dpg.hide_item(WINDOW_SERIAL_CONNECTION_MENU)
def serial_console_button_send(sender, app_data, user_data: AppState) -> None:
text = dpg.get_value(INPUT_SERIAL_CONSOLE)
dpg.set_value(INPUT_SERIAL_CONSOLE, "")
user_data.serial_send_queue.put(text)
print("Put into send queue: " + text)

View File

@@ -0,0 +1,6 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
MAP_IMAGE_WIDTH: int = 1
MAP_IMAGE_HEIGHT: int = 1

View File

@@ -0,0 +1,4 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later

View File

@@ -2,62 +2,161 @@
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com> # Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from concurrent.futures import thread
import os
from queue import Empty from queue import Empty
from sys import base_exec_prefix from sys import base_exec_prefix
from threading import Thread from threading import Thread
import time
from serial import Serial from serial import Serial
import serial.tools.list_ports import serial.tools.list_ports
import dearpygui.dearpygui as dpg
from dataflux import telemetry_common from dataflux import telemetry_common
from dataflux.tags import (
STATUS_LORA_STATUS_BOX,
STATUS_SERIAL_STATUS_BOX,
TEXT_SERIAL_CONSOLE,
)
import dataflux.telemetry_common.telemetry_common import dataflux.telemetry_common.telemetry_common
import dataflux.ui.routines.status import dataflux.ui.routines.status
import dataflux.ui.routines
from dataflux.state import AppState from dataflux.state import AppState
def list_serial_ports() -> list[str]: def list_serial_ports(state: AppState) -> list[str]:
ports = serial.tools.list_ports.comports()
valid_ports: list[str] = [] valid_ports: list[str] = []
for port in ports:
for port in state.ports:
if port.vid is not None and port.pid is not None: if port.vid is not None and port.pid is not None:
valid_ports.append(port.device) valid_ports.append(port.device)
return valid_ports return valid_ports
def connect_lora(state: AppState, device: str) -> None:
if state.lora_port is not None:
state.lora_port.close()
state.lora_port = None
state.lora_port = Serial(port=device, baudrate=115200)
state.lora_thread = Thread(target=lora_reader_worker, args=(state,), daemon=True)
state.lora_status_thread = Thread(
target=lora_status_worker, args=(state,), daemon=True
)
state.lora_thread_running = True
state.lora_status_thread.start()
state.lora_thread.start()
def connect_serial(state: AppState, device: str) -> None: def connect_serial(state: AppState, device: str) -> None:
if state.serial_port is not None: if state.serial_port is not None:
state.serial_port.close() state.serial_port.close()
state.serial_port = None state.serial_port = None
state.serial_port = Serial(port=device, baudrate=115200) state.serial_port = Serial(
state.serial_thread = Thread(target=serial_reader_worker, args=(state,), daemon=True) port=device, baudrate=115200, timeout=0.05, write_timeout=0.1
state.serial_status_thread = Thread(target=serial_status_worker, args=(state,), daemon=True) )
state.serial_thread = Thread(target=serial_worker, args=(state,), daemon=True)
state.serial_status_thread = Thread(
target=serial_status_worker, args=(state,), daemon=True
)
state.serial_thread_running = True state.serial_thread_running = True
state.serial_status_thread.start() state.serial_status_thread.start()
state.serial_thread.start() state.serial_thread.start()
def disconnect_lora(state: AppState) -> None:
if state.lora_port is not None:
state.lora_thread_running = False
try:
state.lora_port.close()
except OSError:
pass
state.lora_port = None
def disconnect_serial(state: AppState) -> None: def disconnect_serial(state: AppState) -> None:
if state.serial_port is not None: if state.serial_port is not None:
state.serial_thread_running = False state.serial_thread_running = False
try:
state.serial_port.close() state.serial_port.close()
except OSError:
pass
state.serial_port = None state.serial_port = None
def lora_status_worker(state: AppState) -> None:
while state.lora_thread_running:
try:
duration = state.lora_status_queue.get_nowait()
except Empty:
continue
dataflux.ui.routines.status.flash_status_connection_status(
duration, STATUS_LORA_STATUS_BOX
)
def serial_worker(state: AppState) -> None:
while state.serial_thread_running:
port = state.serial_port
if port is None:
break
if port.closed:
print("Port closed")
break
if port.port is not None and not os.path.exists(port.port):
break
try:
line = port.readline()
except TypeError:
break
except serial.SerialException:
break
except OSError:
break
if line:
text = line.decode("utf-8", errors="replace")
state.serial_data_queue.put(text)
state.serial_status_queue.put(0.05)
if port.writable():
try:
data: str = state.serial_send_queue.get_nowait()
except Empty:
pass
else:
state.serial_data_queue.put(data + "\n")
state.serial_status_queue.put(0.05)
port.write(data.encode("utf-8"))
disconnect_serial(state)
dataflux.ui.routines.update_global_connection_status(state)
def serial_status_worker(state: AppState) -> None: def serial_status_worker(state: AppState) -> None:
while state.serial_thread_running: while state.serial_thread_running:
try: try:
duration = state.serial_status_queue.get(timeout=0.1) duration = state.serial_status_queue.get_nowait()
except Empty: except Empty:
continue continue
dataflux.ui.routines.status.flash_status_connection_status(duration) dataflux.ui.routines.status.flash_status_connection_status(
duration, STATUS_SERIAL_STATUS_BOX
)
def lora_reader_worker(state: AppState) -> None:
while state.lora_thread_running:
def serial_reader_worker(state: AppState) -> None: port = state.lora_port
while state.serial_thread_running:
port = state.serial_port
if port is None: if port is None:
break break
if port.closed:
print("Port closed")
break
try: try:
packet = read_one_uart_packet(port) packet = read_one_uart_packet(port)
@@ -67,11 +166,13 @@ def serial_reader_worker(state: AppState) -> None:
parsed = parse_uart_packet(packet) parsed = parse_uart_packet(packet)
if parsed is not None: if parsed is not None:
state.packet_queue.put(parsed) state.packet_queue.put(parsed)
state.serial_status_queue.put(0.1) state.lora_status_queue.put(0.1)
print(parsed)
except Exception:
break
disconnect_lora(state)
dataflux.ui.routines.update_global_connection_status(state)
except Exception as e:
print(f"Serial parser error: {e}")
def read_one_uart_packet(port: Serial) -> bytes | None: def read_one_uart_packet(port: Serial) -> bytes | None:
first = port.read(1) first = port.read(1)
@@ -100,15 +201,20 @@ def read_one_uart_packet(port: Serial) -> bytes | None:
return body return body
def parse_uart_packet(body: bytes) -> dict | None: def parse_uart_packet(body: bytes) -> dict | None:
if len(body) < dataflux.telemetry_common.telemetry_common.LORA_HEADER_SIZE: if len(body) < dataflux.telemetry_common.telemetry_common.LORA_HEADER_SIZE:
return None return None
lora = dataflux.telemetry_common.telemetry_common.unpack_lora_header(body[:dataflux.telemetry_common.telemetry_common.LORA_HEADER_SIZE]) lora = dataflux.telemetry_common.telemetry_common.unpack_lora_header(
payload = body[dataflux.telemetry_common.telemetry_common.LORA_HEADER_SIZE:] body[: dataflux.telemetry_common.telemetry_common.LORA_HEADER_SIZE]
)
payload = body[dataflux.telemetry_common.telemetry_common.LORA_HEADER_SIZE :]
if lora.size != len(payload): if lora.size != len(payload):
print(f"Serial size mismatch header says {lora.size} actual payload is {len(payload)}") print(
f"Serial size mismatch header says {lora.size} actual payload is {len(payload)}"
)
return None return None
calc_crc = dataflux.telemetry_common.telemetry_common.crc16_ccitt(payload) calc_crc = dataflux.telemetry_common.telemetry_common.crc16_ccitt(payload)
@@ -128,7 +234,7 @@ def parse_uart_packet(body: bytes) -> dict | None:
return { return {
**base, **base,
"type": "packet1", "type": "packet1",
"ping": pkt.ping.decode("ascii", errors="replace") "ping": pkt.ping.decode("ascii", errors="replace"),
} }
if lora.version == 2: if lora.version == 2:
@@ -144,7 +250,15 @@ def parse_uart_packet(body: bytes) -> dict | None:
"speed": pkt.speed, "speed": pkt.speed,
} }
if lora.version == 3:
pkt = dataflux.telemetry_common.telemetry_common.unpack_packet3(payload)
return {
**base,
"type": "packet3",
"start_time": pkt.start_time,
"duration": pkt.duration,
"count": pkt.count,
}
print("Unknown payload") print("Unknown payload")
return None return None

View File

@@ -0,0 +1,254 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
from bisect import bisect_left
from datetime import datetime, timezone
from queue import Empty
import stat
from tracemalloc import start
from dataflux.state import AppState, Buffers, LapInfo
import time
from pathlib import Path
import csv
def cs_to_datetime(date_utc: datetime, timestamp_cs: int) -> datetime:
if not 0 <= timestamp_cs < 24 * 60 * 60 * 100:
raise ValueError("timestamp_cs must be within one day")
hours, rem = divmod(timestamp_cs, 60 * 60 * 100)
minutes, rem = divmod(rem, 60 * 100)
seconds, cs = divmod(rem, 100)
return datetime(
date_utc.year,
date_utc.month,
date_utc.day,
hours,
minutes,
seconds,
cs * 10_000,
tzinfo=timezone.utc,
)
def datetime_to_cs(dt: datetime) -> int:
return (
dt.hour * 60 * 60 * 100
+ dt.minute * 60 * 100
+ dt.second * 100
+ dt.microsecond // 10_000
)
def telemetry_worker(state: AppState):
while state.telemetry_thread_running:
if not state.lora_thread_running:
time.sleep(1)
continue
try:
dataframe = state.packet_queue.get_nowait()
except Empty:
continue
now = datetime.now(timezone.utc)
time_stamp = datetime_to_cs(now)
if (
dataframe["type"] == "packet2"
and abs(dataframe["time_stamp"] - time_stamp) <= 60 * 100
):
state.latest_telemetry = dataframe
state.telemetry_valid = True
with state.lock:
state.raw_buffers.timestamp.append(dataframe["time_stamp"])
state.raw_buffers.speed.append(dataframe["speed"])
state.raw_buffers.vbat.append(dataframe["vbat"])
state.raw_buffers.teng.append(dataframe["teng"])
state.raw_buffers.lat.append(dataframe["lat"])
state.raw_buffers.lng.append(dataframe["lng"])
state.live_buffers_updated = True
state.live_buffers.timestamp.clear()
state.live_buffers.speed.clear()
state.live_buffers.vbat.clear()
state.live_buffers.teng.clear()
state.live_buffers.lat.clear()
state.live_buffers.lng.clear()
if not state.raw_buffers.timestamp:
return
last_timestamp = state.raw_buffers.timestamp[-1]
cutoff = last_timestamp - (state.live_buffer_len * 100)
i = len(state.raw_buffers.timestamp) - 1
while i >= 0 and state.raw_buffers.timestamp[i] >= cutoff:
elapsed_seconds = (
state.raw_buffers.timestamp[i] - last_timestamp
) / 100.0
state.live_buffers.timestamp.append(elapsed_seconds)
state.live_buffers.speed.append(state.raw_buffers.speed[i])
state.live_buffers.vbat.append(state.raw_buffers.vbat[i])
state.live_buffers.teng.append(state.raw_buffers.teng[i])
state.live_buffers.lat.append(state.raw_buffers.lat[i])
state.live_buffers.lng.append(state.raw_buffers.lng[i])
i -= 1
state.live_buffers.timestamp.reverse()
state.live_buffers.speed.reverse()
state.live_buffers.vbat.reverse()
state.live_buffers.teng.reverse()
state.live_buffers.lat.reverse()
state.live_buffers.lng.reverse()
elif dataframe["type"] == "packet3":
start_time: int = dataframe["start_time"]
end_time: int = dataframe["duration"] + start_time
lap_count = dataframe["count"]
lap: LapInfo = LapInfo(start_time, end_time, lap_count)
state.laps.append(lap)
state.new_laps.put(lap)
def save_lap(state: AppState, start_time: int, end_time: int, count: int) -> None:
time_str = cs_to_datetime(datetime.now(timezone.utc), start_time).strftime(
"%m_%d_%Y_%H_%M"
)
save_path = Path(state.autosave_path) / f"{time_str}_lap_{count}.csv"
data: Buffers = isolate_lap(state, start_time, end_time)
with save_path.open("w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["timestamp", "speed", "vbat", "teng", "lat", "lng"])
for row in zip(
data.timestamp, data.speed, data.vbat, data.teng, data.lat, data.lng
):
writer.writerow(row)
def buffer_dump(state: AppState, path: str) -> None:
save_path = Path(path)
if save_path.is_dir():
save_path = save_path / "output.csv"
with state.lock:
local_raw_buffers = Buffers(
timestamp=list(state.raw_buffers.timestamp),
speed=list(state.raw_buffers.speed),
vbat=list(state.raw_buffers.vbat),
teng=list(state.raw_buffers.teng),
lat=list(state.raw_buffers.lat),
lng=list(state.raw_buffers.lng),
)
with save_path.open("w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["timestamp", "speed", "vbat", "teng", "lat", "lng"])
for row in zip(
local_raw_buffers.timestamp,
local_raw_buffers.speed,
local_raw_buffers.vbat,
local_raw_buffers.teng,
local_raw_buffers.lat,
local_raw_buffers.lng,
):
writer.writerow(row)
state.buffer_dump_thread = None
def autosave_worker(state: AppState, path: str) -> None:
output_dir = Path(path)
state.autosave_path = output_dir
ctr: int = 0
while state.autosave_enabled:
date_str = state.start_time.strftime("%m_%d_%Y_%H_%M")
filename = date_str + ".csv"
save_path = output_dir / filename
buffer_dump(state, save_path)
print(f"Autosave {ctr} complete")
ctr += 1
try:
new_lap: LapInfo = state.new_laps.get_nowait()
except Empty:
pass
else:
save_lap(state, new_lap.start_time, new_lap.end_time, new_lap.count)
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")
pos = bisect_left(values, target)
if pos == 0:
return 0
if pos == len(values):
return len(values) - 1
before = pos - 1
after = pos
if abs(values[after] - target) < abs(values[before] - target):
return after
return before
def isolate_lap(state: AppState, start_time: int, end_time: int) -> Buffers:
output: Buffers = Buffers()
start_idx = closest_idx(state.raw_buffers.timestamp, start_time)
end_idx = closest_idx(state.raw_buffers.timestamp, end_time)
output.timestamp = state.raw_buffers.timestamp[start_idx : end_idx + 1]
output.speed = state.raw_buffers.speed[start_idx : end_idx + 1]
output.vbat = state.raw_buffers.vbat[start_idx : end_idx + 1]
output.teng = state.raw_buffers.teng[start_idx : end_idx + 1]
output.lat = state.raw_buffers.lat[start_idx : end_idx + 1]
output.lng = state.raw_buffers.lng[start_idx : end_idx + 1]
return output

View File

@@ -3,24 +3,83 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from threading import Lock, Thread from threading import Lock, Thread
from serial import Serial from serial import Serial
from queue import Queue from queue import Queue
from serial.tools.list_ports_common import ListPortInfo
@dataclass
class Buffers:
timestamp: list[int] = field(default_factory=list)
speed: list[float] = field(default_factory=list)
vbat: list[float] = field(default_factory=list)
teng: list[float] = field(default_factory=list)
lat: list[float] = field(default_factory=list)
lng: list[float] = field(default_factory=list)
@dataclass
class LapInfo:
start_time: int = field(default_factory=int)
end_time: int = field(default_factory=int)
count: int = field(default_factory=int)
@dataclass @dataclass
class AppState: class AppState:
running: bool = True running: bool = True
start_time: datetime = datetime.now()
ports: list[ListPortInfo] = field(default_factory=list)
ports_thread: Thread | None = None
ports_thread_running: bool = False
lora_port: Serial | None = None
lora_thread: Thread | None = None
lora_thread_running: bool = False
serial_port: Serial | None = None serial_port: Serial | None = None
serial_thread: Thread | None = None serial_thread: Thread | None = None
serial_data_queue: Queue | None = field(default_factory=Queue)
serial_send_queue: Queue | None = field(default_factory=Queue)
serial_thread_running: bool = False serial_thread_running: bool = False
telemetry_thread: Thread | None = None telemetry_thread: Thread | None = None
telemetry_thread_running: bool = False
lora_status_thread: Thread | None = None
lora_status_queue: Queue = field(default_factory=Queue)
serial_status_thread: Thread | None = None serial_status_thread: Thread | None = None
serial_status_queue: Queue = field(default_factory=Queue) serial_status_queue: Queue = field(default_factory=Queue)
ui_worker_thread: Thread | None = None
packet_queue: Queue = field(default_factory=Queue) packet_queue: Queue = field(default_factory=Queue)
latest_telemetry: dict = field(default_factory=dict) latest_telemetry: dict = field(default_factory=dict)
telemetry_valid: bool = False
raw_buffers: Buffers = field(default_factory=Buffers)
live_buffers: Buffers = field(default_factory=Buffers)
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)
buffer_dump_thread: Thread | None = None
autosave_buffer_thread: Thread | None = None
autosave_enabled: bool = False
autosave_path: Path | None = None
lap_loader_thread: Thread | None = None
lock: Lock = field(default_factory=Lock) lock: Lock = field(default_factory=Lock)

View File

@@ -2,14 +2,71 @@
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com> # Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
MENU_FILE_CONNECT: str = "menu_file_connect" MENU_IO_CONNECT_LORA: str = "menu_io_connect_lora"
MENU_FILE_DISCONNECT: str = "menu_file_disconnect" MENU_IO_CONNECT_SERIAL: str = "menu_io_connect_serial"
WINDOW_CONNECTION_MENU: str = "window_connection_menu" MENU_IO_DISCONNECT_LORA: str = "menu_io_disconnect_lora"
WINDOW_CONNECTION_MENU_COMBO: str = "window_connection_menu_combo" 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"
STATUS_LORA_STATUS_BOX: str = "status_lora_status_box"
STATUS_LORA_STATUS_TEXT: str = "status_lora_status_text"
STATUS_SERIAL_STATUS_BOX: str = "status_serial_status_box" STATUS_SERIAL_STATUS_BOX: str = "status_serial_status_box"
STATUS_SERIAL_STATUS_TEXT: str = "status_serial_status_text" STATUS_SERIAL_STATUS_TEXT: str = "status_serial_status_text"
LIVE_DATA_UTC_TIME_VALUE: str = "live_data_utc_time_value"
LIVE_DATA_VEHICLE_TIME_VALUE: str = "live_data_vehicle_time_value"
LIVE_DATA_SPEED_VALUE: str = "live_data_speed_value"
LIVE_DATA_VBAT_VALUE: str = "live_data_vbat_value"
LIVE_DATA_TENG_VALUE: str = "live_data_teng_value"
PAGE_LIVE_DATA: str = "page_live_data"
PAGE_LAP_RECAP: str = "page_lap_recap"
PAGE_SERIAL_CONSOLE: str = "page_serial_console"
TEXT_SERIAL_CONSOLE: str = "text_serial_console"
BUTTON_SERIAL_CONSOLE_SEND: str = "button_serial_console_send"
INPUT_SERIAL_CONSOLE: str = "input_serial_console"
SUB_PAGE_DATA_GRAPHS: str = "sub_page_data_graphs"
SUB_PAGE_MAP: str = "sub_page_map"
THEME_STATUS_DISCONNECTED: str = "theme_status_disconnected" THEME_STATUS_DISCONNECTED: str = "theme_status_disconnected"
THEME_STATUS_CONNECTED: str = "theme_status_connected" THEME_STATUS_CONNECTED: str = "theme_status_connected"
THEME_STATUS_CONNECTED_BRIGHT: str = "theme_status_connected_bigght" THEME_STATUS_CONNECTED_BRIGHT: str = "theme_status_connected_bigght"
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"

View File

@@ -0,0 +1,5 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later

View File

@@ -0,0 +1,4 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later

View File

@@ -5,12 +5,25 @@
import dearpygui.dearpygui as dpg import dearpygui.dearpygui as dpg
from dataflux.state import AppState from dataflux.state import AppState
from dataflux.tags import MENU_FILE_CONNECT, MENU_FILE_DISCONNECT from dataflux.tags import (
MENU_IO_CONNECT_LORA,
MENU_IO_CONNECT_SERIAL,
MENU_IO_DISCONNECT_LORA,
MENU_IO_DISCONNECT_SERIAL,
)
def update_menu_file_connection_status(state: AppState) -> None: def update_menu_file_connection_status(state: AppState) -> None:
if state.serial_port is None: if state.lora_port is None:
dpg.enable_item(MENU_FILE_CONNECT) dpg.enable_item(MENU_IO_CONNECT_LORA)
dpg.disable_item(MENU_FILE_DISCONNECT) dpg.disable_item(MENU_IO_DISCONNECT_LORA)
else: else:
dpg.disable_item(MENU_FILE_CONNECT) dpg.disable_item(MENU_IO_CONNECT_LORA)
dpg.enable_item(MENU_FILE_DISCONNECT) dpg.enable_item(MENU_IO_DISCONNECT_LORA)
if state.serial_port is None:
dpg.enable_item(MENU_IO_CONNECT_SERIAL)
dpg.disable_item(MENU_IO_DISCONNECT_SERIAL)
else:
dpg.disable_item(MENU_IO_CONNECT_SERIAL)
dpg.enable_item(MENU_IO_DISCONNECT_SERIAL)

View File

@@ -0,0 +1,22 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
import dearpygui.dearpygui as dpg
from dataflux.tags import CHILD_WINDOW_SERIAL_CONSOLE, TEXT_SERIAL_CONSOLE
def append_text_to_console(text: str) -> None:
old = dpg.get_value(TEXT_SERIAL_CONSOLE)
dpg.set_value(TEXT_SERIAL_CONSOLE, old + text)
def scroll_to_bottom() -> None:
dpg.set_y_scroll(
CHILD_WINDOW_SERIAL_CONSOLE,
dpg.get_y_scroll_max(CHILD_WINDOW_SERIAL_CONSOLE),
)
frame = dpg.get_frame_count()
dpg.set_frame_callback(frame + 1, scroll_to_bottom)
dpg.set_frame_callback(frame + 2, scroll_to_bottom)

View File

@@ -4,10 +4,26 @@
import dearpygui.dearpygui as dpg import dearpygui.dearpygui as dpg
from dataflux.state import AppState from dataflux.state import AppState
from dataflux.tags import STATUS_SERIAL_STATUS_BOX, STATUS_SERIAL_STATUS_TEXT, THEME_STATUS_CONNECTED, THEME_STATUS_CONNECTED_BRIGHT, THEME_STATUS_DISCONNECTED from dataflux.tags import (
STATUS_LORA_STATUS_BOX,
STATUS_LORA_STATUS_TEXT,
STATUS_SERIAL_STATUS_BOX,
STATUS_SERIAL_STATUS_TEXT,
THEME_STATUS_CONNECTED,
THEME_STATUS_CONNECTED_BRIGHT,
THEME_STATUS_DISCONNECTED,
)
from time import sleep from time import sleep
def update_status_connection_status(state: AppState): def update_status_connection_status(state: AppState):
if state.lora_port is None:
dpg.bind_item_theme(STATUS_LORA_STATUS_BOX, THEME_STATUS_DISCONNECTED)
dpg.set_value(STATUS_LORA_STATUS_TEXT, "LoRa: Disconnected")
else:
dpg.bind_item_theme(STATUS_LORA_STATUS_BOX, THEME_STATUS_CONNECTED)
dpg.set_value(STATUS_LORA_STATUS_TEXT, "LoRa: Connected")
if state.serial_port is None: if state.serial_port is None:
dpg.bind_item_theme(STATUS_SERIAL_STATUS_BOX, THEME_STATUS_DISCONNECTED) dpg.bind_item_theme(STATUS_SERIAL_STATUS_BOX, THEME_STATUS_DISCONNECTED)
dpg.set_value(STATUS_SERIAL_STATUS_TEXT, "Serial: Disconnected") dpg.set_value(STATUS_SERIAL_STATUS_TEXT, "Serial: Disconnected")
@@ -15,7 +31,8 @@ def update_status_connection_status(state: AppState):
dpg.bind_item_theme(STATUS_SERIAL_STATUS_BOX, THEME_STATUS_CONNECTED) dpg.bind_item_theme(STATUS_SERIAL_STATUS_BOX, THEME_STATUS_CONNECTED)
dpg.set_value(STATUS_SERIAL_STATUS_TEXT, "Serial: Connected") dpg.set_value(STATUS_SERIAL_STATUS_TEXT, "Serial: Connected")
def flash_status_connection_status(duration: float) -> None:
dpg.bind_item_theme(STATUS_SERIAL_STATUS_BOX, THEME_STATUS_CONNECTED_BRIGHT) def flash_status_connection_status(duration: float, tag: str) -> None:
dpg.bind_item_theme(tag, THEME_STATUS_CONNECTED_BRIGHT)
sleep(duration) sleep(duration)
dpg.bind_item_theme(STATUS_SERIAL_STATUS_BOX, THEME_STATUS_CONNECTED) dpg.bind_item_theme(tag, THEME_STATUS_CONNECTED)

View File

@@ -4,8 +4,57 @@
import dearpygui.dearpygui as dpg import dearpygui.dearpygui as dpg
from dataflux.services.serial import list_serial_ports from dataflux.services.serial import list_serial_ports
from dataflux.tags import WINDOW_CONNECTION_MENU_COMBO from dataflux.state import AppState
from dataflux.tags import (
PAGE_LAP_RECAP,
PAGE_LIVE_DATA,
PAGE_SERIAL_CONSOLE,
SUB_PAGE_DATA_GRAPHS,
SUB_PAGE_MAP,
WINDOW_LORA_CONNECTION_MENU_COMBO,
WINDOW_SERIAL_CONNECTION_MENU_COMBO,
)
def update_window_connection_menu_combo() -> None:
ports: list[str] = list_serial_ports() def update_window_lora_connection_menu_combo(state: AppState) -> None:
dpg.configure_item(WINDOW_CONNECTION_MENU_COMBO, items=ports) ports: list[str] = list_serial_ports(state)
if state.serial_port is not None and state.serial_thread_running:
port_name = state.serial_port.name
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:
ports: list[str] = list_serial_ports(state)
if state.lora_port is not None and state.lora_thread_running:
port_name = state.lora_port.name
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:
arr = [PAGE_LIVE_DATA, PAGE_LAP_RECAP, PAGE_SERIAL_CONSOLE]
for item in arr:
if tag == item:
dpg.show_item(item)
else:
dpg.hide_item(item)
def toggle_window(tag: str) -> None:
if tag == SUB_PAGE_DATA_GRAPHS:
dpg.show_item(SUB_PAGE_DATA_GRAPHS)
dpg.hide_item(SUB_PAGE_MAP)
hide_all_but(PAGE_LIVE_DATA)
elif tag == SUB_PAGE_MAP:
dpg.show_item(SUB_PAGE_MAP)
dpg.hide_item(SUB_PAGE_DATA_GRAPHS)
hide_all_but(PAGE_LIVE_DATA)
else:
hide_all_but(tag)

View File

@@ -2,44 +2,388 @@
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com> # Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from operator import call
import dearpygui.dearpygui as dpg import dearpygui.dearpygui as dpg
import dataflux.callbacks.menu import dataflux.callbacks.menu
import dataflux.callbacks.serial import dataflux.callbacks.serial
from dataflux.state import AppState from dataflux.state import AppState
from dataflux.tags import MENU_FILE_CONNECT, MENU_FILE_DISCONNECT, STATUS_SERIAL_STATUS_BOX, STATUS_SERIAL_STATUS_TEXT, THEME_STATUS_CONNECTED, THEME_STATUS_CONNECTED_BRIGHT, THEME_STATUS_DISCONNECTED, WINDOW_CONNECTION_MENU, WINDOW_CONNECTION_MENU_COMBO 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,
LIVE_DATA_VBAT_VALUE,
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,
MENU_IO_CONNECT_SERIAL,
MENU_IO_DISCONNECT_SERIAL,
PAGE_LAP_RECAP,
PAGE_LIVE_DATA,
PAGE_SERIAL_CONSOLE,
STATUS_LORA_STATUS_BOX,
STATUS_LORA_STATUS_TEXT,
STATUS_SERIAL_STATUS_BOX,
STATUS_SERIAL_STATUS_TEXT,
SUB_PAGE_DATA_GRAPHS,
SUB_PAGE_MAP,
TEXT_SERIAL_CONSOLE,
THEME_STATUS_CONNECTED,
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,
WINDOW_SERIAL_CONNECTION_MENU,
WINDOW_SERIAL_CONNECTION_MENU_COMBO,
)
from dataflux.ui.colors import STATUS_GREEN_BRIGHT, STATUS_GREEN_DARK, STATUS_RED_DARK from dataflux.ui.colors import STATUS_GREEN_BRIGHT, STATUS_GREEN_DARK, STATUS_RED_DARK
def _add_live_data_row(label: str, value: str, tag: str, units: str) -> None:
with dpg.table_row():
with dpg.table_cell():
dpg.add_text(label)
with dpg.table_cell():
dpg.add_text(value, tag=tag)
with dpg.table_cell():
dpg.add_text(units)
def build_windows(state: AppState) -> None: def build_windows(state: AppState) -> None:
with dpg.window(label='DataFlux',tag="main_window", no_collapse=True): with dpg.window(label="DataFlux", tag="main_window", no_collapse=True):
dpg.set_global_font_scale(0.5)
with dpg.menu_bar(): with dpg.menu_bar():
with dpg.menu(label='File'): with dpg.menu(label="File"):
dpg.add_menu_item(label="Connect", enabled=True, tag=MENU_FILE_CONNECT, callback=dataflux.callbacks.menu.open_connection_window) dpg.add_menu_item(
dpg.add_menu_item(label="Disonnect", enabled=False, tag=MENU_FILE_DISCONNECT, callback=dataflux.callbacks.menu.menu_file_disconnect, user_data=state) label="Dump Buffers",
dpg.add_menu_item(label="Quit") enabled=True,
with dpg.menu(label='Window'): tag=MENU_FILE_DUMP_BUFFERS,
dpg.add_menu_item(label="Live Data", user_data="page_live_data") callback=dataflux.callbacks.menu.menu_file_dump_buffers,
dpg.add_menu_item(label="Lap Recap", user_data="page_lap_recap") )
dpg.add_menu_item(
label="Autosave Buffers",
enabled=True,
check=True,
default_value=False,
tag=MENU_FILE_AUTOSAVE_BUFFERS,
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
)
with dpg.menu(label="IO"):
dpg.add_menu_item(
label="Connect LoRa",
enabled=True,
tag=MENU_IO_CONNECT_LORA,
callback=dataflux.callbacks.menu.open_lora_connection_window,
user_data=state,
)
dpg.add_menu_item(
label="Disonnect LoRa",
enabled=False,
tag=MENU_IO_DISCONNECT_LORA,
callback=dataflux.callbacks.menu.menu_io_disconnect_lora,
user_data=state,
)
dpg.add_menu_item(
label="Connect Serial",
enabled=True,
tag=MENU_IO_CONNECT_SERIAL,
callback=dataflux.callbacks.menu.open_serial_connection_window,
user_data=state,
)
dpg.add_menu_item(
label="Disonnect Serial",
enabled=False,
tag=MENU_IO_DISCONNECT_SERIAL,
callback=dataflux.callbacks.menu.menu_io_disconnect_serial,
user_data=state,
)
with dpg.menu(label="Window"):
dpg.add_menu_item(
label="Live Graphs",
user_data=SUB_PAGE_DATA_GRAPHS,
callback=dataflux.callbacks.menu.menu_window_select,
)
dpg.add_menu_item(
label="Live Map",
user_data=SUB_PAGE_MAP,
callback=dataflux.callbacks.menu.menu_window_select,
)
dpg.add_menu_item(
label="Lap Recap",
user_data=PAGE_LAP_RECAP,
callback=dataflux.callbacks.menu.menu_window_select,
)
dpg.add_menu_item(
label="Serial Console",
user_data=PAGE_SERIAL_CONSOLE,
callback=dataflux.callbacks.menu.menu_window_select,
)
with dpg.menu(label="Data"):
with dpg.menu(label="Timeframe"):
dpg.add_menu_item(
label="30s",
user_data=(state, 30),
callback=dataflux.callbacks.menu.menu_data_timeframe,
)
dpg.add_menu_item(
label="60s",
user_data=(state, 60),
callback=dataflux.callbacks.menu.menu_data_timeframe,
)
dpg.add_menu_item(
label="5m",
user_data=(state, 60 * 5),
callback=dataflux.callbacks.menu.menu_data_timeframe,
)
dpg.add_menu_item(
label="10m",
user_data=(state, 60 * 10),
callback=dataflux.callbacks.menu.menu_data_timeframe,
)
dpg.add_menu_item(
label="15m",
user_data=(state, 60 * 15),
callback=dataflux.callbacks.menu.menu_data_timeframe,
)
dpg.add_menu_item(
label="30m",
user_data=(state, 60 * 30),
callback=dataflux.callbacks.menu.menu_data_timeframe,
)
dpg.add_menu_item(
label="1h",
user_data=(state, 60 * 60),
callback=dataflux.callbacks.menu.menu_data_timeframe,
)
dpg.add_menu_item(
label="2h",
user_data=(state, 60 * 120),
callback=dataflux.callbacks.menu.menu_data_timeframe,
)
with dpg.child_window(tag="content_area", autosize_x=True, height=-32, border=False): with dpg.child_window(
with dpg.group(tag="page_live_data", show=True): tag="content_area", autosize_x=True, height=-32, border=False
):
with dpg.group(tag=PAGE_LIVE_DATA, show=True):
with dpg.group(horizontal=True): with dpg.group(horizontal=True):
with dpg.child_window(tag="realtime_stats", width=250, autosize_y=True, border=True): with dpg.child_window(
dpg.add_text("Speed: 25kmh") tag="realtime_stats", width=260, autosize_y=True, border=True
):
with dpg.table(
header_row=False,
resizable=False,
policy=dpg.mvTable_SizingFixedFit,
borders_innerH=False,
borders_innerV=False,
borders_outerH=False,
borders_outerV=False,
no_host_extendX=True,
):
dpg.add_table_column(width_fixed=True)
dpg.add_table_column(
width_stretch=True, init_width_or_weight=1.0
)
dpg.add_table_column(width_fixed=True)
_add_live_data_row(
"UTC Time", "no_data", LIVE_DATA_UTC_TIME_VALUE, ""
)
_add_live_data_row(
"Vehicle Time",
"no_data",
LIVE_DATA_VEHICLE_TIME_VALUE,
"",
)
_add_live_data_row(
"Speed", "no_data", LIVE_DATA_SPEED_VALUE, "km/h"
)
_add_live_data_row(
"Battery Voltage", "no_data", LIVE_DATA_VBAT_VALUE, "V"
)
_add_live_data_row(
"Engine Temp", "no_data", LIVE_DATA_TENG_VALUE, "°C"
)
with dpg.child_window(tag="data_graphs", autosize_x=True, autosize_y=True, border=True): with dpg.child_window(
with dpg.plot(label="Speed", height=250, width=-1, no_inputs=True): tag=SUB_PAGE_DATA_GRAPHS,
autosize_x=True,
autosize_y=True,
border=True,
show=True,
):
with dpg.plot(
label="Speed", height=250, width=-1, no_inputs=True
):
dpg.add_plot_legend() dpg.add_plot_legend()
dpg.add_plot_axis(dpg.mvXAxis, label="Time", tag="x_axis_speed") dpg.add_plot_axis(
y_axis = dpg.add_plot_axis(dpg.mvYAxis, label="Speed", tag="y_axis_speed") dpg.mvXAxis, label="Time", tag=GRAPH_X_AXIS_SPEED
dpg.set_axis_limits("y_axis_speed", 0, 50) )
dpg.add_line_series([], [], parent=y_axis, tag="speed_series") y_axis_speed = dpg.add_plot_axis(
dpg.mvYAxis, label="Speed", tag=GRAPH_Y_AXIS_SPEED
)
dpg.set_axis_limits(GRAPH_Y_AXIS_SPEED, ymin=0, ymax=50)
dpg.set_axis_limits(GRAPH_X_AXIS_SPEED, ymin=-30, ymax=0)
dpg.add_line_series(
[], [], parent=y_axis_speed, tag=GRAPH_SERIES_SPEED
)
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
)
y_axis_vbat = dpg.add_plot_axis(
dpg.mvYAxis, label="Voltage", tag=GRAPH_Y_AXIS_VBAT
)
dpg.set_axis_limits(GRAPH_Y_AXIS_VBAT, ymin=0, ymax=20)
dpg.set_axis_limits(GRAPH_X_AXIS_VBAT, ymin=-30, ymax=0)
dpg.add_line_series(
[], [], parent=y_axis_vbat, tag=GRAPH_SERIES_VBAT
)
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
)
y_axis_teng = dpg.add_plot_axis(
dpg.mvYAxis, label="Temperature", tag=GRAPH_Y_AXIS_TENG
)
dpg.set_axis_limits(GRAPH_Y_AXIS_TENG, ymin=0, ymax=120)
dpg.set_axis_limits(GRAPH_X_AXIS_TENG, ymin=-30, ymax=0)
dpg.add_line_series(
[], [], parent=y_axis_teng, tag=GRAPH_SERIES_TENG
)
with dpg.group(tag="page_lap_recap", show=False): with dpg.child_window(
tag=SUB_PAGE_MAP,
autosize_x=True,
autosize_y=True,
border=True,
show=False,
no_scrollbar=True,
):
with dpg.drawlist(width=500, height=500, tag="map_drawlist"):
dpg.draw_image("texture_tab", (0, 0), (500, 500))
dpg.draw_circle(
(0, 0),
10,
color=(255, 0, 0, 255),
fill=(255, 0, 0, 255),
)
with dpg.group(tag=PAGE_LAP_RECAP, show=False):
dpg.add_text("Lap Recap") dpg.add_text("Lap Recap")
dpg.add_separator() 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,
width=-1,
height=-40,
border=True,
horizontal_scrollbar=False,
):
dpg.add_text(tag=TEXT_SERIAL_CONSOLE, wrap=0)
with dpg.group(horizontal=True):
dpg.add_input_text(tag=INPUT_SERIAL_CONSOLE, width=-100)
dpg.add_button(
tag=BUTTON_SERIAL_CONSOLE_SEND,
label="Send",
width=100,
callback=dataflux.callbacks.serial.serial_console_button_send,
user_data=state,
)
with dpg.theme(tag=THEME_STATUS_CONNECTED): with dpg.theme(tag=THEME_STATUS_CONNECTED):
with dpg.theme_component(dpg.mvChildWindow): with dpg.theme_component(dpg.mvChildWindow):
dpg.add_theme_color(dpg.mvThemeCol_ChildBg, STATUS_GREEN_DARK) dpg.add_theme_color(dpg.mvThemeCol_ChildBg, STATUS_GREEN_DARK)
@@ -52,25 +396,136 @@ def build_windows(state: AppState) -> None:
with dpg.theme_component(dpg.mvChildWindow): with dpg.theme_component(dpg.mvChildWindow):
dpg.add_theme_color(dpg.mvThemeCol_ChildBg, STATUS_RED_DARK) dpg.add_theme_color(dpg.mvThemeCol_ChildBg, STATUS_RED_DARK)
with dpg.child_window(tag="footer_bar", autosize_x=True, height=28, border=False, no_scrollbar=True): with dpg.child_window(
tag="footer_bar",
autosize_x=True,
height=28,
border=False,
no_scrollbar=True,
):
with dpg.group(horizontal=True): with dpg.group(horizontal=True):
with dpg.child_window(width=200, height=28, border=False, tag=STATUS_SERIAL_STATUS_BOX): with dpg.child_window(
with dpg.table(header_row=False, resizable=False, policy=dpg.mvTable_SizingStretchProp, borders_innerV=False, borders_innerH=False, borders_outerH=False, borders_outerV=False, no_host_extendX=False, no_pad_innerX=True): width=200, height=28, border=False, tag=STATUS_LORA_STATUS_BOX
):
with dpg.table(
header_row=False,
resizable=False,
policy=dpg.mvTable_SizingStretchProp,
borders_innerV=False,
borders_innerH=False,
borders_outerH=False,
borders_outerV=False,
no_host_extendX=False,
no_pad_innerX=True,
):
dpg.add_table_column(init_width_or_weight=1.0) dpg.add_table_column(init_width_or_weight=1.0)
dpg.add_table_column(width_fixed=True) dpg.add_table_column(width_fixed=True)
dpg.add_table_column(init_width_or_weight=1.0) dpg.add_table_column(init_width_or_weight=1.0)
with dpg.table_row(): with dpg.table_row():
with dpg.table_cell(): with dpg.table_cell():
pass pass
with dpg.table_cell(): with dpg.table_cell():
dpg.add_text("Serial: Disconnected", tag=STATUS_SERIAL_STATUS_TEXT) dpg.add_text(
"LoRa: Disconnected",
tag=STATUS_LORA_STATUS_TEXT,
)
with dpg.table_cell():
pass
with dpg.child_window(
width=200, height=28, border=False, tag=STATUS_SERIAL_STATUS_BOX
):
with dpg.table(
header_row=False,
resizable=False,
policy=dpg.mvTable_SizingStretchProp,
borders_innerV=False,
borders_innerH=False,
borders_outerH=False,
borders_outerV=False,
no_host_extendX=False,
no_pad_innerX=True,
):
dpg.add_table_column(init_width_or_weight=1.0)
dpg.add_table_column(width_fixed=True)
dpg.add_table_column(init_width_or_weight=1.0)
with dpg.table_row():
with dpg.table_cell():
pass
with dpg.table_cell():
dpg.add_text(
"Serial: Disconnected",
tag=STATUS_SERIAL_STATUS_TEXT,
)
with dpg.table_cell(): with dpg.table_cell():
pass pass
dpg.bind_item_theme(STATUS_LORA_STATUS_BOX, THEME_STATUS_DISCONNECTED)
dpg.bind_item_theme(STATUS_SERIAL_STATUS_BOX, THEME_STATUS_DISCONNECTED) dpg.bind_item_theme(STATUS_SERIAL_STATUS_BOX, THEME_STATUS_DISCONNECTED)
with dpg.window(label="Connection Menu", tag=WINDOW_CONNECTION_MENU, show=False, modal=True, no_collapse=True, width=300): with dpg.window(
dpg.add_combo([], tag=WINDOW_CONNECTION_MENU_COMBO) label="LoRa Connection Menu",
dpg.add_button(label="Connect", callback=dataflux.callbacks.serial.connection_window_connect_serial, user_data=state) tag=WINDOW_LORA_CONNECTION_MENU,
show=False,
modal=True,
no_collapse=True,
width=400,
no_resize=True,
):
dpg.add_combo([], tag=WINDOW_LORA_CONNECTION_MENU_COMBO)
dpg.add_button(
label="Connect",
callback=dataflux.callbacks.serial.connection_window_connect_lora,
user_data=state,
)
with dpg.window(
label="Serial Connection Menu",
tag=WINDOW_SERIAL_CONNECTION_MENU,
show=False,
modal=True,
no_collapse=True,
width=400,
no_resize=True,
):
dpg.add_combo([], tag=WINDOW_SERIAL_CONNECTION_MENU_COMBO)
dpg.add_button(
label="Connect",
callback=dataflux.callbacks.serial.connection_window_connect_serial,
user_data=state,
)
with dpg.file_dialog(
directory_selector=False,
show=False,
tag=WINDOW_FILE_DIALOG_DUMP_BUFFERS,
width=700,
height=400,
modal=True,
callback=dataflux.callbacks.menu.window_file_dialog_dump_buffers_ok,
user_data=state,
):
dpg.add_file_extension(".csv")
with dpg.file_dialog(
directory_selector=True,
show=False,
tag=WINDOW_FILE_DIALOG_AUTOSAVE_BUFFERS,
width=700,
height=400,
modal=True,
callback=dataflux.callbacks.menu.window_file_dialog_autosave_buffers_ok,
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")

153
src/dataflux/ui/worker.py Normal file
View File

@@ -0,0 +1,153 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
from queue import Empty
import dearpygui.dearpygui as dpg
import datetime
import time
from datetime import datetime, timezone
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
def ports_worker(state: AppState) -> None:
while state.ports_thread_running:
state.ports = serial.tools.list_ports.comports()
time.sleep(5)
def ui_worker(state: AppState):
last_datetime: str = ""
last_veh_time: str = ""
last_veh_speed: str = ""
last_vbat: str = ""
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)
now = datetime.now(timezone.utc)
formatted = now.strftime("%H:%M:%S")
if formatted != last_datetime:
dpg.set_value(LIVE_DATA_UTC_TIME_VALUE, formatted)
last_datetime = formatted
if state.serial_thread_running:
try:
text = state.serial_data_queue.get_nowait()
except Empty:
pass
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
vbat_y: list[float] | None = None
teng_y: list[float] | None = None
with state.lock:
# Vehicle Time
no_data_written = False
veh_time = state.latest_telemetry["time_stamp"]
veh_speed = state.latest_telemetry["speed"]
vbat = state.latest_telemetry["vbat"]
teng = state.latest_telemetry["teng"]
if state.live_buffers_updated:
x_common = list(state.live_buffers.timestamp)
speed_y = list(state.live_buffers.speed)
vbat_y = list(state.live_buffers.vbat)
teng_y = list(state.live_buffers.teng)
state.live_buffers_updated = False
hours = veh_time // 360000
minutes = (veh_time % 360000) // 6000
seconds = (veh_time % 6000) // 100
formatted = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
if formatted != last_veh_time:
dpg.set_value(LIVE_DATA_VEHICLE_TIME_VALUE, formatted)
last_veh_time = formatted
# Speed
formatted = f"{veh_speed:05.2f}"
if formatted != last_veh_speed:
dpg.set_value(LIVE_DATA_SPEED_VALUE, formatted)
last_veh_speed = formatted
# VBAT
formatted = f"{vbat:05.2f}"
if formatted != last_vbat:
dpg.set_value(LIVE_DATA_VBAT_VALUE, formatted)
last_vbat = formatted
# TENG
formatted = f"{teng:05.2f}"
if formatted != last_teng:
dpg.set_value(LIVE_DATA_TENG_VALUE, formatted)
last_teng = formatted
if x_common is not None:
dpg.set_value(GRAPH_SERIES_SPEED, [x_common, speed_y])
dpg.set_value(GRAPH_SERIES_VBAT, [x_common, vbat_y])
dpg.set_value(GRAPH_SERIES_TENG, [x_common, teng_y])
else:
if not no_data_written:
dpg.set_value(LIVE_DATA_VEHICLE_TIME_VALUE, "no_data")
dpg.set_value(LIVE_DATA_SPEED_VALUE, "no_data")
dpg.set_value(LIVE_DATA_VBAT_VALUE, "no_data")
dpg.set_value(LIVE_DATA_TENG_VALUE, "no_data")
last_veh_time = last_veh_speed = last_vbat = last_teng = "no_data"
no_data_written = True
time.sleep(0.05)

100
uv.lock generated
View File

@@ -2,18 +2,29 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.14" requires-python = ">=3.14"
[[package]]
name = "altgraph"
version = "0.17.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
]
[[package]] [[package]]
name = "dataflux" name = "dataflux"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "dearpygui" }, { name = "dearpygui" },
{ name = "pyinstaller" },
{ name = "pyserial" }, { name = "pyserial" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "dearpygui", specifier = ">=2.2" }, { name = "dearpygui", specifier = ">=2.2" },
{ name = "pyinstaller", specifier = ">=6.20.0" },
{ name = "pyserial", specifier = ">=3.5" }, { name = "pyserial", specifier = ">=3.5" },
] ]
@@ -27,6 +38,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/03/aeb4ebe09a0240c8c9337018d2ac3e087fd911f6051a3bb0131248fbd942/dearpygui-2.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe3c8dc37be3ddce0356afb0c16721c0e485a4c94a831886935a0692bb9a9966", size = 1889279, upload-time = "2026-02-17T14:21:46.16Z" }, { url = "https://files.pythonhosted.org/packages/f8/03/aeb4ebe09a0240c8c9337018d2ac3e087fd911f6051a3bb0131248fbd942/dearpygui-2.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe3c8dc37be3ddce0356afb0c16721c0e485a4c94a831886935a0692bb9a9966", size = 1889279, upload-time = "2026-02-17T14:21:46.16Z" },
] ]
[[package]]
name = "macholib"
version = "1.16.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pefile"
version = "2024.8.26"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
]
[[package]]
name = "pyinstaller"
version = "6.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
{ name = "macholib", marker = "sys_platform == 'darwin'" },
{ name = "packaging" },
{ name = "pefile", marker = "sys_platform == 'win32'" },
{ name = "pyinstaller-hooks-contrib" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/60/d03d52e6690d4e9caf333dcd14550cde634ce6c118b3bc8fa3112c3186fd/pyinstaller-6.20.0.tar.gz", hash = "sha256:95c5c7e03d5d61e9dfb8ef259c699cf492bb1041beb6dbe83696608cec07347a", size = 4048728, upload-time = "2026-04-22T20:59:36.96Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/e4/e228d6d1bbb7fd62dc660a8fb202a583b023d3a3624ca95d1a9290ee4d6a/pyinstaller-6.20.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:bf3be4e1284ee78ddccba5e29f99443a12a7b4673168288ffc4c9d38c6f7b90e", size = 1047642, upload-time = "2026-04-22T20:58:32.006Z" },
{ url = "https://files.pythonhosted.org/packages/ce/bd/afb631bcb3f9040efebd4f6d067f0828b51710818f69fb41a2d4b7787f52/pyinstaller-6.20.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:72ae9c1fdea134afa791f58bdc9a1934d5c7609753c111e0026bfc272b32b712", size = 742494, upload-time = "2026-04-22T20:58:36.285Z" },
{ url = "https://files.pythonhosted.org/packages/76/08/0729a5bac14754150e5d83b39d87d842eb42b0bffcaa03dbad6252e23a39/pyinstaller-6.20.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1031bcc307f3fbeffd4e162723e64d46dbf591c82dd0997413afb2a07328b941", size = 754191, upload-time = "2026-04-22T20:58:40.603Z" },
{ url = "https://files.pythonhosted.org/packages/e6/82/bc0ee4c7b97db1958eb651e0da9fb1e672e5ae53ca8867fd97701de52906/pyinstaller-6.20.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:8df3b3f347659fa2562d8d193a98ad4600133b8b8d07c268df89e4154376750e", size = 751902, upload-time = "2026-04-22T20:58:44.7Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e7/770002d6aaa54173881cb2c49bb195ba67b97bf39bac1cdf320f28401629/pyinstaller-6.20.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b0d3cc9dd8120d448459bd3880a12e2f9774c51443af49047801446377999a59", size = 748634, upload-time = "2026-04-22T20:58:48.579Z" },
{ url = "https://files.pythonhosted.org/packages/fe/db/68ba1fccb71278b2124fb90b37b7c8c0bc4c1173fba45b94466df3d9cb7f/pyinstaller-6.20.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:03696bb6350177c6bc23bcaf78e71a33c4a89b6754dd90d1be2f318e978c918b", size = 748490, upload-time = "2026-04-22T20:58:52.749Z" },
{ url = "https://files.pythonhosted.org/packages/03/0f/ac77ffa996a56be3d5c8f85734a007f8347240691657f9704e7de2527fa3/pyinstaller-6.20.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:6357f1699f6af84f37e7367f031d4f68abdba65543b83990c9e8f5a4cebed0b7", size = 747650, upload-time = "2026-04-22T20:58:57.093Z" },
{ url = "https://files.pythonhosted.org/packages/e0/56/1ee91c3a2bc10ca1f36da10a6fd55ff7efc4dec367171eb25992a827874f/pyinstaller-6.20.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0ab39c690abad26ba148e8f664f0478acc82a733997f4f22e757774832802da9", size = 747413, upload-time = "2026-04-22T20:59:01.174Z" },
{ url = "https://files.pythonhosted.org/packages/d7/55/ae264339996953c4cdf9d89d916a0a8fa26a83cf917a742fff8b9d5f3fe8/pyinstaller-6.20.0-py3-none-win32.whl", hash = "sha256:9a7637e8e44b4387b13667fdcaac86ab6b29c446c16d34d8401539b81838759c", size = 1331584, upload-time = "2026-04-22T20:59:07.201Z" },
{ url = "https://files.pythonhosted.org/packages/76/8c/300f57578882cce259bfb5ae56fda3b69caa3fe9df40a176c719920ea6e2/pyinstaller-6.20.0-py3-none-win_amd64.whl", hash = "sha256:d588844e890ee80c4365867f98146636e1849bbca8e4284bbf0c809aff0f161a", size = 1391851, upload-time = "2026-04-22T20:59:14.024Z" },
{ url = "https://files.pythonhosted.org/packages/8a/ea/b2f8e1642aecda78c0b75c7321f708e49e10bb3c00dd4f148c40761a1527/pyinstaller-6.20.0-py3-none-win_arm64.whl", hash = "sha256:bd53282c0a73e5c95573e1ddc8e5d564d4932bec91efbaed4dc5fdff9c2ae7f2", size = 1332259, upload-time = "2026-04-22T20:59:20.509Z" },
]
[[package]]
name = "pyinstaller-hooks-contrib"
version = "2026.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/67/f4452d68793fb15beba4f19ef39a38a8822f0da7452b503c400d5a21f5c1/pyinstaller_hooks_contrib-2026.5.tar.gz", hash = "sha256:f066dfca8f7c45ff6336c9cf9fe25b4e48bfeb322a1aa24faaedfb8a8d1b0b08", size = 173689, upload-time = "2026-05-04T22:36:55.124Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/5c/fd465d11da4d12b50d7eb5d2ee2ceb780d8d049dbb489f3828d131e387af/pyinstaller_hooks_contrib-2026.5-py3-none-any.whl", hash = "sha256:ea1535783fbdac4626351709e83f3ea80b681d3a4745763ebb407b5e27342eb9", size = 457314, upload-time = "2026-05-04T22:36:53.598Z" },
]
[[package]] [[package]]
name = "pyserial" name = "pyserial"
version = "3.5" version = "3.5"
@@ -35,3 +117,21 @@ sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
] ]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]]
name = "setuptools"
version = "82.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
]