barebone app with django and react, sse, jwt token, comport reader, test comport writer, requires com0com, users with groups, sample table vehicles, tokens for access and refresh

This commit is contained in:
2026-01-17 13:03:21 +02:00
commit 7f04566242
81 changed files with 22551 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# COM Port Settings
COM_PORT=COM3
BAUD_RATE=9600
TIMEOUT=1
READ_INTERVAL=0.5
# Backend Server Settings
BACKEND_URL=http://localhost:8000
API_ENDPOINT=/api/readings/
REQUEST_TIMEOUT=5
# Application Settings
DEBUG=False
LOG_LEVEL=INFO
# System Tray Settings
SHOW_WINDOW_ON_START=False
AUTO_CONNECT=True
# Retry Settings
MAX_RETRIES=3
RETRY_DELAY=5
+71
View File
@@ -0,0 +1,71 @@
# Serial Bridge - COM Port Reader
This is the serial port reading application that runs as a system tray service.
## Features
- ✅ Reads data from COM ports
- ✅ Runs in system tray (no console window)
- ✅ Automatically posts data to Django backend
- ✅ Retry logic for failed posts
- ✅ Backend health checks
- ✅ Configurable via environment variables
- ✅ Can be packaged as a Windows .exe
## Setup
1. **Install Python dependencies:**
```bash
pip install -r requirements.txt
```
2. **Configure environment:**
```bash
copy .env.example .env
# Edit .env with your settings
```
3. **Run the application:**
```bash
python app.py
```
## Building as EXE
To create a standalone Windows executable:
```bash
pip install pyinstaller
pyinstaller serial_bridge.spec
```
The executable will be created in the `dist\ScalesApp\` folder.
## Configuration
Edit `.env` to configure:
- `COM_PORT`: COM port to read from (default: COM1)
- `BAUD_RATE`: Serial port baud rate (default: 9600)
- `BACKEND_URL`: Django backend URL (default: http://localhost:8000)
- `AUTO_CONNECT`: Automatically connect on startup (default: True)
- `DEBUG`: Enable debug logging (default: False)
## Architecture
```
COM Port → Serial Reader → Backend Client → Django API
System Tray Icon
```
## Logging
Logs are written to `serial_bridge.log` and console output.
## Troubleshooting
- **COM port not found**: Check `COM_PORT` setting and device connection
- **Cannot connect to backend**: Verify Django server is running on `BACKEND_URL`
- **Data not posting**: Check logs in `serial_bridge.log`
- **Tray icon not appearing**: Run as administrator, check Windows settings
+231
View File
@@ -0,0 +1,231 @@
"""
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()
+117
View File
@@ -0,0 +1,117 @@
"""
Backend API Client
Handles communication with Django backend
"""
import requests
import logging
from typing import Optional, Dict, Any
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
logger = logging.getLogger(__name__)
class BackendClient:
def __init__(self, base_url: str, timeout: int = 5, max_retries: int = 3):
self.base_url = base_url
self.timeout = timeout
self.max_retries = max_retries
self.session = self._create_session()
def _create_session(self) -> requests.Session:
"""Create a requests session with retry logic"""
session = requests.Session()
retry_strategy = Retry(
total=self.max_retries,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["HEAD", "GET", "OPTIONS", "POST"]
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount("http://", adapter)
session.mount("https://", adapter)
return session
def post_reading(self, port: str, data: str) -> bool:
"""Post a serial port reading to the backend"""
try:
url = f"{self.base_url}/api/readings/"
payload = {
'port': port,
'data': data
}
response = self.session.post(
url,
json=payload,
timeout=self.timeout
)
if response.status_code in [200, 201]:
logger.info(f"Successfully posted reading to backend")
return True
else:
logger.warning(f"Backend returned status {response.status_code}")
return False
except requests.exceptions.RequestException as e:
logger.error(f"Error posting reading to backend: {e}")
return False
def get_latest_reading(self, port: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Get the latest reading from backend"""
try:
url = f"{self.base_url}/api/readings/latest/"
params = {'port': port} if port else {}
response = self.session.get(
url,
params=params,
timeout=self.timeout
)
if response.status_code == 200:
return response.json()
else:
logger.warning(f"Backend returned status {response.status_code}")
return None
except requests.exceptions.RequestException as e:
logger.error(f"Error getting latest reading: {e}")
return None
def get_readings(self, port: Optional[str] = None, limit: int = 10) -> Optional[list]:
"""Get readings from backend"""
try:
url = f"{self.base_url}/api/readings/"
params = {'limit': limit}
if port:
params['port'] = port
response = self.session.get(
url,
params=params,
timeout=self.timeout
)
if response.status_code == 200:
return response.json()
else:
logger.warning(f"Backend returned status {response.status_code}")
return None
except requests.exceptions.RequestException as e:
logger.error(f"Error getting readings: {e}")
return None
def health_check(self) -> bool:
"""Check if backend is available"""
try:
url = f"{self.base_url}/api/health/"
response = self.session.get(url, timeout=self.timeout)
return response.status_code == 200
except Exception as e:
logger.error(f"Backend health check failed: {e}")
return False
+31
View File
@@ -0,0 +1,31 @@
"""
Configuration for Serial Bridge
"""
import os
from dotenv import load_dotenv
load_dotenv()
# COM Port Settings
COM_PORT = os.getenv('COM_PORT', 'COM1')
BAUD_RATE = int(os.getenv('BAUD_RATE', 9600))
TIMEOUT = int(os.getenv('TIMEOUT', 1))
READ_INTERVAL = float(os.getenv('READ_INTERVAL', 0.5)) # seconds
# Backend Server Settings
BACKEND_URL = os.getenv('BACKEND_URL', 'http://localhost:8000')
API_ENDPOINT = os.getenv('API_ENDPOINT', '/api/readings/')
REQUEST_TIMEOUT = int(os.getenv('REQUEST_TIMEOUT', 5))
# Application Settings
APP_NAME = 'ScalesApp Serial Bridge'
DEBUG = os.getenv('DEBUG', 'False') == 'True'
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
# System Tray Settings
SHOW_WINDOW_ON_START = os.getenv('SHOW_WINDOW_ON_START', 'False') == 'True'
AUTO_CONNECT = os.getenv('AUTO_CONNECT', 'True') == 'True'
# Retry Settings
MAX_RETRIES = int(os.getenv('MAX_RETRIES', 3))
RETRY_DELAY = int(os.getenv('RETRY_DELAY', 5)) # seconds
+9
View File
@@ -0,0 +1,9 @@
Pillow==12.1.0
pyserial==3.5
python-dotenv==1.0.0
flask==3.1.0
requests==2.32.5
urllib3==2.6.3
pystray==0.19.5
psutil==7.2.1
PyInstaller==6.17.0
+57
View File
@@ -0,0 +1,57 @@
"""
PyInstaller spec file for creating a Windows executable
Run: pyinstaller serial_bridge.spec
"""
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(
['app.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=['serial', 'requests', 'pystray', 'PIL'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludedimports=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='ScalesApp',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False, # No console window
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None, # You can add an icon path here
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='ScalesApp',
)
+110
View File
@@ -0,0 +1,110 @@
"""
Serial Port Reader
Handles reading data from COM ports
"""
import serial
import threading
import logging
from typing import Callable, Optional
from datetime import datetime
logger = logging.getLogger(__name__)
class SerialPortReader:
def __init__(self, port: str, baudrate: int = 9600, timeout: int = 1):
self.port = port
self.baudrate = baudrate
self.timeout = timeout
self.serial_conn: Optional[serial.Serial] = None
self.is_connected = False
self.reader_thread: Optional[threading.Thread] = None
self.is_running = False
self.data_callback: Optional[Callable] = None
def connect(self) -> bool:
"""Connect to the serial port"""
try:
self.serial_conn = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=self.timeout
)
self.is_connected = True
logger.info(f"Connected to {self.port} at {self.baudrate} baud")
return True
except serial.SerialException as e:
logger.error(f"Failed to connect to {self.port}: {e}")
self.is_connected = False
return False
def disconnect(self):
"""Disconnect from the serial port"""
self.is_running = False
if self.reader_thread:
self.reader_thread.join(timeout=2)
if self.serial_conn and self.serial_conn.is_open:
self.serial_conn.close()
self.is_connected = False
logger.info(f"Disconnected from {self.port}")
def set_data_callback(self, callback: Callable):
"""Set callback function for received data"""
self.data_callback = callback
def start_reading(self):
"""Start reading from serial port in a background thread"""
if not self.is_connected:
if not self.connect():
return
self.is_running = True
self.reader_thread = threading.Thread(target=self._read_loop, daemon=True)
self.reader_thread.start()
logger.info("Serial reader started")
def stop_reading(self):
"""Stop reading from serial port"""
self.is_running = False
if self.reader_thread:
self.reader_thread.join(timeout=2)
logger.info("Serial reader stopped")
def _read_loop(self):
"""Main reading loop"""
while self.is_running and self.is_connected:
try:
if self.serial_conn and self.serial_conn.in_waiting > 0:
data = self.serial_conn.readline().decode('utf-8', errors='ignore').strip()
if data and self.data_callback:
self.data_callback({
'port': self.port,
'data': data,
'timestamp': datetime.now().isoformat()
})
except Exception as e:
logger.error(f"Error reading from {self.port}: {e}")
self.is_connected = False
break
def write_data(self, data: str) -> bool:
"""Write data to the serial port"""
try:
if self.is_connected and self.serial_conn:
if isinstance(data, str):
data = data.encode('utf-8')
self.serial_conn.write(data)
return True
except Exception as e:
logger.error(f"Error writing to {self.port}: {e}")
return False
def get_status(self) -> dict:
"""Get current status"""
return {
'port': self.port,
'connected': self.is_connected,
'running': self.is_running,
'baudrate': self.baudrate
}
+82
View File
@@ -0,0 +1,82 @@
"""
System Tray Icon Handler
Manages the application in the system tray
"""
import pystray
import logging
from typing import Callable, Optional
from PIL import Image, ImageDraw
logger = logging.getLogger(__name__)
class SystemTrayIcon:
def __init__(self, app_name: str):
self.app_name = app_name
self.icon: Optional[pystray.Icon] = None
self.status_text = "Initializing..."
self.on_quit: Optional[Callable] = None
self.on_show: Optional[Callable] = None
def _create_image(self, status: str = "ready") -> Image.Image:
"""Create a simple tray icon image"""
size = 64
color_map = {
'ready': (0, 200, 0),
'running': (0, 150, 200),
'error': (200, 0, 0),
'waiting': (200, 150, 0),
}
color = color_map.get(status, (128, 128, 128))
image = Image.new('RGB', (size, size), color)
draw = ImageDraw.Draw(image)
draw.rectangle([0, 0, size-1, size-1], outline=(0, 0, 0), width=2)
return image
def set_status(self, status: str, text: str):
"""Update the tray icon status"""
self.status_text = text
if self.icon:
self.icon.update_menu()
def create_menu(self) -> pystray.Menu:
"""Create the context menu for the tray icon"""
menu_items = [
pystray.MenuItem(f"📊 {self.app_name}", action=None),
pystray.MenuItem("" * 30, action=None),
pystray.MenuItem("Status: " + self.status_text, action=None),
pystray.MenuItem("" * 30, action=None),
pystray.MenuItem("Quit", self._on_quit_click),
]
return pystray.Menu(*menu_items)
def _on_quit_click(self, icon, item):
"""Handle quit click"""
if self.on_quit:
self.on_quit()
if self.icon:
self.icon.stop()
def run(self):
"""Run the tray icon"""
try:
self.icon = pystray.Icon(
name=self.app_name,
icon=self._create_image('running'),
menu=self.create_menu(),
title=self.app_name
)
self.icon.run()
except Exception as e:
logger.error(f"Error running tray icon: {e}")
def stop(self):
"""Stop the tray icon"""
if self.icon:
self.icon.stop()
def set_callbacks(self, on_quit: Optional[Callable] = None, on_show: Optional[Callable] = None):
"""Set callback functions"""
self.on_quit = on_quit
self.on_show = on_show