diff --git a/CHANGELOG.md b/CHANGELOG.md index 39486dd..d14151f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ * [Changelog](#changelog) * [v0.1.0 (2025-07-28)](#v010--2025-07-28-) * [v0.1.1 (2025-08-04)](#v011--2025-08-04-) - * [v0.1.2 (WIP)](#v012--wip-) + * [v0.2.0 (WIP)](#v020--wip-) @@ -31,8 +31,11 @@ --- -## [v0.1.2 (WIP)]() +## [v0.2.2 (WIP)](https://github.com/scalpelspace/pyblasher/releases/tag/v0.2.2) - Pre-release beta test release. +- **Additions:** + - Add new UART Terminal page for developer/debug usage. + - Uses swaping Kivy screen separate from the firmware flash screen. - **Modifications:** - Update and cleanup docs structure. diff --git a/README.md b/README.md index 64aa7ee..da8dceb 100644 --- a/README.md +++ b/README.md @@ -132,10 +132,18 @@ The badge markdown would be as follows: #### 3.2.1 PyInstaller single file executable +Windows: + ```shell pyinstaller --name PyBlasher --onefile --windowed --icon=assets/icon.ico --hidden-import=win32timezone main.py ``` +macOS: + +```shell +pyinstaller --name PyBlasher --onedir --windowed --icon assets/icon.icns main.py +``` + > Hidden imports ensures explicit inclusion of required dependencies. #### 3.2.2 Inno Setup diff --git a/assets/icon.icns b/assets/icon.icns new file mode 100644 index 0000000..868722f Binary files /dev/null and b/assets/icon.icns differ diff --git a/constants.py b/constants.py index 952c708..8c16ba5 100644 --- a/constants.py +++ b/constants.py @@ -1,7 +1,7 @@ """PyBlasher constants.""" # PyBlasher version. -VERSION = "0.1.1" +VERSION = "0.2.0" # CLI version assumed width. CLI_WIDTH = 80 diff --git a/gui.py b/gui.py index 89b687b..9192e2a 100644 --- a/gui.py +++ b/gui.py @@ -13,13 +13,20 @@ from kivy.uix.filechooser import FileChooserListView from kivy.uix.label import Label from kivy.uix.popup import Popup +from kivy.uix.screenmanager import ScreenManager, Screen from kivy.uix.spinner import Spinner from kivy.uix.textinput import TextInput from kivy.uix.widget import Widget from constants import VERSION from flash_firmware import flash_image -from util import resource_path, find_cp2102n_ports +from util import ( + resource_path, + find_cp2102n_ports, + open_serial_port, + write_serial_bytes, + parse_hex, +) MSG_NO_PORTS_FOUND = "No ports found" @@ -233,14 +240,311 @@ def execute_flash(self): popup.open() +class TerminalUI(BoxLayout): + """Minimal UART terminal for sending/receiving arbitrary messages.""" + + def __init__(self, **kwargs): + super().__init__( + orientation="vertical", spacing=10, padding=10, **kwargs + ) + + # Top row: port + connect + refresh + top = BoxLayout( + orientation="horizontal", size_hint=(1, 0.15), spacing=10 + ) + + self.port_spinner = Spinner( + text="Click to select a port", + size_hint=(0.55, 1), + font_size=sp(16), + background_normal="", + background_color=(0.1, 0.1, 0.4, 1), + ) + top.add_widget(self.port_spinner) + + self.connect_btn = Button( + text="Connect", + size_hint=(0.2, 1), + font_size=sp(16), + background_normal="", + background_color=(0.15, 0.5, 0.15, 1), + on_press=self.toggle_connect, + ) + top.add_widget(self.connect_btn) + + top.add_widget( + Button( + text="Refresh Ports", + size_hint=(0.25, 1), + font_size=sp(16), + background_normal="", + background_color=(0.35, 0.35, 0.35, 1), + on_press=lambda *_: self.refresh_ports(), + ) + ) + + self.add_widget(top) + + # Log (read-only) + self.log_box = TextInput( + text="", + readonly=True, + multiline=True, + size_hint=(1, 0.65), + font_size=sp(14), + ) + self.add_widget(self.log_box) + + # Send row + send_row = BoxLayout( + orientation="horizontal", size_hint=(1, 0.2), spacing=10 + ) + + self.tx_input = TextInput( + hint_text="Type ASCII (or HEX if enabled) ...", + multiline=False, + size_hint=(0.55, 1), + font_size=sp(16), + ) + # Bind enter key. + self.tx_input.bind(on_text_validate=lambda *_: self.send_line()) + send_row.add_widget(self.tx_input) + + self.eol_mode = Spinner( + text="CRLF", + values=["None", "LF", "CRLF"], + size_hint=(0.15, 1), + font_size=sp(16), + ) + send_row.add_widget(self.eol_mode) + + self.hex_mode = Spinner( + text="ASCII", + values=["ASCII", "HEX"], + size_hint=(0.15, 1), + font_size=sp(16), + ) + send_row.add_widget(self.hex_mode) + + def _update_eol_enabled(*_): + self.eol_mode.disabled = self.hex_mode.text == "HEX" + + self.hex_mode.bind(text=_update_eol_enabled) + _update_eol_enabled() + + send_row.add_widget( + Button( + text="Send", + size_hint=(0.15, 1), + font_size=sp(16), + background_normal="", + background_color=(0.8, 0.5, 0.1, 1), + on_press=lambda *_: self.send_line(), + ) + ) + + self.add_widget(send_row) + + self._ser = None + self._rx_thread = None + self._rx_buf = bytearray() + self._running = False + + self.refresh_ports() + + def refresh_ports(self): + found_ports = find_cp2102n_ports() + if found_ports: + self.port_spinner.values = found_ports + self.port_spinner.text = found_ports[0] + else: + self.port_spinner.values = [] + self.port_spinner.text = MSG_NO_PORTS_FOUND + self._append( + f"Ports refreshed: {', '.join(found_ports) if found_ports else MSG_NO_PORTS_FOUND}" + ) + + def _append(self, msg: str): + self.log_box.text += msg + "\n" + # scroll to end + try: + row = max(0, len(self.log_box.text.splitlines()) - 1) + self.log_box.cursor = (0, row) + self.log_box.scroll_y = 0 + except Exception: + pass + + def toggle_connect(self, *_): + if self._ser: + self._running = False + try: + self._ser.close() + except Exception: + pass + self._ser = None + self.connect_btn.text = "Connect" + self._append("Disconnected.") + return + + port = self.port_spinner.text + if not port or port == MSG_NO_PORTS_FOUND: + self._append("No valid port selected.") + return + + try: + self._ser = open_serial_port(port, baud=115200) + except Exception as e: + self._ser = None + self._append(f"Connect failed: {e}") + return + + self.connect_btn.text = "Disconnect" + self._append(f"Connected to {port} @ 115200.") + + self._running = True + self._rx_thread = Thread(target=self._rx_loop, daemon=True) + self._rx_thread.start() + + def _rx_loop(self): + while self._running and self._ser: + try: + n = self._ser.in_waiting + data = self._ser.read(n if n else 1) + if not data: + continue + + self._rx_buf.extend(data) + + # Emit complete lines (keeps messages together). + while b"\n" in self._rx_buf: + line, _, rest = self._rx_buf.partition(b"\n") + self._rx_buf = bytearray(rest) + + # Include the '\n' you consumed (optional). + line_bytes = line + b"\n" + + text = line_bytes.decode("utf-8", errors="replace").rstrip( + "\r\n" + ) + hex_part = line_bytes.hex(" ").upper() + + Clock.schedule_once( + lambda *_, t=text, h=hex_part: self._append( + f"RX: {t} [{h}]" + ) + ) + + except Exception as e: + Clock.schedule_once( + lambda *_, err=e: self._append(f"RX error: {err}") + ) + break + + def send_line(self): + def _restore_input_focus(): + self.tx_input.focus = True + + if not self._ser: + self._append("Not connected.") + return + raw = self.tx_input.text + if not raw: + return + try: + if self.hex_mode.text == "HEX": + payload = parse_hex(raw) + else: + cooked = raw.encode("utf-8").decode("unicode_escape") + # Append newline based on dropdown (ASCII mode only). + eol = self.eol_mode.text + if eol == "LF": + if not cooked.endswith("\n"): + cooked += "\n" + elif eol == "CRLF": + if not cooked.endswith("\n"): + cooked += "\r\n" + # "None" -> do nothing. + payload = cooked.encode("utf-8") + write_serial_bytes(self._ser, payload) + # Restore focus. + Clock.schedule_once(lambda *_: _restore_input_focus()) + self._append(f"TX: {raw}") + except Exception as e: + self._append(f"TX error: {e}") + + +class RootUI(BoxLayout): + """Page-swap UI: Firmware flasher + UART terminal.""" + + def __init__(self, **kwargs): + super().__init__( + orientation="vertical", spacing=10, padding=10, **kwargs + ) + + # Nav bar + nav = BoxLayout( + orientation="horizontal", size_hint=(1, 0.12), spacing=10 + ) + self.btn_flash = Button(text="Firmware", font_size=sp(16)) + self.btn_term = Button(text="UART Terminal", font_size=sp(16)) + nav.add_widget(self.btn_flash) + nav.add_widget(self.btn_term) + self.add_widget(nav) + + # Pages + self.sm = ScreenManager() + self.flash_ui = FirmwareToolUI() + self.term_ui = TerminalUI() + + s1 = Screen(name="flash") + s1.add_widget(self.flash_ui) + s2 = Screen(name="term") + s2.add_widget(self.term_ui) + + self.sm.add_widget(s1) + self.sm.add_widget(s2) + self.add_widget(self.sm) + + self.btn_flash.bind(on_press=lambda *_: self._go("flash")) + self.btn_term.bind(on_press=lambda *_: self._go("term")) + + self._go("flash") + + def _go(self, name: str): + self.sm.current = name + if name == "flash": + try: + self.flash_ui.refresh_ports() + except Exception: + pass + elif name == "term": + try: + self.term_ui.refresh_ports() + except Exception: + pass + + class PyBlasherApp(App): def build(self): Window.size = (600, 450) - Window.clearcolor = (0.12, 0.12, 0.12, 1) # Dark gray background + Window.clearcolor = (0.12, 0.12, 0.12, 1) # Dark gray background. Window.minimum_width = 350 Window.minimum_height = 350 Window.set_icon(resource_path("assets\\icon.png")) - return FirmwareToolUI() + self.root_ui = RootUI() + return self.root_ui + + def on_stop(self): + # Ensure serial port is closed when the app exits. + try: + if hasattr(self, "root_ui") and getattr( + self.root_ui, "term_ui", None + ): + self.root_ui.term_ui._running = False + if self.root_ui.term_ui._ser: + self.root_ui.term_ui._ser.close() + except Exception: + pass def run_gui(): diff --git a/util.py b/util.py index edb8f8b..abe920b 100644 --- a/util.py +++ b/util.py @@ -3,11 +3,41 @@ import os.path import sys +import serial from serial.tools import list_ports from constants import * +def parse_hex(s: str) -> bytes: + """Accepts: '01 0A ff', '0x01,0x0A,0xFF', or '010AFF'.""" + cleaned = ( + s.replace("0x", "") + .replace(",", " ") + .replace("\n", " ") + .replace("\t", " ") + .strip() + ) + parts = cleaned.split() + if ( + len(parts) == 1 + and all(c in "0123456789abcdefABCDEF" for c in parts[0]) + and len(parts[0]) % 2 == 0 + ): + return bytes.fromhex(parts[0]) + return bytes(int(p, 16) for p in parts if p) + + +def hexdump(data: bytes, width: int = 16) -> str: + lines = [] + for i in range(0, len(data), width): + chunk = data[i : i + width] + hex_part = " ".join(f"{b:02X}" for b in chunk) + ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) + lines.append(f"{i:04X} {hex_part:<{width*3}} {ascii_part}") + return "\n".join(lines) + + def resource_path(relative_path: str) -> str: """Get absolute path using a relative path to a resource. @@ -30,3 +60,30 @@ def find_cp2102n_ports() -> list[str]: elif port.hwid and vid_pid in port.hwid.lower(): matches.append(port.device) return matches + + +def open_serial_port( + port: str, + baud: int = 115200, + timeout: float = 0.2, + write_timeout: float = 0.5, +) -> serial.Serial: + """Open a serial port with sane defaults and clean buffers.""" + ser = serial.Serial( + port=port, + baudrate=baud, + timeout=timeout, + write_timeout=write_timeout, + rtscts=False, + dsrdtr=False, + ) + ser.dtr = False + ser.rts = False + ser.reset_input_buffer() + ser.reset_output_buffer() + return ser + + +def write_serial_bytes(ser: serial.Serial, data: bytes) -> None: + ser.write(data) + ser.flush()