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.
@@ -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
+ ![Icon](sparkserial/assets/icon.png)
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
+ ![Icon](sparkserial/assets/icon.png)
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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
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
+ ![Icon](sparkserial/assets/icon.png)
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,2 @@
1
+ [console_scripts]
2
+ sparkserial = sparkserial.main:main
@@ -0,0 +1,3 @@
1
+ pyobjc-framework-cocoa>=12.1
2
+ pyqt6>=6.10.2
3
+ pyserial>=3.5
@@ -0,0 +1 @@
1
+ sparkserial