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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user