Moved to proper dpg renderloop and added map buttons
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,3 +11,5 @@ wheels/
|
|||||||
|
|
||||||
# Build generated files
|
# Build generated files
|
||||||
*.spec
|
*.spec
|
||||||
|
|
||||||
|
.cache/
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
17
src/dataflux/callbacks/map.py
Normal file
17
src/dataflux/callbacks/map.py
Normal 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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
167
src/dataflux/services/lora.py
Normal file
167
src/dataflux/services/lora.py
Normal 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
|
||||||
25
src/dataflux/services/ports.py
Normal file
25
src/dataflux/services/ports.py
Normal 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)
|
||||||
@@ -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
|
|
||||||
|
|||||||
92
src/dataflux/services/serial_console.py
Normal file
92
src/dataflux/services/serial_console.py
Normal 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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user