sparkserial 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sparkserial-0.1.0/LICENSE +21 -0
- sparkserial-0.1.0/PKG-INFO +57 -0
- sparkserial-0.1.0/README.md +43 -0
- sparkserial-0.1.0/pyproject.toml +28 -0
- sparkserial-0.1.0/setup.cfg +4 -0
- sparkserial-0.1.0/sparkserial/__init__.py +0 -0
- sparkserial-0.1.0/sparkserial/assets/icon.png +0 -0
- sparkserial-0.1.0/sparkserial/core/__init__.py +0 -0
- sparkserial-0.1.0/sparkserial/core/command_manager.py +58 -0
- sparkserial-0.1.0/sparkserial/core/serial_manager.py +101 -0
- sparkserial-0.1.0/sparkserial/gui/__init__.py +0 -0
- sparkserial-0.1.0/sparkserial/gui/main_window.py +504 -0
- sparkserial-0.1.0/sparkserial/gui/styles.py +178 -0
- sparkserial-0.1.0/sparkserial/main.py +46 -0
- sparkserial-0.1.0/sparkserial.egg-info/PKG-INFO +57 -0
- sparkserial-0.1.0/sparkserial.egg-info/SOURCES.txt +18 -0
- sparkserial-0.1.0/sparkserial.egg-info/dependency_links.txt +1 -0
- sparkserial-0.1.0/sparkserial.egg-info/entry_points.txt +2 -0
- sparkserial-0.1.0/sparkserial.egg-info/requires.txt +3 -0
- sparkserial-0.1.0/sparkserial.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Spark Electronics
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sparkserial
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A premium Serial Utility Tool for professional hardware testing.
|
|
5
|
+
Author-email: Spark Electronics <info@sparkelectronics.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: pyobjc-framework-cocoa>=12.1
|
|
11
|
+
Requires-Dist: pyqt6>=6.10.2
|
|
12
|
+
Requires-Dist: pyserial>=3.5
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# SparkSerial Pro
|
|
16
|
+
|
|
17
|
+
A premium serial communication and hardware testing tool built with Python and PyQt6. Inspired by professional tools like Docklight, **SparkSerial** provides a modern, high-performance interface for engineers and developers.
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- **Professional Configuration**: Comprehensive port settings (Baudrate up to 921600, Data Bits, Parity, Stop Bits, Flow Control).
|
|
24
|
+
- **Command Shortcuts**: Save frequently used commands (Text or Hex) for quick access and batch testing.
|
|
25
|
+
- **Advanced Terminal**: Real-time logging with timestamps, Hex view, and autoscroll.
|
|
26
|
+
- **Persistence**: Automatically saves your command library and connection preferences.
|
|
27
|
+
- **Modern UI**: Dark-mode aesthetic with optimized layout for hardware debugging.
|
|
28
|
+
- **Cross-Platform**: Designed for macOS, Windows, and Linux.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
### From PyPI (Coming Soon)
|
|
33
|
+
```bash
|
|
34
|
+
pip install sparkserial
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Local Development
|
|
38
|
+
Ensure you have [uv](https://github.com/astral-sh/uv) installed:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
./setup.sh
|
|
42
|
+
./run.sh
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
After installing via pip, you can launch the tool directly:
|
|
48
|
+
```bash
|
|
49
|
+
sparkserial
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Technologies Used
|
|
53
|
+
|
|
54
|
+
- **UI Framework**: PyQt6
|
|
55
|
+
- **Serial Communication**: PySerial
|
|
56
|
+
- **Backend Architecture**: Worker-thread pattern for non-blocking I/O.
|
|
57
|
+
- **Styling**: Custom CSS for premium dark-mode aesthetics.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# SparkSerial Pro
|
|
2
|
+
|
|
3
|
+
A premium serial communication and hardware testing tool built with Python and PyQt6. Inspired by professional tools like Docklight, **SparkSerial** provides a modern, high-performance interface for engineers and developers.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Professional Configuration**: Comprehensive port settings (Baudrate up to 921600, Data Bits, Parity, Stop Bits, Flow Control).
|
|
10
|
+
- **Command Shortcuts**: Save frequently used commands (Text or Hex) for quick access and batch testing.
|
|
11
|
+
- **Advanced Terminal**: Real-time logging with timestamps, Hex view, and autoscroll.
|
|
12
|
+
- **Persistence**: Automatically saves your command library and connection preferences.
|
|
13
|
+
- **Modern UI**: Dark-mode aesthetic with optimized layout for hardware debugging.
|
|
14
|
+
- **Cross-Platform**: Designed for macOS, Windows, and Linux.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
### From PyPI (Coming Soon)
|
|
19
|
+
```bash
|
|
20
|
+
pip install sparkserial
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Local Development
|
|
24
|
+
Ensure you have [uv](https://github.com/astral-sh/uv) installed:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
./setup.sh
|
|
28
|
+
./run.sh
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
After installing via pip, you can launch the tool directly:
|
|
34
|
+
```bash
|
|
35
|
+
sparkserial
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Technologies Used
|
|
39
|
+
|
|
40
|
+
- **UI Framework**: PyQt6
|
|
41
|
+
- **Serial Communication**: PySerial
|
|
42
|
+
- **Backend Architecture**: Worker-thread pattern for non-blocking I/O.
|
|
43
|
+
- **Styling**: Custom CSS for premium dark-mode aesthetics.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sparkserial"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A premium Serial Utility Tool for professional hardware testing."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Spark Electronics", email = "info@sparkelectronics.com" }
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pyobjc-framework-cocoa>=12.1",
|
|
12
|
+
"pyqt6>=6.10.2",
|
|
13
|
+
"pyserial>=3.5",
|
|
14
|
+
]
|
|
15
|
+
license = { text = "MIT" }
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
sparkserial = "sparkserial.main:main"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools]
|
|
21
|
+
packages = ["sparkserial", "sparkserial.core", "sparkserial.gui"]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.package-data]
|
|
24
|
+
sparkserial = ["assets/*.png"]
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["setuptools>=61.0"]
|
|
28
|
+
build-backend = "setuptools.build_meta"
|
|
File without changes
|
|
Binary file
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
class CommandManager:
|
|
5
|
+
def __init__(self, filename="saved_commands.json"):
|
|
6
|
+
self.filename = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), filename)
|
|
7
|
+
self.commands = []
|
|
8
|
+
self.load_commands()
|
|
9
|
+
|
|
10
|
+
def load_commands(self):
|
|
11
|
+
if os.path.exists(self.filename):
|
|
12
|
+
try:
|
|
13
|
+
with open(self.filename, 'r') as f:
|
|
14
|
+
self.commands = json.load(f)
|
|
15
|
+
except Exception as e:
|
|
16
|
+
print(f"Error loading commands: {e}")
|
|
17
|
+
self.commands = []
|
|
18
|
+
|
|
19
|
+
if not self.commands:
|
|
20
|
+
# Default sample commands
|
|
21
|
+
self.commands = [
|
|
22
|
+
{"name": "Check Connection", "command": "AT", "is_hex": False},
|
|
23
|
+
{"name": "Get Device Info", "command": "ATI", "is_hex": False},
|
|
24
|
+
{"name": "Reset Device", "command": "ATZ", "is_hex": False}
|
|
25
|
+
]
|
|
26
|
+
self.save_commands()
|
|
27
|
+
|
|
28
|
+
def save_commands(self):
|
|
29
|
+
try:
|
|
30
|
+
with open(self.filename, 'w') as f:
|
|
31
|
+
json.dump(self.commands, f, indent=4)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
print(f"Error saving commands: {e}")
|
|
34
|
+
|
|
35
|
+
def add_command(self, name, command, is_hex=False):
|
|
36
|
+
self.commands.append({
|
|
37
|
+
"name": name,
|
|
38
|
+
"command": command,
|
|
39
|
+
"is_hex": is_hex
|
|
40
|
+
})
|
|
41
|
+
self.save_commands()
|
|
42
|
+
|
|
43
|
+
def update_command(self, index, name, command, is_hex=False):
|
|
44
|
+
if 0 <= index < len(self.commands):
|
|
45
|
+
self.commands[index] = {
|
|
46
|
+
"name": name,
|
|
47
|
+
"command": command,
|
|
48
|
+
"is_hex": is_hex
|
|
49
|
+
}
|
|
50
|
+
self.save_commands()
|
|
51
|
+
|
|
52
|
+
def delete_command(self, index):
|
|
53
|
+
if 0 <= index < len(self.commands):
|
|
54
|
+
self.commands.pop(index)
|
|
55
|
+
self.save_commands()
|
|
56
|
+
|
|
57
|
+
def get_commands(self):
|
|
58
|
+
return self.commands
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import serial
|
|
2
|
+
import serial.tools.list_ports
|
|
3
|
+
from PyQt6.QtCore import QThread, pyqtSignal, QObject
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
class SerialWorker(QObject):
|
|
7
|
+
data_received = pyqtSignal(bytes)
|
|
8
|
+
error_occurred = pyqtSignal(str)
|
|
9
|
+
connection_status = pyqtSignal(bool)
|
|
10
|
+
|
|
11
|
+
def __init__(self, port_name, baudrate, bytesize, parity, stopbits, flowcontrol):
|
|
12
|
+
super().__init__()
|
|
13
|
+
self.port_name = port_name
|
|
14
|
+
self.baudrate = baudrate
|
|
15
|
+
self.bytesize = bytesize
|
|
16
|
+
self.parity = parity
|
|
17
|
+
self.stopbits = stopbits
|
|
18
|
+
self.flowcontrol = flowcontrol
|
|
19
|
+
self.serial_port = None
|
|
20
|
+
self.running = False
|
|
21
|
+
|
|
22
|
+
def run(self):
|
|
23
|
+
try:
|
|
24
|
+
self.serial_port = serial.Serial(
|
|
25
|
+
port=self.port_name,
|
|
26
|
+
baudrate=self.baudrate,
|
|
27
|
+
bytesize=self.bytesize,
|
|
28
|
+
parity=self.parity,
|
|
29
|
+
stopbits=self.stopbits,
|
|
30
|
+
xonxoff=False,
|
|
31
|
+
rtscts=self.flowcontrol == "Hardware (RTS/CTS)",
|
|
32
|
+
dsrdtr=False,
|
|
33
|
+
timeout=0.1
|
|
34
|
+
)
|
|
35
|
+
self.running = True
|
|
36
|
+
self.connection_status.emit(True)
|
|
37
|
+
|
|
38
|
+
while self.running:
|
|
39
|
+
if self.serial_port.in_waiting > 0:
|
|
40
|
+
data = self.serial_port.read(self.serial_port.in_waiting)
|
|
41
|
+
if data:
|
|
42
|
+
self.data_received.emit(data)
|
|
43
|
+
time.sleep(0.01)
|
|
44
|
+
|
|
45
|
+
except Exception as e:
|
|
46
|
+
self.error_occurred.emit(str(e))
|
|
47
|
+
finally:
|
|
48
|
+
if self.serial_port and self.serial_port.is_open:
|
|
49
|
+
self.serial_port.close()
|
|
50
|
+
self.connection_status.emit(False)
|
|
51
|
+
|
|
52
|
+
def stop(self):
|
|
53
|
+
self.running = False
|
|
54
|
+
|
|
55
|
+
def send_data(self, data):
|
|
56
|
+
if self.serial_port and self.serial_port.is_open:
|
|
57
|
+
try:
|
|
58
|
+
self.serial_port.write(data)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
self.error_occurred.emit(str(e))
|
|
61
|
+
|
|
62
|
+
class SerialManager(QObject):
|
|
63
|
+
def __init__(self):
|
|
64
|
+
super().__init__()
|
|
65
|
+
self.worker = None
|
|
66
|
+
self.thread = None
|
|
67
|
+
|
|
68
|
+
def get_available_ports(self):
|
|
69
|
+
return [port.device for port in serial.tools.list_ports.comports()]
|
|
70
|
+
|
|
71
|
+
def connect(self, settings):
|
|
72
|
+
if self.thread and self.thread.isRunning():
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
self.thread = QThread()
|
|
76
|
+
self.worker = SerialWorker(
|
|
77
|
+
settings['port'],
|
|
78
|
+
settings['baudrate'],
|
|
79
|
+
settings['bytesize'],
|
|
80
|
+
settings['parity'],
|
|
81
|
+
settings['stopbits'],
|
|
82
|
+
settings['flowcontrol']
|
|
83
|
+
)
|
|
84
|
+
self.worker.moveToThread(self.thread)
|
|
85
|
+
|
|
86
|
+
self.thread.started.connect(self.worker.run)
|
|
87
|
+
self.worker.connection_status.connect(self._handle_status)
|
|
88
|
+
|
|
89
|
+
self.thread.start()
|
|
90
|
+
return self.worker
|
|
91
|
+
|
|
92
|
+
def disconnect(self):
|
|
93
|
+
if self.worker:
|
|
94
|
+
self.worker.stop()
|
|
95
|
+
if self.thread:
|
|
96
|
+
self.thread.quit()
|
|
97
|
+
self.thread.wait()
|
|
98
|
+
|
|
99
|
+
def _handle_status(self, status):
|
|
100
|
+
if not status:
|
|
101
|
+
self.disconnect()
|
|
File without changes
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from PyQt6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
3
|
+
QGridLayout, QGroupBox, QLabel, QComboBox,
|
|
4
|
+
QPushButton, QPlainTextEdit, QLineEdit, QStatusBar,
|
|
5
|
+
QCheckBox, QSplitter, QListWidget, QListWidgetItem,
|
|
6
|
+
QDialog, QFormLayout, QDialogButtonBox, QMessageBox)
|
|
7
|
+
from PyQt6.QtCore import Qt, pyqtSlot
|
|
8
|
+
from PyQt6.QtGui import QIcon, QTextCursor
|
|
9
|
+
import serial
|
|
10
|
+
from sparkserial.core.serial_manager import SerialManager
|
|
11
|
+
from sparkserial.core.command_manager import CommandManager
|
|
12
|
+
from sparkserial.gui.styles import get_stylesheet
|
|
13
|
+
import os
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
class CommandDialog(QDialog):
|
|
17
|
+
def __init__(self, parent=None, command_info=None):
|
|
18
|
+
super().__init__(parent)
|
|
19
|
+
self.setWindowTitle("Add Command" if not command_info else "Edit Command")
|
|
20
|
+
self.setMinimumWidth(400)
|
|
21
|
+
|
|
22
|
+
layout = QVBoxLayout(self)
|
|
23
|
+
form_layout = QFormLayout()
|
|
24
|
+
|
|
25
|
+
self.name_input = QLineEdit()
|
|
26
|
+
self.name_input.setPlaceholderText("e.g., Get Version")
|
|
27
|
+
if command_info:
|
|
28
|
+
self.name_input.setText(command_info.get('name', ''))
|
|
29
|
+
|
|
30
|
+
self.command_input = QLineEdit()
|
|
31
|
+
self.command_input.setPlaceholderText("e.g., AT+VER")
|
|
32
|
+
if command_info:
|
|
33
|
+
self.command_input.setText(command_info.get('command', ''))
|
|
34
|
+
|
|
35
|
+
self.hex_check = QCheckBox("Send as Hex")
|
|
36
|
+
if command_info:
|
|
37
|
+
self.hex_check.setChecked(command_info.get('is_hex', False))
|
|
38
|
+
|
|
39
|
+
form_layout.addRow("Name:", self.name_input)
|
|
40
|
+
form_layout.addRow("Command:", self.command_input)
|
|
41
|
+
form_layout.addRow("", self.hex_check)
|
|
42
|
+
|
|
43
|
+
layout.addLayout(form_layout)
|
|
44
|
+
|
|
45
|
+
buttons = QDialogButtonBox(
|
|
46
|
+
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
|
47
|
+
)
|
|
48
|
+
buttons.accepted.connect(self.accept)
|
|
49
|
+
buttons.rejected.connect(self.reject)
|
|
50
|
+
layout.addWidget(buttons)
|
|
51
|
+
|
|
52
|
+
def get_data(self):
|
|
53
|
+
return {
|
|
54
|
+
"name": self.name_input.text().strip(),
|
|
55
|
+
"command": self.command_input.text().strip(),
|
|
56
|
+
"is_hex": self.hex_check.isChecked()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class MainWindow(QMainWindow):
|
|
60
|
+
def __init__(self):
|
|
61
|
+
super().__init__()
|
|
62
|
+
self.serial_manager = SerialManager()
|
|
63
|
+
self.command_manager = CommandManager()
|
|
64
|
+
self.current_worker = None
|
|
65
|
+
|
|
66
|
+
self.init_ui()
|
|
67
|
+
self.apply_styles()
|
|
68
|
+
self.refresh_ports()
|
|
69
|
+
|
|
70
|
+
def init_ui(self):
|
|
71
|
+
self.setWindowTitle("SparkSerial Pro")
|
|
72
|
+
self.setMinimumSize(900, 600)
|
|
73
|
+
|
|
74
|
+
# Set icon
|
|
75
|
+
icon_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "assets", "icon.png")
|
|
76
|
+
if os.path.exists(icon_path):
|
|
77
|
+
self.setWindowIcon(QIcon(icon_path))
|
|
78
|
+
|
|
79
|
+
main_widget = QWidget()
|
|
80
|
+
self.setCentralWidget(main_widget)
|
|
81
|
+
main_layout = QHBoxLayout(main_widget)
|
|
82
|
+
|
|
83
|
+
# Left Panel Tool Widget
|
|
84
|
+
left_panel = QWidget()
|
|
85
|
+
left_layout = QVBoxLayout(left_panel)
|
|
86
|
+
left_panel.setFixedWidth(400) # Increased slightly more for complex port names
|
|
87
|
+
|
|
88
|
+
# Connection Settings (Compact)
|
|
89
|
+
config_group = QGroupBox("Configuration")
|
|
90
|
+
config_grid = QGridLayout()
|
|
91
|
+
config_grid.setSpacing(10) # Increased spacing for clarity
|
|
92
|
+
config_grid.setContentsMargins(10, 15, 10, 10)
|
|
93
|
+
config_grid.addWidget(QLabel("Port:"), 0, 0)
|
|
94
|
+
port_row = QHBoxLayout()
|
|
95
|
+
self.port_combo = QComboBox()
|
|
96
|
+
# Remove rigid fixed heights, use standard sizing
|
|
97
|
+
port_row.addWidget(self.port_combo, 1)
|
|
98
|
+
refresh_btn = QPushButton("↻")
|
|
99
|
+
refresh_btn.setToolTip("Refresh Ports")
|
|
100
|
+
refresh_btn.setFixedWidth(40)
|
|
101
|
+
refresh_btn.clicked.connect(self.refresh_ports)
|
|
102
|
+
port_row.addWidget(refresh_btn)
|
|
103
|
+
config_grid.addLayout(port_row, 0, 1, 1, 3)
|
|
104
|
+
|
|
105
|
+
# Baud & Data Bits
|
|
106
|
+
config_grid.addWidget(QLabel("Baud:"), 1, 0)
|
|
107
|
+
self.baud_combo = QComboBox()
|
|
108
|
+
self.baud_combo.addItems(["1200", "2400", "4800", "9600", "19200", "38400", "57600", "115200", "230400", "460800", "921600"])
|
|
109
|
+
self.baud_combo.setCurrentText("9600")
|
|
110
|
+
config_grid.addWidget(self.baud_combo, 1, 1)
|
|
111
|
+
|
|
112
|
+
config_grid.addWidget(QLabel("Data:"), 1, 2)
|
|
113
|
+
self.data_combo = QComboBox()
|
|
114
|
+
self.data_combo.addItems(["5", "6", "7", "8"])
|
|
115
|
+
self.data_combo.setCurrentText("8")
|
|
116
|
+
config_grid.addWidget(self.data_combo, 1, 3)
|
|
117
|
+
|
|
118
|
+
# Parity & Stop Bits
|
|
119
|
+
config_grid.addWidget(QLabel("Parity:"), 2, 0)
|
|
120
|
+
self.parity_combo = QComboBox()
|
|
121
|
+
self.parity_combo.addItems(["None", "Even", "Odd", "Mark", "Space"])
|
|
122
|
+
config_grid.addWidget(self.parity_combo, 2, 1)
|
|
123
|
+
|
|
124
|
+
config_grid.addWidget(QLabel("Stop:"), 2, 2)
|
|
125
|
+
self.stop_combo = QComboBox()
|
|
126
|
+
self.stop_combo.addItems(["1", "1.5", "2"])
|
|
127
|
+
config_grid.addWidget(self.stop_combo, 2, 3)
|
|
128
|
+
|
|
129
|
+
# Flow Control
|
|
130
|
+
config_grid.addWidget(QLabel("Flow:"), 3, 0)
|
|
131
|
+
self.flow_combo = QComboBox()
|
|
132
|
+
self.flow_combo.addItems(["None", "RTS/CTS", "XON/XOFF"])
|
|
133
|
+
config_grid.addWidget(self.flow_combo, 3, 1, 1, 3)
|
|
134
|
+
|
|
135
|
+
config_group.setLayout(config_grid)
|
|
136
|
+
left_layout.addWidget(config_group)
|
|
137
|
+
|
|
138
|
+
# Connect/Disconnect Button
|
|
139
|
+
self.connect_btn = QPushButton("Connect Device")
|
|
140
|
+
self.connect_btn.setObjectName("connectButton")
|
|
141
|
+
self.connect_btn.setMinimumHeight(40) # Make it prominent
|
|
142
|
+
self.connect_btn.clicked.connect(self.toggle_connection)
|
|
143
|
+
left_layout.addWidget(self.connect_btn)
|
|
144
|
+
|
|
145
|
+
# Saved Commands (Now on left)
|
|
146
|
+
saved_group = QGroupBox("Command Shortcuts")
|
|
147
|
+
saved_group_layout = QVBoxLayout()
|
|
148
|
+
saved_group_layout.setContentsMargins(10, 15, 10, 10)
|
|
149
|
+
|
|
150
|
+
self.commands_list = QListWidget()
|
|
151
|
+
self.commands_list.itemDoubleClicked.connect(self.load_saved_command)
|
|
152
|
+
saved_group_layout.addWidget(self.commands_list)
|
|
153
|
+
|
|
154
|
+
cmd_btns = QHBoxLayout()
|
|
155
|
+
add_cmd_btn = QPushButton("Add")
|
|
156
|
+
add_cmd_btn.clicked.connect(self.add_command_dialog)
|
|
157
|
+
edit_cmd_btn = QPushButton("Edit")
|
|
158
|
+
edit_cmd_btn.clicked.connect(self.edit_command_dialog)
|
|
159
|
+
del_cmd_btn = QPushButton("Delete")
|
|
160
|
+
del_cmd_btn.clicked.connect(self.delete_command)
|
|
161
|
+
|
|
162
|
+
# Consistent industry styling
|
|
163
|
+
for btn in [add_cmd_btn, edit_cmd_btn, del_cmd_btn]:
|
|
164
|
+
btn.setMinimumHeight(32)
|
|
165
|
+
|
|
166
|
+
cmd_btns.addWidget(add_cmd_btn)
|
|
167
|
+
cmd_btns.addWidget(edit_cmd_btn)
|
|
168
|
+
cmd_btns.addWidget(del_cmd_btn)
|
|
169
|
+
saved_group_layout.addLayout(cmd_btns)
|
|
170
|
+
|
|
171
|
+
saved_group.setLayout(saved_group_layout)
|
|
172
|
+
left_layout.addWidget(saved_group)
|
|
173
|
+
|
|
174
|
+
# Right Panel: Terminal and Tools
|
|
175
|
+
right_panel = QWidget()
|
|
176
|
+
right_layout = QVBoxLayout(right_panel)
|
|
177
|
+
|
|
178
|
+
# Terminal Settings
|
|
179
|
+
term_settings = QHBoxLayout()
|
|
180
|
+
self.autoscroll_check = QCheckBox("Autoscroll")
|
|
181
|
+
self.autoscroll_check.setChecked(True)
|
|
182
|
+
self.hex_view_check = QCheckBox("Hex View")
|
|
183
|
+
self.timestamp_check = QCheckBox("Timestamps")
|
|
184
|
+
self.logging_check = QCheckBox("Log to File")
|
|
185
|
+
self.logging_check.toggled.connect(self.toggle_logging)
|
|
186
|
+
|
|
187
|
+
clear_btn = QPushButton("Clear")
|
|
188
|
+
clear_btn.clicked.connect(self.clear_terminal)
|
|
189
|
+
|
|
190
|
+
term_settings.addWidget(self.autoscroll_check)
|
|
191
|
+
term_settings.addWidget(self.hex_view_check)
|
|
192
|
+
term_settings.addWidget(self.timestamp_check)
|
|
193
|
+
term_settings.addWidget(self.logging_check)
|
|
194
|
+
term_settings.addStretch()
|
|
195
|
+
term_settings.addWidget(clear_btn)
|
|
196
|
+
|
|
197
|
+
right_layout.addLayout(term_settings)
|
|
198
|
+
|
|
199
|
+
# Terminal Output
|
|
200
|
+
self.terminal = QPlainTextEdit()
|
|
201
|
+
self.terminal.setReadOnly(True)
|
|
202
|
+
right_layout.addWidget(self.terminal)
|
|
203
|
+
|
|
204
|
+
# Input Area
|
|
205
|
+
input_group = QGroupBox("Send Command")
|
|
206
|
+
input_layout = QVBoxLayout()
|
|
207
|
+
|
|
208
|
+
send_row = QHBoxLayout()
|
|
209
|
+
self.command_input = QLineEdit()
|
|
210
|
+
self.command_input.setPlaceholderText("Enter command...")
|
|
211
|
+
self.command_input.returnPressed.connect(self.send_command)
|
|
212
|
+
|
|
213
|
+
self.line_ending_combo = QComboBox()
|
|
214
|
+
self.line_ending_combo.addItems(["None", "CR", "LF", "CR+LF"])
|
|
215
|
+
self.line_ending_combo.setCurrentText("CR+LF")
|
|
216
|
+
|
|
217
|
+
send_btn = QPushButton("Send")
|
|
218
|
+
send_btn.clicked.connect(self.send_command)
|
|
219
|
+
|
|
220
|
+
send_row.addWidget(self.command_input)
|
|
221
|
+
send_row.addWidget(self.line_ending_combo)
|
|
222
|
+
send_row.addWidget(send_btn)
|
|
223
|
+
|
|
224
|
+
input_layout.addLayout(send_row)
|
|
225
|
+
|
|
226
|
+
options_row = QHBoxLayout()
|
|
227
|
+
self.send_hex_check = QCheckBox("Send as Hex")
|
|
228
|
+
self.log_sent_check = QCheckBox("Log Sent")
|
|
229
|
+
self.log_sent_check.setChecked(True)
|
|
230
|
+
options_row.addWidget(self.send_hex_check)
|
|
231
|
+
options_row.addWidget(self.log_sent_check)
|
|
232
|
+
options_row.addStretch()
|
|
233
|
+
|
|
234
|
+
input_layout.addLayout(options_row)
|
|
235
|
+
input_group.setLayout(input_layout)
|
|
236
|
+
right_layout.addWidget(input_group)
|
|
237
|
+
|
|
238
|
+
# Add panels to main layout
|
|
239
|
+
splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
240
|
+
splitter.addWidget(left_panel)
|
|
241
|
+
splitter.addWidget(right_panel)
|
|
242
|
+
splitter.setStretchFactor(1, 1) # Terminal panel gets more space
|
|
243
|
+
main_layout.addWidget(splitter)
|
|
244
|
+
|
|
245
|
+
self.refresh_commands_list()
|
|
246
|
+
|
|
247
|
+
# Status Bar
|
|
248
|
+
self.status_bar = QStatusBar()
|
|
249
|
+
self.setStatusBar(self.status_bar)
|
|
250
|
+
self.status_bar.showMessage("Disconnected")
|
|
251
|
+
|
|
252
|
+
# Logging State
|
|
253
|
+
self.log_file = None
|
|
254
|
+
self.log_path = None
|
|
255
|
+
|
|
256
|
+
def apply_styles(self):
|
|
257
|
+
self.setStyleSheet(get_stylesheet())
|
|
258
|
+
|
|
259
|
+
def refresh_ports(self):
|
|
260
|
+
current_port = self.port_combo.currentText()
|
|
261
|
+
self.port_combo.clear()
|
|
262
|
+
ports = self.serial_manager.get_available_ports()
|
|
263
|
+
self.port_combo.addItems(ports)
|
|
264
|
+
if current_port in ports:
|
|
265
|
+
self.port_combo.setCurrentText(current_port)
|
|
266
|
+
|
|
267
|
+
def toggle_connection(self):
|
|
268
|
+
if self.current_worker:
|
|
269
|
+
self.disconnect_serial()
|
|
270
|
+
else:
|
|
271
|
+
self.connect_serial()
|
|
272
|
+
|
|
273
|
+
def toggle_logging(self, enabled):
|
|
274
|
+
if enabled:
|
|
275
|
+
from PyQt6.QtWidgets import QFileDialog
|
|
276
|
+
from datetime import datetime
|
|
277
|
+
|
|
278
|
+
default_name = f"serial_log_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
|
|
279
|
+
initial_path = os.path.join(os.getcwd(), "logs", default_name)
|
|
280
|
+
|
|
281
|
+
# Ensure logs directory exists as a default suggestion
|
|
282
|
+
if not os.path.exists("logs"):
|
|
283
|
+
os.makedirs("logs")
|
|
284
|
+
|
|
285
|
+
file_path, _ = QFileDialog.getSaveFileName(
|
|
286
|
+
self,
|
|
287
|
+
"Select Log File Location",
|
|
288
|
+
initial_path,
|
|
289
|
+
"Text Files (*.txt);;All Files (*)"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if file_path:
|
|
293
|
+
try:
|
|
294
|
+
self.log_path = file_path
|
|
295
|
+
self.log_file = open(self.log_path, "a", encoding="utf-8")
|
|
296
|
+
self.status_bar.showMessage(f"Logging to: {os.path.basename(self.log_path)}")
|
|
297
|
+
except Exception as e:
|
|
298
|
+
self.status_bar.showMessage(f"Logging Error: {str(e)}")
|
|
299
|
+
self.logging_check.setChecked(False)
|
|
300
|
+
else:
|
|
301
|
+
# User cancelled the dialog
|
|
302
|
+
self.logging_check.setChecked(False)
|
|
303
|
+
else:
|
|
304
|
+
if self.log_file:
|
|
305
|
+
self.log_file.close()
|
|
306
|
+
self.log_file = None
|
|
307
|
+
self.status_bar.showMessage("Logging stopped")
|
|
308
|
+
|
|
309
|
+
def connect_serial(self):
|
|
310
|
+
port = self.port_combo.currentText()
|
|
311
|
+
if not port:
|
|
312
|
+
self.status_bar.showMessage("Error: No port selected")
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
settings = {
|
|
316
|
+
'port': port,
|
|
317
|
+
'baudrate': int(self.baud_combo.currentText()),
|
|
318
|
+
'bytesize': int(self.data_combo.currentText()),
|
|
319
|
+
'parity': getattr(serial, f"PARITY_{self.parity_combo.currentText().upper()}"),
|
|
320
|
+
'stopbits': {
|
|
321
|
+
"1": serial.STOPBITS_ONE,
|
|
322
|
+
"1.5": serial.STOPBITS_ONE_POINT_FIVE,
|
|
323
|
+
"2": serial.STOPBITS_TWO
|
|
324
|
+
}[self.stop_combo.currentText()],
|
|
325
|
+
'flowcontrol': self.flow_combo.currentText()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
self.current_worker = self.serial_manager.connect(settings)
|
|
329
|
+
if self.current_worker:
|
|
330
|
+
self.current_worker.data_received.connect(self.handle_data)
|
|
331
|
+
self.current_worker.error_occurred.connect(self.handle_error)
|
|
332
|
+
self.current_worker.connection_status.connect(self.update_connection_ui)
|
|
333
|
+
|
|
334
|
+
self.connect_btn.setText("Disconnect")
|
|
335
|
+
self.connect_btn.setObjectName("disconnectButton")
|
|
336
|
+
self.apply_styles()
|
|
337
|
+
self.status_bar.showMessage(f"Connected to {port}")
|
|
338
|
+
|
|
339
|
+
def disconnect_serial(self):
|
|
340
|
+
self.serial_manager.disconnect()
|
|
341
|
+
self.current_worker = None
|
|
342
|
+
self.connect_btn.setText("Connect")
|
|
343
|
+
self.connect_btn.setObjectName("connectButton")
|
|
344
|
+
self.apply_styles()
|
|
345
|
+
self.status_bar.showMessage("Disconnected")
|
|
346
|
+
|
|
347
|
+
@pyqtSlot(bool)
|
|
348
|
+
def update_connection_ui(self, connected):
|
|
349
|
+
if not connected:
|
|
350
|
+
self.disconnect_serial()
|
|
351
|
+
|
|
352
|
+
@pyqtSlot(bytes)
|
|
353
|
+
def handle_data(self, data):
|
|
354
|
+
from datetime import datetime
|
|
355
|
+
now = datetime.now()
|
|
356
|
+
|
|
357
|
+
if self.hex_view_check.isChecked():
|
|
358
|
+
text = " ".join([f"{b:02X}" for b in data]) + " "
|
|
359
|
+
else:
|
|
360
|
+
try:
|
|
361
|
+
text = data.decode('utf-8', errors='replace')
|
|
362
|
+
except:
|
|
363
|
+
text = str(data)
|
|
364
|
+
|
|
365
|
+
timestamp_str = now.strftime("[%H:%M:%S.%f] ")[:-4] + "] "
|
|
366
|
+
|
|
367
|
+
# Display logic
|
|
368
|
+
display_text = text
|
|
369
|
+
if self.timestamp_check.isChecked():
|
|
370
|
+
if not self.terminal.toPlainText() or self.terminal.toPlainText().endswith('\n'):
|
|
371
|
+
display_text = timestamp_str + display_text
|
|
372
|
+
display_text = display_text.replace('\n', f'\n{timestamp_str}')
|
|
373
|
+
|
|
374
|
+
self.terminal.moveCursor(QTextCursor.MoveOperation.End)
|
|
375
|
+
self.terminal.insertPlainText(display_text)
|
|
376
|
+
|
|
377
|
+
if self.autoscroll_check.isChecked():
|
|
378
|
+
self.terminal.verticalScrollBar().setValue(self.terminal.verticalScrollBar().maximum())
|
|
379
|
+
|
|
380
|
+
# Logging logic
|
|
381
|
+
if self.log_file:
|
|
382
|
+
log_entry = text
|
|
383
|
+
if self.timestamp_check.isChecked() or True: # Always timestamp logs for better utility
|
|
384
|
+
if not hasattr(self, '_log_at_newline') or self._log_at_newline:
|
|
385
|
+
log_entry = f"RX {timestamp_str}{log_entry}"
|
|
386
|
+
log_entry = log_entry.replace('\n', f'\nRX {timestamp_str}')
|
|
387
|
+
self._log_at_newline = text.endswith('\n')
|
|
388
|
+
|
|
389
|
+
self.log_file.write(log_entry)
|
|
390
|
+
self.log_file.flush()
|
|
391
|
+
|
|
392
|
+
@pyqtSlot(str)
|
|
393
|
+
def handle_error(self, message):
|
|
394
|
+
self.status_bar.showMessage(f"Error: {message}")
|
|
395
|
+
self.disconnect_serial()
|
|
396
|
+
|
|
397
|
+
def send_command(self):
|
|
398
|
+
if not self.current_worker:
|
|
399
|
+
self.status_bar.showMessage("Error: Not connected")
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
command = self.command_input.text()
|
|
403
|
+
if not command:
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
if self.send_hex_check.isChecked():
|
|
408
|
+
data = bytes.fromhex(command.replace(" ", ""))
|
|
409
|
+
else:
|
|
410
|
+
data = command.encode('utf-8')
|
|
411
|
+
|
|
412
|
+
# Add line ending
|
|
413
|
+
ending = self.line_ending_combo.currentText()
|
|
414
|
+
line_end = b''
|
|
415
|
+
if ending == "CR":
|
|
416
|
+
line_end = b'\r'
|
|
417
|
+
elif ending == "LF":
|
|
418
|
+
line_end = b'\n'
|
|
419
|
+
elif ending == "CR+LF":
|
|
420
|
+
line_end = b'\r\n'
|
|
421
|
+
|
|
422
|
+
full_data = data + line_end
|
|
423
|
+
|
|
424
|
+
self.current_worker.send_data(full_data)
|
|
425
|
+
|
|
426
|
+
# Log sent command
|
|
427
|
+
if self.log_file and self.log_sent_check.isChecked():
|
|
428
|
+
from datetime import datetime
|
|
429
|
+
ts = datetime.now().strftime("[%H:%M:%S.%f] ")[:-4] + "] "
|
|
430
|
+
sent_text = command
|
|
431
|
+
if self.send_hex_check.isChecked():
|
|
432
|
+
sent_text = f"HEX({command})"
|
|
433
|
+
self.log_file.write(f"TX {ts}{sent_text}\n")
|
|
434
|
+
self.log_file.flush()
|
|
435
|
+
self._log_at_newline = True
|
|
436
|
+
|
|
437
|
+
self.command_input.clear()
|
|
438
|
+
except Exception as e:
|
|
439
|
+
self.status_bar.showMessage(f"Send Error: {str(e)}")
|
|
440
|
+
|
|
441
|
+
def clear_terminal(self):
|
|
442
|
+
self.terminal.clear()
|
|
443
|
+
|
|
444
|
+
# Saved Commands Methods
|
|
445
|
+
def refresh_commands_list(self):
|
|
446
|
+
self.commands_list.clear()
|
|
447
|
+
for cmd in self.command_manager.get_commands():
|
|
448
|
+
display_text = f"{cmd['name']} : {cmd['command']}"
|
|
449
|
+
item = QListWidgetItem(display_text)
|
|
450
|
+
tooltip = f"Command: {cmd['command']}\nType: {'Hex' if cmd['is_hex'] else 'Text'}"
|
|
451
|
+
item.setToolTip(tooltip)
|
|
452
|
+
self.commands_list.addItem(item)
|
|
453
|
+
|
|
454
|
+
def add_command_dialog(self):
|
|
455
|
+
dialog = CommandDialog(self)
|
|
456
|
+
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
457
|
+
data = dialog.get_data()
|
|
458
|
+
if data['name'] and data['command']:
|
|
459
|
+
self.command_manager.add_command(data['name'], data['command'], data['is_hex'])
|
|
460
|
+
self.refresh_commands_list()
|
|
461
|
+
else:
|
|
462
|
+
QMessageBox.warning(self, "Invalid Input", "Name and Command cannot be empty.")
|
|
463
|
+
|
|
464
|
+
def edit_command_dialog(self):
|
|
465
|
+
index = self.commands_list.currentRow()
|
|
466
|
+
if index < 0:
|
|
467
|
+
QMessageBox.information(self, "Selection Required", "Please select a command to edit.")
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
cmd_info = self.command_manager.get_commands()[index]
|
|
471
|
+
dialog = CommandDialog(self, cmd_info)
|
|
472
|
+
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
473
|
+
data = dialog.get_data()
|
|
474
|
+
if data['name'] and data['command']:
|
|
475
|
+
self.command_manager.update_command(index, data['name'], data['command'], data['is_hex'])
|
|
476
|
+
self.refresh_commands_list()
|
|
477
|
+
|
|
478
|
+
def delete_command(self):
|
|
479
|
+
index = self.commands_list.currentRow()
|
|
480
|
+
if index < 0:
|
|
481
|
+
return
|
|
482
|
+
|
|
483
|
+
confirm = QMessageBox.question(
|
|
484
|
+
self, "Confirm Delete",
|
|
485
|
+
f"Are you sure you want to delete '{self.command_manager.get_commands()[index]['name']}'?",
|
|
486
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
if confirm == QMessageBox.StandardButton.Yes:
|
|
490
|
+
self.command_manager.delete_command(index)
|
|
491
|
+
self.refresh_commands_list()
|
|
492
|
+
|
|
493
|
+
def load_saved_command(self, item):
|
|
494
|
+
index = self.commands_list.row(item)
|
|
495
|
+
cmd_info = self.command_manager.get_commands()[index]
|
|
496
|
+
self.command_input.setText(cmd_info['command'])
|
|
497
|
+
self.send_hex_check.setChecked(cmd_info['is_hex'])
|
|
498
|
+
|
|
499
|
+
if __name__ == "__main__":
|
|
500
|
+
from PyQt6.QtWidgets import QApplication
|
|
501
|
+
app = QApplication(sys.argv)
|
|
502
|
+
window = MainWindow()
|
|
503
|
+
window.show()
|
|
504
|
+
sys.exit(app.exec())
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
def get_stylesheet():
|
|
2
|
+
return """
|
|
3
|
+
QMainWindow {
|
|
4
|
+
background-color: #1e1e2e;
|
|
5
|
+
color: #cdd6f4;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
QWidget {
|
|
9
|
+
font-family: 'Segoe UI', 'Inter', sans-serif;
|
|
10
|
+
font-size: 13px;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
QGroupBox {
|
|
14
|
+
border: 1px solid #45475a;
|
|
15
|
+
border-radius: 8px;
|
|
16
|
+
margin-top: 20px;
|
|
17
|
+
padding-top: 20px;
|
|
18
|
+
font-weight: bold;
|
|
19
|
+
color: #89b4fa;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
QGroupBox::title {
|
|
23
|
+
subcontrol-origin: margin;
|
|
24
|
+
left: 10px;
|
|
25
|
+
padding: 0 5px;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
QLabel {
|
|
29
|
+
color: #bac2de;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
QComboBox, QLineEdit, QPlainTextEdit {
|
|
33
|
+
background-color: #313244;
|
|
34
|
+
border: 1px solid #45475a;
|
|
35
|
+
border-radius: 4px;
|
|
36
|
+
padding: 4px;
|
|
37
|
+
color: #cdd6f4;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
QComboBox {
|
|
41
|
+
padding-left: 10px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
QComboBox::drop-down {
|
|
45
|
+
subcontrol-origin: padding;
|
|
46
|
+
subcontrol-position: top right;
|
|
47
|
+
width: 25px;
|
|
48
|
+
border-left: 1px solid #45475a;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
QComboBox::down-arrow {
|
|
52
|
+
image: none;
|
|
53
|
+
border-left: 4px solid transparent;
|
|
54
|
+
border-right: 4px solid transparent;
|
|
55
|
+
border-top: 4px solid #89b4fa;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
QComboBox QAbstractItemView {
|
|
59
|
+
background-color: #313244;
|
|
60
|
+
color: #cdd6f4;
|
|
61
|
+
selection-background-color: #45475a;
|
|
62
|
+
selection-color: #89b4fa;
|
|
63
|
+
border: 1px solid #45475a;
|
|
64
|
+
outline: none;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
QComboBox QAbstractItemView::item {
|
|
68
|
+
min-height: 30px;
|
|
69
|
+
padding-left: 10px;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
QComboBox:hover, QLineEdit:hover {
|
|
73
|
+
border: 1px solid #89b4fa;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
QPushButton {
|
|
77
|
+
background-color: #45475a;
|
|
78
|
+
border: none;
|
|
79
|
+
border-radius: 4px;
|
|
80
|
+
padding: 8px 16px;
|
|
81
|
+
color: #cdd6f4;
|
|
82
|
+
font-weight: bold;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
QPushButton:hover {
|
|
86
|
+
background-color: #585b70;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
QPushButton#connectButton {
|
|
90
|
+
background-color: #a6e3a1;
|
|
91
|
+
color: #11111b;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
QPushButton#connectButton:hover {
|
|
95
|
+
background-color: #94e2d5;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
QPushButton#disconnectButton {
|
|
99
|
+
background-color: #f38ba8;
|
|
100
|
+
color: #11111b;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
QPushButton#disconnectButton:hover {
|
|
104
|
+
background-color: #eba0ac;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
QPlainTextEdit {
|
|
108
|
+
font-family: 'Consolas', 'Courier New', monospace;
|
|
109
|
+
font-size: 12px;
|
|
110
|
+
selection-background-color: #89b4fa;
|
|
111
|
+
selection-color: #1e1e2e;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
QStatusBar {
|
|
115
|
+
background-color: #181825;
|
|
116
|
+
color: #bac2de;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
QScrollBar:vertical {
|
|
120
|
+
border: none;
|
|
121
|
+
background: #1e1e2e;
|
|
122
|
+
width: 10px;
|
|
123
|
+
margin: 0px;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
QScrollBar::handle:vertical:hover {
|
|
127
|
+
background: #585b70;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
QListWidget {
|
|
131
|
+
background-color: #313244;
|
|
132
|
+
border: 1px solid #45475a;
|
|
133
|
+
border-radius: 4px;
|
|
134
|
+
color: #cdd6f4;
|
|
135
|
+
padding: 5px;
|
|
136
|
+
outline: none;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
QListWidget::item {
|
|
140
|
+
padding: 8px;
|
|
141
|
+
border-bottom: 1px solid #45475a;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
QListWidget::item:selected {
|
|
145
|
+
background-color: #45475a;
|
|
146
|
+
color: #89b4fa;
|
|
147
|
+
border-radius: 4px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
QListWidget::item:hover {
|
|
151
|
+
background-color: #3b3d52;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
QDialog {
|
|
155
|
+
background-color: #1e1e2e;
|
|
156
|
+
color: #cdd6f4;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
QTabWidget::pane {
|
|
160
|
+
border: 1px solid #45475a;
|
|
161
|
+
top: -1px;
|
|
162
|
+
background: #1e1e2e;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
QTabBar::tab {
|
|
166
|
+
background: #313244;
|
|
167
|
+
color: #cdd6f4;
|
|
168
|
+
padding: 8px 12px;
|
|
169
|
+
border-top-left-radius: 4px;
|
|
170
|
+
border-top-right-radius: 4px;
|
|
171
|
+
margin-right: 2px;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
QTabBar::tab:selected {
|
|
175
|
+
background: #1e1e2e;
|
|
176
|
+
border-bottom: 2px solid #89b4fa;
|
|
177
|
+
}
|
|
178
|
+
"""
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
from PyQt6.QtWidgets import QApplication
|
|
4
|
+
from sparkserial.gui.main_window import MainWindow
|
|
5
|
+
|
|
6
|
+
def set_macos_app_details():
|
|
7
|
+
if sys.platform == "darwin":
|
|
8
|
+
try:
|
|
9
|
+
# Set App Name in Dock/Menu
|
|
10
|
+
from Cocoa import NSBundle
|
|
11
|
+
bundle = NSBundle.mainBundle()
|
|
12
|
+
if bundle:
|
|
13
|
+
info = bundle.localizedInfoDictionary() or bundle.infoDictionary()
|
|
14
|
+
if info:
|
|
15
|
+
info['CFBundleName'] = "SparkSerial Pro"
|
|
16
|
+
info['CFBundleDisplayName'] = "SparkSerial Pro"
|
|
17
|
+
|
|
18
|
+
# Set Dock Icon
|
|
19
|
+
from AppKit import NSApp, NSImage
|
|
20
|
+
icon_path = os.path.join(os.path.dirname(__file__), "assets", "icon.png")
|
|
21
|
+
if os.path.exists(icon_path):
|
|
22
|
+
image = NSImage.alloc().initWithContentsOfFile_(icon_path)
|
|
23
|
+
if image:
|
|
24
|
+
NSApp.setApplicationIconImage_(image)
|
|
25
|
+
except Exception as e:
|
|
26
|
+
print(f"macOS specific setup failed: {e}")
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
# Set the application ID for better process management
|
|
30
|
+
# No-op on macOS for name but helps overall
|
|
31
|
+
import ctypes
|
|
32
|
+
if sys.platform == "win32":
|
|
33
|
+
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("antigravity.sparkserial.1.0")
|
|
34
|
+
|
|
35
|
+
app = QApplication(sys.argv)
|
|
36
|
+
app.setApplicationName("SparkSerial Pro")
|
|
37
|
+
|
|
38
|
+
# Needs to be called after QApplication init for NSApp to exist
|
|
39
|
+
set_macos_app_details()
|
|
40
|
+
|
|
41
|
+
window = MainWindow()
|
|
42
|
+
window.show()
|
|
43
|
+
sys.exit(app.exec())
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
main()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sparkserial
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A premium Serial Utility Tool for professional hardware testing.
|
|
5
|
+
Author-email: Spark Electronics <info@sparkelectronics.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: pyobjc-framework-cocoa>=12.1
|
|
11
|
+
Requires-Dist: pyqt6>=6.10.2
|
|
12
|
+
Requires-Dist: pyserial>=3.5
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# SparkSerial Pro
|
|
16
|
+
|
|
17
|
+
A premium serial communication and hardware testing tool built with Python and PyQt6. Inspired by professional tools like Docklight, **SparkSerial** provides a modern, high-performance interface for engineers and developers.
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- **Professional Configuration**: Comprehensive port settings (Baudrate up to 921600, Data Bits, Parity, Stop Bits, Flow Control).
|
|
24
|
+
- **Command Shortcuts**: Save frequently used commands (Text or Hex) for quick access and batch testing.
|
|
25
|
+
- **Advanced Terminal**: Real-time logging with timestamps, Hex view, and autoscroll.
|
|
26
|
+
- **Persistence**: Automatically saves your command library and connection preferences.
|
|
27
|
+
- **Modern UI**: Dark-mode aesthetic with optimized layout for hardware debugging.
|
|
28
|
+
- **Cross-Platform**: Designed for macOS, Windows, and Linux.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
### From PyPI (Coming Soon)
|
|
33
|
+
```bash
|
|
34
|
+
pip install sparkserial
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Local Development
|
|
38
|
+
Ensure you have [uv](https://github.com/astral-sh/uv) installed:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
./setup.sh
|
|
42
|
+
./run.sh
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
After installing via pip, you can launch the tool directly:
|
|
48
|
+
```bash
|
|
49
|
+
sparkserial
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Technologies Used
|
|
53
|
+
|
|
54
|
+
- **UI Framework**: PyQt6
|
|
55
|
+
- **Serial Communication**: PySerial
|
|
56
|
+
- **Backend Architecture**: Worker-thread pattern for non-blocking I/O.
|
|
57
|
+
- **Styling**: Custom CSS for premium dark-mode aesthetics.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
sparkserial/__init__.py
|
|
5
|
+
sparkserial/main.py
|
|
6
|
+
sparkserial.egg-info/PKG-INFO
|
|
7
|
+
sparkserial.egg-info/SOURCES.txt
|
|
8
|
+
sparkserial.egg-info/dependency_links.txt
|
|
9
|
+
sparkserial.egg-info/entry_points.txt
|
|
10
|
+
sparkserial.egg-info/requires.txt
|
|
11
|
+
sparkserial.egg-info/top_level.txt
|
|
12
|
+
sparkserial/assets/icon.png
|
|
13
|
+
sparkserial/core/__init__.py
|
|
14
|
+
sparkserial/core/command_manager.py
|
|
15
|
+
sparkserial/core/serial_manager.py
|
|
16
|
+
sparkserial/gui/__init__.py
|
|
17
|
+
sparkserial/gui/main_window.py
|
|
18
|
+
sparkserial/gui/styles.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sparkserial
|