Deprecation message

This commit is contained in:
2026-08-08 22:43:01 +01:00
parent 2735e6e96b
commit 26ca2d3256
33 changed files with 889 additions and 637 deletions
+8
View File
@@ -0,0 +1,8 @@
# DynaLab-V0
> [!IMPORTANT]
> The DynaLab has moved to the [DynaLab Core](https://git.h3cx.dev/Exergie/dynalab-core) library, it can be found under the `dynalab_core.protocols` python module and documentation about the protocol can be found there
This is the initial implementation of the DynaLab project, unfortunately due to uninformed decisions and bad code segmentation practices, this project has become deemed too entangle between backend and UI to more forward.
That is why it has been moved to the [DynaLab Core](https://git.h3cx.dev/Exergie/dynalab-core) that is a library that provides purely the backend to DynaLab, and will allow for the development of a UI later down the road once the backend is fully functional.
+85
View File
@@ -0,0 +1,85 @@
# DynaLab Architecture and Naming
## Goals
DynaLab accepts live data from multiple connector implementations and presents it in a
desktop UI. The architecture keeps transport code, application state, and Dear PyGui
code independent so that adding a connector or UI feature does not require edits across
unrelated modules.
## Data Flow
```text
connector session -> ConnectorEvent queue -> UI feature controller -> feature state -> view
```
The connector thread owns sockets, handshakes, and heartbeat bookkeeping. It communicates
with the main thread only through immutable events. The main thread owns application state
and all Dear PyGui calls.
## Package Layout
```text
src/dynalab/
app.py composition, lifecycle, frame loop, shutdown
state.py focused application, signal, and recording state
connectors/
events.py typed connector-to-application events
protocol_server.py asyncio server and connector sessions
ui/
shell.py top-level window, menus, and feature navigation
loading/ loading view and tags
errors/ error modal view and tags
live_data/ gauge view, event controller, and tags
recording/ recording view, controller, presenter, and tags
```
Feature folders own their widgets, tags, callbacks, and model-to-widget synchronization.
Avoid global tag collections and modules named after generic implementation techniques,
such as `windows`, `routines`, or `render`.
## Naming
- `build_*` creates widgets once.
- `refresh_*` synchronizes existing widgets from state.
- `on_*` is a thin Dear PyGui callback.
- `handle_*` processes an application event or user intent.
- `start_*` and `stop_*` control service lifecycle.
- `load_*` and `save_*` access persistent storage.
- `*_tag` functions generate dynamic Dear PyGui identifiers.
- `*State` holds mutable state owned by the main thread.
- `*Event` is immutable communication across a boundary.
Tags are feature-qualified strings, for example `recording.dialog` and
`live_data.gauge.<uuid>`. Display labels are never parsed to recover domain identifiers;
features maintain explicit label-to-ID mappings.
## Ownership Rules
- Only the connector thread accesses streams, sessions, and heartbeat timestamps.
- Only the main thread mutates `ApplicationState` and calls Dear PyGui.
- `app.py` composes services and dispatches events; it does not build feature widgets.
- Controllers translate intent or events into state changes and view operations.
- Views construct widgets and contain no connector logic.
- Presenters format state for existing widgets and contain no networking logic.
## Adding a Connector
A connector adapts its transport into the event types in `connectors/events.py`. It must
not mutate application state or invoke Dear PyGui. Connector-specific handshake and
framing stay within its own server/session implementation.
## Adding a UI Feature
Create a folder under `ui/` containing only the pieces the feature needs. A typical
feature has `view.py`, `controller.py`, `presenter.py`, and `tags.py`; small features may
use fewer files. Register its top-level view in `ui/shell.py` and dispatch relevant events
from the application loop.
## Testing Boundaries
- Test event and state behavior without importing Dear PyGui.
- Test controllers with a stubbed gauge or Dear PyGui adapter.
- Test connector sessions with local asyncio streams or protocol fakes.
- Keep manual connector simulators under `test/` or move them to `tools/` when the
automated integration suite is introduced.
BIN
View File
Binary file not shown.
+39 -67
View File
@@ -12,22 +12,19 @@ import dearpygui.dearpygui as dpg
import sys
from dynalab.config import ConfigLoadError, load_config
from dynalab.data.json import json_thread_main
from dynalab.connectors.protocol_server import run_protocol_server
from dynalab.i18n import init_i18n, _
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,
from dynalab.ui.errors.view import build_error_modal, show_error_modal
from dynalab.ui.live_data.controller import handle_connector_event
from dynalab.ui.loading import tags as loading_tags
from dynalab.ui.loading.view import (
WINDOW_HEIGHT as LOADING_WINDOW_HEIGHT,
WINDOW_WIDTH as LOADING_WINDOW_WIDTH,
build_loading_view,
center_loading_content,
)
import dpg_gauges as dpgg
from dpg_gauges.exceptions import GaugeNotFoundError
from dynalab.ui.shell import MAIN_WINDOW, build_shell
from dynalab.utils import resource_path
@@ -55,13 +52,13 @@ def _init_worker(
message_queue.put(_("Starting JSON data server"))
# Init and start json connector thread
state.data_json_thread = Thread(
target=json_thread_main,
state.connector_thread = Thread(
target=run_protocol_server,
name="json-connector-network",
daemon=True,
args=(state,),
)
state.data_json_thread.start()
state.connector_thread.start()
time.sleep(0.5)
message_queue.put(_("JSON data server started"))
time.sleep(0.5)
@@ -71,10 +68,10 @@ def _init_worker(
stop_event.set()
except ConfigLoadError as exc:
state.init_error = str(exc)
state.stop_init.set()
stop_event.set()
except Exception as exc:
state.init_error = f"Unexpected startup error: {exc}"
state.stop_init.set()
stop_event.set()
def _load_image(path: Path, tag: str) -> None:
@@ -98,6 +95,7 @@ def run() -> None:
_setup_logging(logging.DEBUG)
# dpg setup
state = AppState()
dpg.create_context()
try:
@@ -122,7 +120,7 @@ def run() -> None:
_load_image(resource_path("assets/images/logo@300.png"), "logo")
build_loading()
build_loading_view()
build_error_modal()
# Open viewport
@@ -130,15 +128,12 @@ def run() -> None:
dpg.show_viewport()
dpg.configure_item(
"loading_window",
loading_tags.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(
@@ -149,12 +144,8 @@ def run() -> None:
)
loading_thread.start()
while not loading_stop.is_set():
while not loading_stop.is_set() and dpg.is_dearpygui_running():
# 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)
@@ -167,20 +158,30 @@ def run() -> None:
except Empty:
pass
else:
dpg.set_value(item="loading_text", value=msg)
dpg.set_value(item=loading_tags.TEXT, value=msg)
# Render frame
dpg.render_dearpygui_frame()
dpg.delete_item("loading_window")
if not dpg.is_dearpygui_running():
return
if state.init_error is not None:
show_error_modal(state.init_error)
while dpg.is_dearpygui_running():
dpg.run_callbacks(dpg.get_callback_queue())
dpg.render_dearpygui_frame()
return
dpg.delete_item(loading_tags.WINDOW)
# Call ui building function
_maximize_viewport()
build_windows(state)
build_shell(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.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():
@@ -191,47 +192,18 @@ def run() -> None:
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:
for _ in range(100):
try:
new_value_id = state.new_data_id.get_nowait()
event = state.connector_events.get_nowait()
except Empty:
pass
break
else:
try:
dpgg.set_value(
tag=f"{new_value_id}", value=state.live_data[new_value_id]
)
except GaugeNotFoundError:
pass
ctr += 1
handle_connector_event(state, event)
# Render frame
dpg.render_dearpygui_frame()
finally:
state.stop_event.set()
if state.connector_thread is not None:
state.connector_thread.join(timeout=2)
dpg.destroy_context()
+1
View File
@@ -0,0 +1 @@
"""Data connector services and application events."""
+28
View File
@@ -0,0 +1,28 @@
from dataclasses import dataclass
from typing import TypeAlias
from uuid import UUID
from dynalab_protocol.models import SignalDescriptor
@dataclass(frozen=True)
class SignalConnectedEvent:
connector_id: UUID
signal: SignalDescriptor
@dataclass(frozen=True)
class SignalDisconnectedEvent:
connector_id: UUID
signal_id: UUID
@dataclass(frozen=True)
class SignalValueEvent:
signal_id: UUID
value: float
ConnectorEvent: TypeAlias = (
SignalConnectedEvent | SignalDisconnectedEvent | SignalValueEvent
)
+127
View File
@@ -0,0 +1,127 @@
import asyncio
from contextlib import suppress
from functools import partial
import logging
import uuid
from dynalab.state import AppState
from dynalab.connectors.events import (
SignalConnectedEvent,
SignalDisconnectedEvent,
SignalValueEvent,
)
from dynalab_protocol.models import (
ConnectorHello,
DynaLabHello,
HandshakeAccepted,
HandshakeRejected,
Heartbeat,
ValueDescriptor,
VersionDescriptor,
machine_timestamp_ms,
)
from dynalab_protocol.wire import read_message, write_message
logger = logging.getLogger(__name__)
async def _heartbeat_loop(writer: asyncio.StreamWriter, period_ms: int) -> None:
sequence = 0
while True:
await write_message(
writer,
Heartbeat(sequence=sequence, send_timestamp=machine_timestamp_ms()),
)
sequence += 1
await asyncio.sleep(period_ms / 1000)
async def _handle_connector(
state: AppState, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
peer = writer.get_extra_info("peername")
connector_hello: ConnectorHello | None = None
heartbeat_task: asyncio.Task[None] | None = None
logger.info("Connector connected from %s", 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
if not connector_hello.signals:
await write_message(
writer,
HandshakeRejected(reason="Signals list is empty; connector has no data"),
)
return
await write_message(
writer,
HandshakeAccepted(
accepted_signals=[signal.id for signal in connector_hello.signals]
),
)
for signal in connector_hello.signals:
state.connector_events.put(
SignalConnectedEvent(connector_hello.connector_uuid, signal)
)
heartbeat_task = asyncio.create_task(
_heartbeat_loop(writer, dynalab_hello.heartbeat_interval_ms)
)
last_heartbeat = machine_timestamp_ms()
while machine_timestamp_ms() <= last_heartbeat + dynalab_hello.heartbeat_timeout_ms:
try:
message = await asyncio.wait_for(read_message(reader), timeout=0.1)
except TimeoutError:
continue
if isinstance(message, Heartbeat):
last_heartbeat = machine_timestamp_ms()
elif isinstance(message, ValueDescriptor):
state.connector_events.put(
SignalValueEvent(message.signal_id, message.value)
)
except (ConnectionError, asyncio.IncompleteReadError):
logger.info("Connector connection lost: %s", peer)
except Exception:
logger.exception("Connector session failed: %s", peer)
finally:
if connector_hello is not None:
for signal in connector_hello.signals:
state.connector_events.put(
SignalDisconnectedEvent(connector_hello.connector_uuid, signal.id)
)
if heartbeat_task is not None:
heartbeat_task.cancel()
with suppress(asyncio.CancelledError):
await heartbeat_task
writer.close()
with suppress(ConnectionError):
await writer.wait_closed()
logger.info("Connector disconnected: %s", peer)
async def _run_server(state: AppState) -> None:
config = state.app_config.json_connector
server = await asyncio.start_server(
partial(_handle_connector, state), host=config.host, port=config.port
)
logger.info("Connector server listening on %s:%s", config.host, config.port)
async with server:
while not state.stop_event.is_set():
await asyncio.sleep(0.1)
def run_protocol_server(state: AppState) -> None:
asyncio.run(_run_server(state))
-140
View File
@@ -1,140 +0,0 @@
# 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,
ValueDescriptor,
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)
for signal in connector_hello.signals:
state.new_signal_queue.put(signal)
state.available_signals.append(signal)
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
)
if isinstance(message, ValueDescriptor):
state.live_data[message.signal_id] = message.value
state.new_data_id.put_nowait(message.signal_id)
except TimeoutError:
continue
except ConnectionError:
return
finally:
for signal in connector_hello.signals:
state.removed_signal_queue.put(signal)
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=state.app_config.json_connector.host,
port=state.app_config.json_connector.port,
)
print(
f"Json connector server listening on {state.app_config.json_connector.host}:{state.app_config.json_connector.port}"
)
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))
+21 -12
View File
@@ -10,28 +10,37 @@ from dynalab_protocol.models import SignalDescriptor
from dynalab_protocol.dlconf import DLConf
from dynalab.config import AppConfig
from dynalab.connectors.events import ConnectorEvent
@dataclass
class SignalState:
descriptors: dict[UUID, SignalDescriptor] = field(default_factory=dict)
values: dict[UUID, float] = field(default_factory=dict)
connector_ids: dict[UUID, set[UUID]] = field(default_factory=dict)
@dataclass
class RecordingState:
loaded: DLConf | None = None
draft: DLConf = field(default_factory=DLConf)
available_label_ids: dict[str, UUID] = field(default_factory=dict)
selected_label_ids: dict[str, UUID] = field(default_factory=dict)
@dataclass
class AppState:
stop_event = threading.Event()
data_json_thread: threading.Thread | None = None
last_heartbeat: dict[UUID, int] = field(default_factory=dict)
new_signal_queue: Queue[SignalDescriptor] = Queue()
removed_signal_queue: Queue[SignalDescriptor] = Queue()
stop_event: threading.Event = field(default_factory=threading.Event)
connector_thread: threading.Thread | None = None
connector_events: Queue[ConnectorEvent] = field(default_factory=Queue)
available_signals: list[SignalDescriptor] = field(default_factory=list)
live_data: dict[UUID, float] = field(default_factory=dict)
new_data_id: Queue[UUID] = Queue()
signals: SignalState = field(default_factory=SignalState)
recording: RecordingState = field(default_factory=RecordingState)
app_config: AppConfig = field(default_factory=AppConfig)
stop_init = threading.Event()
init_error: str | None = None
window_map: dict[str, str] = field(default_factory=dict)
language: str = ""
loaded_dlconf: DLConf | None = None
building_dlconf: DLConf | None = None
-23
View File
@@ -1,23 +0,0 @@
# 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
WINDOW_RECORD_CONFIGURATION: str = "window_record_configuration"
WINDOW_RECORD_CONFIGURATION_ADD_SIGNAL: str = "window_record_configuration_add_signal"
# Window groups
WINDOW_GROUP_DATA_INPUT: str = "window_group_data_input"
WINDOW_GROUP_RECORD: str = "window_group_record"
WINDOW_DATA_INPUT_GAUGE_GRID: str = "window_data_input_gauge_grid"
# Menu buttons
MENU_FILE_QUIT: str = "menu_file_quit"
MENU_RECORD_CONFIGURATION: str = "menu_record_configuration"
MENU_WINDOW_RECORD: str = "menu_window_record"
MENU_WINDOW_DATA_INPUT: str = "menu_window_data_input"
LISTBOX_RECORD_CONFIGURATION: str = "listbox_record_configuration"
LISTBOX_RECORD_CONFIGURATION_ADD_SINGAL: str = "listbox_record_configuration_add_singal"
TEXT_INPUT_RECORD_CONFIGURATION: str = "text_input_record_configuration"
-80
View File
@@ -1,80 +0,0 @@
import dearpygui.dearpygui as dpg
ERROR_MODAL = "error_modal"
ERROR_TEXT = "error_modal_text"
ERROR_BUTTON_ROW = "error_modal_button_row"
ERROR_BUTTON_LEFT_SPACER = "error_modal_button_left_spacer"
ERROR_OK_BUTTON = "error_modal_ok_button"
ERROR_QUIT_BUTTON = "error_modal_quit_button"
ERROR_MODAL_WIDTH = 420
ERROR_TEXT_WRAP = 380
ERROR_BUTTON_WIDTH = 100
ERROR_BUTTON_SPACING = 8
def build_error_modal() -> None:
with dpg.window(
label="Error",
tag=ERROR_MODAL,
modal=True,
show=False,
no_resize=True,
no_collapse=True,
no_close=True,
width=ERROR_MODAL_WIDTH,
):
dpg.add_text("", tag=ERROR_TEXT, wrap=ERROR_TEXT_WRAP)
dpg.add_spacer(height=10)
with dpg.group(horizontal=True, tag=ERROR_BUTTON_ROW):
dpg.add_spacer(width=0, tag=ERROR_BUTTON_LEFT_SPACER)
dpg.add_button(
label="OK",
tag=ERROR_OK_BUTTON,
width=ERROR_BUTTON_WIDTH,
callback=lambda: dpg.hide_item(ERROR_MODAL),
)
dpg.add_button(
label="Quit",
tag=ERROR_QUIT_BUTTON,
width=ERROR_BUTTON_WIDTH,
callback=lambda: exit(1),
)
def _center_error_buttons() -> None:
if not dpg.does_item_exist(ERROR_BUTTON_LEFT_SPACER):
return
modal_width = dpg.get_item_width(ERROR_MODAL)
content_width = (
min(modal_width, ERROR_TEXT_WRAP) if modal_width > 0 else ERROR_TEXT_WRAP
)
buttons_width = (ERROR_BUTTON_WIDTH * 2) + ERROR_BUTTON_SPACING
spacer_width = max(
(content_width - buttons_width) / 2 - ERROR_BUTTON_SPACING,
0,
)
dpg.set_item_width(ERROR_BUTTON_LEFT_SPACER, spacer_width)
def show_error_modal(message: str) -> None:
dpg.set_value(ERROR_TEXT, message)
dpg.show_item(ERROR_MODAL)
# Optional: center it after showing
viewport_width = dpg.get_viewport_client_width()
viewport_height = dpg.get_viewport_client_height()
window_width = dpg.get_item_width(ERROR_MODAL)
window_height = dpg.get_item_height(ERROR_MODAL)
dpg.set_item_pos(
ERROR_MODAL,
[
(viewport_width - window_width) // 2,
(viewport_height - window_height) // 2,
],
)
_center_error_buttons()
+1
View File
@@ -0,0 +1 @@
"""Error modal UI."""
+6
View File
@@ -0,0 +1,6 @@
MODAL = "errors.modal"
TEXT = "errors.text"
BUTTON_ROW = "errors.button_row"
BUTTON_SPACER = "errors.button_spacer"
OK_BUTTON = "errors.ok"
QUIT_BUTTON = "errors.quit"
+57
View File
@@ -0,0 +1,57 @@
import dearpygui.dearpygui as dpg
from dynalab.ui.errors import tags
MODAL_WIDTH = 420
TEXT_WRAP = 380
BUTTON_WIDTH = 100
BUTTON_SPACING = 8
def build_error_modal() -> None:
with dpg.window(
label="Error",
tag=tags.MODAL,
modal=True,
show=False,
no_resize=True,
no_collapse=True,
no_close=True,
width=MODAL_WIDTH,
):
dpg.add_text("", tag=tags.TEXT, wrap=TEXT_WRAP)
dpg.add_spacer(height=10)
with dpg.group(horizontal=True, tag=tags.BUTTON_ROW):
dpg.add_spacer(width=0, tag=tags.BUTTON_SPACER)
dpg.add_button(
label="OK",
tag=tags.OK_BUTTON,
width=BUTTON_WIDTH,
callback=lambda: dpg.hide_item(tags.MODAL),
)
dpg.add_button(
label="Quit",
tag=tags.QUIT_BUTTON,
width=BUTTON_WIDTH,
callback=dpg.stop_dearpygui,
)
def show_error_modal(message: str) -> None:
dpg.set_value(tags.TEXT, message)
dpg.show_item(tags.MODAL)
viewport_width = dpg.get_viewport_client_width()
viewport_height = dpg.get_viewport_client_height()
window_width = dpg.get_item_width(tags.MODAL)
window_height = dpg.get_item_height(tags.MODAL)
dpg.set_item_pos(
tags.MODAL,
[(viewport_width - window_width) // 2, (viewport_height - window_height) // 2],
)
content_width = min(window_width, TEXT_WRAP) if window_width > 0 else TEXT_WRAP
buttons_width = (BUTTON_WIDTH * 2) + BUTTON_SPACING
dpg.set_item_width(
tags.BUTTON_SPACER,
max((content_width - buttons_width) / 2 - BUTTON_SPACING, 0),
)
+1
View File
@@ -0,0 +1 @@
"""Live connector data UI."""
+60
View File
@@ -0,0 +1,60 @@
import dpg_gauges as dpgg
try:
from dpg_gauges.exceptions import GaugeNotFoundError
except ImportError: # Allows controller tests to use a lightweight gauge stub.
class GaugeNotFoundError(Exception):
pass
from dynalab.connectors.events import (
ConnectorEvent,
SignalConnectedEvent,
SignalDisconnectedEvent,
SignalValueEvent,
)
from dynalab.state import AppState
from dynalab.ui.live_data import tags
def handle_connector_event(state: AppState, event: ConnectorEvent) -> None:
if isinstance(event, SignalConnectedEvent):
owners = state.signals.connector_ids.setdefault(event.signal.id, set())
owners.add(event.connector_id)
if event.signal.id in state.signals.descriptors:
return
state.signals.descriptors[event.signal.id] = event.signal
dpgg.digital_gauge(
tag=tags.signal_gauge_tag(event.signal.id),
label=event.signal.name,
parent=tags.GAUGE_GRID,
unit=event.signal.unit or "",
value=0,
precision=1,
min_value=event.signal.min_value,
max_value=event.signal.max_value,
)
return
if isinstance(event, SignalDisconnectedEvent):
owners = state.signals.connector_ids.get(event.signal_id, set())
owners.discard(event.connector_id)
if owners:
return
state.signals.connector_ids.pop(event.signal_id, None)
state.signals.descriptors.pop(event.signal_id, None)
state.signals.values.pop(event.signal_id, None)
try:
dpgg.delete_gauge(tag=tags.signal_gauge_tag(event.signal_id))
except GaugeNotFoundError:
pass
return
if isinstance(event, SignalValueEvent):
if event.signal_id not in state.signals.descriptors:
return
state.signals.values[event.signal_id] = event.value
try:
dpgg.set_value(
tag=tags.signal_gauge_tag(event.signal_id), value=event.value
)
except GaugeNotFoundError:
pass
+9
View File
@@ -0,0 +1,9 @@
from uuid import UUID
GROUP = "live_data.group"
GAUGE_GRID = "live_data.gauge_grid"
def signal_gauge_tag(signal_id: UUID) -> str:
return f"live_data.gauge.{signal_id}"
+11
View File
@@ -0,0 +1,11 @@
import dpg_gauges as dpgg
from dynalab.ui.live_data import tags
def build_live_data_view() -> None:
with dpgg.gauge_panel(autosize_x=True, autosize_y=True):
with dpgg.gauge_grid(
columns="auto", min_column_width=180, tag=tags.GAUGE_GRID
):
pass
+1
View File
@@ -0,0 +1 @@
"""Application loading view."""
+6
View File
@@ -0,0 +1,6 @@
WINDOW = "loading_window"
LOGO_GROUP = "loading_logo_group"
LOGO = "loading_logo"
TEXT_ROW = "loading_text_row"
TEXT_SPACER = "loading_text_left_spacer"
TEXT = "loading_text"
+64
View File
@@ -0,0 +1,64 @@
import dearpygui.dearpygui as dpg
from dynalab.ui.loading import tags
WINDOW_WIDTH = 600
WINDOW_HEIGHT = 400
def loading_content_layout(
viewport_size: tuple[int, int],
logo_size: tuple[int, int],
text_size: tuple[int, int],
bottom_margin: int = 24,
) -> tuple[list[float], list[float], float]:
viewport_width, viewport_height = viewport_size
logo_width, logo_height = logo_size
text_width, text_height = text_size
return (
[(viewport_width - logo_width) / 2, (viewport_height - logo_height) / 2],
[0, max(viewport_height - text_height - bottom_margin, 0)],
max((viewport_width - text_width) / 2, 0),
)
def build_loading_view() -> None:
with dpg.window(
label="DynaLab",
tag=tags.WINDOW,
width=WINDOW_WIDTH,
height=WINDOW_HEIGHT,
no_collapse=True,
no_resize=True,
no_move=True,
no_title_bar=True,
):
dpg.set_global_font_scale(0.5)
with dpg.group(horizontal=False, tag=tags.LOGO_GROUP):
dpg.add_image(texture_tag="logo", tag=tags.LOGO)
with dpg.group(horizontal=True, tag=tags.TEXT_ROW):
dpg.add_spacer(width=0, tag=tags.TEXT_SPACER)
dpg.add_text(
default_value="Loading DynaLab",
tag=tags.TEXT,
color=(150, 150, 150, 255),
)
def center_loading_content() -> None:
if not dpg.does_item_exist(tags.WINDOW):
return
dpg.set_item_pos(tags.WINDOW, [0, 0])
dpg.set_item_width(tags.WINDOW, WINDOW_WIDTH)
dpg.set_item_height(tags.WINDOW, WINDOW_HEIGHT)
logo_size = dpg.get_item_rect_size(tags.LOGO)
text_size = dpg.get_item_rect_size(tags.TEXT)
if logo_size is None or text_size is None:
return
logo_pos, text_row_pos, spacer_width = loading_content_layout(
(WINDOW_WIDTH, WINDOW_HEIGHT), logo_size, text_size
)
dpg.set_item_pos(tags.LOGO_GROUP, logo_pos)
dpg.set_item_pos(tags.TEXT_ROW, text_row_pos)
dpg.set_item_width(tags.TEXT_SPACER, spacer_width)
+1
View File
@@ -0,0 +1 @@
"""Recording configuration UI."""
+53
View File
@@ -0,0 +1,53 @@
import dearpygui.dearpygui as dpg
from dynalab.state import AppState
from dynalab.ui.recording import tags
from dynalab.ui.recording.presenter import (
refresh_available_signals,
refresh_configuration,
)
def on_configuration_name_changed(
sender: str, app_data: str, state: AppState
) -> None:
state.recording.draft.name = app_data
def on_add_signal_clicked(sender: str, app_data: object, state: AppState) -> None:
refresh_available_signals(state)
dpg.show_item(tags.ADD_SIGNAL_WINDOW)
def on_available_signal_selected(
sender: str, app_data: object, state: AppState
) -> None:
label = dpg.get_value(tags.AVAILABLE_SIGNALS)
signal_id = state.recording.available_label_ids.get(label)
if signal_id is None:
return
signal = state.signals.descriptors.get(signal_id)
if signal is not None and all(
selected.id != signal_id for selected in state.recording.draft.signals
):
state.recording.draft.signals.append(signal)
refresh_configuration(state)
dpg.hide_item(tags.ADD_SIGNAL_WINDOW)
def on_remove_signal_clicked(
sender: str, app_data: object, state: AppState
) -> None:
label = dpg.get_value(tags.SELECTED_SIGNALS)
signal_id = state.recording.selected_label_ids.get(label)
if signal_id is None:
return
state.recording.draft.signals = [
signal for signal in state.recording.draft.signals if signal.id != signal_id
]
refresh_configuration(state)
def on_show_configuration(sender: str, app_data: object, state: AppState) -> None:
refresh_configuration(state)
dpg.show_item(tags.CONFIGURATION_WINDOW)
+31
View File
@@ -0,0 +1,31 @@
import dearpygui.dearpygui as dpg
from dynalab.state import AppState
from dynalab.ui.recording import tags
def _signal_label(name: str, signal_id: object) -> str:
return f"{name} | {signal_id}"
def refresh_configuration(state: AppState) -> None:
draft = state.recording.draft
state.recording.selected_label_ids = {
_signal_label(signal.name, signal.id): signal.id for signal in draft.signals
}
dpg.set_value(tags.CONFIGURATION_NAME, draft.name)
dpg.configure_item(
tags.SELECTED_SIGNALS, items=list(state.recording.selected_label_ids)
)
def refresh_available_signals(state: AppState) -> None:
selected_ids = {signal.id for signal in state.recording.draft.signals}
state.recording.available_label_ids = {
_signal_label(signal.name, signal.id): signal.id
for signal in state.signals.descriptors.values()
if signal.id not in selected_ids
}
dpg.configure_item(
tags.AVAILABLE_SIGNALS, items=list(state.recording.available_label_ids)
)
+5
View File
@@ -0,0 +1,5 @@
CONFIGURATION_WINDOW = "recording.configuration_window"
ADD_SIGNAL_WINDOW = "recording.add_signal_window"
CONFIGURATION_NAME = "recording.configuration_name"
SELECTED_SIGNALS = "recording.selected_signals"
AVAILABLE_SIGNALS = "recording.available_signals"
+54
View File
@@ -0,0 +1,54 @@
import dearpygui.dearpygui as dpg
from dynalab.state import AppState
from dynalab.ui.recording import tags
from dynalab.ui.recording.controller import (
on_add_signal_clicked,
on_available_signal_selected,
on_configuration_name_changed,
on_remove_signal_clicked,
)
def build_recording_dialogs(state: AppState) -> None:
with dpg.window(
label="Recording Configuration",
tag=tags.CONFIGURATION_WINDOW,
no_collapse=True,
show=False,
min_size=(500, 300),
):
dpg.add_text("Configuration Name")
dpg.add_input_text(
tag=tags.CONFIGURATION_NAME,
callback=on_configuration_name_changed,
user_data=state,
)
dpg.add_text("Selected Signals")
dpg.add_listbox(tag=tags.SELECTED_SIGNALS, num_items=10, width=480)
with dpg.group(horizontal=True):
dpg.add_button(
label="Add", callback=on_add_signal_clicked, user_data=state
)
dpg.add_button(
label="Remove", callback=on_remove_signal_clicked, user_data=state
)
with dpg.window(
label="Add Signal",
tag=tags.ADD_SIGNAL_WINDOW,
no_collapse=True,
show=False,
min_size=(500, 300),
modal=True,
):
dpg.add_listbox(tag=tags.AVAILABLE_SIGNALS, num_items=10, width=480)
with dpg.group(horizontal=True):
dpg.add_button(
label="Select",
callback=on_available_signal_selected,
user_data=state,
)
dpg.add_button(
label="Refresh", callback=on_add_signal_clicked, user_data=state
)
-30
View File
@@ -1,30 +0,0 @@
import dearpygui.dearpygui as dpg
from dynalab.state import AppState
from dynalab.tags import (
LISTBOX_RECORD_CONFIGURATION,
LISTBOX_RECORD_CONFIGURATION_ADD_SINGAL,
TEXT_INPUT_RECORD_CONFIGURATION,
)
def render_window_record_configuration(state: AppState) -> None:
dpg.set_value(
item=TEXT_INPUT_RECORD_CONFIGURATION, value=state.building_dlconf.name
)
items: list[str] = []
for signal in state.building_dlconf.signals:
name: str = f"{signal.name} | {signal.id}"
items.append(name)
dpg.configure_item(LISTBOX_RECORD_CONFIGURATION, items=items)
def render_listbox_record_configuration_add_item(state: AppState) -> None:
items: list[str] = []
for item in state.available_signals:
name: str = f"{item.name} | {item.id}"
items.append(name)
dpg.configure_item(LISTBOX_RECORD_CONFIGURATION_ADD_SINGAL, items=items)
-77
View File
@@ -1,77 +0,0 @@
import uuid
from dynalab_protocol.dlconf import DLConf
from dynalab.state import AppState
import dearpygui.dearpygui as dpg
from dynalab.tags import (
LISTBOX_RECORD_CONFIGURATION,
LISTBOX_RECORD_CONFIGURATION_ADD_SINGAL,
TEXT_INPUT_RECORD_CONFIGURATION,
WINDOW_RECORD_CONFIGURATION,
WINDOW_RECORD_CONFIGURATION_ADD_SIGNAL,
)
from dynalab.ui.render import (
render_window_record_configuration,
render_listbox_record_configuration_add_item,
)
def switch_window(sender, app_data, user_data: AppState) -> None:
for button_tag, window_group_tag in user_data.window_map.items():
if button_tag == sender:
dpg.show_item(window_group_tag)
else:
dpg.hide_item(window_group_tag)
def listbox_record_configuration_remove_selected_item(
sender, app_data, user_data: AppState
) -> None:
selected = dpg.get_value(LISTBOX_RECORD_CONFIGURATION)
if selected is None:
return
try:
split = selected.split(" | ")[1]
except Exception:
return
signal_id = uuid.UUID(split)
render_window_record_configuration(user_data)
def listbox_record_configuration_add_item(
sender, app_data, user_data: AppState
) -> None:
render_listbox_record_configuration_add_item(user_data)
dpg.show_item(WINDOW_RECORD_CONFIGURATION_ADD_SIGNAL)
def listbox_record_configuration_add_item_select(
sender, app_data, user_data: AppState
) -> None:
value: str = dpg.get_value(LISTBOX_RECORD_CONFIGURATION_ADD_SINGAL)
try:
split = value.split(" | ")[1]
except Exception:
return
signal_id = uuid.UUID(split)
if user_data.building_dlconf is None:
user_data.building_dlconf = DLConf()
signal = next(
(signal for signal in user_data.available_signals if signal.id == signal_id),
None,
)
if signal is not None:
user_data.building_dlconf.signals.append(signal)
render_window_record_configuration(user_data)
dpg.hide_item(WINDOW_RECORD_CONFIGURATION_ADD_SIGNAL)
def show_window_record_configuration(sender, app_data, user_data: AppState) -> None:
if user_data.building_dlconf is None:
user_data.building_dlconf = DLConf()
render_window_record_configuration(user_data)
dpg.show_item(WINDOW_RECORD_CONFIGURATION)
+64
View File
@@ -0,0 +1,64 @@
import dearpygui.dearpygui as dpg
from dynalab.i18n import _
from dynalab.state import AppState
from dynalab.ui.live_data import tags as live_data_tags
from dynalab.ui.live_data.view import build_live_data_view
from dynalab.ui.recording.controller import on_show_configuration
from dynalab.ui.recording.view import build_recording_dialogs
MAIN_WINDOW = "shell.main_window"
CONTENT_AREA = "shell.content_area"
RECORDING_GROUP = "recording.group"
MENU_RECORDING = "shell.menu.recording"
MENU_LIVE_DATA = "shell.menu.live_data"
def on_switch_feature(sender: str, app_data: object, state: AppState) -> None:
for menu_tag, group_tag in state.window_map.items():
if menu_tag == sender:
dpg.show_item(group_tag)
else:
dpg.hide_item(group_tag)
def build_shell(state: AppState) -> 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(
tag="shell.menu.quit",
label=_("Quit"),
callback=dpg.stop_dearpygui,
)
with dpg.menu(label=_("Record")):
dpg.add_menu_item(
tag="shell.menu.recording_configuration",
label=_("Configuration"),
callback=on_show_configuration,
user_data=state,
)
with dpg.menu(label=_("Window")):
dpg.add_menu_item(
tag=MENU_RECORDING,
label=_("Record"),
callback=on_switch_feature,
user_data=state,
)
dpg.add_menu_item(
tag=MENU_LIVE_DATA,
label=_("Data Input"),
callback=on_switch_feature,
user_data=state,
)
with dpg.child_window(tag=CONTENT_AREA, autosize_x=True, height=0, border=False):
with dpg.group(tag=live_data_tags.GROUP):
state.window_map[MENU_LIVE_DATA] = live_data_tags.GROUP
build_live_data_view()
with dpg.group(tag=RECORDING_GROUP, show=False):
state.window_map[MENU_RECORDING] = RECORDING_GROUP
dpg.add_text("Recording controls")
build_recording_dialogs(state)
+6 -204
View File
@@ -1,211 +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
import dearpygui.dearpygui as dpg
"""Compatibility exports for the loading-layout tests."""
import dpg_gauges as dpgg
from dynalab.state import AppState
from dynalab.tags import (
LISTBOX_RECORD_CONFIGURATION,
LISTBOX_RECORD_CONFIGURATION_ADD_SINGAL,
MENU_FILE_QUIT,
MENU_RECORD_CONFIGURATION,
MENU_WINDOW_DATA_INPUT,
MENU_WINDOW_RECORD,
TEXT_INPUT_RECORD_CONFIGURATION,
WINDOW_DATA_INPUT_GAUGE_GRID,
WINDOW_GROUP_DATA_INPUT,
WINDOW_GROUP_RECORD,
WINDOW_RECORD_CONFIGURATION,
WINDOW_RECORD_CONFIGURATION_ADD_SIGNAL,
from dynalab.ui.loading.view import (
center_loading_content,
loading_content_layout as _loading_content_layout,
)
from dynalab.ui.routines import (
listbox_record_configuration_add_item,
listbox_record_configuration_add_item_select,
listbox_record_configuration_remove_selected_item,
show_window_record_configuration,
switch_window,
)
from dynalab.i18n import _
from dynalab.ui.loading import view as _loading_view
LOADING_WINDOW_WIDTH = 600
LOADING_WINDOW_HEIGHT = 400
def _loading_content_layout(
viewport_size: tuple[int, int],
logo_size: tuple[int, int],
text_size: tuple[int, int],
bottom_margin: int = 24,
) -> tuple[list[float], list[float], float]:
viewport_width, viewport_height = viewport_size
logo_width, logo_height = logo_size
text_width, text_height = text_size
logo_pos = [
(viewport_width - logo_width) / 2,
(viewport_height - logo_height) / 2,
]
text_row_pos = [
0,
max(viewport_height - text_height - bottom_margin, 0),
]
text_spacer_width = max((viewport_width - text_width) / 2, 0)
return logo_pos, text_row_pos, text_spacer_width
def build_loading() -> None:
with dpg.window(
label="DynaLab",
tag="loading_window",
width=LOADING_WINDOW_WIDTH,
height=LOADING_WINDOW_HEIGHT,
no_collapse=True,
no_resize=True,
no_move=True,
no_title_bar=True,
):
dpg.set_global_font_scale(0.5)
with dpg.group(horizontal=False, tag="loading_logo_group"):
dpg.add_image(texture_tag="logo", tag="loading_logo")
with dpg.group(horizontal=True, tag="loading_text_row"):
dpg.add_spacer(width=0, tag="loading_text_left_spacer")
dpg.add_text(
default_value="Loading DynaLab",
tag="loading_text",
color=(150, 150, 150, 255),
)
def center_loading_content() -> None:
if not dpg.does_item_exist("loading_window"):
return
dpg.set_item_pos("loading_window", [0, 0])
dpg.set_item_width("loading_window", LOADING_WINDOW_WIDTH)
dpg.set_item_height("loading_window", LOADING_WINDOW_HEIGHT)
# These may be unavailable before the first rendered frame
logo_size = dpg.get_item_rect_size("loading_logo")
text_size = dpg.get_item_rect_size("loading_text")
if logo_size is None or text_size is None:
return
logo_pos, text_row_pos, text_spacer_width = _loading_content_layout(
(LOADING_WINDOW_WIDTH, LOADING_WINDOW_HEIGHT), logo_size, text_size
)
dpg.set_item_pos("loading_logo_group", logo_pos)
dpg.set_item_pos("loading_text_row", text_row_pos)
dpg.set_item_width("loading_text_left_spacer", text_spacer_width)
def build_record_configuration_add_signal_window(state: AppState) -> None:
with dpg.window(
label="Add Signal",
tag=WINDOW_RECORD_CONFIGURATION_ADD_SIGNAL,
no_collapse=True,
show=False,
min_size=(500, 300),
modal=True,
):
dpg.add_listbox(
tag=LISTBOX_RECORD_CONFIGURATION_ADD_SINGAL,
num_items=10,
width=480,
)
with dpg.group(horizontal=True):
dpg.add_button(
label="Select",
callback=listbox_record_configuration_add_item_select,
user_data=state,
)
dpg.add_button(
label="Refresh",
callback=listbox_record_configuration_add_item,
user_data=state,
)
def build_record_configuration_window(state: AppState) -> None:
with dpg.window(
label="Recording Configuration",
tag=WINDOW_RECORD_CONFIGURATION,
no_collapse=True,
show=False,
min_size=(500, 300),
):
with dpg.group():
dpg.add_text("Configuration Name")
dpg.add_input_text(tag=TEXT_INPUT_RECORD_CONFIGURATION)
dpg.add_text("Selected Signals")
dpg.add_listbox(
tag=LISTBOX_RECORD_CONFIGURATION,
num_items=10,
width=480,
)
with dpg.group(horizontal=True):
dpg.add_button(
label="Add",
callback=listbox_record_configuration_add_item,
user_data=state,
)
dpg.add_button(
label="Remove",
callback=listbox_record_configuration_remove_selected_item,
user_data=state,
)
def build_windows(state: AppState) -> 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(tag=MENU_FILE_QUIT, label=_("Quit"), enabled=True)
with dpg.menu(label=_("Record")):
dpg.add_menu_item(
tag=MENU_RECORD_CONFIGURATION,
label=_("Configuration"),
callback=show_window_record_configuration,
user_data=state,
)
with dpg.menu(label=_("Window")):
dpg.add_menu_item(
tag=MENU_WINDOW_RECORD,
label=_("Record"),
callback=switch_window,
user_data=state,
)
dpg.add_menu_item(
tag=MENU_WINDOW_DATA_INPUT,
label=_("Data Input"),
callback=switch_window,
user_data=state,
)
with dpg.child_window(
tag="content_area", autosize_x=True, height=0, border=False
):
# with dpg.group(tag=MENU_WINDOW_DATA_INPUT):
with dpg.group(tag=WINDOW_GROUP_DATA_INPUT):
state.window_map[MENU_WINDOW_DATA_INPUT] = WINDOW_GROUP_DATA_INPUT
with dpgg.gauge_panel(autosize_x=True, autosize_y=True):
with dpgg.gauge_grid(
columns="auto",
min_column_width=180,
tag=WINDOW_DATA_INPUT_GAUGE_GRID,
):
pass
with dpg.group(tag=WINDOW_GROUP_RECORD, show=False):
state.window_map[MENU_WINDOW_RECORD] = WINDOW_GROUP_RECORD
dpg.add_text("Some text")
build_record_configuration_window(state)
build_record_configuration_add_signal_window(state)
dpg = _loading_view.dpg
+7 -4
View File
@@ -99,6 +99,7 @@ async def run_connector_session() -> None:
handshake: HandshakeAccepted
peer = writer.get_extra_info("peername")
value_task: asyncio.Task[None] | None = None
print(f"Connected to DynaLab at {peer}")
try:
@@ -129,6 +130,8 @@ async def run_connector_session() -> None:
elif isinstance(message, HandshakeRejected):
print(f"Connector handshake rejected: {message.reason}")
return
else:
raise ValueError(f"Expected handshake response, received {message.type}")
value_task = asyncio.create_task(_value_sender(writer, connector_hello))
# Keep the connection alive by returning heartbeat messages.
@@ -137,15 +140,15 @@ async def run_connector_session() -> None:
print(f"Received {message}")
if isinstance(message, Heartbeat):
if message.sequence < 10:
message.return_timestamp = machine_timestamp_ms()
await write_message(writer, message)
message.return_timestamp = machine_timestamp_ms()
await write_message(writer, message)
except asyncio.CancelledError:
raise
finally:
value_task.cancel()
if value_task is not None:
value_task.cancel()
print("Disconnecting")
writer.close()
await writer.wait_closed()
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
import sys
import types
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
dearpygui = types.ModuleType("dearpygui")
dearpygui_dearpygui = types.ModuleType("dearpygui.dearpygui")
sys.modules.setdefault("dearpygui", dearpygui)
sys.modules.setdefault("dearpygui.dearpygui", dearpygui_dearpygui)
sys.modules.setdefault("dpg_gauges", types.ModuleType("dpg_gauges"))
from dynalab.ui import windows
class LoadingLayoutTests(unittest.TestCase):
def test_center_loading_content_positions_logo_and_bottom_text(self) -> None:
calls: list[tuple[str, str, object]] = []
windows.dpg.does_item_exist = lambda tag: tag == "loading_window"
windows.dpg.get_viewport_client_width = lambda: 600
windows.dpg.get_viewport_client_height = lambda: 400
windows.dpg.set_item_width = lambda tag, width: calls.append(
("width", tag, width)
)
windows.dpg.set_item_height = lambda tag, height: calls.append(
("height", tag, height)
)
windows.dpg.set_item_pos = lambda tag, pos: calls.append(("pos", tag, pos))
windows.dpg.get_item_rect_size = lambda tag: {
"loading_logo": (300, 100),
"loading_text": (100, 20),
}[tag]
windows.center_loading_content()
self.assertIn(("pos", "loading_logo_group", [150, 150]), calls)
self.assertIn(("pos", "loading_text_row", [0, 356]), calls)
self.assertIn(("width", "loading_text_left_spacer", 250), calls)
self.assertNotIn(
"loading_text",
[tag for call_type, tag, _ in calls if call_type == "pos"],
)
def test_loading_layout_handles_text_wider_than_logo(self) -> None:
logo_pos, text_row_pos, text_spacer_width = windows._loading_content_layout(
viewport_size=(600, 400),
logo_size=(120, 50),
text_size=(200, 20),
)
self.assertEqual(logo_pos, [240, 175])
self.assertEqual(text_row_pos, [0, 356])
self.assertEqual(text_spacer_width, 200)
if __name__ == "__main__":
unittest.main()
+81
View File
@@ -0,0 +1,81 @@
import unittest
from unittest.mock import patch
from uuid import uuid4
from dynalab.connectors.events import (
SignalConnectedEvent,
SignalDisconnectedEvent,
SignalValueEvent,
)
from dynalab.state import AppState
from dynalab.ui.live_data.controller import handle_connector_event
from dynalab.ui.live_data.tags import signal_gauge_tag
from dynalab_protocol.models import SignalDescriptor
class ApplicationStateTests(unittest.TestCase):
def test_instances_do_not_share_threading_state(self) -> None:
first = AppState()
second = AppState()
first.stop_event.set()
first.connector_events.put(SignalValueEvent(uuid4(), 1.0))
self.assertFalse(second.stop_event.is_set())
self.assertTrue(second.connector_events.empty())
class LiveDataControllerTests(unittest.TestCase):
def setUp(self) -> None:
self.state = AppState()
self.connector_id = uuid4()
self.signal = SignalDescriptor(
id=uuid4(),
name="Engine speed",
type="number",
min_value=0,
max_value=9000,
unit="rpm",
)
@patch("dynalab.ui.live_data.controller.dpgg")
def test_signal_lifecycle_updates_state_and_gauge(self, gauges) -> None:
handle_connector_event(
self.state, SignalConnectedEvent(self.connector_id, self.signal)
)
handle_connector_event(
self.state, SignalValueEvent(self.signal.id, 3250.0)
)
handle_connector_event(
self.state, SignalDisconnectedEvent(self.connector_id, self.signal.id)
)
gauges.digital_gauge.assert_called_once()
gauges.set_value.assert_called_once_with(
tag=signal_gauge_tag(self.signal.id), value=3250.0
)
gauges.delete_gauge.assert_called_once_with(
tag=signal_gauge_tag(self.signal.id)
)
self.assertNotIn(self.signal.id, self.state.signals.descriptors)
self.assertNotIn(self.signal.id, self.state.signals.values)
@patch("dynalab.ui.live_data.controller.dpgg")
def test_shared_signal_remains_until_last_connector_disconnects(self, gauges) -> None:
other_connector_id = uuid4()
handle_connector_event(
self.state, SignalConnectedEvent(self.connector_id, self.signal)
)
handle_connector_event(
self.state, SignalConnectedEvent(other_connector_id, self.signal)
)
handle_connector_event(
self.state, SignalDisconnectedEvent(self.connector_id, self.signal.id)
)
gauges.delete_gauge.assert_not_called()
self.assertIn(self.signal.id, self.state.signals.descriptors)
if __name__ == "__main__":
unittest.main()