Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-)
<!-- TOC -->

</details>
Expand All @@ -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.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added assets/icon.icns
Binary file not shown.
2 changes: 1 addition & 1 deletion constants.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""PyBlasher constants."""

# PyBlasher version.
VERSION = "0.1.1"
VERSION = "0.2.0"

# CLI version assumed width.
CLI_WIDTH = 80
Expand Down
310 changes: 307 additions & 3 deletions gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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():
Expand Down
Loading