diff --git a/.gitignore b/.gitignore index 9c1a3a8..068aa5d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ wheels/ # Build generated files *.spec + +.cache/ diff --git a/src/dataflux/app.py b/src/dataflux/app.py index f1a8120..4cfb6dc 100644 --- a/src/dataflux/app.py +++ b/src/dataflux/app.py @@ -9,8 +9,9 @@ from threading import Thread import dearpygui.dearpygui as dpg from dataflux.state import AppState -import dataflux.config +import dpg_map as dpgm from dataflux.tags import TEXT_SERIAL_CONSOLE +import dataflux.services.ports import dataflux.ui.windows import dataflux.ui.worker import dataflux.services.telemetry @@ -43,21 +44,13 @@ def run() -> None: state: AppState = AppState() state.start_time = datetime.now() + dpgm.configure( + user_agent="DataFlux/0.1 contact:h3cx@h3cx.dev", cache_dir="./.cache" + ) + # Create application context and viewport dpg.create_context() - - 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.configure_app(manual_callback_management=True) dpg.create_viewport(title="DataFlux", width=600, height=600) @@ -82,11 +75,6 @@ def run() -> None: dpg.set_primary_window("main_window", True) dpg.bind_item_font(TEXT_SERIAL_CONSOLE, mono_font) - state.ui_worker_thread = Thread( - target=dataflux.ui.worker.ui_worker, args=(state,), daemon=True - ) - state.ui_worker_thread.start() - state.telemetry_thread_running = True state.telemetry_thread = Thread( target=dataflux.services.telemetry.telemetry_worker, args=(state,), daemon=True @@ -95,10 +83,21 @@ def run() -> None: state.ports_thread_running = True 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() - 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() diff --git a/src/dataflux/callbacks/map.py b/src/dataflux/callbacks/map.py new file mode 100644 index 0000000..eca167d --- /dev/null +++ b/src/dataflux/callbacks/map.py @@ -0,0 +1,17 @@ +# Copyright (C) 2026 Hector van der Aa +# Copyright (C) 2026 Association Exergie +# 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 diff --git a/src/dataflux/callbacks/menu.py b/src/dataflux/callbacks/menu.py index 42d40e9..b0fc2a3 100644 --- a/src/dataflux/callbacks/menu.py +++ b/src/dataflux/callbacks/menu.py @@ -2,14 +2,13 @@ # Copyright (C) 2026 Association Exergie # SPDX-License-Identifier: GPL-3.0-or-later -from ast import arg from threading import Thread import dearpygui.dearpygui as dpg from dataflux.state import AppState from dataflux.ui.routines import update_global_connection_status import dataflux.ui.routines.windows -import dataflux.ui.routines.status -import dataflux.services.serial +import dataflux.services.lora +import dataflux.services.serial_console import dataflux.services.telemetry 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: - dataflux.services.serial.disconnect_lora(user_data) + dataflux.services.lora.disconnect_lora(user_data) update_global_connection_status(user_data) def menu_io_disconnect_serial(sender, app_data, user_data: AppState) -> None: - dataflux.services.serial.disconnect_serial(user_data) + dataflux.services.serial_console.disconnect_serial(user_data) update_global_connection_status(user_data) diff --git a/src/dataflux/callbacks/serial.py b/src/dataflux/callbacks/serial.py index ba4d994..4d15bd7 100644 --- a/src/dataflux/callbacks/serial.py +++ b/src/dataflux/callbacks/serial.py @@ -3,7 +3,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later import dearpygui.dearpygui as dpg -import dataflux.services.serial +import dataflux.services.lora +import dataflux.services.serial_console import dataflux.ui.routines from dataflux.state import AppState @@ -18,16 +19,18 @@ from dataflux.tags import ( 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) + connected = dataflux.services.lora.connect_lora(user_data, device) dataflux.ui.routines.update_global_connection_status(user_data) - dpg.hide_item(WINDOW_LORA_CONNECTION_MENU) + if connected: + dpg.hide_item(WINDOW_LORA_CONNECTION_MENU) def connection_window_connect_serial(sender, app_data, user_data: AppState) -> None: 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) - dpg.hide_item(WINDOW_SERIAL_CONNECTION_MENU) + if connected: + dpg.hide_item(WINDOW_SERIAL_CONNECTION_MENU) def serial_console_button_send(sender, app_data, user_data: AppState) -> None: diff --git a/src/dataflux/services/lora.py b/src/dataflux/services/lora.py new file mode 100644 index 0000000..002119e --- /dev/null +++ b/src/dataflux/services/lora.py @@ -0,0 +1,167 @@ +# Copyright (C) 2026 Hector van der Aa +# Copyright (C) 2026 Association Exergie +# 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 diff --git a/src/dataflux/services/ports.py b/src/dataflux/services/ports.py new file mode 100644 index 0000000..a9a41ee --- /dev/null +++ b/src/dataflux/services/ports.py @@ -0,0 +1,25 @@ +# Copyright (C) 2026 Hector van der Aa +# Copyright (C) 2026 Association Exergie +# 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) diff --git a/src/dataflux/services/serial/__init__.py b/src/dataflux/services/serial/__init__.py index 02ff347..fb5891f 100644 --- a/src/dataflux/services/serial/__init__.py +++ b/src/dataflux/services/serial/__init__.py @@ -2,263 +2,29 @@ # Copyright (C) 2026 Association Exergie # 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, +from dataflux.services.lora import ( + connect_lora, + disconnect_lora, + lora_reader_worker, + parse_uart_packet, + read_one_uart_packet, +) +from dataflux.services.ports import list_serial_ports, ports_worker +from dataflux.services.serial_console import ( + connect_serial, + disconnect_serial, + serial_worker, ) -import dataflux.telemetry_common.telemetry_common -import dataflux.ui.routines.status -import dataflux.ui.routines -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 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, 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 +__all__ = [ + "connect_lora", + "disconnect_lora", + "lora_reader_worker", + "parse_uart_packet", + "read_one_uart_packet", + "list_serial_ports", + "ports_worker", + "connect_serial", + "disconnect_serial", + "serial_worker", +] diff --git a/src/dataflux/services/serial_console.py b/src/dataflux/services/serial_console.py new file mode 100644 index 0000000..b2c8e87 --- /dev/null +++ b/src/dataflux/services/serial_console.py @@ -0,0 +1,92 @@ +# Copyright (C) 2026 Hector van der Aa +# Copyright (C) 2026 Association Exergie +# 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) diff --git a/src/dataflux/state.py b/src/dataflux/state.py index df87d4b..28fb0f9 100644 --- a/src/dataflux/state.py +++ b/src/dataflux/state.py @@ -51,13 +51,11 @@ class AppState: 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) - ui_worker_thread: Thread | None = None + connection_status_dirty: bool = True packet_queue: Queue = field(default_factory=Queue) latest_telemetry: dict = field(default_factory=dict) @@ -82,4 +80,8 @@ class AppState: 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) diff --git a/src/dataflux/tags.py b/src/dataflux/tags.py index f20afc5..f177ef8 100644 --- a/src/dataflux/tags.py +++ b/src/dataflux/tags.py @@ -70,3 +70,6 @@ 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" + +DEFAULT_LAT: float = 47.843834 +DEFAULT_LNG: float = 1.937727 diff --git a/src/dataflux/ui/routines/__init__.py b/src/dataflux/ui/routines/__init__.py index af36ce5..7fe2de9 100644 --- a/src/dataflux/ui/routines/__init__.py +++ b/src/dataflux/ui/routines/__init__.py @@ -2,7 +2,6 @@ # Copyright (C) 2026 Association Exergie # SPDX-License-Identifier: GPL-3.0-or-later -import dearpygui.dearpygui as dpg import dataflux.ui.routines.menu import dataflux.ui.routines.status diff --git a/src/dataflux/ui/routines/status.py b/src/dataflux/ui/routines/status.py index dc90cce..352970a 100644 --- a/src/dataflux/ui/routines/status.py +++ b/src/dataflux/ui/routines/status.py @@ -10,10 +10,8 @@ from dataflux.tags import ( 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): @@ -30,9 +28,3 @@ def update_status_connection_status(state: AppState): else: 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, tag: str) -> None: - dpg.bind_item_theme(tag, THEME_STATUS_CONNECTED_BRIGHT) - sleep(duration) - dpg.bind_item_theme(tag, THEME_STATUS_CONNECTED) diff --git a/src/dataflux/ui/routines/windows.py b/src/dataflux/ui/routines/windows.py index 87e405a..46d9d8f 100644 --- a/src/dataflux/ui/routines/windows.py +++ b/src/dataflux/ui/routines/windows.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later 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.tags import ( PAGE_LAP_RECAP, diff --git a/src/dataflux/ui/windows.py b/src/dataflux/ui/windows.py index 33e9d53..e132060 100644 --- a/src/dataflux/ui/windows.py +++ b/src/dataflux/ui/windows.py @@ -2,8 +2,14 @@ # Copyright (C) 2026 Association Exergie # SPDX-License-Identifier: GPL-3.0-or-later -from operator import call +from ast import arg 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.serial @@ -11,6 +17,8 @@ from dataflux.state import AppState from dataflux.tags import ( BUTTON_SERIAL_CONSOLE_SEND, CHILD_WINDOW_SERIAL_CONSOLE, + DEFAULT_LAT, + DEFAULT_LNG, GRAPH_SERIES_SPEED, GRAPH_SERIES_SPEED_LR, GRAPH_SERIES_TENG, @@ -305,13 +313,31 @@ def build_windows(state: AppState) -> None: show=False, no_scrollbar=True, ): - with dpg.drawlist(width=500, height=500, tag="map_drawlist"): - dpg.draw_image("texture_tab", (0, 0), (500, 500)) - dpg.draw_circle( - (0, 0), - 10, - color=(255, 0, 0, 255), - fill=(255, 0, 0, 255), + with dpgm.map_widget( + tag="live_map", + center=(47.843834, 1.937727), + zoom=15, + width=-1, + height=-36, + ): + 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): diff --git a/src/dataflux/ui/worker.py b/src/dataflux/ui/worker.py index 940bcf3..33c1fbc 100644 --- a/src/dataflux/ui/worker.py +++ b/src/dataflux/ui/worker.py @@ -2,15 +2,17 @@ # Copyright (C) 2026 Association Exergie # 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 queue import Empty +import time + +import dearpygui.dearpygui as dpg +import dpg_map as dpgm from dataflux.state import AppState from dataflux.tags import ( + DEFAULT_LAT, + DEFAULT_LNG, GRAPH_SERIES_SPEED, GRAPH_SERIES_SPEED_LR, GRAPH_SERIES_TENG, @@ -20,134 +22,235 @@ from dataflux.tags import ( GRAPH_X_AXIS_SPEED_LR, GRAPH_X_AXIS_TENG_LR, GRAPH_X_AXIS_VBAT_LR, + LIVE_DATA_SPEED_VALUE, 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, + 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 -def ports_worker(state: AppState) -> None: - while state.ports_thread_running: - state.ports = serial.tools.list_ports.comports() - time.sleep(5) +class UiFrameUpdater: + def __init__(self) -> None: + self.last_datetime: str = "" + 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): - last_datetime: str = "" - last_veh_time: str = "" - last_veh_speed: str = "" - last_vbat: str = "" - last_teng: str = "" - no_data_written = False - while state.running: - if state.autosave_enabled: - dpg.set_value(MENU_FILE_AUTOSAVE_BUFFERS, True) - else: - dpg.set_value(MENU_FILE_AUTOSAVE_BUFFERS, False) + def _update_connection_status(self, state: AppState) -> None: + if not state.connection_status_dirty: + return - now = datetime.now(timezone.utc) - formatted = now.strftime("%H:%M:%S") + update_global_connection_status(state) + 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) - last_datetime = formatted + self.last_datetime = formatted - if state.serial_thread_running: + def _drain_serial_console(self, state: AppState) -> None: + while True: try: text = state.serial_data_queue.get_nowait() except Empty: - pass - else: - append_text_to_console(text) + break - if state.lap_recap_updated: - state.lap_recap_updated = False - timestamps = state.lap_recap_buffers.timestamp + append_text_to_console(text) - 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) + def _update_lap_recap(self, state: AppState) -> None: + if not state.lap_recap_updated: + return - if state.lora_thread_running and state.telemetry_valid: - x_common: list[float] | None = None - speed_y: list[float] | None = None - vbat_y: list[float] | None = None - teng_y: list[float] | None = None - with state.lock: - # Vehicle Time - no_data_written = False - veh_time = state.latest_telemetry["time_stamp"] - veh_speed = state.latest_telemetry["speed"] - vbat = state.latest_telemetry["vbat"] - teng = state.latest_telemetry["teng"] - if state.live_buffers_updated: - x_common = list(state.live_buffers.timestamp) - speed_y = list(state.live_buffers.speed) - vbat_y = list(state.live_buffers.vbat) - teng_y = list(state.live_buffers.teng) - state.live_buffers_updated = False - - hours = veh_time // 360000 - minutes = (veh_time % 360000) // 6000 - seconds = (veh_time % 6000) // 100 - formatted = f"{hours:02d}:{minutes:02d}:{seconds:02d}" - if formatted != last_veh_time: - dpg.set_value(LIVE_DATA_VEHICLE_TIME_VALUE, formatted) - last_veh_time = formatted - - # Speed - formatted = f"{veh_speed:05.2f}" - if formatted != last_veh_speed: - dpg.set_value(LIVE_DATA_SPEED_VALUE, formatted) - last_veh_speed = formatted - - # VBAT - formatted = f"{vbat:05.2f}" - if formatted != last_vbat: - dpg.set_value(LIVE_DATA_VBAT_VALUE, formatted) - last_vbat = formatted - - # TENG - formatted = f"{teng:05.2f}" - if formatted != last_teng: - dpg.set_value(LIVE_DATA_TENG_VALUE, formatted) - last_teng = formatted - - if x_common is not None: - dpg.set_value(GRAPH_SERIES_SPEED, [x_common, speed_y]) - dpg.set_value(GRAPH_SERIES_VBAT, [x_common, vbat_y]) - dpg.set_value(GRAPH_SERIES_TENG, [x_common, teng_y]) + 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: - if not no_data_written: - dpg.set_value(LIVE_DATA_VEHICLE_TIME_VALUE, "no_data") - dpg.set_value(LIVE_DATA_SPEED_VALUE, "no_data") - dpg.set_value(LIVE_DATA_VBAT_VALUE, "no_data") - dpg.set_value(LIVE_DATA_TENG_VALUE, "no_data") - last_veh_time = last_veh_speed = last_vbat = last_teng = "no_data" - no_data_written = True + x_common = [] - time.sleep(0.05) + 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]) + + if not x_common: + return + + 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) + + 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 + speed_y: list[float] | None = None + vbat_y: list[float] | None = None + teng_y: list[float] | None = None + + with state.lock: + self.no_data_written = False + veh_time = state.latest_telemetry["time_stamp"] + veh_speed = state.latest_telemetry["speed"] + vbat = state.latest_telemetry["vbat"] + teng = state.latest_telemetry["teng"] + if state.live_buffers_updated: + x_common = list(state.live_buffers.timestamp) + speed_y = list(state.live_buffers.speed) + vbat_y = list(state.live_buffers.vbat) + teng_y = list(state.live_buffers.teng) + state.live_buffers_updated = False + + hours = veh_time // 360000 + minutes = (veh_time % 360000) // 6000 + seconds = (veh_time % 6000) // 100 + formatted = f"{hours:02d}:{minutes:02d}:{seconds:02d}" + if formatted != self.last_veh_time: + dpg.set_value(LIVE_DATA_VEHICLE_TIME_VALUE, formatted) + self.last_veh_time = formatted + + formatted = f"{veh_speed:05.2f}" + if formatted != self.last_veh_speed: + dpg.set_value(LIVE_DATA_SPEED_VALUE, formatted) + self.last_veh_speed = formatted + + formatted = f"{vbat:05.2f}" + if formatted != self.last_vbat: + dpg.set_value(LIVE_DATA_VBAT_VALUE, formatted) + self.last_vbat = formatted + + formatted = f"{teng:05.2f}" + if formatted != self.last_teng: + dpg.set_value(LIVE_DATA_TENG_VALUE, formatted) + self.last_teng = formatted + + if x_common is not None: + dpg.set_value(GRAPH_SERIES_SPEED, [x_common, speed_y]) + dpg.set_value(GRAPH_SERIES_VBAT, [x_common, vbat_y]) + dpg.set_value(GRAPH_SERIES_TENG, [x_common, teng_y]) + + def _write_no_data(self) -> None: + if self.no_data_written: + return + + dpg.set_value(LIVE_DATA_VEHICLE_TIME_VALUE, "no_data") + dpg.set_value(LIVE_DATA_SPEED_VALUE, "no_data") + dpg.set_value(LIVE_DATA_VBAT_VALUE, "no_data") + dpg.set_value(LIVE_DATA_TENG_VALUE, "no_data") + self.last_veh_time = self.last_veh_speed = self.last_vbat = self.last_teng = ( + "no_data" + ) + self.no_data_written = True + + 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