Skip to content

Commit eae895b

Browse files
committed
Now, NPG-LITE BLE is added with autoscanning of devices
1 parent 03d1465 commit eae895b

File tree

4 files changed

+587
-145
lines changed

4 files changed

+587
-145
lines changed

app.py

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
1-
from flask import Flask, render_template, request, redirect, url_for, jsonify
1+
from flask import Flask, render_template, request, redirect, url_for, jsonify, session
22
import subprocess
33
import psutil
44
import signal
55
import sys
66
import atexit
77
import time
88
import os
9+
import json
10+
from threading import Thread
911

1012
app = Flask(__name__)
13+
app.secret_key = 'your_secret_key_here'
14+
1115
lsl_process = None
1216
lsl_running = False
1317
npg_running = False
1418
npg_process = None
1519
app_processes = {}
1620
current_message = None
21+
discovered_devices = []
22+
npg_connection_thread = None
1723

1824
def is_process_running(name):
1925
for proc in psutil.process_iter(['pid', 'name']):
@@ -23,7 +29,93 @@ def is_process_running(name):
2329

2430
@app.route("/")
2531
def home():
26-
return render_template("index.html", lsl_started=lsl_running, npg_started=npg_running, running_apps=[k for k,v in app_processes.items() if v.poll() is None], message=current_message)
32+
return render_template("index.html", lsl_started=lsl_running, npg_started=npg_running, running_apps=[k for k,v in app_processes.items() if v.poll() is None], message=current_message, devices=session.get('devices', []), selected_device=session.get('selected_device'))
33+
34+
@app.route("/scan_devices", methods=["POST"])
35+
def scan_devices():
36+
global discovered_devices
37+
38+
try:
39+
# Run the scanning in a separate process
40+
scan_process = subprocess.Popen([sys.executable, "npg-ble.py", "--scan"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
41+
42+
# Wait for scan to complete (with timeout)
43+
try:
44+
stdout, stderr = scan_process.communicate(timeout=10)
45+
if scan_process.returncode != 0:
46+
raise Exception(f"Scan failed: {stderr}")
47+
48+
# Parse the output to get devices
49+
devices = []
50+
for line in stdout.split('\n'):
51+
if line.startswith("DEVICE:"):
52+
parts = line[len("DEVICE:"):].strip().split('|')
53+
if len(parts) >= 2:
54+
devices.append({
55+
"name": parts[0],
56+
"address": parts[1]
57+
})
58+
59+
session['devices'] = devices
60+
discovered_devices = devices
61+
return jsonify({"status": "success", "devices": devices})
62+
63+
except subprocess.TimeoutExpired:
64+
scan_process.kill()
65+
return jsonify({"status": "error", "message": "Device scan timed out"})
66+
67+
except Exception as e:
68+
return jsonify({"status": "error", "message": str(e)})
69+
70+
@app.route("/connect_device", methods=["POST"])
71+
def connect_device():
72+
global npg_process, npg_running, npg_connection_thread
73+
74+
device_address = request.form.get("device_address")
75+
if not device_address:
76+
return jsonify({"status": "error", "message": "No device selected"})
77+
78+
session['selected_device'] = device_address
79+
80+
def connect_and_monitor():
81+
global npg_process, npg_running, current_message
82+
83+
try:
84+
script_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "npg-ble.py")
85+
npg_process = subprocess.Popen([sys.executable, script_path, "--connect", device_address], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
86+
87+
# Monitor the output for connection status
88+
connected = False
89+
start_time = time.time()
90+
while time.time() - start_time < 10: # 10 second timeout
91+
line = npg_process.stdout.readline()
92+
if not line:
93+
break
94+
if "Connected to" in line:
95+
connected = True
96+
npg_running = True
97+
current_message = f"Connected to {device_address}"
98+
break
99+
100+
if not connected:
101+
current_message = f"Failed to connect to {device_address}"
102+
if npg_process.poll() is None:
103+
npg_process.terminate()
104+
npg_running = False
105+
106+
except Exception as e:
107+
current_message = f"Connection error: {str(e)}"
108+
npg_running = False
109+
110+
# Start the connection in a separate thread
111+
npg_connection_thread = Thread(target=connect_and_monitor)
112+
npg_connection_thread.start()
113+
114+
return jsonify({"status": "pending"})
115+
116+
@app.route("/check_connection", methods=["GET"])
117+
def check_connection():
118+
return jsonify({"connected": npg_running, "message": current_message})
27119

28120
@app.route("/start_lsl", methods=["POST"])
29121
def start_lsl():

npg-ble.py

Lines changed: 103 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from bleak import BleakScanner, BleakClient
33
import time
44
from pylsl import StreamInfo, StreamOutlet
5+
import sys
6+
import argparse
57

68
# BLE parameters (must match your firmware)
79
DEVICE_NAME_PREFIX = "NPG"
@@ -14,27 +16,39 @@
1416
BLOCK_COUNT = 10 # Batch size: 10 samples per notification
1517
NEW_PACKET_LEN = SINGLE_SAMPLE_LEN * BLOCK_COUNT # Total packet length (70 bytes)
1618

17-
# Set up an LSL stream with int16 data format (irregular rate)
18-
stream_name = "NPG"
19-
info = StreamInfo(stream_name, "EXG", 3, 0, "int16", "uid007")
20-
outlet = StreamOutlet(info)
21-
22-
# Global variables for unrolled counter, sample counting, and timing
23-
prev_unrolled_counter = None # Unrolled (cumulative) counter from firmware
24-
samples_received = 0 # Total samples received in the last second
25-
start_time = None # Time when first sample is received
19+
# Global variables
20+
prev_unrolled_counter = None
21+
samples_received = 0
22+
start_time = None
2623
total_missing_samples = 0
24+
outlet = None
25+
26+
def parse_args():
27+
parser = argparse.ArgumentParser()
28+
parser.add_argument("--scan", action="store_true", help="Scan for devices and print them")
29+
parser.add_argument("--connect", type=str, help="Connect to a specific device address")
30+
return parser.parse_args()
31+
32+
async def scan_devices():
33+
print("Scanning for BLE devices...", file=sys.stderr)
34+
devices = await BleakScanner.discover()
35+
filtered = [d for d in devices if d.name and d.name.startswith(DEVICE_NAME_PREFIX)]
36+
37+
if not filtered:
38+
print("No devices found.", file=sys.stderr)
39+
return
40+
41+
# Print devices in format that Flask can parse
42+
for dev in filtered:
43+
print(f"DEVICE:{dev.name}|{dev.address}")
2744

2845
def process_sample(sample_data: bytearray):
29-
global prev_unrolled_counter, samples_received, start_time, total_missing_samples
46+
global prev_unrolled_counter, samples_received, start_time, total_missing_samples, outlet
47+
3048
if len(sample_data) != SINGLE_SAMPLE_LEN:
3149
print("Unexpected sample length:", len(sample_data))
3250
return
33-
# Expected sample format:
34-
# Byte0: Packet counter (0-255)
35-
# Byte1-2: Channel 0 data (big-endian)
36-
# Byte3-4: Channel 1 data (big-endian)
37-
# Byte5-6: Channel 2 data (big-endian)
51+
3852
sample_counter = sample_data[0]
3953
# Unroll the counter:
4054
if prev_unrolled_counter is None:
@@ -45,76 +59,97 @@ def process_sample(sample_data: bytearray):
4559
current_unrolled = prev_unrolled_counter - last + sample_counter + 256
4660
else:
4761
current_unrolled = prev_unrolled_counter - last + sample_counter
62+
4863
if current_unrolled != prev_unrolled_counter + 1:
49-
print(f"Missing sample: expected {prev_unrolled_counter + 1}, got {current_unrolled}")
50-
total_missing_samples += current_unrolled - (prev_unrolled_counter + 1)
64+
missing = current_unrolled - (prev_unrolled_counter + 1)
65+
print(f"Missing {missing} sample(s): expected {prev_unrolled_counter + 1}, got {current_unrolled}")
66+
total_missing_samples += missing
67+
5168
prev_unrolled_counter = current_unrolled
5269

5370
# Set start_time when first sample is received
5471
if start_time is None:
5572
start_time = time.time()
56-
elapsed = time.time() - start_time
57-
58-
channels = []
59-
for ch in range(3):
60-
offset = 1 + ch * 2
61-
value = int.from_bytes(sample_data[offset:offset+2], byteorder='big', signed=True)
62-
channels.append(value)
63-
print(f"Sample {prev_unrolled_counter} at {elapsed:.2f} s: Channels: {channels} Total missing samples: {total_missing_samples}")
64-
outlet.push_sample(channels)
73+
74+
# Process channels
75+
channels = [
76+
int.from_bytes(sample_data[1:3]), # Channel 0
77+
int.from_bytes(sample_data[3:5]), # Channel 1
78+
int.from_bytes(sample_data[5:7])]
79+
80+
# Push to LSL
81+
if outlet:
82+
outlet.push_sample(channels)
83+
6584
samples_received += 1
85+
86+
# Periodic status print
87+
if samples_received % 100 == 0:
88+
elapsed = time.time() - start_time
89+
print(f"Sample {prev_unrolled_counter} at {elapsed:.2f}s - Channels: {channels} - Missing: {total_missing_samples}")
6690

6791
def notification_handler(sender, data: bytearray):
68-
if len(data) == NEW_PACKET_LEN:
69-
for i in range(0, NEW_PACKET_LEN, SINGLE_SAMPLE_LEN):
70-
sample = data[i:i+SINGLE_SAMPLE_LEN]
71-
process_sample(sample)
72-
elif len(data) == SINGLE_SAMPLE_LEN:
73-
process_sample(data)
74-
else:
75-
print("Unexpected packet length:", len(data))
76-
77-
async def print_rate():
78-
global samples_received
79-
while True:
80-
await asyncio.sleep(1)
81-
print(f"Samples per second: {samples_received}")
82-
samples_received = 0
83-
84-
async def run():
85-
print("Scanning for BLE devices with name starting with", DEVICE_NAME_PREFIX)
86-
devices = await BleakScanner.discover()
87-
# Filter devices with names starting with the prefix
88-
filtered = [d for d in devices if d.name and d.name.lower().startswith(DEVICE_NAME_PREFIX.lower())]
89-
if not filtered:
90-
print("No devices found.")
91-
return
92+
try:
93+
if len(data) == NEW_PACKET_LEN:
94+
# Process batched samples
95+
for i in range(0, NEW_PACKET_LEN, SINGLE_SAMPLE_LEN):
96+
process_sample(data[i:i+SINGLE_SAMPLE_LEN])
97+
elif len(data) == SINGLE_SAMPLE_LEN:
98+
# Process single sample
99+
process_sample(data)
100+
else:
101+
print(f"Unexpected packet length: {len(data)} bytes")
102+
except Exception as e:
103+
print(f"Error processing data: {e}")
92104

93-
print("Found devices:")
94-
for idx, dev in enumerate(filtered):
95-
print(f"{idx}: {dev.name} ({dev.address})")
105+
async def connect_to_device(device_address):
106+
global outlet
107+
108+
print(f"Attempting to connect to {device_address}...", file=sys.stderr)
96109

97-
# Let the user choose which device to connect to
110+
# Set up LSL stream (500Hz sampling rate)
111+
info = StreamInfo("NPG", "EXG", 3, 500, "int16", "npg1234")
112+
outlet = StreamOutlet(info)
113+
114+
client = None
98115
try:
99-
choice = int(input("Enter the index of the device to connect: "))
100-
target = filtered[choice]
101-
except (ValueError, IndexError):
102-
print("Invalid selection.")
103-
return
104-
105-
print("Connecting to:", target.name, target.address)
106-
async with BleakClient(target) as client:
116+
client = BleakClient(device_address)
117+
await client.connect()
118+
107119
if not client.is_connected:
108-
print("Failed to connect")
109-
return
110-
print("Connected to", target.name)
120+
print("Failed to connect", file=sys.stderr)
121+
return False
122+
123+
print(f"Connected to {device_address}")
124+
125+
# Send start command
111126
await client.write_gatt_char(CONTROL_CHAR_UUID, b"START", response=True)
112127
print("Sent START command")
128+
129+
# Subscribe to notifications
113130
await client.start_notify(DATA_CHAR_UUID, notification_handler)
114131
print("Subscribed to data notifications")
115-
asyncio.create_task(print_rate())
116-
while True:
132+
133+
# Keep connection alive
134+
while client.is_connected:
117135
await asyncio.sleep(1)
136+
137+
return True
138+
139+
except Exception as e:
140+
print(f"Connection error: {str(e)}", file=sys.stderr)
141+
return False
142+
finally:
143+
if client and client.is_connected:
144+
await client.disconnect()
118145

119146
if __name__ == "__main__":
120-
asyncio.run(run())
147+
args = parse_args()
148+
149+
if args.scan:
150+
asyncio.run(scan_devices())
151+
elif args.connect:
152+
asyncio.run(connect_to_device(args.connect))
153+
else:
154+
print("Please specify --scan or --connect", file=sys.stderr)
155+
sys.exit(1)

0 commit comments

Comments
 (0)