Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 69 additions & 24 deletions .github/actions/setup-venv/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,50 +48,95 @@ runs:
path: |
~/Library/Caches/Homebrew
/opt/homebrew/Cellar/lsl
/opt/homebrew/lib/liblsl.dylib
/opt/homebrew/Frameworks/LSL.framework
/usr/local/Cellar/lsl
/usr/local/Frameworks/LSL.framework
key: brew-${{ runner.os }}-${{ hashFiles('.github/actions/setup-venv/action.yaml') }}

- name: Install LSL (macOS)
if: runner.os == 'macOS' && inputs.install-lsl == 'true'
shell: bash
run: |
# Check if LSL is already installed and working
if ! brew list lsl &>/dev/null || ! ls /opt/homebrew/lib/liblsl.dylib &>/dev/null; then
BREW_PREFIX=$(brew --prefix)

# Check if LSL is already installed.
# Homebrew's lsl formula may install as a framework (LSL.framework) rather
# than a standalone liblsl.dylib.
if ! brew list lsl &>/dev/null; then
echo "Installing LSL..."
brew install labstreaminglayer/tap/lsl
else
echo "LSL already installed and library found"
fi

# Verify installation
if ls /opt/homebrew/lib/liblsl.dylib &>/dev/null; then
echo "LSL library confirmed at: /opt/homebrew/lib/liblsl.dylib"
# Set environment variable for LSL
echo "PYLSL_LIB=/opt/homebrew/lib/liblsl.dylib" >> $GITHUB_ENV
echo "DYLD_LIBRARY_PATH=/opt/homebrew/lib:$DYLD_LIBRARY_PATH" >> $GITHUB_ENV
LSL_LIB=""
if [[ -f "${BREW_PREFIX}/Frameworks/LSL.framework/LSL" ]]; then
LSL_LIB="${BREW_PREFIX}/Frameworks/LSL.framework/LSL"
elif [[ -f "${BREW_PREFIX}/Frameworks/LSL.framework/Versions/Current/LSL" ]]; then
LSL_LIB="${BREW_PREFIX}/Frameworks/LSL.framework/Versions/Current/LSL"
elif [[ -f "${BREW_PREFIX}/lib/liblsl.dylib" ]]; then
LSL_LIB="${BREW_PREFIX}/lib/liblsl.dylib"
fi

if [[ -z "$LSL_LIB" ]]; then
LSL_LIB=$(find "${BREW_PREFIX}/Cellar" -path "*/Frameworks/LSL.framework/LSL" -type f 2>/dev/null | head -n 1)
fi

if [[ -z "$LSL_LIB" ]]; then
LSL_LIB=$(find "${BREW_PREFIX}" -name "liblsl.dylib" -type f 2>/dev/null | head -n 1)
fi

if [[ -n "$LSL_LIB" ]]; then
echo "LSL library found at: $LSL_LIB"
echo "PYLSL_LIB=$LSL_LIB" >> $GITHUB_ENV
else
echo "Warning: LSL library not found after installation"
find /opt/homebrew -name "liblsl.*" -type f 2>/dev/null || true
echo "ERROR: LSL library not found after installation"
find "${BREW_PREFIX}" -name "liblsl.*" -type f 2>/dev/null || true
find "${BREW_PREFIX}" -name "LSL.framework" -type d 2>/dev/null || true
exit 1
fi

- name: Install system dependencies (Windows)
if: runner.os == 'Windows' && inputs.install-lsl == 'true'
uses: jwlawson/actions-setup-cmake@v1
with:
cmake-version: '3.25.0'

# Install LSL if needed
- name: Install LSL on Windows
if: inputs.install-lsl == 'true' && runner.os == 'Windows'
shell: pwsh
run: |
git clone --depth=1 https://github.com/sccn/liblsl.git
cd liblsl
mkdir build
cd build
cmake ..
cmake --build . --config Release
cmake --install . --config Release
$ErrorActionPreference = 'Stop'

# Download a prebuilt liblsl release to avoid compiling from source.
$headers = @{
'User-Agent' = 'github-actions'
}
$release = Invoke-RestMethod -Uri 'https://api.github.com/repos/sccn/liblsl/releases/latest' -Headers $headers

# GitHub-hosted Windows runners are x64/amd64. liblsl release assets are
# named like: liblsl-<ver>-Win_amd64.zip, liblsl-<ver>-Win_arm64.zip, etc.
$zipAssets = $release.assets | Where-Object { $_.name -match '\.zip$' }
$asset = $zipAssets | Where-Object { $_.name -match '(?i)Win_amd64' } | Select-Object -First 1
if (-not $asset) {
$asset = $zipAssets | Where-Object { $_.name -match '(?i)win' -and $_.name -match '(?i)(amd64|x86_64|x64)' } | Select-Object -First 1
}
if (-not $asset) {
$names = ($release.assets | ForEach-Object { $_.name }) -join ", "
throw "Could not find a Windows amd64 liblsl zip asset in the latest release. Assets: $names"
}

$destDir = Join-Path $env:RUNNER_TEMP 'liblsl'
New-Item -ItemType Directory -Path $destDir -Force | Out-Null
$zipPath = Join-Path $destDir $asset.name

Write-Host "Downloading $($asset.browser_download_url)"
Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $zipPath
Expand-Archive -LiteralPath $zipPath -DestinationPath $destDir -Force

$lslDll = Get-ChildItem -Path $destDir -Recurse -Filter 'lsl.dll' | Select-Object -First 1
if (-not $lslDll) {
throw 'lsl.dll not found after extracting liblsl release asset.'
}

Write-Host "LSL library found at: $($lslDll.FullName)"
"PYLSL_LIB=$($lslDll.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append
"PATH=$($lslDll.Directory.FullName);$env:PATH" | Out-File -FilePath $env:GITHUB_ENV -Append

- name: Install LSL on Ubuntu
if: inputs.install-lsl == 'true' && runner.os == 'Linux'
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ readme = "README.md"
requires-python = ">=3.10,<3.13"
dependencies = [
"pylsl>=1.17.6",
"numpy>=1.26.4,<2.3.0"
"numpy>=1.26.4,<2.3.0",
"websockets>=14.0"
]

[project.scripts]
Expand Down
1 change: 1 addition & 0 deletions src/MoBI_View/web/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""This is the web submodule for MoBI_View."""
191 changes: 191 additions & 0 deletions src/MoBI_View/web/broadcaster.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"""WebSocket broadcaster for real-time data streaming.

This module provides the Broadcaster class that reads data from the presenter
and broadcasts it as JSON frames to all connected WebSocket clients.
"""

import asyncio
import json
import logging
import threading
import time
from typing import Any, Dict, List, Optional, Set

from websockets.asyncio import server

from MoBI_View.core import config
from MoBI_View.presenters import main_app_presenter

logger = logging.getLogger("MoBI-View.web.broadcaster")


class Broadcaster:
"""Broadcasts real-time data from presenter to WebSocket clients.

The Broadcaster runs a background thread that continuously polls the presenter
for new data, formats it as JSON, and broadcasts to all connected clients.

Attributes:
presenter: The MainAppPresenter instance providing data.
clients: Set of connected WebSocket clients.
broadcast_interval: Time between broadcasts in seconds.
"""

CLIENT_SEND_TIMEOUT: float = 1.0
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will move to config if we decide to keep it and if we want to make it tunable for users. Used in _broadcast_to_clients() and stop() timeout calculations, currently set as 1.0, which is pretty conservative.


def __init__(
self,
presenter: main_app_presenter.MainAppPresenter,
broadcast_interval: Optional[float] = None,
) -> None:
"""Initializes the Broadcaster with a presenter and interval.

Args:
presenter: The MainAppPresenter instance to poll for data.
broadcast_interval: Time between broadcasts in seconds. Defaults to
Config.TIMER_INTERVAL converted to seconds.
"""
self.presenter = presenter
self.clients: Set[server.ServerConnection] = set()
self._clients_lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._loop: Optional[asyncio.AbstractEventLoop] = None

self.broadcast_interval = (
broadcast_interval or config.Config.TIMER_INTERVAL / 1000
)

def start(self) -> None:
"""Starts the broadcast loop in a background thread.

Creates a new thread running the broadcast loop. If already running,
this method does nothing.
"""
if self._running:
logger.warning("Broadcaster already running")
return

self._running = True
self._thread = threading.Thread(target=lambda: None, daemon=True)
self._thread.start()
logger.info("Broadcaster started")

def stop(self) -> None:
"""Stops the broadcast loop and waits for thread termination.

Signals the broadcast loop to stop and waits for the thread to finish.
If not running, this method does nothing.
"""
if not self._running:
logger.warning("Broadcaster not running")
return

self._running = False
if self._thread is None:
return

with self._clients_lock:
client_count = len(self.clients)
timeout = (
self.broadcast_interval
+ (client_count * self.CLIENT_SEND_TIMEOUT)
+ self.CLIENT_SEND_TIMEOUT
)
self._thread.join(timeout=timeout)
self._thread = None
self._loop = None
logger.info("Broadcaster stopped")

def add_client(self, client: server.ServerConnection) -> None:
"""Adds a WebSocket client to the broadcast set.

Args:
client: The WebSocket connection to add.
"""
with self._clients_lock:
self.clients.add(client)
logger.info("Client added, total clients: %d", len(self.clients))

def remove_client(self, client: server.ServerConnection) -> None:
"""Removes a WebSocket client from the broadcast set.

Args:
client: The WebSocket connection to remove.
"""
with self._clients_lock:
self.clients.discard(client)
logger.info("Client removed, total clients: %d", len(self.clients))

def format_frame(self, streams_data: List[Dict[str, Any]]) -> str:
"""Formats stream data as a JSON frame for broadcasting.

Creates a JSON structure containing timestamp and all stream data.

Args:
streams_data: List of stream data dictionaries from presenter.poll_data().
Each dictionary contains 'stream_name', 'data', and 'channel_labels'.

Returns:
JSON string containing the formatted frame.
"""
frame = {
"streams": streams_data,
}
return json.dumps(frame)

def _run(self) -> None:
"""Main broadcast loop running in background thread.

Continuously polls the presenter for data, formats it, and broadcasts
to all connected clients. Runs until stop() is called.
"""
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)

logger.info("Broadcast loop started")

while self._running:
try:
streams_data = self.presenter.poll_data()

if streams_data:
frame = self.format_frame(streams_data)
self._broadcast_to_clients(frame)

time.sleep(self.broadcast_interval)

except Exception as err:
logger.error("Error in broadcast loop: %s", err)
time.sleep(self.broadcast_interval)

self._loop.close()
logger.info("Broadcast loop ended")

def _broadcast_to_clients(self, message: str) -> None:
"""Sends a message to all connected clients.

Iterates through all clients and sends the message asynchronously.
Disconnected clients are removed from the set.

Args:
message: The JSON message string to broadcast.
"""
with self._clients_lock:
clients_snapshot = set(self.clients)

disconnected: List[server.ServerConnection] = []

for client in clients_snapshot:
try:
if self._loop is not None:
future = asyncio.run_coroutine_threadsafe(
client.send(message), self._loop
)
future.result(timeout=self.CLIENT_SEND_TIMEOUT)
except Exception as err:
logger.warning("Failed to send to client: %s", err)
disconnected.append(client)

for client in disconnected:
self.remove_client(client)
Loading
Loading