Files
DynaLab/src/dynalab/app.py

257 lines
7.6 KiB
Python

# 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 logging
from pathlib import Path
from queue import Empty, Queue
from threading import Thread
import threading
import time
import traceback
import dearpygui.dearpygui as dpg
import sys
from dynalab.config import ConfigLoadError, load_config
from dynalab.data.json import json_thread_main
from dynalab.state import AppState
from dynalab.tags import WINDOW_DATA_INPUT_GAUGE_GRID
from dynalab.ui.error import build_error_modal, show_error_modal
import dynalab.ui.windows
from dynalab.ui.windows import (
LOADING_WINDOW_HEIGHT,
LOADING_WINDOW_WIDTH,
build_loading,
build_windows,
center_loading_content,
)
import dpg_gauges as dpgg
from dpg_gauges.exceptions import GaugeNotFoundError
def _setup_logging(level: int = logging.INFO) -> None:
logging.basicConfig(
level=level,
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%H:%M:%S",
stream=sys.stdout,
)
def _asset_path(relative_path: str) -> str:
path = Path(relative_path)
candidates: list[Path] = []
bundle_dir = getattr(sys, "_MEIPASS", None)
if bundle_dir is not None:
candidates.append(Path(bundle_dir) / path)
candidates.extend(
(
Path.cwd() / path,
Path(__file__).resolve().parents[2] / path,
)
)
for candidate in candidates:
if candidate.exists():
return str(candidate)
searched = ", ".join(str(candidate) for candidate in candidates)
raise FileNotFoundError(f"Missing asset {relative_path!r}. Searched: {searched}")
def _init_worker(
state: AppState, stop_event: threading.Event, message_queue: Queue[str]
) -> None:
try:
time.sleep(1.0)
message_queue.put("Fetching DynaLab config")
time.sleep(0.5)
state.app_config = load_config()
message_queue.put("Fetched DynaLab config")
time.sleep(0.5)
message_queue.put("Starting JSON data server")
# Init and start json connector thread
state.data_json_thread = Thread(
target=json_thread_main,
name="json-connector-network",
daemon=True,
args=(state,),
)
state.data_json_thread.start()
time.sleep(0.5)
message_queue.put("JSON data server started")
time.sleep(0.5)
message_queue.put("DynaLab Ready")
time.sleep(1.0)
stop_event.set()
except ConfigLoadError as exc:
state.init_error = str(exc)
state.stop_init.set()
except Exception as exc:
state.init_error = f"Unexpected startup error: {exc}"
state.stop_init.set()
def _load_image(path: Path, tag: str) -> None:
width, height, channels, data = dpg.load_image(f"{path}")
with dpg.texture_registry():
dpg.add_static_texture(width=width, height=height, default_value=data, tag=tag)
def _maximize_viewport() -> None:
maximize_viewport = getattr(dpg, "maximize_viewport", None)
if callable(maximize_viewport):
maximize_viewport()
return
dpg.set_viewport_height(720)
dpg.set_viewport_width(1280)
def run() -> None:
# Setup logging format and level
_setup_logging(logging.DEBUG)
# dpg setup
dpg.create_context()
try:
# Switch callback management to manual
dpg.configure_app(manual_callback_management=True)
# Initialize viewport
dpg.create_viewport(
title="DynaLab", width=LOADING_WINDOW_WIDTH, height=LOADING_WINDOW_HEIGHT
)
# Load fonts at 2x size for HiDPI scaling
with dpg.font_registry():
app_font = dpg.add_font(
_asset_path("assets/fonts/Inter-Regular.ttf"), 18 * 2
)
mono_font = dpg.add_font(
_asset_path("assets/fonts/JetBrainsMono-Regular.ttf"),
size=36,
label="mono_font",
)
dpg.bind_font(app_font)
_load_image(_asset_path("assets/images/logo@300.png"), "logo")
build_loading()
build_error_modal()
# Open viewport
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.configure_item(
"loading_window",
pos=(0, 0),
width=LOADING_WINDOW_WIDTH,
height=LOADING_WINDOW_HEIGHT,
)
# Init global app state
state: AppState = AppState()
loading_msg_queue: Queue[str] = Queue()
loading_stop: threading.Event = threading.Event()
loading_thread = Thread(
target=_init_worker,
name="init-worker-thread",
args=(state, loading_stop, loading_msg_queue),
daemon=True,
)
loading_thread.start()
while not loading_stop.is_set():
# Fetch and execute callbacks
if state.stop_init.is_set():
show_error_modal(
state.init_error if state.init_error is not None else "Fatal error"
)
jobs = dpg.get_callback_queue()
try:
dpg.run_callbacks(jobs)
except Exception:
traceback.print_exc()
center_loading_content()
try:
msg = loading_msg_queue.get_nowait()
except Empty:
pass
else:
dpg.set_value(item="loading_text", value=msg)
# Render frame
dpg.render_dearpygui_frame()
dpg.delete_item("loading_window")
# Call ui building function
_maximize_viewport()
build_windows(state)
# Config main window size
vp_w = dpg.get_viewport_client_width()
vp_h = dpg.get_viewport_client_height()
dpg.configure_item("main_window", pos=(0, 0), width=vp_w, height=vp_h)
dpg.set_primary_window("main_window", True)
# dpg UI loop
while dpg.is_dearpygui_running():
# Fetch and execute callbacks
jobs = dpg.get_callback_queue()
try:
dpg.run_callbacks(jobs)
except Exception:
traceback.print_exc()
try:
new_signal = state.new_signal_queue.get_nowait()
except Empty:
pass
else:
dpgg.digital_gauge(
tag=f"{new_signal.id}",
label=f"{new_signal.name}",
parent=WINDOW_DATA_INPUT_GAUGE_GRID,
unit=new_signal.unit if new_signal.unit is not None else "",
value=0,
precision=1,
min_value=new_signal.min_value,
max_value=new_signal.max_value,
)
try:
removed_signal = state.removed_signal_queue.get_nowait()
except Empty:
pass
else:
dpgg.delete_gauge(f"{removed_signal.id}")
ctr: int = 0
while not state.new_data_id.empty() or ctr < 5:
try:
new_value_id = state.new_data_id.get_nowait()
except Empty:
pass
else:
try:
dpgg.set_value(
tag=f"{new_value_id}", value=state.live_data[new_value_id]
)
except GaugeNotFoundError:
pass
ctr += 1
# Render frame
dpg.render_dearpygui_frame()
finally:
state.stop_event.set()
dpg.destroy_context()