Archived
Deprecation message
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
Binary file not shown.
+39
-67
@@ -12,22 +12,19 @@ import dearpygui.dearpygui as dpg
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from dynalab.config import ConfigLoadError, load_config
|
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.i18n import init_i18n, _
|
||||||
from dynalab.state import AppState
|
from dynalab.state import AppState
|
||||||
from dynalab.tags import WINDOW_DATA_INPUT_GAUGE_GRID
|
from dynalab.ui.errors.view import build_error_modal, show_error_modal
|
||||||
from dynalab.ui.error import build_error_modal, show_error_modal
|
from dynalab.ui.live_data.controller import handle_connector_event
|
||||||
import dynalab.ui.windows
|
from dynalab.ui.loading import tags as loading_tags
|
||||||
from dynalab.ui.windows import (
|
from dynalab.ui.loading.view import (
|
||||||
LOADING_WINDOW_HEIGHT,
|
WINDOW_HEIGHT as LOADING_WINDOW_HEIGHT,
|
||||||
LOADING_WINDOW_WIDTH,
|
WINDOW_WIDTH as LOADING_WINDOW_WIDTH,
|
||||||
build_loading,
|
build_loading_view,
|
||||||
build_windows,
|
|
||||||
center_loading_content,
|
center_loading_content,
|
||||||
)
|
)
|
||||||
|
from dynalab.ui.shell import MAIN_WINDOW, build_shell
|
||||||
import dpg_gauges as dpgg
|
|
||||||
from dpg_gauges.exceptions import GaugeNotFoundError
|
|
||||||
|
|
||||||
from dynalab.utils import resource_path
|
from dynalab.utils import resource_path
|
||||||
|
|
||||||
@@ -55,13 +52,13 @@ def _init_worker(
|
|||||||
|
|
||||||
message_queue.put(_("Starting JSON data server"))
|
message_queue.put(_("Starting JSON data server"))
|
||||||
# Init and start json connector thread
|
# Init and start json connector thread
|
||||||
state.data_json_thread = Thread(
|
state.connector_thread = Thread(
|
||||||
target=json_thread_main,
|
target=run_protocol_server,
|
||||||
name="json-connector-network",
|
name="json-connector-network",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
args=(state,),
|
args=(state,),
|
||||||
)
|
)
|
||||||
state.data_json_thread.start()
|
state.connector_thread.start()
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
message_queue.put(_("JSON data server started"))
|
message_queue.put(_("JSON data server started"))
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
@@ -71,10 +68,10 @@ def _init_worker(
|
|||||||
stop_event.set()
|
stop_event.set()
|
||||||
except ConfigLoadError as exc:
|
except ConfigLoadError as exc:
|
||||||
state.init_error = str(exc)
|
state.init_error = str(exc)
|
||||||
state.stop_init.set()
|
stop_event.set()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
state.init_error = f"Unexpected startup error: {exc}"
|
state.init_error = f"Unexpected startup error: {exc}"
|
||||||
state.stop_init.set()
|
stop_event.set()
|
||||||
|
|
||||||
|
|
||||||
def _load_image(path: Path, tag: str) -> None:
|
def _load_image(path: Path, tag: str) -> None:
|
||||||
@@ -98,6 +95,7 @@ def run() -> None:
|
|||||||
_setup_logging(logging.DEBUG)
|
_setup_logging(logging.DEBUG)
|
||||||
|
|
||||||
# dpg setup
|
# dpg setup
|
||||||
|
state = AppState()
|
||||||
dpg.create_context()
|
dpg.create_context()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -122,7 +120,7 @@ def run() -> None:
|
|||||||
|
|
||||||
_load_image(resource_path("assets/images/logo@300.png"), "logo")
|
_load_image(resource_path("assets/images/logo@300.png"), "logo")
|
||||||
|
|
||||||
build_loading()
|
build_loading_view()
|
||||||
build_error_modal()
|
build_error_modal()
|
||||||
|
|
||||||
# Open viewport
|
# Open viewport
|
||||||
@@ -130,15 +128,12 @@ def run() -> None:
|
|||||||
dpg.show_viewport()
|
dpg.show_viewport()
|
||||||
|
|
||||||
dpg.configure_item(
|
dpg.configure_item(
|
||||||
"loading_window",
|
loading_tags.WINDOW,
|
||||||
pos=(0, 0),
|
pos=(0, 0),
|
||||||
width=LOADING_WINDOW_WIDTH,
|
width=LOADING_WINDOW_WIDTH,
|
||||||
height=LOADING_WINDOW_HEIGHT,
|
height=LOADING_WINDOW_HEIGHT,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Init global app state
|
|
||||||
state: AppState = AppState()
|
|
||||||
|
|
||||||
loading_msg_queue: Queue[str] = Queue()
|
loading_msg_queue: Queue[str] = Queue()
|
||||||
loading_stop: threading.Event = threading.Event()
|
loading_stop: threading.Event = threading.Event()
|
||||||
loading_thread = Thread(
|
loading_thread = Thread(
|
||||||
@@ -149,12 +144,8 @@ def run() -> None:
|
|||||||
)
|
)
|
||||||
loading_thread.start()
|
loading_thread.start()
|
||||||
|
|
||||||
while not loading_stop.is_set():
|
while not loading_stop.is_set() and dpg.is_dearpygui_running():
|
||||||
# Fetch and execute callbacks
|
# 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()
|
jobs = dpg.get_callback_queue()
|
||||||
try:
|
try:
|
||||||
dpg.run_callbacks(jobs)
|
dpg.run_callbacks(jobs)
|
||||||
@@ -167,20 +158,30 @@ def run() -> None:
|
|||||||
except Empty:
|
except Empty:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
dpg.set_value(item="loading_text", value=msg)
|
dpg.set_value(item=loading_tags.TEXT, value=msg)
|
||||||
# Render frame
|
# Render frame
|
||||||
dpg.render_dearpygui_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
|
# Call ui building function
|
||||||
_maximize_viewport()
|
_maximize_viewport()
|
||||||
build_windows(state)
|
build_shell(state)
|
||||||
|
|
||||||
# Config main window size
|
# Config main window size
|
||||||
vp_w = dpg.get_viewport_client_width()
|
vp_w = dpg.get_viewport_client_width()
|
||||||
vp_h = dpg.get_viewport_client_height()
|
vp_h = dpg.get_viewport_client_height()
|
||||||
dpg.configure_item("main_window", pos=(0, 0), width=vp_w, height=vp_h)
|
dpg.configure_item(MAIN_WINDOW, pos=(0, 0), width=vp_w, height=vp_h)
|
||||||
dpg.set_primary_window("main_window", True)
|
dpg.set_primary_window(MAIN_WINDOW, True)
|
||||||
|
|
||||||
# dpg UI loop
|
# dpg UI loop
|
||||||
while dpg.is_dearpygui_running():
|
while dpg.is_dearpygui_running():
|
||||||
@@ -191,47 +192,18 @@ def run() -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
|
for _ in range(100):
|
||||||
try:
|
try:
|
||||||
new_signal = state.new_signal_queue.get_nowait()
|
event = state.connector_events.get_nowait()
|
||||||
except Empty:
|
except Empty:
|
||||||
pass
|
break
|
||||||
else:
|
else:
|
||||||
dpgg.digital_gauge(
|
handle_connector_event(state, event)
|
||||||
tag=f"{new_signal.id}",
|
|
||||||
label=f"{new_signal.name}",
|
|
||||||
parent=WINDOW_DATA_INPUT_GAUGE_GRID,
|
|
||||||
unit=new_signal.unit if new_signal.unit is not None else "",
|
|
||||||
value=0,
|
|
||||||
precision=1,
|
|
||||||
min_value=new_signal.min_value,
|
|
||||||
max_value=new_signal.max_value,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
removed_signal = state.removed_signal_queue.get_nowait()
|
|
||||||
except Empty:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
dpgg.delete_gauge(f"{removed_signal.id}")
|
|
||||||
|
|
||||||
ctr: int = 0
|
|
||||||
while not state.new_data_id.empty() or ctr < 5:
|
|
||||||
try:
|
|
||||||
new_value_id = state.new_data_id.get_nowait()
|
|
||||||
except Empty:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
dpgg.set_value(
|
|
||||||
tag=f"{new_value_id}", value=state.live_data[new_value_id]
|
|
||||||
)
|
|
||||||
except GaugeNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
ctr += 1
|
|
||||||
|
|
||||||
# Render frame
|
# Render frame
|
||||||
dpg.render_dearpygui_frame()
|
dpg.render_dearpygui_frame()
|
||||||
finally:
|
finally:
|
||||||
state.stop_event.set()
|
state.stop_event.set()
|
||||||
|
if state.connector_thread is not None:
|
||||||
|
state.connector_thread.join(timeout=2)
|
||||||
dpg.destroy_context()
|
dpg.destroy_context()
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Data connector services and application events."""
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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))
|
||||||
@@ -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
@@ -10,28 +10,37 @@ from dynalab_protocol.models import SignalDescriptor
|
|||||||
from dynalab_protocol.dlconf import DLConf
|
from dynalab_protocol.dlconf import DLConf
|
||||||
|
|
||||||
from dynalab.config import AppConfig
|
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
|
@dataclass
|
||||||
class AppState:
|
class AppState:
|
||||||
stop_event = threading.Event()
|
stop_event: threading.Event = field(default_factory=threading.Event)
|
||||||
data_json_thread: threading.Thread | None = None
|
connector_thread: threading.Thread | None = None
|
||||||
last_heartbeat: dict[UUID, int] = field(default_factory=dict)
|
connector_events: Queue[ConnectorEvent] = field(default_factory=Queue)
|
||||||
new_signal_queue: Queue[SignalDescriptor] = Queue()
|
|
||||||
removed_signal_queue: Queue[SignalDescriptor] = Queue()
|
|
||||||
|
|
||||||
available_signals: list[SignalDescriptor] = field(default_factory=list)
|
signals: SignalState = field(default_factory=SignalState)
|
||||||
live_data: dict[UUID, float] = field(default_factory=dict)
|
recording: RecordingState = field(default_factory=RecordingState)
|
||||||
new_data_id: Queue[UUID] = Queue()
|
|
||||||
|
|
||||||
app_config: AppConfig = field(default_factory=AppConfig)
|
app_config: AppConfig = field(default_factory=AppConfig)
|
||||||
|
|
||||||
stop_init = threading.Event()
|
|
||||||
init_error: str | None = None
|
init_error: str | None = None
|
||||||
|
|
||||||
window_map: dict[str, str] = field(default_factory=dict)
|
window_map: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
language: str = ""
|
language: str = ""
|
||||||
|
|
||||||
loaded_dlconf: DLConf | None = None
|
|
||||||
building_dlconf: DLConf | None = None
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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()
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Error modal UI."""
|
||||||
@@ -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"
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Live connector data UI."""
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Application loading view."""
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Recording configuration UI."""
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
@@ -1,211 +1,13 @@
|
|||||||
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
|
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
|
||||||
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
|
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# 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.ui.loading.view import (
|
||||||
|
center_loading_content,
|
||||||
from dynalab.state import AppState
|
loading_content_layout as _loading_content_layout,
|
||||||
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.routines import (
|
from dynalab.ui.loading import view as _loading_view
|
||||||
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 _
|
|
||||||
|
|
||||||
|
|
||||||
LOADING_WINDOW_WIDTH = 600
|
dpg = _loading_view.dpg
|
||||||
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)
|
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ async def run_connector_session() -> None:
|
|||||||
handshake: HandshakeAccepted
|
handshake: HandshakeAccepted
|
||||||
|
|
||||||
peer = writer.get_extra_info("peername")
|
peer = writer.get_extra_info("peername")
|
||||||
|
value_task: asyncio.Task[None] | None = None
|
||||||
print(f"Connected to DynaLab at {peer}")
|
print(f"Connected to DynaLab at {peer}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -129,6 +130,8 @@ async def run_connector_session() -> None:
|
|||||||
elif isinstance(message, HandshakeRejected):
|
elif isinstance(message, HandshakeRejected):
|
||||||
print(f"Connector handshake rejected: {message.reason}")
|
print(f"Connector handshake rejected: {message.reason}")
|
||||||
return
|
return
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Expected handshake response, received {message.type}")
|
||||||
|
|
||||||
value_task = asyncio.create_task(_value_sender(writer, connector_hello))
|
value_task = asyncio.create_task(_value_sender(writer, connector_hello))
|
||||||
# Keep the connection alive by returning heartbeat messages.
|
# Keep the connection alive by returning heartbeat messages.
|
||||||
@@ -137,7 +140,6 @@ async def run_connector_session() -> None:
|
|||||||
print(f"Received {message}")
|
print(f"Received {message}")
|
||||||
|
|
||||||
if isinstance(message, Heartbeat):
|
if isinstance(message, Heartbeat):
|
||||||
if message.sequence < 10:
|
|
||||||
message.return_timestamp = machine_timestamp_ms()
|
message.return_timestamp = machine_timestamp_ms()
|
||||||
await write_message(writer, message)
|
await write_message(writer, message)
|
||||||
|
|
||||||
@@ -145,6 +147,7 @@ async def run_connector_session() -> None:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
if value_task is not None:
|
||||||
value_task.cancel()
|
value_task.cancel()
|
||||||
print("Disconnecting")
|
print("Disconnecting")
|
||||||
writer.close()
|
writer.close()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user