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:
@@ -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()
|
||||
|
||||
3
src/dynalab/data/__init__.py
Normal file
3
src/dynalab/data/__init__.py
Normal 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
126
src/dynalab/data/json.py
Normal 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
13
src/dynalab/state.py
Normal 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
4
src/dynalab/tags.py
Normal 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"
|
||||
3
src/dynalab/ui/__init__.py
Normal file
3
src/dynalab/ui/__init__.py
Normal 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
29
src/dynalab/ui/windows.py
Normal 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)
|
||||
Reference in New Issue
Block a user