Compare commits

..

13 Commits

30 changed files with 1337 additions and 461 deletions

3
.gitignore vendored
View File

@@ -8,3 +8,6 @@ wheels/
# Virtual environments
.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.

View File

Before

Width:  |  Height:  |  Size: 8.9 MiB

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"
dependencies = [
"dearpygui>=2.2",
"pyinstaller>=6.20.0",
"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,33 +2,73 @@
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# 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
from dataflux.state import AppState
import dataflux.config
from dataflux.tags import TEXT_SERIAL_CONSOLE
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:
state: AppState = AppState()
state.start_time = datetime.now()
# Create application context and viewport
dpg.create_context()
width, height, channels, data = dpg.load_image("map.png")
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.add_static_texture(
width=width, height=height, default_value=data, tag="texture_tab"
)
dpg.create_viewport(title='DataFlux', width=600, height=600)
dpg.create_viewport(title="DataFlux", width=600, height=600)
# Add Inter font to registry and bind as main app font
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)
dataflux.ui.windows.build_windows(state)
@@ -36,23 +76,29 @@ def run() -> None:
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.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 = 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 = 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.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,6 +1,8 @@
# 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 ast import arg
from threading import Thread
import dearpygui.dearpygui as dpg
from dataflux.state import AppState
@@ -10,23 +12,95 @@ import dataflux.ui.routines.status
import dataflux.services.serial
import dataflux.services.telemetry
from dataflux.tags import WINDOW_CONNECTION_MENU, WINDOW_FILE_DIALOG_DUMP_BUFFERS
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)
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 = 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
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:
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.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

@@ -1,2 +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,12 +2,21 @@
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
from concurrent.futures import thread
import os
from queue import Empty
from sys import base_exec_prefix
from threading import Thread
import time
from serial import Serial
import serial.tools.list_ports
import dearpygui.dearpygui as dpg
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.ui.routines.status
import dataflux.ui.routines
@@ -15,48 +24,134 @@ import dataflux.ui.routines
from dataflux.state import AppState
def list_serial_ports() -> list[str]:
ports = serial.tools.list_ports.comports()
def list_serial_ports(state: AppState) -> 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:
valid_ports.append(port.device)
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:
if state.serial_port is not None:
state.serial_port.close()
state.serial_port = None
state.serial_port = Serial(port=device, baudrate=115200)
state.serial_thread = Thread(target=serial_reader_worker, args=(state,), daemon=True)
state.serial_status_thread = Thread(target=serial_status_worker, args=(state,), daemon=True)
state.serial_port = Serial(
port=device, baudrate=115200, timeout=0.05, write_timeout=0.1
)
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_status_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:
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
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:
while state.serial_thread_running:
try:
duration = state.serial_status_queue.get(timeout=0.1)
duration = state.serial_status_queue.get_nowait()
except Empty:
continue
dataflux.ui.routines.status.flash_status_connection_status(duration)
dataflux.ui.routines.status.flash_status_connection_status(
duration, STATUS_SERIAL_STATUS_BOX
)
def serial_reader_worker(state: AppState) -> None:
while state.serial_thread_running:
port = state.serial_port
def lora_reader_worker(state: AppState) -> None:
while state.lora_thread_running:
port = state.lora_port
if port is None:
break
if port.closed:
@@ -71,14 +166,13 @@ def serial_reader_worker(state: AppState) -> None:
parsed = parse_uart_packet(packet)
if parsed is not None:
state.packet_queue.put(parsed)
state.serial_status_queue.put(0.1)
state.lora_status_queue.put(0.1)
except Exception as e:
print(f"Serial parser error: {e}")
except Exception:
break
disconnect_serial(state)
disconnect_lora(state)
dataflux.ui.routines.update_global_connection_status(state)
def read_one_uart_packet(port: Serial) -> bytes | None:
first = port.read(1)
@@ -107,19 +201,24 @@ def read_one_uart_packet(port: Serial) -> bytes | None:
return body
def parse_uart_packet(body: bytes) -> dict | None:
if len(body) < dataflux.telemetry_common.telemetry_common.LORA_HEADER_SIZE:
return None
lora = dataflux.telemetry_common.telemetry_common.unpack_lora_header(body[:dataflux.telemetry_common.telemetry_common.LORA_HEADER_SIZE])
payload = body[dataflux.telemetry_common.telemetry_common.LORA_HEADER_SIZE:]
lora = dataflux.telemetry_common.telemetry_common.unpack_lora_header(
body[: dataflux.telemetry_common.telemetry_common.LORA_HEADER_SIZE]
)
payload = body[dataflux.telemetry_common.telemetry_common.LORA_HEADER_SIZE :]
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
calc_crc = dataflux.telemetry_common.telemetry_common.crc16_ccitt(payload)
if calc_crc != lora.crc16:
print("crc mismatch")
return None
@@ -129,13 +228,13 @@ def parse_uart_packet(body: bytes) -> dict | None:
"dest": lora.dest,
"version": lora.version,
}
if lora.version == 1:
pkt = dataflux.telemetry_common.telemetry_common.unpack_packet1(payload)
return {
**base,
"type": "packet1",
"ping": pkt.ping.decode("ascii", errors="replace")
"ping": pkt.ping.decode("ascii", errors="replace"),
}
if lora.version == 2:
@@ -151,7 +250,15 @@ def parse_uart_packet(body: bytes) -> dict | None:
"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")
return None

View File

@@ -2,72 +2,135 @@
# 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
from dataflux.state import AppState, Buffers
import stat
from tracemalloc import start
from dataflux.state import AppState, Buffers, LapInfo
import time
from pathlib import Path
import csv
LIVE_BUFFER_WINDOW_CS = 30 * 100
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 state.serial_thread_running == False:
if not state.lora_thread_running:
time.sleep(1)
continue
try:
dataframe = state.packet_queue.get(timeout=0.1)
dataframe = state.packet_queue.get_nowait()
except Empty:
continue
state.latest_telemetry = dataframe
state.telemetry_valid = True
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)
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"])
writer.writerow(["timestamp", "speed", "vbat", "teng", "lat", "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()
for row in zip(
data.timestamp, data.speed, data.vbat, data.teng, data.lat, data.lng
):
writer.writerow(row)
if not state.raw_buffers.timestamp:
return
last_timestamp = state.raw_buffers.timestamp[-1]
cutoff = last_timestamp - LIVE_BUFFER_WINDOW_CS
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()
def buffer_dump(state: AppState, path: str):
save_path = Path(path)
def buffer_dump(state: AppState, path: str) -> None:
save_path = Path(path)
if save_path.is_dir():
save_path = save_path / "output.csv"
@@ -86,9 +149,106 @@ def buffer_dump(state: AppState, path: str):
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):
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,10 +3,15 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from threading import Lock, Thread
from serial import Serial
from queue import Queue
from serial.tools.list_ports_common import ListPortInfo
@dataclass
class Buffers:
timestamp: list[int] = field(default_factory=list)
@@ -16,17 +21,39 @@ class Buffers:
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
class AppState:
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_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
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_queue: Queue = field(default_factory=Queue)
@@ -39,7 +66,20 @@ class AppState:
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)

View File

@@ -2,13 +2,25 @@
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
MENU_FILE_CONNECT: str = "menu_file_connect"
MENU_FILE_DISCONNECT: str = "menu_file_disconnect"
MENU_IO_CONNECT_LORA: str = "menu_io_connect_lora"
MENU_IO_CONNECT_SERIAL: str = "menu_io_connect_serial"
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"
WINDOW_CONNECTION_MENU: str = "window_connection_menu"
WINDOW_CONNECTION_MENU_COMBO: str = "window_connection_menu_combo"
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_TEXT: str = "status_serial_status_text"
@@ -20,6 +32,13 @@ 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"
@@ -32,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"

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

@@ -2,11 +2,11 @@
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
STATUS_RED_DARK = (140, 35, 35, 255)
STATUS_RED_BRIGHT = (205, 85, 85, 255)
STATUS_RED_DARK = (140, 35, 35, 255)
STATUS_RED_BRIGHT = (205, 85, 85, 255)
STATUS_ORANGE_DARK = (160, 90, 20, 255)
STATUS_ORANGE_DARK = (160, 90, 20, 255)
STATUS_ORANGE_BRIGHT = (210, 140, 60, 255)
STATUS_GREEN_DARK = (40, 130, 55, 255)
STATUS_GREEN_BRIGHT = (95, 185, 115, 255)
STATUS_GREEN_DARK = (40, 130, 55, 255)
STATUS_GREEN_BRIGHT = (95, 185, 115, 255)

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
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:
if state.serial_port is None:
dpg.enable_item(MENU_FILE_CONNECT)
dpg.disable_item(MENU_FILE_DISCONNECT)
if state.lora_port is None:
dpg.enable_item(MENU_IO_CONNECT_LORA)
dpg.disable_item(MENU_IO_DISCONNECT_LORA)
else:
dpg.disable_item(MENU_FILE_CONNECT)
dpg.enable_item(MENU_FILE_DISCONNECT)
dpg.disable_item(MENU_IO_CONNECT_LORA)
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
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
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:
dpg.bind_item_theme(STATUS_SERIAL_STATUS_BOX, THEME_STATUS_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.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)
sleep(duration)
dpg.bind_item_theme(STATUS_SERIAL_STATUS_BOX, THEME_STATUS_CONNECTED)
def flash_status_connection_status(duration: float, tag: str) -> None:
dpg.bind_item_theme(tag, THEME_STATUS_CONNECTED_BRIGHT)
sleep(duration)
dpg.bind_item_theme(tag, THEME_STATUS_CONNECTED)

View File

@@ -3,23 +3,50 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import dearpygui.dearpygui as dpg
import dataflux.config
from dataflux.services.serial import list_serial_ports
from dataflux.tags import PAGE_LAP_RECAP, PAGE_LIVE_DATA, SUB_PAGE_DATA_GRAPHS, SUB_PAGE_MAP, 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()
dpg.configure_item(WINDOW_CONNECTION_MENU_COMBO, items=ports)
def update_window_lora_connection_menu_combo(state: AppState) -> None:
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]
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)
@@ -31,6 +58,3 @@ def toggle_window(tag: str) -> None:
hide_all_but(PAGE_LIVE_DATA)
else:
hide_all_but(tag)

View File

@@ -2,14 +2,70 @@
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# 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
from dataflux.state import AppState
from dataflux.tags import GRAPH_SERIES_SPEED, GRAPH_SERIES_TENG, GRAPH_SERIES_VBAT, GRAPH_X_AXIS_SPEED, GRAPH_X_AXIS_TENG, GRAPH_X_AXIS_VBAT, GRAPH_Y_AXIS_SPEED, GRAPH_Y_AXIS_TENG, GRAPH_Y_AXIS_VBAT, LIVE_DATA_TENG_VALUE, LIVE_DATA_UTC_TIME_VALUE, LIVE_DATA_VBAT_VALUE, LIVE_DATA_VEHICLE_TIME_VALUE, LIVE_DATA_SPEED_VALUE, MENU_FILE_CONNECT, MENU_FILE_DISCONNECT, MENU_FILE_DUMP_BUFFERS, PAGE_LAP_RECAP, PAGE_LIVE_DATA, STATUS_SERIAL_STATUS_BOX, STATUS_SERIAL_STATUS_TEXT, SUB_PAGE_DATA_GRAPHS, SUB_PAGE_MAP, THEME_STATUS_CONNECTED, THEME_STATUS_CONNECTED_BRIGHT, THEME_STATUS_DISCONNECTED, WINDOW_CONNECTION_MENU, WINDOW_CONNECTION_MENU_COMBO, WINDOW_FILE_DIALOG_DUMP_BUFFERS
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
def _add_live_data_row(label: str, value: str, tag: str, units: str) -> None:
with dpg.table_row():
with dpg.table_cell():
@@ -19,66 +75,315 @@ def _add_live_data_row(label: str, value: str, tag: str, units: str) -> None:
with dpg.table_cell():
dpg.add_text(units)
def build_windows(state: AppState) -> None:
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", enabled=True, tag=MENU_FILE_CONNECT, callback=dataflux.callbacks.menu.open_connection_window)
dpg.add_menu_item(label="Disonnect", enabled=False, tag=MENU_FILE_DISCONNECT, callback=dataflux.callbacks.menu.menu_file_disconnect, user_data=state)
dpg.add_menu_item(label="Dump Buffers", enabled=True, tag=MENU_FILE_DUMP_BUFFERS, callback=dataflux.callbacks.menu.menu_file_dump_buffers)
dpg.add_menu_item(label="Quit")
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)
with dpg.child_window(tag="content_area", autosize_x=True, height=-32, border=False):
def build_windows(state: AppState) -> None:
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(label="File"):
dpg.add_menu_item(
label="Dump Buffers",
enabled=True,
tag=MENU_FILE_DUMP_BUFFERS,
callback=dataflux.callbacks.menu.menu_file_dump_buffers,
)
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.group(tag=PAGE_LIVE_DATA, show=True):
with dpg.group(horizontal=True):
with dpg.child_window(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):
with dpg.child_window(
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_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")
_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=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):
with dpg.child_window(
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_axis(dpg.mvXAxis, label="Time", tag=GRAPH_X_AXIS_SPEED)
y_axis_speed = dpg.add_plot_axis(dpg.mvYAxis, label="Speed", tag=GRAPH_Y_AXIS_SPEED)
dpg.add_plot_axis(
dpg.mvXAxis, label="Time", tag=GRAPH_X_AXIS_SPEED
)
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_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.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_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.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)
dpg.add_line_series(
[], [], parent=y_axis_teng, tag=GRAPH_SERIES_TENG
)
with dpg.child_window(tag=SUB_PAGE_MAP, autosize_x=True, autosize_y=True, border=True, show=False, no_scrollbar=True):
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))
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_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_component(dpg.mvChildWindow):
dpg.add_theme_color(dpg.mvThemeCol_ChildBg, STATUS_GREEN_DARK)
@@ -91,28 +396,103 @@ def build_windows(state: AppState) -> None:
with dpg.theme_component(dpg.mvChildWindow):
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.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):
with dpg.child_window(
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(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)
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():
pass
dpg.bind_item_theme(STATUS_LORA_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):
dpg.add_combo([], tag=WINDOW_CONNECTION_MENU_COMBO)
dpg.add_button(label="Connect", callback=dataflux.callbacks.serial.connection_window_connect_serial, user_data=state)
with dpg.window(
label="LoRa Connection Menu",
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,
@@ -122,6 +502,30 @@ def build_windows(state: AppState) -> None:
height=400,
modal=True,
callback=dataflux.callbacks.menu.window_file_dialog_dump_buffers_ok,
user_data=state
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")

View File

@@ -1,13 +1,39 @@
# 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_TENG, GRAPH_SERIES_VBAT, GRAPH_X_AXIS_SPEED, LIVE_DATA_TENG_VALUE, LIVE_DATA_UTC_TIME_VALUE, LIVE_DATA_VBAT_VALUE, LIVE_DATA_VEHICLE_TIME_VALUE, LIVE_DATA_SPEED_VALUE
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):
@@ -17,15 +43,55 @@ def ui_worker(state: AppState):
last_vbat: str = ""
last_teng: str = ""
no_data_written = False
while state.running:
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 and state.telemetry_valid:
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
@@ -58,7 +124,6 @@ def ui_worker(state: AppState):
dpg.set_value(LIVE_DATA_SPEED_VALUE, formatted)
last_veh_speed = formatted
# VBAT
formatted = f"{vbat:05.2f}"
if formatted != last_vbat:
@@ -86,7 +151,3 @@ def ui_worker(state: AppState):
no_data_written = True
time.sleep(0.05)

100
uv.lock generated
View File

@@ -2,18 +2,29 @@ version = 1
revision = 3
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]]
name = "dataflux"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "dearpygui" },
{ name = "pyinstaller" },
{ name = "pyserial" },
]
[package.metadata]
requires-dist = [
{ name = "dearpygui", specifier = ">=2.2" },
{ name = "pyinstaller", specifier = ">=6.20.0" },
{ 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" },
]
[[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]]
name = "pyserial"
version = "3.5"
@@ -35,3 +117,21 @@ sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6
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" },
]
[[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" },
]