You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

232 lines
6.4 KiB
Python

"""
Serial Bridge - SSE Server
Reads from COM port and streams data to React frontend via Server-Sent Events
No backend storage - direct streaming only
"""
import os
import logging
import serial
import serial.tools.list_ports
from dotenv import load_dotenv
from threading import Thread
from datetime import datetime
from flask import Flask, Response
from flask_cors import CORS, cross_origin
from queue import Queue
import json
import time
# Load environment variables
load_dotenv()
# Configure logging
logging.basicConfig(
level=os.getenv('LOG_LEVEL', 'INFO'),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('serial_bridge.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
CORS(app) # Enable CORS for all routes
# Global state
data_queue = Queue()
connected_clients = []
is_running = False
reader_thread = None
class SerialReader:
def __init__(self, port, baudrate, timeout):
self.port = port
self.baudrate = baudrate
self.timeout = timeout
self.ser = None
self.is_connected = False
def connect(self):
try:
self.ser = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=self.timeout
)
self.is_connected = True
logger.info(f"[OK] Connected to {self.port} at {self.baudrate} baud")
return True
except Exception as e:
logger.error(f"[ERROR] Failed to connect to {self.port}: {e}")
self.is_connected = False
return False
def read_data(self):
if not self.is_connected:
return None
try:
if self.ser.in_waiting:
line = self.ser.readline().decode('utf-8', errors='ignore').strip()
if line:
return {
'port': self.port,
'data': line,
'timestamp': datetime.now().isoformat(),
'received_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
except Exception as e:
logger.error(f"Error reading from {self.port}: {e}")
return None
def disconnect(self):
if self.ser:
self.ser.close()
self.is_connected = False
logger.info(f"[OK] Disconnected from {self.port}")
def read_serial_data():
"""Continuously read from serial port and queue data"""
global is_running
reader = SerialReader(
port=os.getenv('COM_PORT', 'COM3'),
baudrate=int(os.getenv('BAUD_RATE', '9600')),
timeout=float(os.getenv('TIMEOUT', '1'))
)
if not reader.connect():
logger.error("Failed to connect to COM port")
return
read_interval = float(os.getenv('READ_INTERVAL', '0.5'))
while is_running:
try:
data = reader.read_data()
if data:
data_queue.put(data)
logger.info(f"[RX] {data['port']}: {data['data']}")
time.sleep(read_interval)
except KeyboardInterrupt:
logger.info("Serial reader interrupted")
break
except Exception as e:
logger.error(f"Error in serial reader: {e}")
reader.disconnect()
@app.route('/events')
@cross_origin()
def events():
"""SSE endpoint - streams serial data to connected clients"""
def generate():
client_id = id(object())
connected_clients.append(client_id)
logger.info(f"[CONNECT] Client connected. Total: {len(connected_clients)}")
try:
# Send connection message
yield f"data: {json.dumps({'status': 'connected', 'message': 'Connected to serial bridge'})}\n\n"
# Stream data from queue
while is_running:
try:
# Get data with timeout to allow graceful shutdown
data = data_queue.get(timeout=1)
yield f"data: {json.dumps(data)}\n\n"
logger.debug(f"[TX] Sent: {data['data']}")
except:
# Queue timeout - send heartbeat
yield f": heartbeat\n\n"
except GeneratorExit:
# Client disconnected
pass
finally:
if client_id in connected_clients:
connected_clients.remove(client_id)
logger.info(f"[DISCONNECT] Client disconnected. Total: {len(connected_clients)}")
return Response(
generate(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
'Connection': 'keep-alive'
}
)
@app.route('/status')
def status():
"""Get current status"""
return {
'status': 'running' if is_running else 'stopped',
'connected_clients': len(connected_clients),
'com_port': os.getenv('COM_PORT', 'COM3'),
'baud_rate': int(os.getenv('BAUD_RATE', '9600'))
}
@app.route('/ports')
def list_ports():
"""List available COM ports"""
ports = []
for port, desc, hwid in serial.tools.list_ports.comports():
ports.append({
'port': port,
'description': desc,
'hwid': hwid
})
return {'ports': ports}
def start_app():
"""Start the application"""
global is_running, reader_thread
logger.info("=" * 60)
logger.info("ScalesApp Serial Bridge - SSE Streaming")
logger.info("=" * 60)
logger.info(f"COM Port: {os.getenv('COM_PORT', 'COM3')}")
logger.info(f"Baud Rate: {os.getenv('BAUD_RATE', '9600')}")
logger.info(f"SSE Endpoint: http://127.0.0.1:5000/events")
logger.info("=" * 60)
is_running = True
# Start serial reader thread
reader_thread = Thread(target=read_serial_data, daemon=True)
reader_thread.start()
logger.info("[OK] Serial reader thread started\n")
# Start Flask app
debug = os.getenv('DEBUG', 'False').lower() == 'true'
app.run(host='127.0.0.1', port=5000, debug=debug, use_reloader=False)
def stop_app():
"""Stop the application"""
global is_running
is_running = False
logger.info("\n[OK] Application stopped")
if __name__ == '__main__':
try:
start_app()
except KeyboardInterrupt:
stop_app()