From 42c515742f215ca3960655f6a4130ab26e1f50b6 Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Wed, 25 Jun 2025 15:27:47 +0530 Subject: [PATCH 01/24] Creating chordspy Python Package --- LICENSE | 21 -------- README.md | 114 ---------------------------------------- __init__.py | 2 + app.py | 40 +++++++++----- app_requirements.txt | 13 ----- beetle.py | 2 +- chords_requirements.txt | 4 -- connection.py | 6 +-- csvplotter.py | 7 ++- emgenvelope.py | 9 ++-- eog.py | 7 ++- ffteeg.py | 7 ++- game.py | 2 +- gui.py | 7 ++- heartbeat_ecg.py | 7 ++- keystroke.py | 2 +- requirements.txt | 19 ------- static/script.js | 44 +++++++++++++--- 18 files changed, 101 insertions(+), 212 deletions(-) delete mode 100644 LICENSE delete mode 100644 README.md create mode 100644 __init__.py delete mode 100644 app_requirements.txt delete mode 100644 chords_requirements.txt delete mode 100644 requirements.txt diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 296fdf87..00000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 Upside Down Labs - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md deleted file mode 100644 index a149400f..00000000 --- a/README.md +++ /dev/null @@ -1,114 +0,0 @@ -# Chords - Python - -Chords- Python is a bag of tools designed to interface with Micro-controller development boards running [Chords Arduino Firmware](https://github.com/upsidedownlabs/Chords-Arduino-Firmware).Use Upside Down Labs bio-potential amplifiers to read data, visualize it, record data in CSV Files, and stream it via Lab Streaming Layer. - -> [!NOTE] -> **Firmware Required:** -> - For Arduino: [Chords Arduino Firmware](https://github.com/upsidedownlabs/Chords-Arduino-Firmware) - -## Features -- **Multiple Protocols**: Supports `Wi-Fi`, `Bluetooth`, and `Serial` communication. -- **LSL Data Streaming**:Once the LSL stream starts, any PC on the same Wi-Fi network can access the data using tools like BrainVision LSL Viewer. -- **CSV Logging**: Save raw data with Counter -- **GUI**: Live plotting for all channels. -- **Applications**: EEG/ECG/EMG/EOG-based games and utilities (e.g., Tug of War, Keystroke Emulator). - -## Installation -1. **Python**: Ensure Latest version of Python is installed. -2. **Virtual Environment**: - ```bash - python -m venv venv - source venv/bin/activate # Linux/macOS - .\venv\Scripts\activate # Windows - ``` -3. **Dependencies**: - ```bash - pip install -r requirements.txt - ``` - -> [!IMPORTANT] -> On Windows, if scripts are blocked, run: -> ```powershell -> Set-ExecutionPolicy Unrestricted -Scope Process -> ``` - -## Usage -Run the script and access the web interface: -```bash -python app.py -``` -**Web Interface Preview**: -![Web Interface Screenshot](./media/Interface.png) - -![Web Interface Screenshot](./media/Webinterface.png) - -### Key Options: - -- **LSL Streaming**: Choose a protocol (`Wi-Fi`, `Bluetooth`, `Serial`). -- **CSV Logging**: Data saved as `ChordsPy_{timestamp}.csv`. -- **Applications**: Multiple Applications can be Launch from the Interface simultaneously(e.g., `EEG Tug of War`). - -## Connection Guide - -#### WIFI Connection - 1. Upload the NPG-Lite WIFI Code to your device. - 2. Connect to the device's WIFI network. - 3. Click the **WIFI** button in the interface, then select **CONNECT**. - 4. Once connected, the button will change to **Disconnect**, and a pop-up will confirm: *"Connected via Wifi!"* - -#### Bluetooth Connection - 1. Ensure Bluetooth is turned ON on your system. - 2. Upload the Bluetooth code to your device. - 3. Click the **Bluetooth** button to scan for available devices. - 4. Select your device from the list and click **Connect**. - 5. Once connected, the button will change to **Disconnect**, and a pop-up will confirm: *"Connected via Bluetooth!"* - -#### Serial Connection - 1. Ensure Bluetooth is OFF and the device is connected via USB. - 2. Upload the required code to your hardware. - 3. Click the **Serial** button, then select **Connect**. - 4. Once connected, the button will change to **Disconnect**, and a pop-up will confirm: *"Connected via Serial!"* - -## CSV Logging -To save sensor data for future analysis, follow these steps: -1. **Start Data Streaming** – Begin streaming data via **WiFi, Bluetooth, or Serial**. -2. **Start Recording** – Click the **Start Recording** button (it will change to **Stop Recording**). -3. **File Saved Automatically** – The data is saved as `ChordsPy_{timestamp}.csv` in your default folder. - -Visualizing CSV Data - You can plot the recorded data using the **CSV Plotter** tool. - -## Applications -| Application | Description | -|----------------------------|------------------------------------------------------------------| -| **ECG with Heart Rate** | Real-time ECG with BPM calculation. | -| **EMG with Envelope** | Real-time EMG Visualization with Envelope. | -| **EOG with Blinks** | Real-time EOG Signal visualization with Blinks marked as Red Dot.| -| **EEG with FFT** | Real-time EEG Signal visualization with FFT and Brainpower bands.| -| **EEG Tug of War Game** | 2 Player EEG Based Game | -| **EEG Beetle game** | Real-time EEG focus based game. | -| **EOG Keystroke Emulator** | Blink detection triggers spacebar. | -| **GUI** | Visualize raw data in real-time | -| **CSV Plotter** | Tool to plot the recorded CSV Files | - -## Troubleshooting - -- **Arduino Not Detected:** Ensure the Arduino is properly connected and powered. Check the serial port and baud rate settings. -- **CSV File Not Created:** Ensure you have write permissions in the directory where the script is run. -- **LSL Stream Issues:** Ensure that the `pylsl` library is properly installed and configured. Additionally, confirm that Bluetooth is turned off. - -## How to Contribute - -You can add your project to this repo: - -- Add a button in apps.yaml to link your application. -- Include your script as a .py file with LSL Data Reception code. -(Pull requests welcome!) - -## Contributors - -We are thankful to our awesome contributors, the list below is alphabetically sorted. - -- [Aman Maheshwari](https://github.com/Amanmahe) -- [Payal Lakra](https://github.com/payallakra) - -The audio file used in `game.py` is sourced from [Pixabay](https://pixabay.com/sound-effects/brass-fanfare-with-timpani-and-windchimes-reverberated-146260/) \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 00000000..6867a364 --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +from chordspy.app import main +from chordspy.connection import Connection \ No newline at end of file diff --git a/app.py b/app.py index 29be055b..26d6e2c6 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from flask import Flask, render_template, request, jsonify -from connection import Connection +from chordspy.connection import Connection import threading import asyncio import logging @@ -9,6 +9,7 @@ import yaml from pathlib import Path import os +import webbrowser console_queue = queue.Queue() app = Flask(__name__) @@ -54,16 +55,19 @@ def index(): @app.route('/get_apps_config') def get_apps_config(): try: - config_path = Path('config') / 'apps.yaml' + config_path = Path(__file__).parent / 'config' / 'apps.yaml' # Try package-relative path first + if not config_path.exists(): + config_path = Path('chordspy.config') / 'apps.yaml' # Fallback to local path + if config_path.exists(): with open(config_path, 'r') as file: config = yaml.safe_load(file) return jsonify(config) - return jsonify + return jsonify({'apps': []}) except Exception as e: logging.error(f"Error loading apps config: {str(e)}") - return jsonify + return jsonify({'apps': [], 'error': str(e)}) @app.route('/scan_ble') @run_async @@ -112,26 +116,27 @@ def launch_application(): return jsonify({'status': 'error', 'message': 'No active stream'}), 400 data = request.get_json() - app_name = data.get('app') + module_name = data.get('app') - if not app_name: + if not module_name: return jsonify({'status': 'error', 'message': 'No application specified'}), 400 # Check if app is already running - if app_name in running_apps and running_apps[app_name].poll() is None: - return jsonify({'status': 'error', 'message': f'{app_name} is already running','code': 'ALREADY_RUNNING'}), 400 + if module_name in running_apps and running_apps[module_name].poll() is None: + return jsonify({'status': 'error', 'message': f'{module_name} is already running','code': 'ALREADY_RUNNING'}), 400 try: import subprocess import sys - python_exec = sys.executable - process = subprocess.Popen([python_exec, f"{app_name}.py"]) - running_apps[app_name] = process + # Run the module using Python's -m flag + process = subprocess.Popen([sys.executable, "-m", f"chordspy.{module_name}"]) + + running_apps[module_name] = process - return jsonify({'status': 'success', 'message': f'Launched {app_name}'}) + return jsonify({'status': 'success', 'message': f'Launched {module_name}'}) except Exception as e: - logging.error(f"Error launching {app_name}: {str(e)}") + logging.error(f"Error launching {module_name}: {str(e)}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/check_app_status/') @@ -240,5 +245,12 @@ def stop_recording(): return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': 'No active connection'}), 400 +def main(): + def open_browser(): + webbrowser.open("http://localhost:5000") + + threading.Timer(1.5, open_browser).start() + app.run(debug=True, use_reloader=False, host='0.0.0.0', port=5000) + if __name__ == "__main__": - app.run(debug=True) \ No newline at end of file + main() \ No newline at end of file diff --git a/app_requirements.txt b/app_requirements.txt deleted file mode 100644 index 45f8c6ec..00000000 --- a/app_requirements.txt +++ /dev/null @@ -1,13 +0,0 @@ -pyqtgraph==0.13.7 -PyQt5==5.15.11 -keyboard==0.13.5 -scipy==1.14.1 -pygame==2.6.1 -neurokit2==0.2.10 -plotly==5.24.1 -pandas==2.2.3 -tk==0.1.0 -PyAutoGUI==0.9.54 -Flask==3.1.1 -psutil==6.1.1 -websocket-client==1.8.0 \ No newline at end of file diff --git a/beetle.py b/beetle.py index f9d52c1d..76b8d3bd 100644 --- a/beetle.py +++ b/beetle.py @@ -55,7 +55,7 @@ calibration_duration = 10 sprite_count = 10 -beetle_sprites = [pygame.image.load(f'media/Beetle{i}.png') for i in range(1, sprite_count + 1)] +beetle_sprites = [pygame.image.load(f'chordspy/media/Beetle{i}.png') for i in range(1, sprite_count + 1)] beetle_sprites = [pygame.transform.smoothscale(sprite, (140, 160)) for sprite in beetle_sprites] # Animation Variables diff --git a/chords_requirements.txt b/chords_requirements.txt deleted file mode 100644 index dca1001b..00000000 --- a/chords_requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -numpy==2.1.3 -pylsl==1.16.2 -pyserial==3.5 -bleak==0.22.3 \ No newline at end of file diff --git a/connection.py b/connection.py index af525de3..fadcb63f 100644 --- a/connection.py +++ b/connection.py @@ -1,6 +1,6 @@ -from chords_serial import Chords_USB -from chords_wifi import Chords_WIFI -from chords_ble import Chords_BLE +from chordspy.chords_serial import Chords_USB +from chordspy.chords_wifi import Chords_WIFI +from chordspy.chords_ble import Chords_BLE from pylsl import StreamInfo, StreamOutlet import argparse import time diff --git a/csvplotter.py b/csvplotter.py index a280faf3..c5605c2d 100644 --- a/csvplotter.py +++ b/csvplotter.py @@ -99,7 +99,10 @@ def plot_data(self): ) fig.show() # Display the plot in a new window -if __name__ == "__main__": +def main(): root = tk.Tk() # Create the main Tkinter root window app = CSVPlotterApp(root) # Create an instance of the CSVPlotterApp class - root.mainloop() # Start the Tkinter main loop \ No newline at end of file + root.mainloop() # Start the Tkinter main loop + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/emgenvelope.py b/emgenvelope.py index 78139a13..b9e0aeac 100644 --- a/emgenvelope.py +++ b/emgenvelope.py @@ -135,9 +135,12 @@ def update_plot(self): print("LSL stream disconnected!") self.timer.stop() self.close() - -if __name__ == "__main__": + +def main(): app = QApplication(sys.argv) window = EMGMonitor() window.show() - sys.exit(app.exec_()) \ No newline at end of file + sys.exit(app.exec_()) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/eog.py b/eog.py index e8de7cb8..44a7380b 100644 --- a/eog.py +++ b/eog.py @@ -183,9 +183,12 @@ def detect_peaks(self, signal, threshold): return peaks -if __name__ == "__main__": +def main(): app = QApplication(sys.argv) window = EOGMonitor() print("Note: There will be a 2s calibration delay before peak detection starts.") window.show() - sys.exit(app.exec_()) \ No newline at end of file + sys.exit(app.exec_()) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ffteeg.py b/ffteeg.py index a46991a4..cbcdb367 100644 --- a/ffteeg.py +++ b/ffteeg.py @@ -418,8 +418,11 @@ def update_brainpower_plot(self): relative_powers = [band_powers['delta'], band_powers['theta'], band_powers['alpha'], band_powers['beta'], band_powers['gamma']] self.brainwave_bars.setOpts(height=relative_powers) -if __name__ == "__main__": +def main(): app = QApplication(sys.argv) window = EEGMonitor() window.show() - sys.exit(app.exec_()) \ No newline at end of file + sys.exit(app.exec_()) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/game.py b/game.py index eddc9444..09fe0450 100644 --- a/game.py +++ b/game.py @@ -129,7 +129,7 @@ def eeg_data_thread(eeg_queue): continue try: sample, timestamp = inlet.pull_sample() - if len(sample) >= 6: + if len(sample) >= 3: channel1_data = sample[0] # PLAYER A channel2_data = sample[1] # PLAYER B diff --git a/gui.py b/gui.py index 00f9d78d..bc79daa3 100644 --- a/gui.py +++ b/gui.py @@ -122,7 +122,10 @@ def init_gui(): return app -if __name__ == "__main__": +def main(): plot_lsl_data() if inlet: - sys.exit(app.exec_()) # Start the Qt application only if a stream was connected \ No newline at end of file + sys.exit(app.exec_()) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/heartbeat_ecg.py b/heartbeat_ecg.py index aa10fcf8..4dda0bfc 100644 --- a/heartbeat_ecg.py +++ b/heartbeat_ecg.py @@ -160,8 +160,11 @@ def plot_r_peaks(self, filtered_ecg): r_peak_values = filtered_ecg[self.r_peaks] # Get corresponding ECG values self.r_peak_curve.setData(r_peak_times, r_peak_values) # Plot R-peaks as red dots -if __name__ == "__main__": +def main(): app = QApplication(sys.argv) window = ECGMonitor() window.show() - sys.exit(app.exec_()) \ No newline at end of file + sys.exit(app.exec_()) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/keystroke.py b/keystroke.py index 4566cf1f..9fe0835b 100644 --- a/keystroke.py +++ b/keystroke.py @@ -203,7 +203,7 @@ def move(event): horizontal_frame = tk.Frame(popup) horizontal_frame.pack(expand=True, pady=10) - eye_icon = PhotoImage(file="media\\icons8-eye-30.png") + eye_icon = PhotoImage(file="chordspy\\media\\icons8-eye-30.png") blink_button = tk.Button(horizontal_frame, image=eye_icon, width=70, height=38, bg="#FFFFFF") blink_button.image = eye_icon diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 2eecceff..00000000 --- a/requirements.txt +++ /dev/null @@ -1,19 +0,0 @@ -numpy==2.1.3 -pylsl==1.16.2 -pyserial==3.5 -bleak==0.22.3 - -pyqtgraph==0.13.7 -PyQt5==5.15.11 -keyboard==0.13.5 -scipy==1.14.1 -pygame==2.6.1 -neurokit2==0.2.10 -plotly==5.24.1 -pandas==2.2.3 -tk==0.1.0 -PyAutoGUI==0.9.54 -Flask==3.1.1 -psutil==6.1.1 -websocket-client==1.8.0 -PyYAML==6.0.2 \ No newline at end of file diff --git a/static/script.js b/static/script.js index ed1684e4..6a340615 100644 --- a/static/script.js +++ b/static/script.js @@ -112,19 +112,42 @@ function renderApps(apps) {

${app.description}

+ `; - updateAppStatus(app.script); - card.addEventListener('click', async () => { - await handleAppClick(app, card); - }); - appGrid.appendChild(card); }); } +async function launchApp(appScript) { + try { + const response = await fetch('/launch_app', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ app: appScript }) + }); + + const result = await response.json(); + + if (result.status === 'error') { + if (result.code === 'ALREADY_RUNNING') { + alert(`Application is already running.`); + } else { + alert(`Failed to launch application: ${result.message}`); + } + } else { + console.log(`Launched ${appScript}`); + } + } catch (error) { + logError('Error launching app:', error); + } +} + async function handleAppClick(app, card) { const statusElement = document.getElementById(`status-${app.script}`); if (statusElement && !statusElement.classList.contains('hidden')) { @@ -926,8 +949,13 @@ checkStreamStatus(); // Initialize the app when DOM is loaded document.addEventListener('DOMContentLoaded', () => { -initializeApplication(); -window.onerror = function(message, source, lineno, colno, error) { + initializeApplication(); + checkStreamStatus(); + setInterval(checkStreamStatus, 1000); + startTimestampUpdater(); + + // Error handling + window.onerror = function(message, source, lineno, colno, error) { logError(error || message); return true; }; @@ -936,6 +964,6 @@ document.getElementById('github-btn').addEventListener('click', () => { }); document.getElementById('info-btn').addEventListener('click', () => { - alert('Chords Python - Biopotential Data Acquisition System\nVersion 2.1.0'); + alert('Chords Python - Bio-potential Data Acquisition System\nVersion 0.1.0'); }); }); \ No newline at end of file From 6a2908c454886ed360c36ee7ef817179c591bfdd Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Wed, 25 Jun 2025 18:34:29 +0530 Subject: [PATCH 02/24] Add chordspy --- LICENSE | 21 ++++ MANIFEST.in | 5 + README.md | 114 ++++++++++++++++++ __init__.py => chordspy/__init__.py | 0 app.py => chordspy/app.py | 3 + beetle.py => chordspy/beetle.py | 0 chords_ble.py => chordspy/chords_ble.py | 0 chords_serial.py => chordspy/chords_serial.py | 0 chords_wifi.py => chordspy/chords_wifi.py | 0 {config => chordspy/config}/apps.yaml | 0 connection.py => chordspy/connection.py | 0 csvplotter.py => chordspy/csvplotter.py | 0 emgenvelope.py => chordspy/emgenvelope.py | 0 eog.py => chordspy/eog.py | 0 ffteeg.py => chordspy/ffteeg.py | 0 game.py => chordspy/game.py | 0 gui.py => chordspy/gui.py | 0 heartbeat_ecg.py => chordspy/heartbeat_ecg.py | 0 keystroke.py => chordspy/keystroke.py | 0 {media => chordspy/media}/Beetle1.png | Bin {media => chordspy/media}/Beetle10.png | Bin {media => chordspy/media}/Beetle2.png | Bin {media => chordspy/media}/Beetle3.png | Bin {media => chordspy/media}/Beetle4.png | Bin {media => chordspy/media}/Beetle5.png | Bin {media => chordspy/media}/Beetle6.png | Bin {media => chordspy/media}/Beetle7.png | Bin {media => chordspy/media}/Beetle8.png | Bin {media => chordspy/media}/Beetle9.png | Bin {media => chordspy/media}/Interface.png | Bin {media => chordspy/media}/Webinterface.png | Bin ...pani-and-winchimes-reverberated-146260.wav | Bin {media => chordspy/media}/icons8-eye-30.png | Bin {Notebooks => chordspy/notebooks}/ecg.ipynb | 0 {Notebooks => chordspy/notebooks}/emg.ipynb | 0 {Notebooks => chordspy/notebooks}/eog.ipynb | 0 {static => chordspy/static}/script.js | 0 {templates => chordspy/templates}/index.html | 0 {test => chordspy/test}/new_parser.py | 0 requirements.txt | 18 +++ setup.py | 40 ++++++ 41 files changed, 201 insertions(+) create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.md rename __init__.py => chordspy/__init__.py (100%) rename app.py => chordspy/app.py (98%) rename beetle.py => chordspy/beetle.py (100%) rename chords_ble.py => chordspy/chords_ble.py (100%) rename chords_serial.py => chordspy/chords_serial.py (100%) rename chords_wifi.py => chordspy/chords_wifi.py (100%) rename {config => chordspy/config}/apps.yaml (100%) rename connection.py => chordspy/connection.py (100%) rename csvplotter.py => chordspy/csvplotter.py (100%) rename emgenvelope.py => chordspy/emgenvelope.py (100%) rename eog.py => chordspy/eog.py (100%) rename ffteeg.py => chordspy/ffteeg.py (100%) rename game.py => chordspy/game.py (100%) rename gui.py => chordspy/gui.py (100%) rename heartbeat_ecg.py => chordspy/heartbeat_ecg.py (100%) rename keystroke.py => chordspy/keystroke.py (100%) rename {media => chordspy/media}/Beetle1.png (100%) rename {media => chordspy/media}/Beetle10.png (100%) rename {media => chordspy/media}/Beetle2.png (100%) rename {media => chordspy/media}/Beetle3.png (100%) rename {media => chordspy/media}/Beetle4.png (100%) rename {media => chordspy/media}/Beetle5.png (100%) rename {media => chordspy/media}/Beetle6.png (100%) rename {media => chordspy/media}/Beetle7.png (100%) rename {media => chordspy/media}/Beetle8.png (100%) rename {media => chordspy/media}/Beetle9.png (100%) rename {media => chordspy/media}/Interface.png (100%) rename {media => chordspy/media}/Webinterface.png (100%) rename {media => chordspy/media}/brass-fanfare-with-timpani-and-winchimes-reverberated-146260.wav (100%) rename {media => chordspy/media}/icons8-eye-30.png (100%) rename {Notebooks => chordspy/notebooks}/ecg.ipynb (100%) rename {Notebooks => chordspy/notebooks}/emg.ipynb (100%) rename {Notebooks => chordspy/notebooks}/eog.ipynb (100%) rename {static => chordspy/static}/script.js (100%) rename {templates => chordspy/templates}/index.html (100%) rename {test => chordspy/test}/new_parser.py (100%) create mode 100644 requirements.txt create mode 100644 setup.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..296fdf87 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Upside Down Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..c9e3e5ab --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include requirements.txt +include README.md +recursive-include chordspy/templates * +recursive-include chordspy/static * +recursive-include chordspy/config * \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..0ef99af3 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# Chords - Python + +Chords- Python is an open-source bag of tools designed to interface with Micro-controller development boards running [Chords Arduino Firmware](https://github.com/upsidedownlabs/Chords-Arduino-Firmware). Use Upside Down Labs bio-potential amplifiers to read data, visualize it, record data in CSV Files, and stream it via Lab Streaming Layer. + +> [!NOTE] +> **Firmware Required for Arduino:** [Chords Arduino Firmware](https://github.com/upsidedownlabs/Chords-Arduino-Firmware) + +## Features +- **Multiple Protocols**: Supports `Wi-Fi`, `Bluetooth`, and `Serial` communication. +- **LSL Data Streaming**:Once the LSL stream starts, any PC on the same Wi-Fi network can access the data using tools like BrainVision LSL Viewer. +- **CSV Logging**: Save raw data with Counter +- **GUI**: Live plotting for all channels. +- **Applications**: EEG/ECG/EMG/EOG-based games and utilities (e.g., Tug of War, Keystroke Emulator). + + +## Installation + +- Make sure you have the latest version of Python installed. + +- Open command prompt and run: +```bash +python -m venv chordspy +``` + +```bash +chordspy\Scripts\activate # For Windows +source chordspy/bin/activate # For MacOS/Linux +``` + +```bash +pip install chordspy +``` + +## Usage +Run the command and access the web interface: +```bash +chordspy +``` + +**Web Interface Preview**: +![Web Interface Screenshot](./chordspy/media/Interface.png) + +![Web Interface Screenshot](./chordspy/media/Webinterface.png) + +### Key Options: + +- **LSL Streaming**: Choose a protocol (`Wi-Fi`, `Bluetooth`, `Serial`). +- **CSV Logging**: Data saved as `ChordsPy_{timestamp}.csv`. +- **Applications**: Multiple Applications can be Launch from the Interface simultaneously(e.g., `EEG Tug of War`). + +## Connection Guide + +#### WIFI Connection + 1. Upload the NPG-Lite WIFI Code to your device. + 2. Connect to the device's WIFI network. + 3. Click the **WIFI** button in the interface, then select **CONNECT**. + 4. Once connected, the button will change to **Disconnect**, and a pop-up will confirm: *"Connected via Wifi!"* + +#### Bluetooth Connection + 1. Ensure Bluetooth is turned ON on your system. + 2. Upload the Bluetooth code to your device. + 3. Click the **Bluetooth** button to scan for available devices. + 4. Select your device from the list and click **Connect**. + 5. Once connected, the button will change to **Disconnect**, and a pop-up will confirm: *"Connected via Bluetooth!"* + +#### Serial Connection + 1. Ensure Bluetooth is OFF and the device is connected via USB. + 2. Upload the required code to your hardware. + 3. Click the **Serial** button, then select **Connect**. + 4. Once connected, the button will change to **Disconnect**, and a pop-up will confirm: *"Connected via Serial!"* + +## CSV Logging +To save sensor data for future analysis, follow these steps: +1. **Start Data Streaming** – Begin streaming data via **WiFi, Bluetooth, or Serial**. +2. **Start Recording** – Click the **Start Recording** button (it will change to **Stop Recording**). +3. **File Saved Automatically** – The data is saved as `ChordsPy_{timestamp}.csv` in your default folder. + +Visualizing CSV Data - You can plot the recorded data using the **CSV Plotter** tool. + +## Applications +| Application | Description | +|----------------------------|------------------------------------------------------------------| +| **ECG with Heart Rate** | Real-time ECG with BPM calculation. | +| **EMG with Envelope** | Real-time EMG Visualization with Envelope. | +| **EOG with Blinks** | Real-time EOG Signal visualization with Blinks marked as Red Dot.| +| **EEG with FFT** | Real-time EEG Signal visualization with FFT and Brainpower bands.| +| **EEG Tug of War Game** | 2 Player EEG Based Game | +| **EEG Beetle game** | Real-time EEG focus based game. | +| **EOG Keystroke Emulator** | Blink detection triggers spacebar. | +| **GUI** | Visualize raw data in real-time | +| **CSV Plotter** | Tool to plot the recorded CSV Files | + +## Troubleshooting + +- **Arduino Not Detected:** Ensure the Arduino is properly connected and powered. Check the serial port and baud rate settings. +- **CSV File Not Created:** Ensure you have write permissions in the directory where the script is run. +- **LSL Stream Issues:** Ensure that the `pylsl` library is properly installed and configured. Additionally, confirm that Bluetooth is turned off. + +## How to Contribute + +You can add your project to this repo: + +- Add a button in apps.yaml to link your application. +- Include your script as a .py file with LSL Data Reception code. +(Pull requests welcome!) + +## Contributors + +We are thankful to our awesome contributors, the list below is alphabetically sorted. + +- [Aman Maheshwari](https://github.com/Amanmahe) +- [Payal Lakra](https://github.com/payallakra) + +The audio file used in `game.py` is sourced from [Pixabay](https://pixabay.com/sound-effects/brass-fanfare-with-timpani-and-windchimes-reverberated-146260/) \ No newline at end of file diff --git a/__init__.py b/chordspy/__init__.py similarity index 100% rename from __init__.py rename to chordspy/__init__.py diff --git a/app.py b/chordspy/app.py similarity index 98% rename from app.py rename to chordspy/app.py index 26d6e2c6..1665b1cb 100644 --- a/app.py +++ b/chordspy/app.py @@ -10,10 +10,13 @@ from pathlib import Path import os import webbrowser +import logging console_queue = queue.Queue() app = Flask(__name__) logging.basicConfig(level=logging.INFO) +log = logging.getLogger('werkzeug') +log.setLevel(logging.ERROR) # Only show errors # Global variables connection_manager = None diff --git a/beetle.py b/chordspy/beetle.py similarity index 100% rename from beetle.py rename to chordspy/beetle.py diff --git a/chords_ble.py b/chordspy/chords_ble.py similarity index 100% rename from chords_ble.py rename to chordspy/chords_ble.py diff --git a/chords_serial.py b/chordspy/chords_serial.py similarity index 100% rename from chords_serial.py rename to chordspy/chords_serial.py diff --git a/chords_wifi.py b/chordspy/chords_wifi.py similarity index 100% rename from chords_wifi.py rename to chordspy/chords_wifi.py diff --git a/config/apps.yaml b/chordspy/config/apps.yaml similarity index 100% rename from config/apps.yaml rename to chordspy/config/apps.yaml diff --git a/connection.py b/chordspy/connection.py similarity index 100% rename from connection.py rename to chordspy/connection.py diff --git a/csvplotter.py b/chordspy/csvplotter.py similarity index 100% rename from csvplotter.py rename to chordspy/csvplotter.py diff --git a/emgenvelope.py b/chordspy/emgenvelope.py similarity index 100% rename from emgenvelope.py rename to chordspy/emgenvelope.py diff --git a/eog.py b/chordspy/eog.py similarity index 100% rename from eog.py rename to chordspy/eog.py diff --git a/ffteeg.py b/chordspy/ffteeg.py similarity index 100% rename from ffteeg.py rename to chordspy/ffteeg.py diff --git a/game.py b/chordspy/game.py similarity index 100% rename from game.py rename to chordspy/game.py diff --git a/gui.py b/chordspy/gui.py similarity index 100% rename from gui.py rename to chordspy/gui.py diff --git a/heartbeat_ecg.py b/chordspy/heartbeat_ecg.py similarity index 100% rename from heartbeat_ecg.py rename to chordspy/heartbeat_ecg.py diff --git a/keystroke.py b/chordspy/keystroke.py similarity index 100% rename from keystroke.py rename to chordspy/keystroke.py diff --git a/media/Beetle1.png b/chordspy/media/Beetle1.png similarity index 100% rename from media/Beetle1.png rename to chordspy/media/Beetle1.png diff --git a/media/Beetle10.png b/chordspy/media/Beetle10.png similarity index 100% rename from media/Beetle10.png rename to chordspy/media/Beetle10.png diff --git a/media/Beetle2.png b/chordspy/media/Beetle2.png similarity index 100% rename from media/Beetle2.png rename to chordspy/media/Beetle2.png diff --git a/media/Beetle3.png b/chordspy/media/Beetle3.png similarity index 100% rename from media/Beetle3.png rename to chordspy/media/Beetle3.png diff --git a/media/Beetle4.png b/chordspy/media/Beetle4.png similarity index 100% rename from media/Beetle4.png rename to chordspy/media/Beetle4.png diff --git a/media/Beetle5.png b/chordspy/media/Beetle5.png similarity index 100% rename from media/Beetle5.png rename to chordspy/media/Beetle5.png diff --git a/media/Beetle6.png b/chordspy/media/Beetle6.png similarity index 100% rename from media/Beetle6.png rename to chordspy/media/Beetle6.png diff --git a/media/Beetle7.png b/chordspy/media/Beetle7.png similarity index 100% rename from media/Beetle7.png rename to chordspy/media/Beetle7.png diff --git a/media/Beetle8.png b/chordspy/media/Beetle8.png similarity index 100% rename from media/Beetle8.png rename to chordspy/media/Beetle8.png diff --git a/media/Beetle9.png b/chordspy/media/Beetle9.png similarity index 100% rename from media/Beetle9.png rename to chordspy/media/Beetle9.png diff --git a/media/Interface.png b/chordspy/media/Interface.png similarity index 100% rename from media/Interface.png rename to chordspy/media/Interface.png diff --git a/media/Webinterface.png b/chordspy/media/Webinterface.png similarity index 100% rename from media/Webinterface.png rename to chordspy/media/Webinterface.png diff --git a/media/brass-fanfare-with-timpani-and-winchimes-reverberated-146260.wav b/chordspy/media/brass-fanfare-with-timpani-and-winchimes-reverberated-146260.wav similarity index 100% rename from media/brass-fanfare-with-timpani-and-winchimes-reverberated-146260.wav rename to chordspy/media/brass-fanfare-with-timpani-and-winchimes-reverberated-146260.wav diff --git a/media/icons8-eye-30.png b/chordspy/media/icons8-eye-30.png similarity index 100% rename from media/icons8-eye-30.png rename to chordspy/media/icons8-eye-30.png diff --git a/Notebooks/ecg.ipynb b/chordspy/notebooks/ecg.ipynb similarity index 100% rename from Notebooks/ecg.ipynb rename to chordspy/notebooks/ecg.ipynb diff --git a/Notebooks/emg.ipynb b/chordspy/notebooks/emg.ipynb similarity index 100% rename from Notebooks/emg.ipynb rename to chordspy/notebooks/emg.ipynb diff --git a/Notebooks/eog.ipynb b/chordspy/notebooks/eog.ipynb similarity index 100% rename from Notebooks/eog.ipynb rename to chordspy/notebooks/eog.ipynb diff --git a/static/script.js b/chordspy/static/script.js similarity index 100% rename from static/script.js rename to chordspy/static/script.js diff --git a/templates/index.html b/chordspy/templates/index.html similarity index 100% rename from templates/index.html rename to chordspy/templates/index.html diff --git a/test/new_parser.py b/chordspy/test/new_parser.py similarity index 100% rename from test/new_parser.py rename to chordspy/test/new_parser.py diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..eaf7adfb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +numpy==2.1.3 +pylsl==1.16.2 +pyserial==3.5 +bleak==0.22.3 +pyqtgraph==0.13.7 +PyQt5==5.15.11 +keyboard==0.13.5 +scipy==1.14.1 +pygame==2.6.1 +neurokit2==0.2.10 +plotly==5.24.1 +pandas==2.2.3 +tk==0.1.0 +PyAutoGUI==0.9.54 +Flask==3.1.1 +psutil==6.1.1 +websocket-client==1.8.0 +PyYAML==6.0.2 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..93fa0953 --- /dev/null +++ b/setup.py @@ -0,0 +1,40 @@ +from setuptools import setup, find_packages + +with open('requirements.txt') as f: + requirements = f.read().splitlines() + +with open('README.md', 'r', encoding='utf-8') as f: + long_description = f.read() + +setup( + name='chordspy', + version='0.1.0', + packages=find_packages(), + include_package_data=True, + install_requires=requirements, + entry_points={ + 'console_scripts': [ + 'chordspy=chordspy.app:main', + ], + }, + author='Upside Down Labs', + author_email='support@upsidedownlabs.tech', + description='An open source bag of tools for recording and visualizing Bio-potential signals like EEG, ECG, EMG , or EOG.', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/upsidedownlabs/Chords-Python', + classifiers=[ + 'Programming Language :: Python :: 3', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + ], + package_data={ + 'chordspy': [ + 'config/*.yaml', + 'static/*', + 'templates/*', + 'apps/*.py' + ], + }, + python_requires='>=3.9', +) \ No newline at end of file From 4265aae2a5af8050d6f38afefe805691db1c2d10 Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Thu, 26 Jun 2025 11:35:23 +0530 Subject: [PATCH 03/24] Updated Readme --- README.md | 4 ++-- chordspy/csvplotter.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0ef99af3..62daee31 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,11 @@ Chords- Python is an open-source bag of tools designed to interface with Micro-c - Open command prompt and run: ```bash -python -m venv chordspy +python -m venv venv ``` ```bash -chordspy\Scripts\activate # For Windows +venv\Scripts\activate # For Windows source chordspy/bin/activate # For MacOS/Linux ``` diff --git a/chordspy/csvplotter.py b/chordspy/csvplotter.py index c5605c2d..ba8ab566 100644 --- a/chordspy/csvplotter.py +++ b/chordspy/csvplotter.py @@ -101,7 +101,7 @@ def plot_data(self): def main(): root = tk.Tk() # Create the main Tkinter root window - app = CSVPlotterApp(root) # Create an instance of the CSVPlotterApp class + CSVPlotterApp(root) # Create an instance of the CSVPlotterApp class root.mainloop() # Start the Tkinter main loop if __name__ == "__main__": From d45eb5bd3818329f5d05a3574dd705e16ab7b029 Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Thu, 26 Jun 2025 11:40:37 +0530 Subject: [PATCH 04/24] Updated Readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 62daee31..1d96121e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Chords- Python is an open-source bag of tools designed to interface with Micro-c ## Features - **Multiple Protocols**: Supports `Wi-Fi`, `Bluetooth`, and `Serial` communication. -- **LSL Data Streaming**:Once the LSL stream starts, any PC on the same Wi-Fi network can access the data using tools like BrainVision LSL Viewer. +- **LSL Data Streaming**: Once the LSL stream starts, any PC on the same Wi-Fi network can access the data using tools like BrainVision LSL Viewer. - **CSV Logging**: Save raw data with Counter - **GUI**: Live plotting for all channels. - **Applications**: EEG/ECG/EMG/EOG-based games and utilities (e.g., Tug of War, Keystroke Emulator). @@ -23,8 +23,8 @@ python -m venv venv ``` ```bash -venv\Scripts\activate # For Windows -source chordspy/bin/activate # For MacOS/Linux +venv\Scripts\activate # For Windows +source venv/bin/activate # For MacOS/Linux ``` ```bash From 32b5523a046c2642331f708485eb4fb8af8ff933 Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Thu, 26 Jun 2025 12:49:20 +0530 Subject: [PATCH 05/24] Create python-publish.yml --- .github/workflows/python-publish.yml | 70 ++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/python-publish.yml diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 00000000..82f8dbd9 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,70 @@ +# This workflow will upload a Python Package to PyPI when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + release-build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Build release distributions + run: | + # NOTE: put your own distribution build steps here. + python -m pip install build + python -m build + + - name: Upload distributions + uses: actions/upload-artifact@v4 + with: + name: release-dists + path: dist/ + + pypi-publish: + runs-on: ubuntu-latest + needs: + - release-build + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + + # Dedicated environments with protections for publishing are strongly recommended. + # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules + environment: + name: pypi + # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status: + # url: https://pypi.org/p/YOURPROJECT + # + # ALTERNATIVE: if your GitHub Release name is the PyPI project version string + # ALTERNATIVE: exactly, uncomment the following line instead: + # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }} + + steps: + - name: Retrieve release distributions + uses: actions/download-artifact@v4 + with: + name: release-dists + path: dist/ + + - name: Publish release distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ From 6608a352dd9560449084ecf3f3f59caedcea7152 Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Thu, 26 Jun 2025 12:54:58 +0530 Subject: [PATCH 06/24] Update python-publish.yml --- .github/workflows/python-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 82f8dbd9..29af077b 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -6,7 +6,7 @@ # separate terms of service, privacy policy, and support # documentation. -name: Upload Python Package +name: chordspy on: release: @@ -24,7 +24,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.x" + python-version: "3.13" - name: Build release distributions run: | From e298113829b2643f466444b1869ee8eb357d1de9 Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Thu, 26 Jun 2025 13:05:23 +0530 Subject: [PATCH 07/24] Final changes before release --- chordspy/.github/workflows/workflow.yml | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 chordspy/.github/workflows/workflow.yml diff --git a/chordspy/.github/workflows/workflow.yml b/chordspy/.github/workflows/workflow.yml new file mode 100644 index 00000000..d72239ed --- /dev/null +++ b/chordspy/.github/workflows/workflow.yml @@ -0,0 +1,39 @@ +name: Publish Python Package + +on: + push: + tags: + - "v0.1.0 + +jobs: + build-n-publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install build dependencies + run: python -m pip install --upgrade pip setuptools wheel build twine + + - name: Build package + run: python -m build + + - name: Verify package + run: twine check dist/* + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + files: dist/* + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From 299d169b364144f1cb98895114d90492d574a489 Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Thu, 26 Jun 2025 13:07:33 +0530 Subject: [PATCH 08/24] Update python-publish.yml --- .github/workflows/python-publish.yml | 89 +++++++++++----------------- 1 file changed, 33 insertions(+), 56 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 29af077b..a715d88e 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -6,65 +6,42 @@ # separate terms of service, privacy policy, and support # documentation. -name: chordspy +name: Publish Python Package on: - release: - types: [published] - -permissions: - contents: read + push: + tags: + - "v0.1.0 jobs: - release-build: + build-n-publish: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - - name: Build release distributions - run: | - # NOTE: put your own distribution build steps here. - python -m pip install build - python -m build - - - name: Upload distributions - uses: actions/upload-artifact@v4 - with: - name: release-dists - path: dist/ - - pypi-publish: - runs-on: ubuntu-latest - needs: - - release-build - permissions: - # IMPORTANT: this permission is mandatory for trusted publishing - id-token: write - - # Dedicated environments with protections for publishing are strongly recommended. - # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules - environment: - name: pypi - # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status: - # url: https://pypi.org/p/YOURPROJECT - # - # ALTERNATIVE: if your GitHub Release name is the PyPI project version string - # ALTERNATIVE: exactly, uncomment the following line instead: - # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }} - - steps: - - name: Retrieve release distributions - uses: actions/download-artifact@v4 - with: - name: release-dists - path: dist/ - - - name: Publish release distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: dist/ + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install build dependencies + run: python -m pip install --upgrade pip setuptools wheel build twine + + - name: Build package + run: python -m build + + - name: Verify package + run: twine check dist/* + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + files: dist/* + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 3e1afb02e47de64659dda7ed9c59df0deb26f54f Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Thu, 26 Jun 2025 13:25:35 +0530 Subject: [PATCH 09/24] Remove unnecessary files --- chordspy/.github/workflows/workflow.yml | 39 ------------------------- 1 file changed, 39 deletions(-) delete mode 100644 chordspy/.github/workflows/workflow.yml diff --git a/chordspy/.github/workflows/workflow.yml b/chordspy/.github/workflows/workflow.yml deleted file mode 100644 index d72239ed..00000000 --- a/chordspy/.github/workflows/workflow.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Publish Python Package - -on: - push: - tags: - - "v0.1.0 - -jobs: - build-n-publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.x' - - - name: Install build dependencies - run: python -m pip install --upgrade pip setuptools wheel build twine - - - name: Build package - run: python -m build - - - name: Verify package - run: twine check dist/* - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} - - - name: Create GitHub Release - uses: softprops/action-gh-release@v1 - with: - files: dist/* - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From cc04e1960ce1a55fbe3240801986fb5f613099ed Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Thu, 26 Jun 2025 14:05:38 +0530 Subject: [PATCH 10/24] Adding release.yml file --- chordspy/.github/workflows/release.yaml | 167 ++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 chordspy/.github/workflows/release.yaml diff --git a/chordspy/.github/workflows/release.yaml b/chordspy/.github/workflows/release.yaml new file mode 100644 index 00000000..8145c288 --- /dev/null +++ b/chordspy/.github/workflows/release.yaml @@ -0,0 +1,167 @@ +name: release + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+a[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+b[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" + +env: + PACKAGE_NAME: "chordspy" + OWNER: "Upside Down Labs" + +jobs: + details: + runs-on: ubuntu-latest + outputs: + new_version: ${{ steps.release.outputs.new_version }} + suffix: ${{ steps.release.outputs.suffix }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - uses: actions/checkout@v2 + + - name: Extract tag and Details + id: release + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + TAG_NAME=${GITHUB_REF#refs/tags/} + NEW_VERSION=$(echo $TAG_NAME | awk -F'-' '{print $1}') + SUFFIX=$(echo $TAG_NAME | grep -oP '[a-z]+[0-9]+' || echo "") + echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "suffix=$SUFFIX" >> "$GITHUB_OUTPUT" + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" + echo "Version is $NEW_VERSION" + echo "Suffix is $SUFFIX" + echo "Tag name is $TAG_NAME" + else + echo "No tag found" + exit 1 + fi + + check_pypi: + needs: details + runs-on: ubuntu-latest + steps: + - name: Fetch information from PyPI + run: | + response=$(curl -s https://pypi.org/pypi/${{ env.PACKAGE_NAME }}/json || echo "{}") + latest_previous_version=$(echo $response | jq --raw-output "select(.releases != null) | .releases | keys_unsorted | last") + if [ -z "$latest_previous_version" ]; then + echo "Package not found on PyPI." + latest_previous_version="0.0.0" + fi + echo "Latest version on PyPI: $latest_previous_version" + echo "latest_previous_version=$latest_previous_version" >> $GITHUB_ENV + + - name: Compare versions and exit if not newer + run: | + NEW_VERSION=${{ needs.details.outputs.new_version }} + LATEST_VERSION=$latest_previous_version + if [ "$(printf '%s\n' "$LATEST_VERSION" "$NEW_VERSION" | sort -rV | head -n 1)" != "$NEW_VERSION" ] || [ "$NEW_VERSION" == "$LATEST_VERSION" ]; then + echo "The new version $NEW_VERSION is not greater than the latest version $LATEST_VERSION on PyPI." + exit 1 + else + echo "The new version $NEW_VERSION is greater than the latest version $LATEST_VERSION on PyPI." + fi + + setup_and_build: + needs: [details, check_pypi] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.13" + + - name: Install Poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Set project version with Poetry + run: | + poetry version ${{ needs.details.outputs.new_version }} + + - name: Install dependencies + run: poetry install --sync --no-interaction + + - name: Build source and wheel distribution + run: | + poetry build + + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: dist + path: dist/ + + pypi_publish: + name: Upload release to PyPI + needs: [setup_and_build, details] + runs-on: ubuntu-latest + environment: + name: release + permissions: + id-token: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + name: dist + path: dist/ + + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + github_release: + name: Create GitHub Release + needs: [setup_and_build, details] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout Code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + name: dist + path: dist/ + + - name: Create GitHub Release + id: create_release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create ${{ needs.details.outputs.tag_name }} dist/* --title ${{ needs.details.outputs.tag_name }} --generate-notes + + bump_homebrew_formula: + name: Dispatch event to Repo B + needs: [details, github_release, pypi_publish] + runs-on: ubuntu-latest + environment: + name: release + steps: + - name: Dispatch Repository Dispatch event + uses: peter-evans/repository-dispatch@v2 + with: + token: ${{ secrets.PYPI_API_TOKEN }} + repository: ${{ env.OWNER }}/{{ env.TAP_NAME }} + event-type: "update-formula" + client-payload: |- + { + "formula_version": "${{env.FORMULA_VERSION}}", + "formula_url": "${{ env.FORMULA_URL }}", + "formula_name": "${{ env.FORMULA_NAME }}" + } + env: + FORMULA_VERSION: ${{ needs.details.outputs.new_version }} + FORMULA_NAME: ${{ env.PACKAGE_NAME }} + FORMULA_URL: https://github.com/${{env.OWNER}}/${{env.PACKAGE_NAME}}/releases/download/${{ needs.details.outputs.new_version }}/${{env.PACKAGE_NAME}}-${{ needs.details.outputs.new_version }}.tar.gz \ No newline at end of file From 29d3c57541061c4cafc4e6bb67486946c98f58a7 Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Thu, 26 Jun 2025 14:19:26 +0530 Subject: [PATCH 11/24] Update the location of workflow --- {chordspy/.github => .github}/workflows/release.yaml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {chordspy/.github => .github}/workflows/release.yaml (100%) diff --git a/chordspy/.github/workflows/release.yaml b/.github/workflows/release.yaml similarity index 100% rename from chordspy/.github/workflows/release.yaml rename to .github/workflows/release.yaml From 27e4afd62e65b5aa2a79236d9f696a058d922905 Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Thu, 26 Jun 2025 14:32:19 +0530 Subject: [PATCH 12/24] Update actions/upload-artifact --- .github/workflows/release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8145c288..b3feb71a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -94,7 +94,7 @@ jobs: poetry build - name: Upload artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: dist path: dist/ From 179742203accd0b9d7e78312936f7b694f5d40c2 Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Thu, 26 Jun 2025 16:07:21 +0530 Subject: [PATCH 13/24] Adding command to create .toml file --- .github/workflows/release.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b3feb71a..3f8bedf8 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -81,7 +81,8 @@ jobs: run: | curl -sSL https://install.python-poetry.org | python3 - echo "$HOME/.local/bin" >> $GITHUB_PATH - + poetry convert-setup-py + - name: Set project version with Poetry run: | poetry version ${{ needs.details.outputs.new_version }} From cb2bc4452d7184b163ac97775a41db191d0ea213 Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Thu, 26 Jun 2025 16:36:28 +0530 Subject: [PATCH 14/24] pyproject.toml file --- .github/workflows/python-publish.yml | 47 ---------------------------- .github/workflows/release.yaml | 3 +- pyproject.toml | 25 +++++++++++++++ setup.py | 40 ----------------------- 4 files changed, 26 insertions(+), 89 deletions(-) delete mode 100644 .github/workflows/python-publish.yml create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml deleted file mode 100644 index a715d88e..00000000 --- a/.github/workflows/python-publish.yml +++ /dev/null @@ -1,47 +0,0 @@ -# This workflow will upload a Python Package to PyPI when a release is created -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -name: Publish Python Package - -on: - push: - tags: - - "v0.1.0 - -jobs: - build-n-publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.x' - - - name: Install build dependencies - run: python -m pip install --upgrade pip setuptools wheel build twine - - - name: Build package - run: python -m build - - - name: Verify package - run: twine check dist/* - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} - - - name: Create GitHub Release - uses: softprops/action-gh-release@v1 - with: - files: dist/* - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3f8bedf8..b3feb71a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -81,8 +81,7 @@ jobs: run: | curl -sSL https://install.python-poetry.org | python3 - echo "$HOME/.local/bin" >> $GITHUB_PATH - poetry convert-setup-py - + - name: Set project version with Poetry run: | poetry version ${{ needs.details.outputs.new_version }} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..90fe8c9d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[tool.poetry] +name = "chordspy" +version = "0.1.0" +description = "An open source bag of tools for recording and visualizing Bio-potential signals like EEG, ECG, EMG, or EOG." +authors = ["Upside Down Labs "] +readme = "README.md" +license = "MIT" +homepage = "https://github.com/upsidedownlabs/Chords-Python" +packages = [{include = "chordspy"}] +include = ["chordspy/config/*.yaml", "chordspy/static/*", "chordspy/templates/*", "chordspy/apps/*.py"] + +[tool.poetry.dependencies] +python = ">=3.9" +# Add your requirements.txt dependencies here +# (Poetry will automatically include them if you used poetry convert-setup-py) + +[tool.poetry.scripts] +chordspy = "chordspy.app:main" + +[tool.poetry.urls] +Homepage = "https://github.com/upsidedownlabs/Chords-Python" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 93fa0953..00000000 --- a/setup.py +++ /dev/null @@ -1,40 +0,0 @@ -from setuptools import setup, find_packages - -with open('requirements.txt') as f: - requirements = f.read().splitlines() - -with open('README.md', 'r', encoding='utf-8') as f: - long_description = f.read() - -setup( - name='chordspy', - version='0.1.0', - packages=find_packages(), - include_package_data=True, - install_requires=requirements, - entry_points={ - 'console_scripts': [ - 'chordspy=chordspy.app:main', - ], - }, - author='Upside Down Labs', - author_email='support@upsidedownlabs.tech', - description='An open source bag of tools for recording and visualizing Bio-potential signals like EEG, ECG, EMG , or EOG.', - long_description=long_description, - long_description_content_type='text/markdown', - url='https://github.com/upsidedownlabs/Chords-Python', - classifiers=[ - 'Programming Language :: Python :: 3', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - ], - package_data={ - 'chordspy': [ - 'config/*.yaml', - 'static/*', - 'templates/*', - 'apps/*.py' - ], - }, - python_requires='>=3.9', -) \ No newline at end of file From f59f7480d41ba9e0b71cd270a0759df5bfdc22d2 Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Thu, 26 Jun 2025 17:10:45 +0530 Subject: [PATCH 15/24] Updated artifact --- .github/workflows/release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b3feb71a..a9317ef2 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -109,7 +109,7 @@ jobs: id-token: write steps: - name: Download artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: dist path: dist/ From 8bc1bd5b00524224c52f531b7fac397fddf0acb3 Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Thu, 26 Jun 2025 17:22:17 +0530 Subject: [PATCH 16/24] updated --- .github/workflows/release.yaml | 112 ++++++++++++--------------------- 1 file changed, 39 insertions(+), 73 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a9317ef2..223bf6ec 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,7 +20,7 @@ jobs: suffix: ${{ steps.release.outputs.suffix }} tag_name: ${{ steps.release.outputs.tag_name }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Extract tag and Details id: release @@ -32,9 +32,6 @@ jobs: echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" echo "suffix=$SUFFIX" >> "$GITHUB_OUTPUT" echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" - echo "Version is $NEW_VERSION" - echo "Suffix is $SUFFIX" - echo "Tag name is $TAG_NAME" else echo "No tag found" exit 1 @@ -44,124 +41,93 @@ jobs: needs: details runs-on: ubuntu-latest steps: - - name: Fetch information from PyPI + - name: Fetch PyPI version run: | response=$(curl -s https://pypi.org/pypi/${{ env.PACKAGE_NAME }}/json || echo "{}") - latest_previous_version=$(echo $response | jq --raw-output "select(.releases != null) | .releases | keys_unsorted | last") - if [ -z "$latest_previous_version" ]; then - echo "Package not found on PyPI." - latest_previous_version="0.0.0" - fi - echo "Latest version on PyPI: $latest_previous_version" - echo "latest_previous_version=$latest_previous_version" >> $GITHUB_ENV + latest_version=$(echo $response | jq -r '.info.version // "0.0.0"') + echo "latest_version=$latest_version" >> $GITHUB_ENV - - name: Compare versions and exit if not newer + - name: Compare versions run: | - NEW_VERSION=${{ needs.details.outputs.new_version }} - LATEST_VERSION=$latest_previous_version - if [ "$(printf '%s\n' "$LATEST_VERSION" "$NEW_VERSION" | sort -rV | head -n 1)" != "$NEW_VERSION" ] || [ "$NEW_VERSION" == "$LATEST_VERSION" ]; then - echo "The new version $NEW_VERSION is not greater than the latest version $LATEST_VERSION on PyPI." + if [ "$(printf '%s\n' "$latest_version" "${{ needs.details.outputs.new_version }}" | sort -rV | head -n1)" != "${{ needs.details.outputs.new_version }}" ]; then + echo "Version ${{ needs.details.outputs.new_version }} is not newer than PyPI version $latest_version" exit 1 - else - echo "The new version $NEW_VERSION is greater than the latest version $LATEST_VERSION on PyPI." fi setup_and_build: needs: [details, check_pypi] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: "3.13" + python-version: "3.10" # Changed from 3.13 to stable version - name: Install Poetry - run: | - curl -sSL https://install.python-poetry.org | python3 - - echo "$HOME/.local/bin" >> $GITHUB_PATH + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true - - name: Set project version with Poetry + - name: Configure Poetry run: | - poetry version ${{ needs.details.outputs.new_version }} + poetry config virtualenvs.in-project true + poetry config virtualenvs.create true + + - name: Set version + run: poetry version ${{ needs.details.outputs.new_version }} - name: Install dependencies - run: poetry install --sync --no-interaction + run: poetry install --sync --no-interaction --no-root - - name: Build source and wheel distribution - run: | - poetry build + - name: Build package + run: poetry build - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: dist - path: dist/ + path: dist/* pypi_publish: - name: Upload release to PyPI - needs: [setup_and_build, details] + needs: setup_and_build runs-on: ubuntu-latest environment: name: release permissions: - id-token: write + id-token: write # Essential for trusted publishing steps: - - name: Download artifacts - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v4 with: name: dist path: dist/ - - name: Publish distribution to PyPI + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + with: + verbose: true # For better debugging github_release: - name: Create GitHub Release - needs: [setup_and_build, details] + needs: [setup_and_build, pypi_publish] runs-on: ubuntu-latest permissions: contents: write steps: - - name: Checkout Code - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Download artifacts - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: dist path: dist/ - - name: Create GitHub Release - id: create_release - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release create ${{ needs.details.outputs.tag_name }} dist/* --title ${{ needs.details.outputs.tag_name }} --generate-notes - - bump_homebrew_formula: - name: Dispatch event to Repo B - needs: [details, github_release, pypi_publish] - runs-on: ubuntu-latest - environment: - name: release - steps: - - name: Dispatch Repository Dispatch event - uses: peter-evans/repository-dispatch@v2 + - name: Create Release + uses: softprops/action-gh-release@v1 with: - token: ${{ secrets.PYPI_API_TOKEN }} - repository: ${{ env.OWNER }}/{{ env.TAP_NAME }} - event-type: "update-formula" - client-payload: |- - { - "formula_version": "${{env.FORMULA_VERSION}}", - "formula_url": "${{ env.FORMULA_URL }}", - "formula_name": "${{ env.FORMULA_NAME }}" - } - env: - FORMULA_VERSION: ${{ needs.details.outputs.new_version }} - FORMULA_NAME: ${{ env.PACKAGE_NAME }} - FORMULA_URL: https://github.com/${{env.OWNER}}/${{env.PACKAGE_NAME}}/releases/download/${{ needs.details.outputs.new_version }}/${{env.PACKAGE_NAME}}-${{ needs.details.outputs.new_version }}.tar.gz \ No newline at end of file + tag_name: ${{ needs.details.outputs.tag_name }} + files: | + dist/* + generate_release_notes: true \ No newline at end of file From 6572dcd9d5473392dfd8d22f1a6b57726afb573b Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Thu, 26 Jun 2025 18:03:20 +0530 Subject: [PATCH 17/24] update version just to check --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 90fe8c9d..8c20be8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chordspy" -version = "0.1.0" +version = "0.1.1" description = "An open source bag of tools for recording and visualizing Bio-potential signals like EEG, ECG, EMG, or EOG." authors = ["Upside Down Labs "] readme = "README.md" From c6beed7f0ef98fd40cfe9be2e40e1e394f620b31 Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Thu, 26 Jun 2025 18:06:11 +0530 Subject: [PATCH 18/24] version updated --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8c20be8a..90fe8c9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chordspy" -version = "0.1.1" +version = "0.1.0" description = "An open source bag of tools for recording and visualizing Bio-potential signals like EEG, ECG, EMG, or EOG." authors = ["Upside Down Labs "] readme = "README.md" From 75a6d25ff8ab59de7f8fe411c990bc652fee0ea2 Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Sat, 28 Jun 2025 12:54:38 +0530 Subject: [PATCH 19/24] Updated pyproject.toml file --- pyproject.toml | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 90fe8c9d..7b3d5cd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,9 +10,24 @@ packages = [{include = "chordspy"}] include = ["chordspy/config/*.yaml", "chordspy/static/*", "chordspy/templates/*", "chordspy/apps/*.py"] [tool.poetry.dependencies] -python = ">=3.9" -# Add your requirements.txt dependencies here -# (Poetry will automatically include them if you used poetry convert-setup-py) +numpy="2.1.3" +pylsl="1.16.2" +pyserial="3.5" +bleak="0.22.3" +pyqtgraph="0.13.7" +PyQt5="5.15.11" +keyboard="0.13.5" +scipy="1.14.1" +pygame="2.6.1" +neurokit2="0.2.10" +plotly="5.24.1" +pandas="2.2.3" +tk="0.1.0" +PyAutoGUI="0.9.54" +Flask="3.1.1" +psutil="6.1.1" +websocket-client="1.8.0" +PyYAML="6.0.2" [tool.poetry.scripts] chordspy = "chordspy.app:main" From 34ba1a837f309209bf9e92e22f74f880433f478d Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Sat, 5 Jul 2025 13:29:46 +0530 Subject: [PATCH 20/24] Fully Documented Code --- chordspy/chords_ble.py | 120 ++++++-- chordspy/chords_serial.py | 192 ++++++++++--- chordspy/chords_wifi.py | 84 +++++- chordspy/connection.py | 585 ++++++++++++++++++++++---------------- 4 files changed, 662 insertions(+), 319 deletions(-) diff --git a/chordspy/chords_ble.py b/chordspy/chords_ble.py index 2267c259..92024af1 100644 --- a/chordspy/chords_ble.py +++ b/chordspy/chords_ble.py @@ -1,3 +1,8 @@ +""" +This scripts scan and then connects to the selected devices via BLE, reads data packets, processes them, and handles connection status. +""" + +# Importing necessary libraries import asyncio from bleak import BleakScanner, BleakClient import time @@ -6,38 +11,56 @@ import threading class Chords_BLE: + """ + A class to handle BLE communication with NPG devices via BLE. + This class provides functionality to: + - Scan for compatible BLE devices + - Connect to a device + - Receive and process data packets + - Monitor connection status + - Handle disconnections and errors + """ + # Class constants - DEVICE_NAME_PREFIX = "NPG" - SERVICE_UUID = "4fafc201-1fb5-459e-8fcc-c5c9c331914b" - DATA_CHAR_UUID = "beb5483e-36e1-4688-b7f5-ea07361b26a8" - CONTROL_CHAR_UUID = "0000ff01-0000-1000-8000-00805f9b34fb" + DEVICE_NAME_PREFIX = "NPG" # Prefix for compatible device names + SERVICE_UUID = "4fafc201-1fb5-459e-8fcc-c5c9c331914b" # UUID for the BLE service + DATA_CHAR_UUID = "beb5483e-36e1-4688-b7f5-ea07361b26a8" # UUID for data characteristic + CONTROL_CHAR_UUID = "0000ff01-0000-1000-8000-00805f9b34fb" # UUID for control characteristic # Packet parameters - NUM_CHANNELS = 3 - SINGLE_SAMPLE_LEN = (NUM_CHANNELS * 2) + 1 # (1 Counter + Num_Channels * 2 bytes) + NUM_CHANNELS = 3 # Number of channels + SINGLE_SAMPLE_LEN = (NUM_CHANNELS * 2) + 1 # (1 Counter + Num_Channels * 2 bytes) BLOCK_COUNT = 10 - NEW_PACKET_LEN = SINGLE_SAMPLE_LEN * BLOCK_COUNT + NEW_PACKET_LEN = SINGLE_SAMPLE_LEN * BLOCK_COUNT # Total length of a data packet def __init__(self): - self.prev_unrolled_counter = None - self.samples_received = 0 - self.start_time = None - self.total_missing_samples = 0 - self.last_received_time = None - self.DATA_TIMEOUT = 2.0 - self.client = None - self.monitor_task = None - self.print_rate_task = None - self.running = False - self.loop = None - self.connection_event = threading.Event() - self.stop_event = threading.Event() + """ + Initialize the BLE client with default values and state variables. + """ + self.prev_unrolled_counter = None # Tracks the last sample counter value + self.samples_received = 0 # Count of received samples + self.start_time = None # Timestamp when first sample is received + self.total_missing_samples = 0 # Count of missing samples + self.last_received_time = None # Timestamp of last received data + self.DATA_TIMEOUT = 2.0 # Timeout period for data reception (seconds) + self.client = None # BLE client instance + self.monitor_task = None # Task for monitoring connection + self.print_rate_task = None # Task for printing sample rate + self.running = False # Flag indicating if client is running + self.loop = None # Asyncio event loop + self.connection_event = threading.Event() # Event for connection status + self.stop_event = threading.Event() # Event for stopping operations @classmethod async def scan_devices(cls): + """ + Scan for BLE devices with the NPG prefix. + Returns: + list: A list of discovered devices matching the NPG prefix + """ print("Scanning for BLE devices...") devices = await BleakScanner.discover() - filtered = [d for d in devices if d.name and d.name.startswith(cls.DEVICE_NAME_PREFIX)] + filtered = [d for d in devices if d.name and d.name.startswith(cls.DEVICE_NAME_PREFIX)] # Filter devices by name prefix if not filtered: print("No NPG devices found.") @@ -46,13 +69,19 @@ async def scan_devices(cls): return filtered def process_sample(self, sample_data: bytearray): - """Process a single EEG sample packet""" + """ + Process a single sample packet. + Args: + sample_data (bytearray): The raw sample data to process + """ self.last_received_time = time.time() + # Validate sample length if len(sample_data) != self.SINGLE_SAMPLE_LEN: print("Unexpected sample length:", len(sample_data)) return + # Extract and process sample counter sample_counter = sample_data[0] if self.prev_unrolled_counter is None: self.prev_unrolled_counter = sample_counter @@ -63,6 +92,7 @@ def process_sample(self, sample_data: bytearray): else: current_unrolled = self.prev_unrolled_counter - last + sample_counter + # Check for missing samples if current_unrolled != self.prev_unrolled_counter + 1: missing = current_unrolled - (self.prev_unrolled_counter + 1) print(f"Missing {missing} sample(s)") @@ -70,34 +100,47 @@ def process_sample(self, sample_data: bytearray): self.prev_unrolled_counter = current_unrolled + # Record start time if this is the first sample if self.start_time is None: self.start_time = time.time() + # Extract channel data (2 bytes per channel, big-endian, signed) channels = [int.from_bytes(sample_data[i:i+2], byteorder='big', signed=True) for i in range(1, len(sample_data), 2)] self.samples_received += 1 def notification_handler(self, sender, data: bytearray): - """Handle incoming notifications from the BLE device""" + """ + Handle incoming notifications from the BLE device. + Args: + sender: The characteristic that sent the notification + data (bytearray): The received data packet + """ try: - if len(data) == self.NEW_PACKET_LEN: - for i in range(0, self.NEW_PACKET_LEN, self.SINGLE_SAMPLE_LEN): + if len(data) == self.NEW_PACKET_LEN: # Process data based on packet length + for i in range(0, self.NEW_PACKET_LEN, self.SINGLE_SAMPLE_LEN): # Process a block of samples self.process_sample(data[i:i+self.SINGLE_SAMPLE_LEN]) elif len(data) == self.SINGLE_SAMPLE_LEN: - self.process_sample(data) + self.process_sample(data) # Process a single sample else: print(f"Unexpected packet length: {len(data)} bytes") except Exception as e: print(f"Error processing data: {e}") async def print_rate(self): + """Print the current sample rate every second.""" while not self.stop_event.is_set(): await asyncio.sleep(1) self.samples_received = 0 async def monitor_connection(self): - """Monitor the connection status and check for data interruptions""" + """ + Monitor the connection status and check for data interruptions. + This runs in a loop to check: + - If data hasn't been received within the timeout period + - If the BLE connection has been lost + """ while not self.stop_event.is_set(): if self.last_received_time and (time.time() - self.last_received_time) > self.DATA_TIMEOUT: print("\nData Interrupted") @@ -112,6 +155,13 @@ async def monitor_connection(self): await asyncio.sleep(0.5) async def async_connect(self, device_address): + """ + Asynchronously connect to a BLE device and start data reception. + Args: + device_address (str): The MAC address of the device to connect to + Returns: + bool: True if connection was successful, otherwise False + """ try: print(f"Attempting to connect to {device_address}...") @@ -125,16 +175,20 @@ async def async_connect(self, device_address): print(f"Connected to {device_address}", flush=True) self.connection_event.set() + # Initialize monitoring tasks self.last_received_time = time.time() self.monitor_task = asyncio.create_task(self.monitor_connection()) self.print_rate_task = asyncio.create_task(self.print_rate()) + # Send start command to device await self.client.write_gatt_char(self.CONTROL_CHAR_UUID, b"START", response=True) print("Sent START command") + # Subscribe to data notifications await self.client.start_notify(self.DATA_CHAR_UUID, self.notification_handler) print("Subscribed to data notifications") + # Main loop self.running = True while self.running and not self.stop_event.is_set(): await asyncio.sleep(1) @@ -148,6 +202,7 @@ async def async_connect(self, device_address): await self.cleanup() async def cleanup(self): + """Clean up resources and disconnect from the device.""" if self.monitor_task: self.monitor_task.cancel() if self.print_rate_task: @@ -158,6 +213,11 @@ async def cleanup(self): self.connection_event.clear() def connect(self, device_address): + """ + Connect to a BLE device (wrapper for async_connect). + Args: + device_address (str): The MAC address of the device to connect to + """ self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) @@ -171,12 +231,14 @@ def connect(self, device_address): self.loop.close() def stop(self): + """Stop all operations and clean up resources.""" self.stop_event.set() self.running = False if self.loop and self.loop.is_running(): self.loop.call_soon_threadsafe(self.loop.stop) def parse_args(): + """Parse command line arguments.""" parser = argparse.ArgumentParser() parser.add_argument("--scan", action="store_true", help="Scan for devices") parser.add_argument("--connect", type=str, help="Connect to device address") @@ -188,11 +250,11 @@ def parse_args(): try: if args.scan: - devices = asyncio.run(Chords_BLE.scan_devices()) + devices = asyncio.run(Chords_BLE.scan_devices()) # Scan for devices for dev in devices: print(f"DEVICE:{dev.name}|{dev.address}") elif args.connect: - client.connect(args.connect) + client.connect(args.connect) # Connect to specified device try: while client.running: time.sleep(1) diff --git a/chordspy/chords_serial.py b/chordspy/chords_serial.py index bbbe4dd2..98a999e9 100644 --- a/chordspy/chords_serial.py +++ b/chordspy/chords_serial.py @@ -1,3 +1,11 @@ +""" +CHORDS USB Data Acquisition Script - This script supports detecting, connecting to, and reading data from +supported development boards via USB. It handles device identification, serial communication, +data packet parsing, and streaming management. The system supports multiple microcontroller +boards with different sampling rates and channel configurations. +""" + +# Importing necessary libraries import serial import time import numpy as np @@ -7,11 +15,31 @@ import threading class Chords_USB: - SYNC_BYTE1 = 0xc7 - SYNC_BYTE2 = 0x7c - END_BYTE = 0x01 - HEADER_LENGTH = 3 + """ + A class to interface with microcontroller development hardware for data acquisition. + This class handles communication with various supported microcontroller boards, data streaming, and packet parsing. It provides methods for hardware detection, connection, and data acquisition. + Attributes: + SYNC_BYTE1 (int): First synchronization byte for packet identification + SYNC_BYTE2 (int): Second synchronization byte for packet identification + END_BYTE (int): End byte marking the end of a packet + HEADER_LENGTH (int): Length of the packet header (sync bytes + counter) + supported_boards (dict): Dictionary of supported boards with their specifications + ser (serial.Serial): Serial connection object + buffer (bytearray): Buffer for incoming data + retry_limit (int): Maximum connection retries + packet_length (int): Expected packet length for current board + num_channels (int): Number of channels for current board + data (numpy.ndarray): Array for storing channel data + board (str): Detected board name + streaming_active (bool): Streaming state flag + """ + # Packet protocol constants + SYNC_BYTE1 = 0xc7 # First synchronization byte + SYNC_BYTE2 = 0x7c # Second synchronization byte + END_BYTE = 0x01 # End of packet marker + HEADER_LENGTH = 3 # Length of packet header (sync bytes + counter) + # Supported boards with their sampling rate and Number of Channels supported_boards = { "UNO-R3": {"sampling_rate": 250, "Num_channels": 6}, "UNO-CLONE": {"sampling_rate": 250, "Num_channels": 6}, @@ -29,51 +57,80 @@ class Chords_USB: } def __init__(self): - self.ser = None - self.buffer = bytearray() - self.retry_limit = 4 - self.packet_length = None - self.num_channels = None - self.data = None - self.board = "" + """ + Initialize the Chords_USB client and sets up serial connection attributes, data buffer, and installs signal handler for clean exit on interrupt signals. + """ + self.ser = None # Serial connection object + self.buffer = bytearray() # Buffer for incoming data + self.retry_limit = 4 # Maximum connection retries + self.packet_length = None # Expected packet length for current board + self.num_channels = None # Number of data channels for current board + self.data = None # Numpy array for storing channel data + self.board = "" # Detected board name + self.streaming_active = False # Streaming state flag # Only install signal handler in the main thread if threading.current_thread() is threading.main_thread(): signal.signal(signal.SIGINT, self.signal_handler) def connect_hardware(self, port, baudrate, timeout=1): + """ + Attempt to connect to hardware at the specified port and baudrate. + Args: + port (str): Serial port to connect to (e.g., 'COM3' or '/dev/ttyUSB0') + baudrate (int): Baud rate for serial communication + timeout (float, optional): Serial timeout in seconds. Defaults to 1. + Returns: + bool: True if connection and board identification succeeded, False otherwise + + The method performs the following steps: + 1. Establishes serial connection + 2. Sends 'WHORU' command + 3. Validates response against supported boards + 4. Configures parameters based on detected board + """ try: - self.ser = serial.Serial(port, baudrate=baudrate, timeout=timeout) + self.ser = serial.Serial(port, baudrate=baudrate, timeout=timeout) # Initialize serial connection retry_counter = 0 response = None - while retry_counter < self.retry_limit: - self.ser.write(b'WHORU\n') + while retry_counter < self.retry_limit: # Try to identify the board with retries + self.ser.write(b'WHORU\n') # Send identification command try: response = self.ser.readline().strip().decode() except UnicodeDecodeError: response = None - if response in self.supported_boards: + if response in self.supported_boards: # Board identified successfully self.board = response print(f"{response} detected at {port} with baudrate {baudrate}") self.num_channels = self.supported_boards[self.board]["Num_channels"] sampling_rate = self.supported_boards[self.board]["sampling_rate"] - self.packet_length = (2 * self.num_channels) + self.HEADER_LENGTH + 1 - self.data = np.zeros((self.num_channels, 2000)) + self.packet_length = (2 * self.num_channels) + self.HEADER_LENGTH + 1 # Calculate expected packet length: 2 bytes per channel + header + end byte + self.data = np.zeros((self.num_channels, 2000)) # Initialize data buffer with 2000 samples per channel return True retry_counter += 1 + # Connection failed after retries self.ser.close() except Exception as e: print(f"Connection Error: {e}") return False def detect_hardware(self, timeout=1): - baudrates = [230400, 115200] - ports = serial.tools.list_ports.comports() + """ + Automatically detect and connect to supported hardware. + Scans available serial ports and tries common baud rates to find a supported CHORDS USB device. + Args: + timeout (float, optional): Serial timeout in seconds. Defaults to 1. + Returns: + bool: True if hardware was detected and connected, False otherwise + """ + baudrates = [230400, 115200] # Common baud rates to try with + ports = serial.tools.list_ports.comports() # Get list of available serial ports + # Try all ports and baud rates for port in ports: for baud in baudrates: print(f"Trying {port.device} at {baud}...") @@ -84,48 +141,68 @@ def detect_hardware(self, timeout=1): return False def send_command(self, command): + """ + Send a command to the connected hardware. + Args: + command (str): Command to send (e.g., 'START', 'STOP') + Returns: + str: Response from hardware if available, None otherwise + Note: Flushes input/output buffers before sending command to ensure clean communication + """ if self.ser and self.ser.is_open: - self.ser.flushInput() - self.ser.flushOutput() - self.ser.write(f"{command}\n".encode()) - time.sleep(0.1) - response = self.ser.readline().decode('utf-8', errors='ignore').strip() + self.ser.flushInput() # Clear buffers to avoid stale data + self.ser.flushOutput() # Clear buffers to avoid stale data + self.ser.write(f"{command}\n".encode()) # Send command with newline terminator + time.sleep(0.1) # Small delay to allow hardware response + response = self.ser.readline().decode('utf-8', errors='ignore').strip() # Read and decode response return response return None def read_data(self): + """ + Read and process incoming data from the serial connection. Parses packets, validates them, and stores channel data in the data buffer. + serial.SerialException raised: If serial port is disconnected or no data received + """ try: + # Read available data or wait for at least 1 byte raw_data = self.ser.read(self.ser.in_waiting or 1) if raw_data == b'': raise serial.SerialException("Serial port disconnected or No data received.") self.buffer.extend(raw_data) + # Process complete packets in the buffer while len(self.buffer) >= self.packet_length: - sync_index = self.buffer.find(bytes([self.SYNC_BYTE1, self.SYNC_BYTE2])) + sync_index = self.buffer.find(bytes([self.SYNC_BYTE1, self.SYNC_BYTE2])) # Find synchronization bytes in buffer if sync_index == -1: - self.buffer.clear() + self.buffer.clear() # No sync found, clear buffer continue + # Check if we have a complete packet if len(self.buffer) >= sync_index + self.packet_length: packet = self.buffer[sync_index:sync_index + self.packet_length] - if packet[0] == self.SYNC_BYTE1 and packet[1] == self.SYNC_BYTE2 and packet[-1] == self.END_BYTE: - channel_data = [] + # Validate packet structure + if (packet[0] == self.SYNC_BYTE1 and packet[1] == self.SYNC_BYTE2 and packet[-1] == self.END_BYTE): + channel_data = [] # Extract channel data for ch in range(self.num_channels): + # Combine high and low bytes for each channel high_byte = packet[2 * ch + self.HEADER_LENGTH] low_byte = packet[2 * ch + self.HEADER_LENGTH + 1] value = (high_byte << 8) | low_byte channel_data.append(float(value)) - self.data = np.roll(self.data, -1, axis=1) + self.data = np.roll(self.data, -1, axis=1) # Update data buffer (rolling window) self.data[:, -1] = channel_data - del self.buffer[:sync_index + self.packet_length] + del self.buffer[:sync_index + self.packet_length] # Remove processed packet from buffer else: - del self.buffer[:sync_index + 1] + del self.buffer[:sync_index + 1] # Invalid packet, skip the first sync byte except serial.SerialException: self.cleanup() def start_streaming(self): + """ + Start continuous data streaming from the hardware by sending the 'START' command and enters a loop to continuously read and process incoming data until stopped or interrupted. + """ self.send_command('START') self.streaming_active = True try: @@ -136,10 +213,16 @@ def start_streaming(self): self.cleanup() def stop_streaming(self): + """ + Stop data streaming by sending 'STOP' Command to the hardware and sets the streaming flag to False. + """ self.streaming_active = False self.send_command('STOP') def cleanup(self): + """ + Clean up resources and ensure proper shutdown.It stops streaming, closes serial connection, and handles any cleanup errors. + """ self.stop_streaming() try: if self.ser and self.ser.is_open: @@ -148,9 +231,50 @@ def cleanup(self): print(f"Error during cleanup: {e}") def signal_handler(self, sig, frame): + """ + Signal handler for interrupt signals (Ctrl+C).It ensures clean shutdown when the program is interrupted. + """ + print("\nInterrupt received, shutting down...") self.cleanup() + sys.exit(0) + + def start_timer(self): + """ + Start the timer for packet counting and logging. + """ + global start_time, last_ten_minute_time, total_packet_count, cumulative_packet_count + current_time = time.time() + start_time = current_time # Session start time + last_ten_minute_time = current_time # 10-minute interval start time + total_packet_count = 0 # Counter for packets in current second + cumulative_packet_count = 0 # Counter for all packets + + def log_one_second_data(self): + """ + Log data for one second intervals and displays: Number of packets received in the last second, Number of missing samples (if any) + """ + global total_packet_count, samples_per_second, missing_samples + samples_per_second = total_packet_count + print(f"Data count for the last second: {total_packet_count} samples, "f"Missing samples: {missing_samples}") + total_packet_count = 0 # Reset for next interval + + def log_ten_minute_data(self): + """ + Log data for 10-minute intervals and displays: Total packets received, Actual sampling rate, Drift from expected rate + """ + global cumulative_packet_count, last_ten_minute_time, supported_boards, board + print(f"Total data count after 10 minutes: {cumulative_packet_count}") + sampling_rate = cumulative_packet_count / (10 * 60) # Calculate actual sampling rate + print(f"Sampling rate: {sampling_rate:.2f} samples/second") + expected_sampling_rate = supported_boards[board]["sampling_rate"] + drift = ((sampling_rate - expected_sampling_rate) / expected_sampling_rate) * 3600 # Calculate drift from expected rate + print(f"Drift: {drift:.2f} seconds/hour") + + # Reset counters + cumulative_packet_count = 0 + last_ten_minute_time = time.time() if __name__ == "__main__": - client = Chords_USB() - client.detect_hardware() - client.start_streaming() \ No newline at end of file + client = Chords_USB() # Create and run the USB client + client.detect_hardware() # Detect and connect to hardware + client.start_streaming() # Start streaming data \ No newline at end of file diff --git a/chordspy/chords_wifi.py b/chordspy/chords_wifi.py index 33b91f1b..4ba7c2a2 100644 --- a/chordspy/chords_wifi.py +++ b/chordspy/chords_wifi.py @@ -1,3 +1,9 @@ +""" +CHORDS WiFi Data Acquisition Script: This script provides a WebSocket client for connecting to and receiving data from +a CHORDS-compatible WiFi device. It handles connection management, data reception, and basic data validation. +""" + +# Importing necessary libraries import time import sys import websocket @@ -5,13 +11,43 @@ from scipy.signal import butter, filtfilt class Chords_WIFI: + """ + A class for connecting to and receiving data from a CHORDS WiFi device. + This class handles WebSocket communication with a CHORDS device, processes incoming data packets, and provides basic data validation and rate calculation. + Attributes: + stream_name (str): Name of the data stream (default: 'NPG') + channels (int): Number of data channels (default: 3) + sampling_rate (int): Expected sampling rate in Hz (default: 500) + block_size (int): Size of each data block in bytes (default: 13) + timeout_sec (int): Timeout period for no data received in seconds (default: 1) + packet_size (int): Count of received packets + data_size (int): Total size of received data in bytes + sample_size (int): Count of received samples + previous_sample_number (int): Last received sample number for validation + previous_data (list): Last received channel data + start_time (float): Timestamp when measurement started + last_data_time (float): Timestamp of last received data + cleanup_done (bool): Flag indicating if cleanup was performed + ws (websocket.WebSocket): WebSocket connection object + """ + def __init__(self, stream_name='NPG', channels=3, sampling_rate=500, block_size=13, timeout_sec=1): + """ + Initialize the WiFi client with connection parameters. + Args: + stream_name (str): Name of the data stream (default: 'NPG') + channels (int): Number of data channels (default: 3) + sampling_rate (int): Expected sampling rate in Hz (default: 500) + block_size (int): Size of each data block in bytes (default: 13) + timeout_sec (int): Timeout period for no data in seconds (default: 1) + """ self.stream_name = stream_name self.channels = channels self.sampling_rate = sampling_rate self.block_size = block_size self.timeout_sec = timeout_sec # Timeout for no data received + # Data tracking variables self.packet_size = 0 self.data_size = 0 self.sample_size = 0 @@ -23,8 +59,13 @@ def __init__(self, stream_name='NPG', channels=3, sampling_rate=500, block_size= self.ws = None def connect(self): + """ + Establish WebSocket connection to the CHORDS device. It Attempts to resolve the hostname 'multi-emg.local' and connect to its WebSocket server. + """ try: - host_ip = socket.gethostbyname("multi-emg.local") + host_ip = socket.gethostbyname("multi-emg.local") # Resolve hostname to IP address + + # Create and connect WebSocket self.ws = websocket.WebSocket() self.ws.connect(f"ws://{host_ip}:81") sys.stderr.write(f"{self.stream_name} WebSocket connected!\n") @@ -34,13 +75,30 @@ def connect(self): sys.exit(1) def calculate_rate(self, size, elapsed_time): + """ + Calculate rate (samples/packets/bytes per second). + Args: + size (int): Count of items (samples, packets, or bytes) + elapsed_time (float): Time period in seconds + Returns: + float: Rate in items per second, or 0 if elapsed_time is 0 + """ return size / elapsed_time if elapsed_time > 0 else 0 def process_data(self): + """ + Main data processing loop. It continuously receives data from the WebSocket, validates samples, and calculates rates. Handles connection errors and timeouts. + The method: + 1. Receives data from WebSocket + 2. Checks for connection timeouts + 3. Calculates rates every second + 4. Validates sample sequence numbers + 5. Processes channel data + """ try: while True: try: - data = self.ws.recv() + data = self.ws.recv() # Receive data from WebSocket self.last_data_time = time.time() # Update when data is received except (websocket.WebSocketConnectionClosedException, ConnectionResetError) as e: print(f"\nConnection closed: {str(e)}") @@ -60,43 +118,51 @@ def process_data(self): # Process your data here self.data_size += len(data) - current_time = time.time() + current_time = time.time() # Calculate rates every second elapsed_time = current_time - self.start_time if elapsed_time >= 1.0: + # Calculate samples, packets, and bytes per second sps = self.calculate_rate(self.sample_size, elapsed_time) fps = self.calculate_rate(self.packet_size, elapsed_time) bps = self.calculate_rate(self.data_size, elapsed_time) + # Reset counters self.packet_size = 0 self.sample_size = 0 self.data_size = 0 self.start_time = current_time + # Process binary data if isinstance(data, (bytes, list)): self.packet_size += 1 + # Process each block in the packet for i in range(0, len(data), self.block_size): self.sample_size += 1 block = data[i:i + self.block_size] - if len(block) < self.block_size: + if len(block) < self.block_size: # Skip incomplete blocks continue - sample_number = block[0] + sample_number = block[0] # Extract sample number (first byte) channel_data = [] + # Extract channel data (2 bytes per channel) for ch in range(self.channels): offset = 1 + ch * 2 sample = int.from_bytes(block[offset:offset + 2], byteorder='big', signed=True) channel_data.append(sample) + # Validate sample sequence if self.previous_sample_number == -1: self.previous_sample_number = sample_number self.previous_data = channel_data else: + # Check for missing samples if sample_number - self.previous_sample_number > 1: print("\nError: Sample Lost") self.cleanup() sys.exit(1) + # Check for duplicate samples elif sample_number == self.previous_sample_number: print("\nError: Duplicate Sample") self.cleanup() @@ -113,6 +179,9 @@ def process_data(self): self.cleanup() def cleanup(self): + """ + Clean up resources and close connections. It safely closes the WebSocket connection if it exists and ensures cleanup only happens once. + """ if not self.cleanup_done: try: if hasattr(self, 'ws') and self.ws: @@ -124,12 +193,15 @@ def cleanup(self): self.cleanup_done = True def __del__(self): + """ + Destructor to ensure cleanup when object is garbage collected. + """ self.cleanup() if __name__ == "__main__": client = None try: - client = Chords_WIFI() + client = Chords_WIFI() # Create and run WiFi client client.connect() client.process_data() except Exception as e: diff --git a/chordspy/connection.py b/chordspy/connection.py index fadcb63f..89e0c639 100644 --- a/chordspy/connection.py +++ b/chordspy/connection.py @@ -1,317 +1,317 @@ -from chordspy.chords_serial import Chords_USB -from chordspy.chords_wifi import Chords_WIFI -from chordspy.chords_ble import Chords_BLE -from pylsl import StreamInfo, StreamOutlet -import argparse -import time -import asyncio -import csv -from datetime import datetime -import threading -from collections import deque -from pylsl import local_clock -from pylsl import StreamInlet, resolve_stream -import numpy as np +""" +CHORDS Data Connection- +This scripts provides a unified interface for connecting to CHORDS devices via multiple protocols +(USB, WiFi, BLE) and streaming data to LSL (Lab Streaming Layer) and/or CSV files. + +Key Features: +- Multi-protocol support (USB, WiFi, BLE) +- Simultaneous LSL streaming and CSV recording +- Automatic device discovery and connection + +Typical Usage: +1. Initialize Connection object +2. Connect to device via preferred protocol +3. Configure LSL stream parameters +4. Start data streaming/CSV recording +5. Process incoming data +6. Clean shutdown on exit +""" + +# Importing necessary libraries +from chordspy.chords_serial import Chords_USB # USB protocol handler +from chordspy.chords_wifi import Chords_WIFI # WiFi protocol handler +from chordspy.chords_ble import Chords_BLE # BLE protocol handler +import argparse # For command-line argument parsing +import time # For timing operations and timestamps +import asyncio # For asynchronous BLE operations +import csv # For CSV file recording +from datetime import datetime # For timestamp generation +import threading # For multi-threaded operations +from collections import deque # For efficient rate calculation +from pylsl import StreamInfo, StreamOutlet # LSL streaming components +from pylsl import StreamInlet, resolve_stream # LSL stream resolution +from pylsl import local_clock # For precise timing +import numpy as np # For numerical operations class Connection: + """ + Main connection manager class for supported devices. + This class serves as the central hub for all device communication, providing: + - Unified interface across multiple connection protocols(WiFi/BLE/USB) + - Data streaming to LSL + - Data recording to CSV files + - Connection state management + - Sample validation and rate monitoring + The class maintains separate connection handlers for each protocol (USB/WiFi/BLE) + and manages their lifecycle. It implements thread-safe operations for concurrent + data handling and provides clean shutdown procedures. + """ def __init__(self): - self.ble_connection = None - self.wifi_connection = None - self.usb_connection = None - self.lsl_connection = None - self.stream_name = "BioAmpDataStream" - self.stream_type = "EXG" - self.stream_format = "float32" - self.stream_id = "UDL" - self.last_sample = None - self.samples_received = 0 - self.start_time = time.time() - self.csv_file = None - self.csv_writer = None - self.sample_counter = 0 - self.num_channels = 0 - self.sampling_rate = 0 - self.stream_active = False - self.recording_active = False - self.usb_thread = None - self.ble_thread = None - self.wifi_thread = None - self.running = False - self.sample_count = 0 - self.rate_window = deque(maxlen=10) - self.last_timestamp = time.perf_counter() - self.rate_update_interval = 0.5 - self.ble_samples_received = 0 + """ + Initialize the connection manager with default values. + """ + # Protocol Connection Handlers + self.ble_connection = None # BLE protocol handler + self.wifi_connection = None # WiFi protocol handler + self.usb_connection = None # USB protocol handler + self.lsl_connection = None # LSL stream outlet (created when streaming starts) + + # LSL Stream Configuration + self.stream_name = "BioAmpDataStream" # Default LSL stream name + self.stream_type = "EXG" # LSL stream type + self.stream_format = "float32" # Data format for LSL samples + self.stream_id = "UDL" # Unique stream identifier + + # Data Tracking Systems + self.last_sample = None # Stores the most recent sample received + self.samples_received = 0 # Total count of samples received + self.start_time = time.time() # Timestamp when connection was established + + # CSV Recording Systems + self.csv_file = None # File handle for CSV output + self.csv_writer = None # CSV writer object + self.sample_counter = 0 # Count of samples written to CSV + + # Stream Parameters + self.num_channels = 0 # Number of data channels + self.sampling_rate = 0 # Current sampling rate in Hz + + # System State Flags + self.stream_active = False # True when LSL streaming is active + self.recording_active = False # True when CSV recording is active + + # Thread Management + self.usb_thread = None # Thread for USB data handling + self.ble_thread = None # Thread for BLE data handling + self.wifi_thread = None # Thread for WiFi data handling + self.running = False # Main system running flag + + # Rate Monitoring Systems + self.sample_count = 0 # Samples received in current interval + self.rate_window = deque(maxlen=10) # Window for rate calculation + self.last_timestamp = time.perf_counter() # Last rate calculation time + self.rate_update_interval = 0.5 # Seconds between rate updates + self.ble_samples_received = 0 # Count of BLE-specific samples async def get_ble_device(self): - devices = await Chords_BLE.scan_devices() + """c + Scan for and select a BLE device interactively. + This asynchronous method: Scans for available BLE devices using Chords_BLE scanner, presents discovered devices to user, handles user selection, returns selected device object. + Returns: + Device: The selected BLE device object or None if no devices found, invalid selection, user cancellation. + """ + devices = await Chords_BLE.scan_devices() # Scan for available BLE devices + + # Handle case where no devices are found if not devices: print("No NPG devices found!") return None - print("\nFound NPG Devices:") + print("\nFound NPG Devices:") # Display discovered devices to user for i, device in enumerate(devices): print(f"[{i}] {device.name} - {device.address}") try: - selection = int(input("\nEnter device number to connect: ")) - if 0 <= selection < len(devices): + selection = int(input("\nEnter device number to connect: ")) # Get user selection + if 0 <= selection < len(devices): # Validate selection return devices[selection] print("Invalid selection!") return None except (ValueError, KeyboardInterrupt): - print("\nCancelled.") + print("\nCancelled.") # Handle invalid input or user cancellation return None def setup_lsl(self, num_channels, sampling_rate): + """ + Set up LSL (Lab Streaming Layer) stream outlet. + This method: creates a new LSL stream info object, initializes the LSL outlet, updates stream parameters, sets streaming state flag. + Args: + num_channels (int): Number of data channels in stream + sampling_rate (float): Sampling rate in Hz + """ + # Create LSL stream info with configured parameters info = StreamInfo(self.stream_name, self.stream_type, num_channels, sampling_rate, self.stream_format, self.stream_id) - self.lsl_connection = StreamOutlet(info) + self.lsl_connection = StreamOutlet(info) # Initialize LSL outlet print(f"LSL stream started: {num_channels} channels at {sampling_rate}Hz") self.stream_active = True self.num_channels = num_channels self.sampling_rate = sampling_rate def start_csv_recording(self, filename=None): + """ + Start CSV recording session. + This method: Verify recording isn't already active, generates filename, opens CSV file and initializes writer, writes column headers, sets recording state flag. + Args: + filename (str, optional): Custom filename without extension + Returns: + bool: True if recording started successfully, False otherwise + """ + # Check if recording is already active if self.recording_active: return False try: + # Generate filename if not provided if not filename: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"ChordsPy_{timestamp}.csv" elif not filename.endswith('.csv'): filename += '.csv' - self.csv_file = open(filename, 'w', newline='') - headers = ['Counter'] + [f'Channel{i+1}' for i in range(self.num_channels)] + self.csv_file = open(filename, 'w', newline='') # Open CSV file and initialize writer + headers = ['Counter'] + [f'Channel{i+1}' for i in range(self.num_channels)] # Create column headers self.csv_writer = csv.writer(self.csv_file) self.csv_writer.writerow(headers) - self.recording_active = True + self.recording_active = True # Update state self.sample_counter = 0 print(f"CSV recording started: {filename}") return True except Exception as e: - print(f"Error starting CSV recording: {str(e)}") + print(f"Error starting CSV recording: {str(e)}") # Handle file operation errors return False def stop_csv_recording(self): + """ + Stop CSV recording session. + This method: Validates recording is active, closes CSV file, cleans up resources, resets recording state. + Returns: + bool: True if recording stopped successfully, False otherwise + """ + # Check if recording is inactive if not self.recording_active: return False try: + # Close file and clean up if self.csv_file: self.csv_file.close() self.csv_file = None self.csv_writer = None - self.recording_active = False + self.recording_active = False # Update state print("CSV recording stopped") return True except Exception as e: - print(f"Error stopping CSV recording: {str(e)}") + print(f"Error stopping CSV recording: {str(e)}") # Handle file closing errors return False def log_to_csv(self, sample_data): + """ + Log a sample to CSV file. + This method: Validates recording is active, formats sample data, writes to CSV, handles write errors. + Args: + sample_data (list): List of channel values to record + """ + # Check if recording is inactive if not self.recording_active or not self.csv_writer: return try: + # Format and write sample self.sample_counter += 1 row = [self.sample_counter] + sample_data self.csv_writer.writerow(row) except Exception as e: - print(f"Error writing to CSV: {str(e)}") + print(f"Error writing to CSV: {str(e)}") # Handle write errors and stop recording self.stop_csv_recording() def update_sample_rate(self): - now = time.perf_counter() - elapsed = now - self.last_timestamp - self.sample_count += 1 + """ + Update and display current sample rate. It calculates rate over a moving window and prints to console. It uses perf_counter() for highest timing precision. + """ + now = time.perf_counter() # Get current high-resolution timestamp + elapsed = now - self.last_timestamp # Calculate time elapsed since last calculation + self.sample_count += 1 # Increment sample counter for this interval + # Only update display if we've collected enough time (default 0.5s) if elapsed >= self.rate_update_interval: - current_rate = self.sample_count / elapsed - self.rate_window.append(current_rate) + current_rate = self.sample_count / elapsed # Calculate current instantaneous rate (samples/second) + self.rate_window.append(current_rate) # Add to our moving window of recent rates (default 10 values) # Print average rate avg_rate = sum(self.rate_window) / len(self.rate_window) - print(f"\rCurrent sampling rate: {avg_rate:.2f} Hz", end="", flush=True) + print(f"\rCurrent sampling rate: {avg_rate:.2f} Hz", end="", flush=True) # Using \r to overwrite previous line + # Reset counters for next interval self.sample_count = 0 self.last_timestamp = now - def lsl_rate_checker(self, duration=2.0): + def lsl_rate_checker(self, duration=1.0): # For USB Only(Need modification) + """ + Independently verifies the actual streaming rate of the LSL outlet. + This method: Collects timestamps over a measurement period -> calculates rate from timestamp differences. + Args: + duration: Measurement duration in seconds + """ try: streams = resolve_stream('type', self.stream_type) if not streams: print("No LSL stream found to verify.") return - - inlet = StreamInlet(streams[0]) + inlet = StreamInlet(streams[0]) # Create an inlet to receive data timestamps = [] start_time = time.time() + # Collect data for specified duration while time.time() - start_time < duration: sample, ts = inlet.pull_sample(timeout=1.0) if ts: timestamps.append(ts) if len(timestamps) > 10: - diffs = np.diff(timestamps) - filtered_diffs = [d for d in diffs if d > 0] + diffs = np.diff(timestamps) # Calculate time differences between consecutive samples + filtered_diffs = [d for d in diffs if d > 0] # Filter out zero/negative differences (invalid) if filtered_diffs: - estimated_rate = 1 / np.mean(filtered_diffs) + estimated_rate = 1 / np.mean(filtered_diffs) # Rate = 1/average interval between samples else: - print("\nAll timestamps had zero difference.") + print("\nAll timestamps had zero difference (invalid).") else: print("\nNot enough timestamps collected to estimate rate.") + except Exception as e: print(f"Error in LSL rate check: {str(e)}") - def counter_based_data_handler(self): - last_counter = -1 - dropped_samples = 0 - total_samples = 0 - last_print_time = time.time() - - while self.running and self.connection: - try: - raw_sample = self.connection.get_latest_sample() - if not raw_sample: - continue - - current_counter = raw_sample[2] - channel_data = raw_sample[3:] - - # Handle counter rollover (0-255) - if last_counter != -1: - expected_counter = (last_counter + 1) % 256 - - if current_counter != expected_counter: - if current_counter > last_counter: - missed = current_counter - last_counter - 1 - else: - missed = (256 - last_counter - 1) + current_counter - - dropped_samples += missed - print(f"\nWarning: {missed} samples dropped. Counter jump: {last_counter} -> {current_counter}") - - # Only process if this is a new sample - if current_counter != last_counter: - total_samples += 1 - timestamp = local_clock() - - if self.lsl_connection: - self.lsl_connection.push_sample(channel_data, timestamp=timestamp) - - if self.recording_active: - self.log_to_csv(channel_data) - - last_counter = current_counter - - self.update_sample_rate() - - # Print stats every 5 seconds - if time.time() - last_print_time > 5: - drop_rate = (dropped_samples / total_samples) * 100 if total_samples > 0 else 0 - print(f"\nStats - Processed: {total_samples}, Dropped: {dropped_samples} ({drop_rate:.2f}%)") - last_print_time = time.time() - - except Exception as e: - print(f"\nCounter-based handler error: {str(e)}") - print(f"Last counter: {last_counter}, Current counter: {current_counter}") - break - - def hybrid_data_handler(self): - last_counter = -1 - target_interval = 1.0 / 500.0 - last_timestamp = local_clock() - dropped_samples = 0 - total_samples = 0 - last_print_time = time.time() - - while self.running and self.connection: - try: - raw_sample = self.connection.get_latest_sample() - if not raw_sample: - continue - - current_counter = raw_sample[2] - channel_data = raw_sample[3:] - - if current_counter == last_counter: - continue - - current_time = local_clock() - - counter_diff = (current_counter - last_counter) % 256 - if counter_diff == 0: - counter_diff = 256 - - # Check for missed samples - if last_counter != -1 and counter_diff > 1: - dropped_samples += (counter_diff - 1) - print(f"\nWarning: {counter_diff - 1} samples dropped. Counter jump: {last_counter} -> {current_counter}") - print(f"Current timestamp: {current_time}") - print(f"Sample data: {channel_data}") - - time_per_sample = target_interval - - for i in range(counter_diff): - sample_timestamp = last_timestamp + (i + 1) * time_per_sample - - # Check if we're falling behind - if local_clock() > sample_timestamp + time_per_sample * 2: - print(f"\nWarning: Falling behind by {local_clock() - sample_timestamp:.4f}s, skipping samples") - break - - if self.lsl_connection: - self.lsl_connection.push_sample(channel_data, timestamp=sample_timestamp) - - if self.recording_active: - self.log_to_csv(channel_data) - - total_samples += 1 - - last_counter = current_counter - last_timestamp = current_time - - if time.time() - last_print_time > 5: - drop_rate = (dropped_samples / total_samples) * 100 if total_samples > 0 else 0 - print(f"\nStats - Processed: {total_samples}, Dropped: {dropped_samples} ({drop_rate:.2f}%)") - last_print_time = time.time() - - except Exception as e: - print(f"\nHybrid handler error: {str(e)}") - print(f"Last counter: {last_counter}, Current counter: {current_counter}") - break - def ble_data_handler(self): - TARGET_SAMPLE_RATE = 500.0 - SAMPLE_INTERVAL = 1.0 / TARGET_SAMPLE_RATE - next_sample_time = local_clock() + """ + BLE-specific data handler with precise timing control. + The handler ensures: + 1. Precise sample timing using local_clock() + 2. Constant sampling rate regardless of BLE packet timing + 3. Graceful handling of buffer overflows + 4. Thread-safe operation with the main controller + """ + # Target specifications for the BLE stream + SAMPLE_INTERVAL = 1.0 / self.sampling_rate # Time between samples in seconds + next_sample_time = local_clock() # Initialize timing baseline + # Main processing loop - runs while system is active and BLE connected while self.running and self.ble_connection: try: + # Check if new BLE data is available if hasattr(self.ble_connection, 'data_available') and self.ble_connection.data_available: - current_time = local_clock() + current_time = local_clock() # Get precise current timestamp + # Only process if we've reached the next scheduled sample time if current_time >= next_sample_time: sample = self.ble_connection.get_latest_sample() if sample: - channel_data = sample[:self.num_channels] + channel_data = sample[:self.num_channels] # Extract channel data # Calculate precise timestamp sample_time = next_sample_time - next_sample_time += SAMPLE_INTERVAL + next_sample_time += SAMPLE_INTERVAL # Schedule next sample time # If we're falling behind, skip samples to catch up if current_time > next_sample_time + SAMPLE_INTERVAL: next_sample_time = current_time + SAMPLE_INTERVAL + # Stream to LSL if enabled if self.lsl_connection: self.lsl_connection.push_sample(channel_data, timestamp=sample_time) + # Update rate display self.update_sample_rate() + # Log to CSV if recording if self.recording_active: self.log_to_csv(channel_data) except Exception as e: @@ -319,15 +319,19 @@ def ble_data_handler(self): break def wifi_data_handler(self): - TARGET_SAMPLE_RATE = 500.0 - SAMPLE_INTERVAL = 1.0 / TARGET_SAMPLE_RATE - next_sample_time = local_clock() + """ + WiFi-specific data handler with network-optimized timing. + """ + SAMPLE_INTERVAL = 1.0 / self.sampling_rate # Time between samples in seconds + next_sample_time = local_clock() # Initialize timing baseline while self.running and self.wifi_connection: try: + # Verify WiFi data is available if hasattr(self.wifi_connection, 'data_available') and self.wifi_connection.data_available: current_time = local_clock() + # Timing gate ensures precise sample rate if current_time >= next_sample_time: sample = self.wifi_connection.get_latest_sample() if sample: @@ -353,26 +357,31 @@ def wifi_data_handler(self): break def usb_data_handler(self): - TARGET_SAMPLE_RATE = 500.0 - SAMPLE_INTERVAL = 1.0 / TARGET_SAMPLE_RATE - next_sample_time = local_clock() + """ + USB data handler with serial port optimization. + """ + SAMPLE_INTERVAL = 1.0 / self.sampling_rate # Time between samples in seconds + next_sample_time = local_clock() # Initialize timing baseline while self.running and self.usb_connection: try: + # Verify USB port is open and active if hasattr(self.usb_connection, 'ser') and self.usb_connection.ser.is_open: - self.usb_connection.read_data() + self.usb_connection.read_data() # Read raw data from serial port + # Process if new data exists if hasattr(self.usb_connection, 'data'): current_time = local_clock() if current_time >= next_sample_time: - sample = self.usb_connection.data[:, -1] - channel_data = sample.tolist() + sample = self.usb_connection.data[:, -1] # Get most recent sample from numpy array + channel_data = sample.tolist() # Convert to list format # Calculate precise timestamp sample_time = next_sample_time next_sample_time += SAMPLE_INTERVAL + # USB-specific overflow handling if current_time > next_sample_time + SAMPLE_INTERVAL: next_sample_time = current_time + SAMPLE_INTERVAL @@ -387,25 +396,21 @@ def usb_data_handler(self): print(f"\nUSB data handler error: {str(e)}") break - def connect_usb_with_counter(self): - self.usb_connection = Chords_USB() - if not self.usb_connection.detect_hardware(): - return False - - self.num_channels = self.usb_connection.num_channels - self.sampling_rate = self.usb_connection.supported_boards[self.usb_connection.board]["sampling_rate"] - - self.setup_lsl(self.num_channels, self.sampling_rate) - self.usb_connection.send_command('START') - - self.running = True - self.usb_thread = threading.Thread(target=self.counter_based_data_handler) - self.usb_thread.daemon = True - self.usb_thread.start() - - return True - def connect_ble(self, device_address=None): + """ + Establishes and manages a Bluetooth Low Energy (BLE) connection with a device. + The method handles the complete BLE lifecycle including: + - Device discovery and selection (if no address provided) + - Connection establishment + - Data stream configuration + - Real-time data processing pipeline + Args: + device_address (str, optional): MAC address in "XX:XX:XX:XX:XX:XX" format. If None, initiates interactive device selection. + Returns: + bool: True if connection succeeds, False on failure + Workflow: Initialize BLE handler instance -> Configure custom data notification handler -> Establish connection (direct or interactive) -> Set up data processing pipeline -> Maintain connection until termination. + """ + # Initialize BLE protocol handler self.ble_connection = Chords_BLE() original_notification_handler = self.ble_connection.notification_handler @@ -442,58 +447,99 @@ def notification_handler(sender, data): print(f"Connecting to BLE device: {selected_device.name}") self.ble_connection.connect(selected_device.address) - print("BLE connection established. Waiting for data...") + self.running = True + self.ble_thread = threading.Thread(target=self.ble_data_handler) + self.ble_thread.daemon = True + self.ble_thread.start() + threading.Thread(target=self.lsl_rate_checker, daemon=True).start() # Start independent rate monitoring return True except Exception as e: print(f"BLE connection failed: {str(e)}") return False def connect_wifi(self): + """ + Manages WiFi connection and data streaming for CHORDS devices. + Implements a persistent connection loop that: + - Maintains websocket connection + - Validates incoming data blocks + - Handles data conversion and distribution + - Provides graceful shutdown on interrupt + The method runs continuously until KeyboardInterrupt, automatically cleaning up resources on exit. + """ + # Initialize WiFi handler and establish connection self.wifi_connection = Chords_WIFI() self.wifi_connection.connect() + # Configure stream parameters from device self.num_channels = self.wifi_connection.channels sampling_rate = self.wifi_connection.sampling_rate + # Initialize LSL stream if needed if not self.lsl_connection: self.setup_lsl(self.num_channels, sampling_rate) + + # Start the data handler thread + self.running = True + self.wifi_thread = threading.Thread(target=self.wifi_data_handler) + self.wifi_thread.daemon = True + self.wifi_thread.start() + threading.Thread(target=self.lsl_rate_checker, daemon=True).start() # Start independent rate monitoring try: print("\nConnected! (Press Ctrl+C to stop)") while True: - data = self.wifi_connection.ws.recv() + data = self.wifi_connection.ws.recv() # Receive data via websocket + # Handle both binary and text-formatted data if isinstance(data, (bytes, list)): - for i in range(0, len(data), self.wifi_connection.block_size): - block = data[i:i + self.wifi_connection.block_size] - if len(block) < self.wifi_connection.block_size: + # Process data in protocol-defined blocks + block_size = self.wifi_connection.block_size + for i in range(0, len(data), block_size): + block = data[i:i + block_size] + + # Skip partial blocks + if len(block) < block_size: continue + # Extract and convert channel samples channel_data = [] for ch in range(self.wifi_connection.channels): - offset = 1 + ch * 2 + offset = 1 + ch * 2 # Calculate byte offset for each channel sample = int.from_bytes(block[offset:offset + 2], byteorder='big', signed=True) channel_data.append(sample) if self.lsl_connection: # Push to LSL self.lsl_connection.push_sample(channel_data) + + # Record to CSV if self.recording_active: self.log_to_csv(channel_data) except KeyboardInterrupt: - self.wifi_connection.disconnect() print("\nDisconnected") finally: - self.stop_csv_recording() + self.stop_csv_recording() # Ensure resources are released def connect_usb(self): + """ + Handles USB device connection and data streaming. + Implements: Automatic device detection, Hardware-specific configuration, Multi-threaded data handling, Rate monitoring. + Returns: + bool: True if successful initialization, False on failure + """ + # Initialize USB handler self.usb_connection = Chords_USB() + # Detect and validate connected hardware if not self.usb_connection.detect_hardware(): return False + # Configure stream based on detected board self.num_channels = self.usb_connection.num_channels - self.sampling_rate = self.usb_connection.supported_boards[self.usb_connection.board]["sampling_rate"] + board_config = self.usb_connection.supported_boards[self.usb_connection.board] + self.sampling_rate = board_config["sampling_rate"] + # Initialize LSL stream self.setup_lsl(self.num_channels, self.sampling_rate) # Start the USB streaming command @@ -505,18 +551,25 @@ def connect_usb(self): self.usb_thread.daemon = True self.usb_thread.start() + # Start independent rate monitoring threading.Thread(target=self.lsl_rate_checker, daemon=True).start() return True def cleanup(self): - self.running = False - self.stop_csv_recording() - + """ + Clean up all resources and connections in a safe and orderly manner. + The cleanup process follows this sequence: First stop data recording -> Then stop LSL streaming -> Next terminate all threads -> Finally close all hardware connections. + """ + self.running = False # Signal all threads to stop + self.stop_csv_recording() # Stop CSV recording if active + + # Clean up LSL stream if active if self.lsl_connection: self.lsl_connection = None self.stream_active = False print("\nLSL stream stopped") + # Collect all active threads threads = [] if self.usb_thread and self.usb_thread.is_alive(): threads.append(self.usb_thread) @@ -525,72 +578,104 @@ def cleanup(self): if self.wifi_thread and self.wifi_thread.is_alive(): threads.append(self.wifi_thread) + # Wait for threads to finish (with timeout to prevent hanging) for t in threads: - t.join(timeout=1) + t.join(timeout=1) # 1 second timeout per thread - # Clean up connections + # Clean up USB connection if self.usb_connection: try: + # Check if serial port is open and send stop command if hasattr(self.usb_connection, 'ser') and self.usb_connection.ser.is_open: - self.usb_connection.send_command('STOP') - self.usb_connection.ser.close() + self.usb_connection.send_command('STOP') # Graceful stop + self.usb_connection.ser.close() # Close serial port print("USB connection closed") except Exception as e: print(f"Error closing USB connection: {str(e)}") finally: self.usb_connection = None + # Clean up BLE connection if self.ble_connection: try: - self.ble_connection.stop() + self.ble_connection.stop() # Stop BLE operations print("BLE connection closed") except Exception as e: print(f"Error closing BLE connection: {str(e)}") finally: self.ble_connection = None + # Clean up WiFi connection if self.wifi_connection: try: - self.wifi_connection.cleanup() + self.wifi_connection.cleanup() # WiFi-specific cleanup print("WiFi connection closed") except Exception as e: print(f"Error closing WiFi connection: {str(e)}") finally: self.wifi_connection = None + # Reset all state flags self.stream_active = False self.recording_active = False def __del__(self): + """ + Destructor to ensure cleanup when object is garbage collected. It simply calls the main cleanup method. + """ self.cleanup() def main(): + """ + Main entry point for command line execution of the CHORDS connection manager. + It handles: Command line argument parsing, protocol-specific connection setup, main execution loop, clean shutdown on exit. + + Usage Examples: + $ python chords_connection.py --protocol usb + $ python chords_connection.py --protocol wifi + $ python chords_connection.py --protocol ble + $ python chords_connection.py --protocol ble --ble-address AA:BB:CC:DD:EE:FF + + The main execution flow: + 1. Parse command line arguments + 2. Create connection manager instance + 3. Establish requested connection + 4. Enter main loop (until interrupted) + 5. Clean up resources on exit + """ + # Set up command line argument parser parser = argparse.ArgumentParser(description='Connect to device') - parser.add_argument('--protocol', choices=['usb', 'wifi', 'ble'], required=True, help='Connection protocol') + parser.add_argument('--protocol', choices=['usb', 'wifi', 'ble'], required=True, help='Connection protocol to use (usb|wifi|ble)') parser.add_argument('--ble-address', help='Direct BLE device address') - args = parser.parse_args() - - manager = Connection() + + args = parser.parse_args() # Parse command line arguments + manager = Connection() # Create connection manager instance try: + # USB Protocol Handling if args.protocol == 'usb': - if manager.connect_usb(): - while manager.running: - time.sleep(1) + if manager.connect_usb(): # Attempt USB connection + while manager.running: # Main execution loop + time.sleep(1) # Prevent CPU overutilization + + # WiFi Protocol Handling elif args.protocol == 'wifi': - if manager.connect_wifi(): - while manager.running: - time.sleep(1) + if manager.connect_wifi(): # Attempt WiFi connection + while manager.running: # Main execution loop + time.sleep(1) # Prevent CPU overutilization + + # BLE Protocol Handling elif args.protocol == 'ble': - if manager.connect_ble(args.ble_address): - while manager.running: - time.sleep(1) + if manager.connect_ble(args.ble_address): # Attempt BLE connection + while manager.running: # Main execution loop + time.sleep(1) # Prevent CPU overutilization + except KeyboardInterrupt: print("\nCleanup Completed.") except Exception as e: print(f"\nError: {str(e)}") finally: - manager.cleanup() + manager.cleanup() # Ensure cleanup always runs if __name__ == '__main__': main() \ No newline at end of file From 187e58b500751309a1bb7074d0afe6778b8971e2 Mon Sep 17 00:00:00 2001 From: Payal Lakra Date: Sat, 5 Jul 2025 14:15:53 +0530 Subject: [PATCH 21/24] Fully Documented Code --- chordspy/app.py | 166 ++++++++++++++++++++++++++++------ chordspy/templates/index.html | 54 ++++++++++- 2 files changed, 187 insertions(+), 33 deletions(-) diff --git a/chordspy/app.py b/chordspy/app.py index 1665b1cb..e67cdd53 100644 --- a/chordspy/app.py +++ b/chordspy/app.py @@ -1,47 +1,73 @@ -from flask import Flask, render_template, request, jsonify -from chordspy.connection import Connection -import threading -import asyncio -import logging -from bleak import BleakScanner -from flask import Response -import queue -import yaml -from pathlib import Path -import os -import webbrowser -import logging +""" +Flask-based web interface for managing connections to devices and applications. +This module provides a web-based GUI for: +- Scanning and connecting to devices via USB, WiFi, or BLE +- Managing data streaming and recording +- Launching and monitoring Chords-Python applications +- Displaying real-time console updates +- Handling error logging +The application uses Server-Sent Events (SSE) for real-time updates to the frontend. +""" -console_queue = queue.Queue() -app = Flask(__name__) -logging.basicConfig(level=logging.INFO) +# Importing Necessary Libraries +from flask import Flask, render_template, request, jsonify # Flask web framework +from chordspy.connection import Connection # Connection management module +import threading # For running connection management in a separate thread +import asyncio # For asynchronous operations, especially with BLE +import logging # For logging errors and information +from bleak import BleakScanner # BLE device scanner from Bleak library +from flask import Response # For handling server-sent events (SSE) +import queue # Queue for managing console messages +import yaml # For loading application configuration from YAML files +from pathlib import Path # For handling file paths in a platform-independent way +import os # For file and directory operations +import webbrowser # For opening the web interface in a browser +import logging # For logging errors and information + +console_queue = queue.Queue() # Global queue for console messages to be displayed in the web interface +app = Flask(__name__) # Initialize Flask application +logging.basicConfig(level=logging.INFO) # Configure logging log = logging.getLogger('werkzeug') -log.setLevel(logging.ERROR) # Only show errors +log.setLevel(logging.ERROR) # Only show errors from Werkzeug (Flask's WSGI) # Global variables -connection_manager = None -connection_thread = None -ble_devices = [] -stream_active = False -running_apps = {} # Dictionary to track running apps +connection_manager = None # Manages the device connection +connection_thread = None # Thread for connection management +ble_devices = [] # List of discovered BLE devices +stream_active = False # Flag indicating if data stream is active +running_apps = {} # Dictionary to track running applications +# Error logging endpoint. This allows the frontend to send error messages to be logged. @app.route('/log_error', methods=['POST']) def log_error(): + """ + Endpoint for logging errors from the frontend. It receives error data via POST request and writes it to a log file. + Returns: + JSON response with status and optional error message. + """ try: error_data = request.get_json() if not error_data or 'error' not in error_data or 'log_error' in str(error_data): return jsonify({'status': 'error', 'message': 'Invalid data'}), 400 - os.makedirs('logs', exist_ok=True) + os.makedirs('logs', exist_ok=True) # Ensure logs directory exists - with open('logs/logging.txt', 'a') as f: + with open('logs/logging.txt', 'a') as f: # Append error to log file f.write(error_data['error']) return jsonify({'status': 'success'}) except Exception as e: return jsonify({'status': 'error', 'message': 'Logging failed'}), 500 +# Decorator to run async functions in a synchronous context. It allows us to call async functions from Flask routes. def run_async(coro): + """ + Decorator to run async functions in a synchronous context. + Args: + coro: The coroutine to be executed. + Returns: + A wrapper function that runs the coroutine in a new event loop. + """ def wrapper(*args, **kwargs): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -51,12 +77,20 @@ def wrapper(*args, **kwargs): loop.close() return wrapper +# Main route for the web interface. It renders the index.html template. @app.route('/') def index(): + """Render the main index page of the web interface.""" return render_template('index.html') +# Route to retrieve the configuration for available Chord-Python applications. @app.route('/get_apps_config') def get_apps_config(): + """ + Retrieve the configuration for available applications.It looks for apps.yaml in either the package config directory or a local config directory. + Returns: + JSON response containing the application configuration or an empty list if not found. + """ try: config_path = Path(__file__).parent / 'config' / 'apps.yaml' # Try package-relative path first if not config_path.exists(): @@ -72,9 +106,15 @@ def get_apps_config(): logging.error(f"Error loading apps config: {str(e)}") return jsonify({'apps': [], 'error': str(e)}) +# Route to scan for nearby BLE devices. It uses BleakScanner to discover devices. @app.route('/scan_ble') @run_async async def scan_ble_devices(): + """ + Scan for nearby BLE devices. It uses BleakScanner to discover devices for 5 seconds and filters for devices with names starting with 'NPG' or 'npg'. + Returns: + JSON response with list of discovered devices or error message. + """ global ble_devices try: devices = await BleakScanner.discover(timeout=5) @@ -85,18 +125,36 @@ async def scan_ble_devices(): logging.error(f"BLE scan error: {str(e)}") return jsonify({'status': 'error', 'message': str(e)}), 500 +# Route to check if the data stream is currently active. It checks the connection manager's stream_active flag. @app.route('/check_stream') def check_stream(): + """ + Check if data stream is currently active. + Returns: + JSON response with connection status. + """ is_connected = connection_manager.stream_active if hasattr(connection_manager, 'stream_active') else False return jsonify({'connected': is_connected}) +# Route to check the current connection status with the device. It returns 'connected' if the stream is active, otherwise 'connecting'. @app.route('/check_connection') def check_connection(): + """ + Check the current connection status with the device. + Returns: + JSON response with connection status ('connected' or 'connecting'). + """ if connection_manager and connection_manager.stream_active: return jsonify({'status': 'connected'}) return jsonify({'status': 'connecting'}) +# Function to post messages to the console queue. It updates the stream_active flag based on the message content. This function is used to send messages to the web interface for display in real-time. def post_console_message(message): + """ + Post a message to the console queue for display in the web interface and updates the stream_active flag based on message content. + Args: + message: The message to be displayed in the console. + """ global stream_active if "LSL stream started" in message: stream_active = True @@ -104,17 +162,30 @@ def post_console_message(message): stream_active = False console_queue.put(message) +# Route for Server-Sent Events (SSE) to provide real-time console updates to the web interface. @app.route('/console_updates') def console_updates(): + """ + Server-Sent Events (SSE) endpoint for real-time console updates. + Returns: + SSE formatted messages from the console queue. + """ def event_stream(): + """Generator function that yields messages from the console queue as SSE formatted messages.""" while True: message = console_queue.get() yield f"data: {message}\n\n" return Response(event_stream(), mimetype="text/event-stream") +# Route to launch Chord-Python application as a subprocess. It receives the application name via POST request and starts it as a Python module. @app.route('/launch_app', methods=['POST']) def launch_application(): + """ + Launch a Chord-Python application as a subprocess.It receives the application name via POST request and starts it as a Python module. + Returns: + JSON response indicating success or failure of application launch. + """ if not connection_manager or not connection_manager.stream_active: return jsonify({'status': 'error', 'message': 'No active stream'}), 400 @@ -134,16 +205,23 @@ def launch_application(): # Run the module using Python's -m flag process = subprocess.Popen([sys.executable, "-m", f"chordspy.{module_name}"]) - - running_apps[module_name] = process + running_apps[module_name] = process # Track running application return jsonify({'status': 'success', 'message': f'Launched {module_name}'}) except Exception as e: logging.error(f"Error launching {module_name}: {str(e)}") return jsonify({'status': 'error', 'message': str(e)}), 500 +# Route to check the status of a running application. It checks if the application is in the running_apps dictionary and whether its process is still active. @app.route('/check_app_status/') def check_app_status(app_name): + """ + Check the status of a running application. + Args: + app_name: Name of the application to check. + Returns: + JSON response indicating if the application is running or not. + """ if app_name in running_apps: if running_apps[app_name].poll() is None: # Still running return jsonify({'status': 'running'}) @@ -152,8 +230,14 @@ def check_app_status(app_name): return jsonify({'status': 'not_running'}) return jsonify({'status': 'not_running'}) +# Route to connect to a device using the specified protocol. It supports USB, WiFi, and BLE connections. Starts connection in a separate thread. @app.route('/connect', methods=['POST']) def connect_device(): + """ + Establish connection to a device using the specified protocol.It supports USB, WiFi, and BLE connections. Starts connection in a separate thread. + Returns: + JSON response indicating connection status. + """ global connection_manager, connection_thread, stream_active data = request.get_json() @@ -173,6 +257,9 @@ def connect_device(): connection_manager = Connection() def run_connection(): + """ + Internal function to handle the connection process in a thread. + """ try: if protocol == 'usb': success = connection_manager.connect_usb() @@ -202,8 +289,14 @@ def run_connection(): return jsonify({'status': 'connecting', 'protocol': protocol}) +# Route to disconnect from the currently connected device. It cleans up the connection manager and resets the stream status. @app.route('/disconnect', methods=['POST']) def disconnect_device(): + """ + Disconnect from the currently connected device. + Returns: + JSON response indicating disconnection status. + """ global connection_manager, stream_active if connection_manager: connection_manager.cleanup() @@ -212,8 +305,14 @@ def disconnect_device(): return jsonify({'status': 'disconnected'}) return jsonify({'status': 'no active connection'}) +# Route to start recording data from the connected device to a CSV file. @app.route('/start_recording', methods=['POST']) def start_recording(): + """ + Start recording data from the connected device to a CSV file. + Returns: + JSON response indicating recording status. + """ global connection_manager if not connection_manager: return jsonify({'status': 'error', 'message': 'No active connection'}), 400 @@ -234,8 +333,14 @@ def start_recording(): logging.error(f"Recording error: {str(e)}") return jsonify({'status': 'error', 'message': str(e)}), 500 +# Route to stop the current recording session. It calls the stop_csv_recording method of the connection manager. @app.route('/stop_recording', methods=['POST']) def stop_recording(): + """ + Stop the current recording session. + Returns: + JSON response indicating recording stop status. + """ global connection_manager if connection_manager: try: @@ -248,12 +353,17 @@ def stop_recording(): return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': 'No active connection'}), 400 +# Route to check if a specific application is running. It checks the running_apps dictionary for the application's process. def main(): + """ + Main entry point for the application. It starts the Flask server and opens the web browser to the application. + """ def open_browser(): + """Open the default web browser to the application URL.""" webbrowser.open("http://localhost:5000") - threading.Timer(1.5, open_browser).start() - app.run(debug=True, use_reloader=False, host='0.0.0.0', port=5000) + threading.Timer(1, open_browser).start() # Open browser after 1 seconds to allow server to start + app.run(debug=True, use_reloader=False, host='0.0.0.0', port=5000) # Start Flask application if __name__ == "__main__": main() \ No newline at end of file diff --git a/chordspy/templates/index.html b/chordspy/templates/index.html index eaf842a1..ee79e77b 100644 --- a/chordspy/templates/index.html +++ b/chordspy/templates/index.html @@ -1,25 +1,39 @@ + + Chords Python + + + - +
+ Chords Python +
+ + + @@ -27,23 +41,30 @@
- +
+
+ + +
+ @@ -57,8 +78,10 @@ Disconnecting... + +
+ +
+
- +