Initial implementation

Contains JSON hanshake and heartbeat procedure, including rejecting
handshake when no data exposed by connector
Simple loading screen, and then UI opens, no major UI work has been done
yet
This commit is contained in:
2026-07-07 16:33:41 +02:00
parent a94f845cdb
commit 2748a90576
11 changed files with 806 additions and 4 deletions

View File

@@ -1,2 +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
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.data.json import json_thread_main
from dynalab.state import AppState
import dynalab.ui.windows
from dynalab.ui.windows import build_loading, build_windows
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:
time.sleep(1.0)
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()
def run() -> None:
print("Hello World!")
# 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=600, height=400)
# 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)
build_loading()
# Open viewport
dpg.setup_dearpygui()
dpg.show_viewport()
# Config main window size
vp_w = dpg.get_viewport_client_width()
vp_h = dpg.get_viewport_client_height()
dpg.configure_item("loading_window", pos=(0, 0), width=vp_w, height=vp_h)
dpg.set_primary_window("loading_window", True)
# 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
jobs = dpg.get_callback_queue()
try:
dpg.run_callbacks(jobs)
except Exception:
traceback.print_exc()
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
dpg.set_viewport_height(720)
dpg.set_viewport_width(1280)
build_windows()
# 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()
# Render frame
dpg.render_dearpygui_frame()
finally:
state.stop_event.set()
dpg.destroy_context()

View File

@@ -0,0 +1,3 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later

126
src/dynalab/data/json.py Normal file
View File

@@ -0,0 +1,126 @@
# 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 asyncio
from functools import partial
import uuid
from dynalab.state import AppState
from dynalab_protocol.models import (
ConnectorHello,
DynaLabHello,
HandshakeAccepted,
HandshakeRejected,
Heartbeat,
VersionDescriptor,
machine_timestamp_ms,
)
from dynalab_protocol.wire import write_message, read_message
async def _heartbeat_loop(writer: asyncio.StreamWriter, period_ms: int = 1000) -> None:
sequence: int = 0
while True:
heartbeat = Heartbeat(sequence=sequence, send_timestamp=machine_timestamp_ms())
await write_message(writer, heartbeat)
print(f"Sent heartbeat {sequence}")
sequence += 1
await asyncio.sleep(float(period_ms) / 1000)
async def _timeout_checker(
state: AppState,
connector_uuid: uuid.UUID,
max_timeout: int,
timeout_event: asyncio.Event,
) -> None:
while True:
last_heartbeat = state.last_heartbeat.get(uuid)
if last_heartbeat is not None:
if machine_timestamp_ms() > last_heartbeat + max_timeout:
timeout_event.set()
await asyncio.sleep(0.5)
async def _handle_connector(
state: AppState, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
peer = writer.get_extra_info("peername")
print(f"Connector connected {peer}")
try:
dynalab_hello = DynaLabHello(
instance_id=uuid.uuid4(),
version=VersionDescriptor(type="alpha", major=0, minor=0, patch=1),
)
await write_message(writer, dynalab_hello)
message = await asyncio.wait_for(read_message(reader), timeout=5.0)
if not isinstance(message, ConnectorHello):
raise ValueError(f"Expected connector_hello, received {message.type}")
connector_hello = message
print(f"Connector connected: {connector_hello}")
if len(connector_hello.signals) == 0:
handshake_rejected = HandshakeRejected(
reason="Signals list is empty, cannot connecto to empty connector"
)
await write_message(writer, handshake_rejected)
print("Rejecting handshake due to empty list")
return
handshake_accepted = HandshakeAccepted(
accepted_signals=[signal.id for signal in connector_hello.signals]
)
await write_message(writer, handshake_accepted)
heartbeat_task = asyncio.create_task(_heartbeat_loop(writer))
timeout_event = asyncio.Event()
timeout_task = asyncio.create_task(
_timeout_checker(
state,
connector_hello.connector_uuid,
dynalab_hello.heartbeat_timeout_ms,
timeout_event,
)
)
while not timeout_event.is_set():
try:
message = await asyncio.wait_for(read_message(reader), timeout=0.1)
if isinstance(message, Heartbeat):
state.last_heartbeat[connector_hello.connector_uuid] = (
message.return_timestamp
)
except TimeoutError:
continue
except ConnectionError:
return
finally:
heartbeat_task.cancel()
timeout_task.cancel()
print(f"Connector disconnected {peer}")
writer.close()
await writer.wait_closed()
async def _json_main(state: AppState) -> None:
server = await asyncio.start_server(
partial(_handle_connector, state), host="127.0.0.1", port=8765
)
print("Json connector server listening on 127.0.0.1:8765")
async with server:
while not state.stop_event.is_set():
await asyncio.sleep(0.1)
def json_thread_main(state: AppState) -> None:
asyncio.run(_json_main(state))

13
src/dynalab/state.py Normal file
View File

@@ -0,0 +1,13 @@
# 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 dataclasses import dataclass, field
import threading
from uuid import UUID
@dataclass
class AppState:
stop_event = threading.Event()
data_json_thread: threading.Thread | None = None
last_heartbeat: dict[UUID, int] = field(default_factory=dict)

4
src/dynalab/tags.py Normal file
View File

@@ -0,0 +1,4 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
MENU_WINDOW_DATA_INPUT: str = "menu_window_data_input"

View File

@@ -0,0 +1,3 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later

29
src/dynalab/ui/windows.py Normal file
View File

@@ -0,0 +1,29 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
import dearpygui.dearpygui as dpg
import dpg_gauges as dpgg
from dynalab.tags import MENU_WINDOW_DATA_INPUT
def build_loading() -> None:
with dpg.window(label="DynaLab", tag="loading_window", no_collapse=True):
dpg.set_global_font_scale(0.5)
dpg.add_text(default_value="Loading DynaLab", tag="loading_text")
def build_windows() -> None:
with dpg.window(label="DynaLab", tag="main_window", no_collapse=True):
with dpg.menu_bar():
with dpg.menu(label="File"):
dpg.add_menu_item(label="Quit", enabled=True)
with dpg.menu(label="Window"):
dpg.add_menu_item(label="Data Input")
with dpg.child_window(
tag="content_area", autosize_x=True, height=0, border=False
):
with dpg.group(tag=MENU_WINDOW_DATA_INPUT):
dpgg.status_light(label="JSON", show_value=False)