Moved to proper dpg renderloop and added map buttons

This commit is contained in:
2026-06-07 12:30:30 +02:00
parent 8f5ecf6d60
commit 00b1e67f89
16 changed files with 617 additions and 422 deletions

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ wheels/
# Build generated files # Build generated files
*.spec *.spec
.cache/

View File

@@ -9,8 +9,9 @@ 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 import dpg_map as dpgm
from dataflux.tags import TEXT_SERIAL_CONSOLE from dataflux.tags import TEXT_SERIAL_CONSOLE
import dataflux.services.ports
import dataflux.ui.windows import dataflux.ui.windows
import dataflux.ui.worker import dataflux.ui.worker
import dataflux.services.telemetry import dataflux.services.telemetry
@@ -43,21 +44,13 @@ def run() -> None:
state: AppState = AppState() state: AppState = AppState()
state.start_time = datetime.now() state.start_time = datetime.now()
dpgm.configure(
user_agent="DataFlux/0.1 contact:h3cx@h3cx.dev", cache_dir="./.cache"
)
# Create application context and viewport # Create application context and viewport
dpg.create_context() dpg.create_context()
dpg.configure_app(manual_callback_management=True)
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) dpg.create_viewport(title="DataFlux", width=600, height=600)
@@ -82,11 +75,6 @@ def run() -> None:
dpg.set_primary_window("main_window", True) dpg.set_primary_window("main_window", True)
dpg.bind_item_font(TEXT_SERIAL_CONSOLE, mono_font) 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_running = True
state.telemetry_thread = Thread( state.telemetry_thread = Thread(
target=dataflux.services.telemetry.telemetry_worker, args=(state,), daemon=True target=dataflux.services.telemetry.telemetry_worker, args=(state,), daemon=True
@@ -95,10 +83,21 @@ def run() -> None:
state.ports_thread_running = True state.ports_thread_running = True
state.ports_thread = Thread( state.ports_thread = Thread(
target=dataflux.ui.worker.ports_worker, args=(state,), daemon=True target=dataflux.services.ports.ports_worker, args=(state,), daemon=True
) )
state.ports_thread.start() state.ports_thread.start()
dpg.start_dearpygui() ui_updater = dataflux.ui.worker.UiFrameUpdater()
while dpg.is_dearpygui_running():
jobs = dpg.get_callback_queue()
dpg.run_callbacks(jobs)
ui_updater.update(state)
dpg.render_dearpygui_frame()
state.running = False
state.telemetry_thread_running = False
state.ports_thread_running = False
state.lora_thread_running = False
state.serial_thread_running = False
dpg.destroy_context() dpg.destroy_context()

View File

@@ -0,0 +1,17 @@
# 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 dataflux.state import AppState
def live_map_recenter(sender, app_data, user_data: AppState) -> None:
user_data.live_map_pending_recenter = True
def live_map_zoom_in(sender, app_data, user_data: AppState) -> None:
user_data.live_map_pending_zoom_in = True
def live_map_zoom_out(sender, app_data, user_data: AppState) -> None:
user_data.live_map_pending_zoom_out = True

View File

@@ -2,14 +2,13 @@
# 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 from threading import Thread
import dearpygui.dearpygui as dpg import dearpygui.dearpygui as dpg
from dataflux.state import AppState 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.services.lora
import dataflux.services.serial import dataflux.services.serial_console
import dataflux.services.telemetry import dataflux.services.telemetry
from dataflux.tags import ( from dataflux.tags import (
@@ -35,12 +34,12 @@ def open_serial_connection_window(sender, app_data, user_data: AppState) -> None
def menu_io_disconnect_lora(sender, app_data, user_data: AppState) -> None: def menu_io_disconnect_lora(sender, app_data, user_data: AppState) -> None:
dataflux.services.serial.disconnect_lora(user_data) dataflux.services.lora.disconnect_lora(user_data)
update_global_connection_status(user_data) update_global_connection_status(user_data)
def menu_io_disconnect_serial(sender, app_data, user_data: AppState) -> None: def menu_io_disconnect_serial(sender, app_data, user_data: AppState) -> None:
dataflux.services.serial.disconnect_serial(user_data) dataflux.services.serial_console.disconnect_serial(user_data)
update_global_connection_status(user_data) update_global_connection_status(user_data)

View File

@@ -3,7 +3,8 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import dearpygui.dearpygui as dpg import dearpygui.dearpygui as dpg
import dataflux.services.serial import dataflux.services.lora
import dataflux.services.serial_console
import dataflux.ui.routines import dataflux.ui.routines
from dataflux.state import AppState from dataflux.state import AppState
@@ -18,15 +19,17 @@ from dataflux.tags import (
def connection_window_connect_lora(sender, app_data, user_data: AppState) -> None: def connection_window_connect_lora(sender, app_data, user_data: AppState) -> None:
device = dpg.get_value(WINDOW_LORA_CONNECTION_MENU_COMBO) device = dpg.get_value(WINDOW_LORA_CONNECTION_MENU_COMBO)
dataflux.services.serial.connect_lora(user_data, device) connected = dataflux.services.lora.connect_lora(user_data, device)
dataflux.ui.routines.update_global_connection_status(user_data) dataflux.ui.routines.update_global_connection_status(user_data)
if connected:
dpg.hide_item(WINDOW_LORA_CONNECTION_MENU) 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_SERIAL_CONNECTION_MENU_COMBO) device = dpg.get_value(WINDOW_SERIAL_CONNECTION_MENU_COMBO)
dataflux.services.serial.connect_serial(user_data, device) connected = dataflux.services.serial_console.connect_serial(user_data, device)
dataflux.ui.routines.update_global_connection_status(user_data) dataflux.ui.routines.update_global_connection_status(user_data)
if connected:
dpg.hide_item(WINDOW_SERIAL_CONNECTION_MENU) dpg.hide_item(WINDOW_SERIAL_CONNECTION_MENU)

View File

@@ -0,0 +1,167 @@
# 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 threading import Thread
from serial import Serial
import serial
import dataflux.telemetry_common.telemetry_common
from dataflux.state import AppState
def connect_lora(state: AppState, device: str) -> bool:
if not device:
print("No LoRa port selected")
state.connection_status_dirty = True
return False
if state.lora_port is not None:
state.lora_port.close()
state.lora_port = None
try:
state.lora_port = Serial(port=device, baudrate=115200)
except serial.SerialException as exc:
print(f"Could not open LoRa port {device!r}: {exc}")
state.lora_port = None
state.lora_thread_running = False
state.connection_status_dirty = True
return False
state.lora_thread = Thread(target=lora_reader_worker, args=(state,), daemon=True)
state.lora_thread_running = True
state.connection_status_dirty = True
state.lora_thread.start()
return True
def disconnect_lora(state: AppState) -> None:
if state.lora_port is None:
return
state.lora_thread_running = False
try:
state.lora_port.close()
except OSError:
pass
state.lora_port = None
state.connection_status_dirty = True
def lora_reader_worker(state: AppState) -> None:
while state.lora_thread_running:
port = state.lora_port
if port is None:
break
if port.closed:
print("Port closed")
break
try:
packet = read_one_uart_packet(port)
if packet is None:
continue
parsed = parse_uart_packet(packet)
if parsed is not None:
state.packet_queue.put(parsed)
state.lora_status_queue.put(0.1)
except Exception:
break
disconnect_lora(state)
def read_one_uart_packet(port: Serial) -> bytes | None:
first = port.read(1)
if not first:
return None
if first != dataflux.telemetry_common.telemetry_common.UART_MAGIC[:1]:
return None
rest_magic = port.read(3)
if len(rest_magic) != 3:
return None
if first + rest_magic != dataflux.telemetry_common.telemetry_common.UART_MAGIC:
return None
size_bytes = port.read(1)
if len(size_bytes) != 1:
return None
body_size = size_bytes[0]
body = port.read(body_size)
if len(body) != body_size:
return None
return body
def parse_uart_packet(body: bytes) -> dict | None:
telemetry_common = dataflux.telemetry_common.telemetry_common
if len(body) < telemetry_common.LORA_HEADER_SIZE:
return None
lora = telemetry_common.unpack_lora_header(
body[: telemetry_common.LORA_HEADER_SIZE]
)
payload = body[telemetry_common.LORA_HEADER_SIZE :]
if lora.size != len(payload):
print(
f"Serial size mismatch header says {lora.size} actual payload is {len(payload)}"
)
return None
calc_crc = telemetry_common.crc16_ccitt(payload)
if calc_crc != lora.crc16:
print("crc mismatch")
return None
base = {
"source": lora.source,
"dest": lora.dest,
"version": lora.version,
}
if lora.version == 1:
pkt = telemetry_common.unpack_packet1(payload)
return {
**base,
"type": "packet1",
"ping": pkt.ping.decode("ascii", errors="replace"),
}
if lora.version == 2:
pkt = telemetry_common.unpack_packet2(payload)
return {
**base,
"type": "packet2",
"time_stamp": pkt.time_stamp,
"vbat": pkt.vbat,
"teng": pkt.teng,
"lat": pkt.lat,
"lng": pkt.lng,
"speed": pkt.speed,
}
if lora.version == 3:
pkt = 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

@@ -0,0 +1,25 @@
# 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 time
import serial.tools.list_ports
from dataflux.state import AppState
def list_serial_ports(state: AppState) -> list[str]:
valid_ports: list[str] = []
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 ports_worker(state: AppState) -> None:
while state.ports_thread_running:
state.ports = serial.tools.list_ports.comports()
time.sleep(5)

View File

@@ -2,263 +2,29 @@
# 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 from dataflux.services.lora import (
import os connect_lora,
from queue import Empty disconnect_lora,
from sys import base_exec_prefix lora_reader_worker,
from threading import Thread parse_uart_packet,
import time read_one_uart_packet,
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 from dataflux.services.ports import list_serial_ports, ports_worker
import dataflux.ui.routines.status from dataflux.services.serial_console import (
import dataflux.ui.routines connect_serial,
disconnect_serial,
from dataflux.state import AppState serial_worker,
def list_serial_ports(state: AppState) -> list[str]:
valid_ports: list[str] = []
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 __all__ = [
state.lora_status_thread.start() "connect_lora",
state.lora_thread.start() "disconnect_lora",
"lora_reader_worker",
"parse_uart_packet",
def connect_serial(state: AppState, device: str) -> None: "read_one_uart_packet",
if state.serial_port is not None: "list_serial_ports",
state.serial_port.close() "ports_worker",
state.serial_port = None "connect_serial",
"disconnect_serial",
state.serial_port = Serial( "serial_worker",
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
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_nowait()
except Empty:
continue
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:
port = state.lora_port
if port is None:
break
if port.closed:
print("Port closed")
break
try:
packet = read_one_uart_packet(port)
if packet is None:
continue
parsed = parse_uart_packet(packet)
if parsed is not None:
state.packet_queue.put(parsed)
state.lora_status_queue.put(0.1)
except Exception:
break
disconnect_lora(state)
dataflux.ui.routines.update_global_connection_status(state)
def read_one_uart_packet(port: Serial) -> bytes | None:
first = port.read(1)
if not first:
return None
if first != dataflux.telemetry_common.telemetry_common.UART_MAGIC[:1]:
return None
rest_magic = port.read(3)
if len(rest_magic) != 3:
return None
if first + rest_magic != dataflux.telemetry_common.telemetry_common.UART_MAGIC:
return None
size_bytes = port.read(1)
if len(size_bytes) != 1:
return None
body_size = size_bytes[0]
body = port.read(body_size)
if len(body) != body_size:
return 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 :]
if lora.size != 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
base = {
"source": lora.source,
"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"),
}
if lora.version == 2:
pkt = dataflux.telemetry_common.telemetry_common.unpack_packet2(payload)
return {
**base,
"type": "packet2",
"time_stamp": pkt.time_stamp,
"vbat": pkt.vbat,
"teng": pkt.teng,
"lat": pkt.lat,
"lng": pkt.lng,
"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

@@ -0,0 +1,92 @@
# 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 os
from queue import Empty
from threading import Thread
from serial import Serial
import serial
from dataflux.state import AppState
def connect_serial(state: AppState, device: str) -> bool:
if not device:
print("No serial console port selected")
state.connection_status_dirty = True
return False
if state.serial_port is not None:
state.serial_port.close()
state.serial_port = None
try:
state.serial_port = Serial(
port=device, baudrate=115200, timeout=0.05, write_timeout=0.1
)
except serial.SerialException as exc:
print(f"Could not open serial console port {device!r}: {exc}")
state.serial_port = None
state.serial_thread_running = False
state.connection_status_dirty = True
return False
state.serial_thread = Thread(target=serial_worker, args=(state,), daemon=True)
state.serial_thread_running = True
state.connection_status_dirty = True
state.serial_thread.start()
return True
def disconnect_serial(state: AppState) -> None:
if state.serial_port is None:
return
state.serial_thread_running = False
try:
state.serial_port.close()
except OSError:
pass
state.serial_port = None
state.connection_status_dirty = True
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)

View File

@@ -51,13 +51,11 @@ class AppState:
telemetry_thread: Thread | None = None telemetry_thread: Thread | None = None
telemetry_thread_running: bool = False telemetry_thread_running: bool = False
lora_status_thread: Thread | None = None
lora_status_queue: Queue = field(default_factory=Queue) lora_status_queue: Queue = field(default_factory=Queue)
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 connection_status_dirty: bool = True
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)
@@ -82,4 +80,8 @@ class AppState:
lap_loader_thread: Thread | None = None lap_loader_thread: Thread | None = None
live_map_pending_recenter: bool = False
live_map_pending_zoom_in: bool = False
live_map_pending_zoom_out: bool = False
lock: Lock = field(default_factory=Lock) lock: Lock = field(default_factory=Lock)

View File

@@ -70,3 +70,6 @@ GRAPH_SERIES_TENG: str = "graph_series_teng"
GRAPH_X_AXIS_TENG_LR: str = "graph_x_axis_teng_lr" GRAPH_X_AXIS_TENG_LR: str = "graph_x_axis_teng_lr"
GRAPH_Y_AXIS_TENG_LR: str = "graph_y_axis_teng_lr" GRAPH_Y_AXIS_TENG_LR: str = "graph_y_axis_teng_lr"
GRAPH_SERIES_TENG_LR: str = "graph_series_teng_lr" GRAPH_SERIES_TENG_LR: str = "graph_series_teng_lr"
DEFAULT_LAT: float = 47.843834
DEFAULT_LNG: float = 1.937727

View File

@@ -2,7 +2,6 @@
# 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
import dearpygui.dearpygui as dpg
import dataflux.ui.routines.menu import dataflux.ui.routines.menu
import dataflux.ui.routines.status import dataflux.ui.routines.status

View File

@@ -10,10 +10,8 @@ from dataflux.tags import (
STATUS_SERIAL_STATUS_BOX, STATUS_SERIAL_STATUS_BOX,
STATUS_SERIAL_STATUS_TEXT, STATUS_SERIAL_STATUS_TEXT,
THEME_STATUS_CONNECTED, THEME_STATUS_CONNECTED,
THEME_STATUS_CONNECTED_BRIGHT,
THEME_STATUS_DISCONNECTED, THEME_STATUS_DISCONNECTED,
) )
from time import sleep
def update_status_connection_status(state: AppState): def update_status_connection_status(state: AppState):
@@ -30,9 +28,3 @@ def update_status_connection_status(state: AppState):
else: else:
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, 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,7 +3,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import dearpygui.dearpygui as dpg import dearpygui.dearpygui as dpg
from dataflux.services.serial import list_serial_ports from dataflux.services.ports import list_serial_ports
from dataflux.state import AppState from dataflux.state import AppState
from dataflux.tags import ( from dataflux.tags import (
PAGE_LAP_RECAP, PAGE_LAP_RECAP,

View File

@@ -2,8 +2,14 @@
# 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 from ast import arg
import dearpygui.dearpygui as dpg import dearpygui.dearpygui as dpg
import dpg_map as dpgm
from dataflux.callbacks.map import (
live_map_recenter,
live_map_zoom_in,
live_map_zoom_out,
)
import dataflux.callbacks.menu import dataflux.callbacks.menu
import dataflux.callbacks.serial import dataflux.callbacks.serial
@@ -11,6 +17,8 @@ from dataflux.state import AppState
from dataflux.tags import ( from dataflux.tags import (
BUTTON_SERIAL_CONSOLE_SEND, BUTTON_SERIAL_CONSOLE_SEND,
CHILD_WINDOW_SERIAL_CONSOLE, CHILD_WINDOW_SERIAL_CONSOLE,
DEFAULT_LAT,
DEFAULT_LNG,
GRAPH_SERIES_SPEED, GRAPH_SERIES_SPEED,
GRAPH_SERIES_SPEED_LR, GRAPH_SERIES_SPEED_LR,
GRAPH_SERIES_TENG, GRAPH_SERIES_TENG,
@@ -305,13 +313,31 @@ def build_windows(state: AppState) -> None:
show=False, show=False,
no_scrollbar=True, no_scrollbar=True,
): ):
with dpg.drawlist(width=500, height=500, tag="map_drawlist"): with dpgm.map_widget(
dpg.draw_image("texture_tab", (0, 0), (500, 500)) tag="live_map",
dpg.draw_circle( center=(47.843834, 1.937727),
(0, 0), zoom=15,
10, width=-1,
color=(255, 0, 0, 255), height=-36,
fill=(255, 0, 0, 255), ):
dpgm.add_marker(
"vehicle", lat=DEFAULT_LAT, lon=DEFAULT_LNG, show=True
)
with dpg.group(horizontal=True):
dpg.add_button(
label="Recenter",
callback=live_map_recenter,
user_data=state,
)
dpg.add_button(
label="Zoom In",
callback=live_map_zoom_in,
user_data=state,
)
dpg.add_button(
label="Zoom Out",
callback=live_map_zoom_out,
user_data=state,
) )
with dpg.group(tag=PAGE_LAP_RECAP, show=False): with dpg.group(tag=PAGE_LAP_RECAP, show=False):

View File

@@ -2,15 +2,17 @@
# 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 queue import Empty
import dearpygui.dearpygui as dpg
import datetime
import time
from datetime import datetime, timezone from datetime import datetime, timezone
import serial.tools.list_ports from queue import Empty
import time
import dearpygui.dearpygui as dpg
import dpg_map as dpgm
from dataflux.state import AppState from dataflux.state import AppState
from dataflux.tags import ( from dataflux.tags import (
DEFAULT_LAT,
DEFAULT_LNG,
GRAPH_SERIES_SPEED, GRAPH_SERIES_SPEED,
GRAPH_SERIES_SPEED_LR, GRAPH_SERIES_SPEED_LR,
GRAPH_SERIES_TENG, GRAPH_SERIES_TENG,
@@ -20,51 +22,114 @@ from dataflux.tags import (
GRAPH_X_AXIS_SPEED_LR, GRAPH_X_AXIS_SPEED_LR,
GRAPH_X_AXIS_TENG_LR, GRAPH_X_AXIS_TENG_LR,
GRAPH_X_AXIS_VBAT_LR, GRAPH_X_AXIS_VBAT_LR,
LIVE_DATA_SPEED_VALUE,
LIVE_DATA_TENG_VALUE, LIVE_DATA_TENG_VALUE,
LIVE_DATA_UTC_TIME_VALUE, LIVE_DATA_UTC_TIME_VALUE,
LIVE_DATA_VBAT_VALUE, LIVE_DATA_VBAT_VALUE,
LIVE_DATA_VEHICLE_TIME_VALUE, LIVE_DATA_VEHICLE_TIME_VALUE,
LIVE_DATA_SPEED_VALUE,
MENU_FILE_AUTOSAVE_BUFFERS, MENU_FILE_AUTOSAVE_BUFFERS,
STATUS_LORA_STATUS_BOX,
STATUS_SERIAL_STATUS_BOX,
THEME_STATUS_CONNECTED,
THEME_STATUS_CONNECTED_BRIGHT,
) )
from dataflux.ui.routines import update_global_connection_status
from dataflux.ui.routines.serial import append_text_to_console from dataflux.ui.routines.serial import append_text_to_console
def ports_worker(state: AppState) -> None: class UiFrameUpdater:
while state.ports_thread_running: def __init__(self) -> None:
state.ports = serial.tools.list_ports.comports() self.last_datetime: str = ""
time.sleep(5) self.last_veh_time: str = ""
self.last_veh_speed: str = ""
self.last_vbat: str = ""
self.last_teng: str = ""
self.no_data_written = False
self.lora_flash_until = 0.0
self.serial_flash_until = 0.0
self.last_map_update = 0.0
def update(self, state: AppState) -> None:
self._update_connection_status(state)
self._update_status_flashes(state)
self._update_autosave_menu(state)
self._update_utc_time()
self._drain_serial_console(state)
self._update_lap_recap(state)
self._update_live_telemetry(state)
self._update_live_map(state)
def ui_worker(state: AppState): def _update_connection_status(self, state: AppState) -> None:
last_datetime: str = "" if not state.connection_status_dirty:
last_veh_time: str = "" return
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) update_global_connection_status(state)
formatted = now.strftime("%H:%M:%S") state.connection_status_dirty = False
if formatted != last_datetime: def _update_status_flashes(self, state: AppState) -> None:
now = time.monotonic()
self.lora_flash_until = self._drain_flash_queue(
state.lora_status_queue, STATUS_LORA_STATUS_BOX, self.lora_flash_until, now
)
self.serial_flash_until = self._drain_flash_queue(
state.serial_status_queue,
STATUS_SERIAL_STATUS_BOX,
self.serial_flash_until,
now,
)
if state.lora_port is not None:
theme = (
THEME_STATUS_CONNECTED_BRIGHT
if now < self.lora_flash_until
else THEME_STATUS_CONNECTED
)
dpg.bind_item_theme(STATUS_LORA_STATUS_BOX, theme)
if state.serial_port is not None:
theme = (
THEME_STATUS_CONNECTED_BRIGHT
if now < self.serial_flash_until
else THEME_STATUS_CONNECTED
)
dpg.bind_item_theme(STATUS_SERIAL_STATUS_BOX, theme)
def _drain_flash_queue(
self, queue, tag: str, flash_until: float, now: float
) -> float:
while True:
try:
duration = queue.get_nowait()
except Empty:
return flash_until
flash_until = max(flash_until, now + duration)
dpg.bind_item_theme(tag, THEME_STATUS_CONNECTED_BRIGHT)
def _update_autosave_menu(self, state: AppState) -> None:
dpg.set_value(MENU_FILE_AUTOSAVE_BUFFERS, state.autosave_enabled)
def _update_utc_time(self) -> None:
formatted = datetime.now(timezone.utc).strftime("%H:%M:%S")
if formatted != self.last_datetime:
dpg.set_value(LIVE_DATA_UTC_TIME_VALUE, formatted) dpg.set_value(LIVE_DATA_UTC_TIME_VALUE, formatted)
last_datetime = formatted self.last_datetime = formatted
if state.serial_thread_running: def _drain_serial_console(self, state: AppState) -> None:
while True:
try: try:
text = state.serial_data_queue.get_nowait() text = state.serial_data_queue.get_nowait()
except Empty: except Empty:
pass break
else:
append_text_to_console(text) append_text_to_console(text)
if state.lap_recap_updated: def _update_lap_recap(self, state: AppState) -> None:
if not state.lap_recap_updated:
return
state.lap_recap_updated = False state.lap_recap_updated = False
timestamps = state.lap_recap_buffers.timestamp timestamps = state.lap_recap_buffers.timestamp
@@ -73,32 +138,32 @@ def ui_worker(state: AppState):
x_common = [(t - t0) / 100.0 for t in timestamps] x_common = [(t - t0) / 100.0 for t in timestamps]
else: else:
x_common = [] x_common = []
dpg.set_value(
GRAPH_SERIES_SPEED_LR, dpg.set_value(GRAPH_SERIES_SPEED_LR, [x_common, state.lap_recap_buffers.speed])
[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])
dpg.set_value(
GRAPH_SERIES_VBAT_LR, if not x_common:
[x_common, state.lap_recap_buffers.vbat], return
)
dpg.set_value(
GRAPH_SERIES_TENG_LR,
[x_common, state.lap_recap_buffers.teng],
)
axis_min = x_common[0] axis_min = x_common[0]
axis_max = x_common[-1] 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_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_VBAT_LR, ymin=axis_min, ymax=axis_max)
dpg.set_axis_limits(GRAPH_X_AXIS_TENG_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: def _update_live_telemetry(self, state: AppState) -> None:
if not (state.lora_thread_running and state.telemetry_valid):
self._write_no_data()
return
x_common: list[float] | None = None x_common: list[float] | None = None
speed_y: list[float] | None = None speed_y: list[float] | None = None
vbat_y: list[float] | None = None vbat_y: list[float] | None = None
teng_y: list[float] | None = None teng_y: list[float] | None = None
with state.lock: with state.lock:
# Vehicle Time self.no_data_written = False
no_data_written = False
veh_time = state.latest_telemetry["time_stamp"] veh_time = state.latest_telemetry["time_stamp"]
veh_speed = state.latest_telemetry["speed"] veh_speed = state.latest_telemetry["speed"]
vbat = state.latest_telemetry["vbat"] vbat = state.latest_telemetry["vbat"]
@@ -114,40 +179,78 @@ def ui_worker(state: AppState):
minutes = (veh_time % 360000) // 6000 minutes = (veh_time % 360000) // 6000
seconds = (veh_time % 6000) // 100 seconds = (veh_time % 6000) // 100
formatted = f"{hours:02d}:{minutes:02d}:{seconds:02d}" formatted = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
if formatted != last_veh_time: if formatted != self.last_veh_time:
dpg.set_value(LIVE_DATA_VEHICLE_TIME_VALUE, formatted) dpg.set_value(LIVE_DATA_VEHICLE_TIME_VALUE, formatted)
last_veh_time = formatted self.last_veh_time = formatted
# Speed
formatted = f"{veh_speed:05.2f}" formatted = f"{veh_speed:05.2f}"
if formatted != last_veh_speed: if formatted != self.last_veh_speed:
dpg.set_value(LIVE_DATA_SPEED_VALUE, formatted) dpg.set_value(LIVE_DATA_SPEED_VALUE, formatted)
last_veh_speed = formatted self.last_veh_speed = formatted
# VBAT
formatted = f"{vbat:05.2f}" formatted = f"{vbat:05.2f}"
if formatted != last_vbat: if formatted != self.last_vbat:
dpg.set_value(LIVE_DATA_VBAT_VALUE, formatted) dpg.set_value(LIVE_DATA_VBAT_VALUE, formatted)
last_vbat = formatted self.last_vbat = formatted
# TENG
formatted = f"{teng:05.2f}" formatted = f"{teng:05.2f}"
if formatted != last_teng: if formatted != self.last_teng:
dpg.set_value(LIVE_DATA_TENG_VALUE, formatted) dpg.set_value(LIVE_DATA_TENG_VALUE, formatted)
last_teng = formatted self.last_teng = formatted
if x_common is not None: if x_common is not None:
dpg.set_value(GRAPH_SERIES_SPEED, [x_common, speed_y]) 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_VBAT, [x_common, vbat_y])
dpg.set_value(GRAPH_SERIES_TENG, [x_common, teng_y]) dpg.set_value(GRAPH_SERIES_TENG, [x_common, teng_y])
else: def _write_no_data(self) -> None:
if not no_data_written: if self.no_data_written:
return
dpg.set_value(LIVE_DATA_VEHICLE_TIME_VALUE, "no_data") dpg.set_value(LIVE_DATA_VEHICLE_TIME_VALUE, "no_data")
dpg.set_value(LIVE_DATA_SPEED_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_VBAT_VALUE, "no_data")
dpg.set_value(LIVE_DATA_TENG_VALUE, "no_data") dpg.set_value(LIVE_DATA_TENG_VALUE, "no_data")
last_veh_time = last_veh_speed = last_vbat = last_teng = "no_data" self.last_veh_time = self.last_veh_speed = self.last_vbat = self.last_teng = (
no_data_written = True "no_data"
)
self.no_data_written = True
time.sleep(0.05) def _update_live_map(self, state: AppState) -> None:
now = time.monotonic()
if state.live_map_pending_recenter:
with state.lock:
try:
lat = float(state.latest_telemetry.get("lat", DEFAULT_LAT))
except TypeError, ValueError:
lat = DEFAULT_LAT
try:
lon = float(state.latest_telemetry.get("lng", DEFAULT_LNG))
except TypeError, ValueError:
lon = DEFAULT_LNG
dpgm.set_center(lat=lat, lon=lon, map_tag="live_map")
state.live_map_pending_recenter = False
if state.live_map_pending_zoom_in:
zl: int = dpgm.get_zoom(map_tag="live_map")
dpgm.set_zoom(zoom=zl + 1, map_tag="live_map")
state.live_map_pending_zoom_in = False
if state.live_map_pending_zoom_out:
zl: int = dpgm.get_zoom(map_tag="live_map")
dpgm.set_zoom(zoom=zl - 1, map_tag="live_map")
state.live_map_pending_zoom_out = False
if now - self.last_map_update < 0.1:
return
if not (state.lora_thread_running and state.telemetry_valid):
return
with state.lock:
lat: float = state.latest_telemetry["lat"]
lon: float = state.latest_telemetry["lng"]
dpgm.update_marker("vehicle", lat=lat, lon=lon, map_tag="live_map")
self.last_map_update = now