Skip to content

Commit d80ec22

Browse files
committed
refactor: add the web server and its tests
1 parent 90d72b0 commit d80ec22

24 files changed

Lines changed: 1712 additions & 1250 deletions

ARCHITECTURE.md

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
# MoBI-View Architecture
2+
3+
## Overview
4+
5+
MoBI-View follows a **layered architecture** with clear separation of concerns for real-time LSL stream visualization.
6+
7+
## Architecture Layers
8+
9+
```
10+
┌──────────────────────────────────────────────────────────────┐
11+
│ Application Layer (main.py) │
12+
│ - Discovers LSL streams │
13+
│ - Creates DataInlet instances │
14+
│ - Initializes Presenter with DataInlets │
15+
│ - Starts WebSocket server │
16+
└───────────────────────────┬──────────────────────────────────┘
17+
18+
19+
┌─────────────────────┐
20+
│ Model Layer │
21+
│ (DataInlet) │
22+
│ │
23+
│ - Connects to ONE │
24+
│ LSL stream │
25+
│ - Buffers samples │
26+
│ - pull_sample() │
27+
└──────────┬──────────┘
28+
29+
30+
┌─────────────────────┐
31+
│ Presenter Layer │
32+
│ (MainAppPresenter) │
33+
│ │
34+
│ - Manages list of │
35+
│ DataInlets │
36+
│ - Polls all inlets │
37+
│ - Formats data │
38+
│ - poll_data() │
39+
└──────────┬──────────┘
40+
41+
42+
┌─────────────────────┐
43+
│ View Layer │
44+
│ (WebServer) │
45+
│ │
46+
│ - Reads from │
47+
│ presenter │
48+
│ - Broadcasts to │
49+
│ WebSocket clients │
50+
│ - Handles UI state │
51+
│ (visibility, etc) │
52+
└─────────────────────┘
53+
```
54+
55+
**Data Flow**: Application → Model → Presenter → View
56+
57+
- **Application** discovers streams and wires components together
58+
- **Model** (DataInlet) pulls data from LSL streams
59+
- **Presenter** polls models and formats data
60+
- **View** reads from presenter and renders to users
61+
62+
## Component Responsibilities
63+
64+
### 1. Application Layer (`main.py`)
65+
66+
**Purpose**: Entry point that wires everything together
67+
68+
**Responsibilities**:
69+
- Discover LSL streams using `discover_and_create_inlets()`
70+
- Create `DataInlet` instances for each discovered stream
71+
- Initialize `MainAppPresenter` with the inlets
72+
- Start the WebSocket server with `run_server()`
73+
- Schedule browser launch
74+
75+
**Key Functions**:
76+
- `main()` - Main entry point
77+
78+
### 2. Model Layer (`core/data_inlet.py`)
79+
80+
**Purpose**: Represents a single LSL stream connection
81+
82+
**Responsibilities**:
83+
- Connect to ONE LSL stream
84+
- Buffer incoming samples
85+
- Pull samples from the stream
86+
- Provide stream metadata (name, type, channels, sample rate)
87+
88+
**Key Methods**:
89+
- `__init__(partial_info: StreamInfo)` - Create connection
90+
- `pull_sample()` - Pull one sample from the stream
91+
- `get_channel_information()` - Extract channel metadata
92+
93+
**Attributes**:
94+
- `stream_name`, `stream_type`, `source_id` - Stream identifiers
95+
- `buffers` - Circular buffer for samples
96+
- `ptr` - Current position in buffer
97+
- `channel_info` - Channel labels, types, units
98+
99+
### 3. Presenter Layer (`presenters/main_app_presenter.py`)
100+
101+
**Purpose**: Business logic layer that manages multiple streams
102+
103+
**Responsibilities**:
104+
- Manage list of `DataInlet` instances
105+
- Poll all inlets at regular intervals
106+
- Format data for consumption by views
107+
- Manage channel visibility state
108+
- Handle errors from individual streams
109+
110+
**Key Methods**:
111+
- `poll_data()` - Poll all inlets and return formatted data
112+
- `on_data_updated()` - Format sample data for views
113+
- `update_channel_visibility()` - Toggle channel display
114+
115+
**Key Principle**: The presenter does NOT know about LSL or discovery - it just manages DataInlets that are passed to it.
116+
117+
### 4. View Layer (`web/server.py`)
118+
119+
**Purpose**: Pure view - reads from presenter and serves WebSocket clients
120+
121+
**Responsibilities**:
122+
- Serve static HTML/CSS/JS files
123+
- Maintain WebSocket connections
124+
- Read data from presenter's inlets
125+
- Broadcast formatted data to connected clients
126+
- Handle client commands (e.g., discover button)
127+
128+
**Key Components**:
129+
- `Broadcaster` - Periodically reads from presenter and sends to WebSocket clients
130+
- `poll_presenter_continuously()` - Background task that calls `presenter.poll_data()`
131+
- `ws_handler()` - Handle WebSocket connections and messages
132+
- `run_server()` - Main server entry point
133+
134+
**Key Principle**: The server does NOT discover streams or create inlets - it only reads from the presenter.
135+
136+
### 5. Discovery Utilities (`core/discovery.py`)
137+
138+
**Purpose**: Shared utility for discovering LSL streams
139+
140+
**Responsibilities**:
141+
- Call `pylsl.resolve_streams()` to find available streams
142+
- Create `DataInlet` instances for discovered streams
143+
- Deduplicate streams based on (source_id, name, type)
144+
- Handle errors during inlet creation
145+
146+
**Key Functions**:
147+
- `discover_and_create_inlets()` - Returns list of new DataInlet instances
148+
149+
**Usage**: Called by application layer (main.py) and by WebSocket handler when user clicks "Discover Streams" button
150+
151+
## Data Flow
152+
153+
### Startup Flow
154+
155+
```
156+
main.py
157+
├─> discover_and_create_inlets()
158+
│ └─> resolve_streams() (pylsl)
159+
│ └─> DataInlet(info) for each stream
160+
161+
├─> MainAppPresenter(data_inlets=[...])
162+
│ └─> _initialize_channels()
163+
164+
└─> run_server(presenter)
165+
├─> start_http_static_server()
166+
├─> poll_presenter_continuously()
167+
│ └─> presenter.poll_data() (every 2ms)
168+
│ └─> inlet.pull_sample() for each inlet
169+
170+
└─> Broadcaster._run()
171+
└─> Read from presenter.data_inlets
172+
└─> ws.send(json) to all clients
173+
```
174+
175+
### Runtime Data Flow (Polling)
176+
177+
```
178+
[LSL Stream] ──samples──> [DataInlet.pull_sample()]
179+
180+
├─> Store in buffers[ptr]
181+
182+
[Presenter.poll_data()]
183+
184+
├─> Read buffers[latest_index]
185+
├─> Format as dict
186+
187+
[Broadcaster._run()]
188+
189+
└─> Send to WebSocket clients
190+
```
191+
192+
### User-Triggered Discovery Flow
193+
194+
```
195+
[User clicks "Discover Streams" button]
196+
197+
198+
[WebSocket message: {type: "discover_streams"}]
199+
200+
201+
[ws_handler()]
202+
203+
├─> discover_and_create_inlets()
204+
│ └─> resolve_streams()
205+
│ └─> Create new DataInlets
206+
207+
├─> Append new inlets to presenter.data_inlets
208+
├─> Initialize channel_visibility
209+
210+
└─> Send response: {type: "discover_result", count: X}
211+
```
212+
213+
## Key Architectural Decisions
214+
215+
### 1. No Discovery in Presenter
216+
217+
The presenter does NOT discover streams. Discovery happens at the application layer (main.py) or through user action (discover button).
218+
219+
**Rationale**: Presenter is business logic layer, not responsible for I/O or system initialization.
220+
221+
### 2. No Discovery in Server
222+
223+
The server does NOT discover streams automatically. It only handles user-triggered discovery via WebSocket commands.
224+
225+
**Rationale**: Server is pure view layer, should not contain business logic or know about LSL.
226+
227+
### 3. Mutable Inlet List
228+
229+
The `presenter.data_inlets` list is mutable and can be appended to directly. No need for `add_inlet()` method.
230+
231+
**Rationale**: Simple and direct. The presenter initializes channel visibility in `__init__`, and the WebSocket handler can manually initialize visibility for new inlets.
232+
233+
### 4. Separation of Concerns
234+
235+
- **DataInlet**: ONE stream, ONE responsibility (buffer samples)
236+
- **Presenter**: Coordinate MULTIPLE inlets, format data
237+
- **Server**: Read from presenter, serve to clients
238+
- **Application**: Wire everything together
239+
240+
### 5. Discovery Utility
241+
242+
Shared `discover_and_create_inlets()` function used by both:
243+
- Startup (main.py)
244+
- Runtime (WebSocket handler when user clicks discover button)
245+
246+
247+
## Channel Color Configuration
248+
249+
The visualization uses a **pattern-based color assignment system** located in `web/static/colors.js`:
250+
251+
**Key Features:**
252+
- **Pattern Matching**: Channel names are matched against regex patterns (e.g., `/^c\d+$/i` for C3, C4, etc.)
253+
- **Pre-configured Sensor Types**: Includes EEG, EMG, EOG, ECG, accelerometer, gyroscope, and more
254+
- **Customizable**: Users can edit `colors.js` to add custom patterns or change colors
255+
- **Deterministic**: Same channel name always gets same color across sessions
256+
- **Fallback Hash**: If no pattern matches, color is generated from channel name hash
257+
258+
**Example Usage:**
259+
```javascript
260+
// In colors.js - add custom pattern
261+
{
262+
pattern: /^mydevice/i, // Matches "MyDevice_1", "mydevice_A"
263+
color: 'hsl(180, 80%, 55%)', // Teal color
264+
description: 'My custom device' // Documentation
265+
}
266+
```
267+
268+
**Benefits:**
269+
- **Semantic Grouping**: Related channels get similar colors (e.g., all frontal EEG in blue)
270+
- **Readable Configuration**: HSL format is intuitive (hue, saturation, lightness)
271+
- **Flexible Matching**: Supports wildcards, regex, case-insensitive matching
272+
- **Well-Documented**: See `README_COLORS.md` for complete customization guide

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ dependencies = [
1717

1818
[project.scripts]
1919
mobi-view = "MoBI_View.main:main"
20-
mobi-view-web = "MoBI_View.web.web_main:main"
2120

2221
[dependency-groups]
2322
dev = [

src/MoBI_View/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
"""This is a web based viewer submodule for MoBI_View."""
1+
"""This is a web based viewer submodule for MoBI_View."""

src/MoBI_View/core/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,5 @@ class Config:
3434
WS_HOST: str = "0.0.0.0"
3535
WS_PORT: int = 8765
3636
POLL_INTERVAL: float = 0.002
37-
RESOLVE_INTERVAL: float = 2.0
38-
RESOLVE_WAIT: float = 0.5
37+
RESOLVE_INTERVAL: float = 5.0
38+
RESOLVE_WAIT: float = 0.1

src/MoBI_View/core/data_inlet.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class DataInlet:
2323
inlet: The LSL stream inlet for acquiring data.
2424
stream_name: The name of the LSL stream.
2525
stream_type: The content type of the LSL stream (e.g., EEG, Gaze).
26+
source_id: The source ID of the LSL stream (unique identifier).
2627
channel_info: Information about channels, including labels, types, and units.
2728
channel_count: The number of channels in the LSL stream.
2829
channel_format: The format (data type) of the channel data.
@@ -52,6 +53,7 @@ def __init__(self, partial_info: StreamInfo) -> None:
5253

5354
self.stream_name: str = info.name()
5455
self.stream_type: str = info.type()
56+
self.source_id: str = info.source_id()
5557
self.channel_info: Dict[str, List[str]] = self.get_channel_information(info)
5658
self.channel_count: int = info.channel_count()
5759
self.channel_format: int = info.channel_format()

src/MoBI_View/core/discovery.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Stream discovery utilities for MoBI-View.
2+
3+
This module provides shared functions for discovering LSL streams and creating
4+
DataInlets. These functions are used by:
5+
- Application layer (main.py) at startup to initialize the system
6+
- View layer (server.py) when the user clicks "Discover Streams" button
7+
8+
The key function is discover_and_create_inlets(), which:
9+
1. Calls pylsl.resolve_streams() to find available LSL streams
10+
2. Deduplicates against existing inlets using (source_id, name, type) tuple
11+
3. Creates a new DataInlet for each unique stream
12+
4. Returns the list of new inlets and count
13+
"""
14+
15+
from typing import List, Set, Tuple
16+
17+
from pylsl import StreamInfo, resolve_streams
18+
19+
from MoBI_View.core.data_inlet import DataInlet
20+
21+
22+
def discover_and_create_inlets(
23+
wait_time: float = 1.0,
24+
existing_inlets: List[DataInlet] | None = None,
25+
) -> Tuple[List[DataInlet], int]:
26+
"""Discover LSL streams and create DataInlet instances.
27+
28+
This function resolves available LSL streams and creates DataInlet instances
29+
for any new streams not already in the existing_inlets list.
30+
31+
Args:
32+
wait_time: How long to wait for streams to be discovered (seconds).
33+
existing_inlets: Optional list of existing DataInlets to check for duplicates.
34+
35+
Returns:
36+
Tuple of (list of NEW DataInlet instances created, total count of new streams)
37+
"""
38+
new_inlets: List[DataInlet] = []
39+
40+
existing_streams: Set[Tuple[str, str, str]] = set()
41+
if existing_inlets:
42+
existing_streams = {
43+
(inlet.source_id, inlet.stream_name, inlet.stream_type)
44+
for inlet in existing_inlets
45+
}
46+
47+
try:
48+
discovered_streams: List[StreamInfo] = resolve_streams(wait_time)
49+
50+
for info in discovered_streams:
51+
try:
52+
source_id = info.source_id()
53+
stream_name = info.name()
54+
stream_type = info.type()
55+
stream_id = (source_id, stream_name, stream_type)
56+
57+
if stream_id in existing_streams:
58+
continue
59+
60+
inlet = DataInlet(info)
61+
new_inlets.append(inlet)
62+
existing_streams.add(stream_id)
63+
64+
print(
65+
f"Discovered new stream: {stream_name} "
66+
f"({stream_type}, {inlet.channel_count} channels)"
67+
)
68+
69+
except Exception as err:
70+
stream_name = getattr(info, "name", lambda: "unknown")()
71+
print(f"Skipping stream {stream_name}: {err}")
72+
continue
73+
74+
except Exception as err:
75+
print(f"Error during stream discovery: {err}")
76+
77+
return new_inlets, len(new_inlets)

0 commit comments

Comments
 (0)