diff --git a/README.md b/README.md index e69de29..f484775 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..38c7b11 --- /dev/null +++ b/docs/architecture.md @@ -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.`. 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. diff --git a/messages.mo b/messages.mo new file mode 100644 index 0000000..6545ed6 Binary files /dev/null and b/messages.mo differ diff --git a/src/dynalab/app.py b/src/dynalab/app.py index bcbc5f2..91dd6c1 100644 --- a/src/dynalab/app.py +++ b/src/dynalab/app.py @@ -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() diff --git a/src/dynalab/connectors/__init__.py b/src/dynalab/connectors/__init__.py new file mode 100644 index 0000000..db388e2 --- /dev/null +++ b/src/dynalab/connectors/__init__.py @@ -0,0 +1 @@ +"""Data connector services and application events.""" diff --git a/src/dynalab/connectors/events.py b/src/dynalab/connectors/events.py new file mode 100644 index 0000000..aa442d9 --- /dev/null +++ b/src/dynalab/connectors/events.py @@ -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 +) diff --git a/src/dynalab/connectors/protocol_server.py b/src/dynalab/connectors/protocol_server.py new file mode 100644 index 0000000..258384b --- /dev/null +++ b/src/dynalab/connectors/protocol_server.py @@ -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)) diff --git a/src/dynalab/data/json.py b/src/dynalab/data/json.py deleted file mode 100644 index 7108303..0000000 --- a/src/dynalab/data/json.py +++ /dev/null @@ -1,140 +0,0 @@ -# Copyright (C) 2026 Hector van der Aa -# Copyright (C) 2026 Association Exergie -# 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)) diff --git a/src/dynalab/state.py b/src/dynalab/state.py index 8ed5141..6932d41 100644 --- a/src/dynalab/state.py +++ b/src/dynalab/state.py @@ -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 diff --git a/src/dynalab/tags.py b/src/dynalab/tags.py deleted file mode 100644 index 06597d6..0000000 --- a/src/dynalab/tags.py +++ /dev/null @@ -1,23 +0,0 @@ -# Copyright (C) 2026 Hector van der Aa -# Copyright (C) 2026 Association Exergie -# 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" diff --git a/src/dynalab/ui/error.py b/src/dynalab/ui/error.py deleted file mode 100644 index 2c04daf..0000000 --- a/src/dynalab/ui/error.py +++ /dev/null @@ -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() diff --git a/src/dynalab/ui/errors/__init__.py b/src/dynalab/ui/errors/__init__.py new file mode 100644 index 0000000..9b4bb5e --- /dev/null +++ b/src/dynalab/ui/errors/__init__.py @@ -0,0 +1 @@ +"""Error modal UI.""" diff --git a/src/dynalab/ui/errors/tags.py b/src/dynalab/ui/errors/tags.py new file mode 100644 index 0000000..559fad2 --- /dev/null +++ b/src/dynalab/ui/errors/tags.py @@ -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" diff --git a/src/dynalab/ui/errors/view.py b/src/dynalab/ui/errors/view.py new file mode 100644 index 0000000..5b1125a --- /dev/null +++ b/src/dynalab/ui/errors/view.py @@ -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), + ) diff --git a/src/dynalab/ui/live_data/__init__.py b/src/dynalab/ui/live_data/__init__.py new file mode 100644 index 0000000..8910b4e --- /dev/null +++ b/src/dynalab/ui/live_data/__init__.py @@ -0,0 +1 @@ +"""Live connector data UI.""" diff --git a/src/dynalab/ui/live_data/controller.py b/src/dynalab/ui/live_data/controller.py new file mode 100644 index 0000000..6f4faa9 --- /dev/null +++ b/src/dynalab/ui/live_data/controller.py @@ -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 diff --git a/src/dynalab/ui/live_data/tags.py b/src/dynalab/ui/live_data/tags.py new file mode 100644 index 0000000..fe1143c --- /dev/null +++ b/src/dynalab/ui/live_data/tags.py @@ -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}" diff --git a/src/dynalab/ui/live_data/view.py b/src/dynalab/ui/live_data/view.py new file mode 100644 index 0000000..6c86945 --- /dev/null +++ b/src/dynalab/ui/live_data/view.py @@ -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 diff --git a/src/dynalab/ui/loading/__init__.py b/src/dynalab/ui/loading/__init__.py new file mode 100644 index 0000000..ccd71bf --- /dev/null +++ b/src/dynalab/ui/loading/__init__.py @@ -0,0 +1 @@ +"""Application loading view.""" diff --git a/src/dynalab/ui/loading/tags.py b/src/dynalab/ui/loading/tags.py new file mode 100644 index 0000000..ea23d32 --- /dev/null +++ b/src/dynalab/ui/loading/tags.py @@ -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" diff --git a/src/dynalab/ui/loading/view.py b/src/dynalab/ui/loading/view.py new file mode 100644 index 0000000..ea5b868 --- /dev/null +++ b/src/dynalab/ui/loading/view.py @@ -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) diff --git a/src/dynalab/ui/recording/__init__.py b/src/dynalab/ui/recording/__init__.py new file mode 100644 index 0000000..3f0862d --- /dev/null +++ b/src/dynalab/ui/recording/__init__.py @@ -0,0 +1 @@ +"""Recording configuration UI.""" diff --git a/src/dynalab/ui/recording/controller.py b/src/dynalab/ui/recording/controller.py new file mode 100644 index 0000000..3e08c23 --- /dev/null +++ b/src/dynalab/ui/recording/controller.py @@ -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) diff --git a/src/dynalab/ui/recording/presenter.py b/src/dynalab/ui/recording/presenter.py new file mode 100644 index 0000000..055992d --- /dev/null +++ b/src/dynalab/ui/recording/presenter.py @@ -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) + ) diff --git a/src/dynalab/ui/recording/tags.py b/src/dynalab/ui/recording/tags.py new file mode 100644 index 0000000..0c61acf --- /dev/null +++ b/src/dynalab/ui/recording/tags.py @@ -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" diff --git a/src/dynalab/ui/recording/view.py b/src/dynalab/ui/recording/view.py new file mode 100644 index 0000000..d5f50d7 --- /dev/null +++ b/src/dynalab/ui/recording/view.py @@ -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 + ) diff --git a/src/dynalab/ui/render.py b/src/dynalab/ui/render.py deleted file mode 100644 index 82c422e..0000000 --- a/src/dynalab/ui/render.py +++ /dev/null @@ -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) diff --git a/src/dynalab/ui/routines.py b/src/dynalab/ui/routines.py deleted file mode 100644 index 6a370c7..0000000 --- a/src/dynalab/ui/routines.py +++ /dev/null @@ -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) diff --git a/src/dynalab/ui/shell.py b/src/dynalab/ui/shell.py new file mode 100644 index 0000000..ad7906c --- /dev/null +++ b/src/dynalab/ui/shell.py @@ -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) diff --git a/src/dynalab/ui/windows.py b/src/dynalab/ui/windows.py index 62cdf3b..6ba4048 100644 --- a/src/dynalab/ui/windows.py +++ b/src/dynalab/ui/windows.py @@ -1,211 +1,13 @@ # Copyright (C) 2026 Hector van der Aa # Copyright (C) 2026 Association Exergie # 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 diff --git a/test/minimal_connector.py b/test/minimal_connector.py index c5dd302..a3af8aa 100644 --- a/test/minimal_connector.py +++ b/test/minimal_connector.py @@ -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() diff --git a/test/test_loading_layout.py b/test/test_loading_layout.py new file mode 100644 index 0000000..e8816b2 --- /dev/null +++ b/test/test_loading_layout.py @@ -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() diff --git a/test/test_state_and_connector_events.py b/test/test_state_and_connector_events.py new file mode 100644 index 0000000..69d9352 --- /dev/null +++ b/test/test_state_and_connector_events.py @@ -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()