diff --git a/ameba_control_panel/app.py b/ameba_control_panel/app.py index 7bad246..67ac83c 100644 --- a/ameba_control_panel/app.py +++ b/ameba_control_panel/app.py @@ -232,7 +232,19 @@ def _setup_logging() -> None: logging.getLogger(__name__).info("App started v%s", config.APP_VERSION) +def _setup_linux_platform() -> None: + """Ensure correct Qt platform plugin on Linux.""" + import os + if sys.platform != "linux" or os.environ.get("QT_QPA_PLATFORM"): + return + if os.environ.get("WAYLAND_DISPLAY"): + os.environ["QT_QPA_PLATFORM"] = "wayland;xcb" + elif os.environ.get("DISPLAY"): + os.environ["QT_QPA_PLATFORM"] = "xcb" + + def main() -> None: + _setup_linux_platform() _setup_logging() QApplication.setStyle("Fusion") app = QApplication(sys.argv) diff --git a/ameba_control_panel/config.py b/ameba_control_panel/config.py index 68d4fa9..dc1ba07 100644 --- a/ameba_control_panel/config.py +++ b/ameba_control_panel/config.py @@ -76,8 +76,12 @@ def parse_baud(text: str) -> int: def app_data_dir() -> Path: - base = os.environ.get("LOCALAPPDATA") - if base: - return Path(base) / "AmebaControlPanel" - # Fallback for non-Windows dev environments. + if os.name == "nt": + base = os.environ.get("LOCALAPPDATA") + if base: + return Path(base) / "AmebaControlPanel" + # Linux/macOS: use XDG_DATA_HOME or default + xdg = os.environ.get("XDG_DATA_HOME") + if xdg: + return Path(xdg) / "AmebaControlPanel" return Path.home() / ".local" / "share" / "AmebaControlPanel" diff --git a/ameba_control_panel/managers/log_manager.py b/ameba_control_panel/managers/log_manager.py index beadbf8..8203f42 100644 --- a/ameba_control_panel/managers/log_manager.py +++ b/ameba_control_panel/managers/log_manager.py @@ -62,7 +62,7 @@ class LogManager(QObject): if not (text.startswith("[") and "]" in text): break prefix = text.split("]", 1)[0] - if prefix.startswith("[COM") or prefix.startswith("[main"): + if prefix.startswith("[COM") or prefix.startswith("[/dev/") or prefix.startswith("[main"): text = text.split("]", 1)[1].lstrip() else: break diff --git a/ameba_control_panel/services/flash_runner.py b/ameba_control_panel/services/flash_runner.py index 7119c14..6b9d7ef 100644 --- a/ameba_control_panel/services/flash_runner.py +++ b/ameba_control_panel/services/flash_runner.py @@ -115,7 +115,7 @@ class FlashRunner(QThread): # Strip optional "[COMxx]" or "[main]" prefixes that follow while trimmed.startswith("[") and "]" in trimmed: prefix = trimmed.split("]", 1)[0] - if prefix.startswith("[COM") or prefix.startswith("[main"): + if prefix.startswith("[COM") or prefix.startswith("[/dev/") or prefix.startswith("[main"): trimmed = trimmed.split("]", 1)[1].lstrip() continue break diff --git a/ameba_control_panel/theme.py b/ameba_control_panel/theme.py index c3b5ca9..493229a 100644 --- a/ameba_control_panel/theme.py +++ b/ameba_control_panel/theme.py @@ -118,7 +118,7 @@ def build_stylesheet(p: Palette) -> str: QWidget {{ background-color: {p.bg}; color: {p.text}; - font-family: "Segoe UI", "SF Pro Text", sans-serif; + font-family: "Segoe UI", "SF Pro Text", "Ubuntu", "Noto Sans", sans-serif; font-size: 10pt; }} diff --git a/script/auto_run.py b/script/auto_run.py index 5486226..0624a14 100644 --- a/script/auto_run.py +++ b/script/auto_run.py @@ -1,3 +1,4 @@ +import os import sys from pathlib import Path @@ -10,7 +11,18 @@ def _bootstrap_path() -> None: sys.path.insert(0, str(root)) +def _setup_linux_platform() -> None: + """Set Qt platform plugin for Linux X11/Wayland compatibility.""" + if sys.platform != "linux" or os.environ.get("QT_QPA_PLATFORM"): + return + if os.environ.get("WAYLAND_DISPLAY"): + os.environ["QT_QPA_PLATFORM"] = "wayland;xcb" + elif os.environ.get("DISPLAY"): + os.environ["QT_QPA_PLATFORM"] = "xcb" + + _bootstrap_path() +_setup_linux_platform() from ameba_control_panel.app import main # noqa: E402 diff --git a/script/package_bin.py b/script/package_bin.py new file mode 100644 index 0000000..ff2287a --- /dev/null +++ b/script/package_bin.py @@ -0,0 +1,250 @@ +"""Build Ameba Control Panel Linux binary with PyInstaller (onedir).""" +from __future__ import annotations + +import argparse +import os +import shutil +import sys +from pathlib import Path + + +def _add_data(src: Path, dest: str) -> str: + return f"{src}:{dest}" + + +_EXCLUDES = [ + "tkinter", + "matplotlib", + "numpy", + "scipy", + "pandas", + "PIL", + "PySide6.QtWebEngine", + "PySide6.QtWebEngineCore", + "PySide6.QtWebEngineWidgets", + "PySide6.Qt3DCore", + "PySide6.Qt3DRender", + "PySide6.Qt3DInput", + "PySide6.Qt3DAnimation", + "PySide6.Qt3DExtras", + "PySide6.Qt3DLogic", + "PySide6.QtCharts", + "PySide6.QtDataVisualization", + "PySide6.QtMultimedia", + "PySide6.QtMultimediaWidgets", + "PySide6.QtQuick", + "PySide6.QtQuick3D", + "PySide6.QtQuickWidgets", + "PySide6.QtQml", + "PySide6.QtRemoteObjects", + "PySide6.QtSensors", + "PySide6.QtSerialBus", + "PySide6.QtBluetooth", + "PySide6.QtNfc", + "PySide6.QtPositioning", + "PySide6.QtLocation", + "PySide6.QtTest", + "PySide6.QtPdf", + "PySide6.QtPdfWidgets", + "PySide6.QtSvgWidgets", + "PySide6.QtNetworkAuth", + "PySide6.QtDesigner", + "PySide6.QtHelp", + "PySide6.QtOpenGL", + "PySide6.QtOpenGLWidgets", + "PySide6.QtSpatialAudio", + "PySide6.QtStateMachine", + "PySide6.QtTextToSpeech", + "PySide6.QtHttpServer", + "PySide6.QtGraphs", +] + +_DESKTOP_ENTRY = """\ +[Desktop Entry] +Name=Ameba Control Panel +Comment=Realtek Ameba device control panel for serial communication, flashing, and debugging +Exec={exec_path} +Icon={icon_path} +Terminal=false +Type=Application +Categories=Development;Electronics; +StartupWMClass=AmebaControlPanel +""" + +_UDEV_RULES = """\ +# Ameba Control Panel - USB serial device permissions +# Install: sudo cp 99-ameba-serial.rules /etc/udev/rules.d/ +# Reload: sudo udevadm control --reload-rules && sudo udevadm trigger + +# FTDI devices (FT232, FT2232, etc.) +SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", MODE="0666" + +# Silicon Labs CP210x +SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", MODE="0666" + +# WCH CH340/CH341 +SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", MODE="0666" + +# Prolific PL2303 +SUBSYSTEM=="tty", ATTRS{idVendor}=="067b", MODE="0666" + +# Realtek devices +SUBSYSTEM=="tty", ATTRS{idVendor}=="0bda", MODE="0666" + +# SEGGER J-Link +SUBSYSTEM=="tty", ATTRS{idVendor}=="1366", MODE="0666" + +# STMicroelectronics STLink +SUBSYSTEM=="tty", ATTRS{idVendor}=="0483", MODE="0666" + +# Generic USB CDC ACM devices (covers most USB-to-serial adapters) +SUBSYSTEM=="tty", KERNEL=="ttyACM[0-9]*", MODE="0666" +SUBSYSTEM=="tty", KERNEL=="ttyUSB[0-9]*", MODE="0666" +""" + +_LAUNCH_SCRIPT = """\ +#!/bin/bash +# Ameba Control Panel launcher +# Handles X11/Wayland compatibility + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Detect display server and set Qt platform if needed +if [ -n "$WAYLAND_DISPLAY" ]; then + # Wayland session — let Qt use wayland or xcb fallback + export QT_QPA_PLATFORM="${{QT_QPA_PLATFORM:-wayland;xcb}}" +elif [ -n "$DISPLAY" ]; then + # X11 session + export QT_QPA_PLATFORM="${{QT_QPA_PLATFORM:-xcb}}" +fi + +# Ensure bundled libs are found +export LD_LIBRARY_PATH="$SCRIPT_DIR:$LD_LIBRARY_PATH" + +exec "$SCRIPT_DIR/AmebaControlPanel" "$@" +""" + + +def _generate_support_files(dist_dir: Path) -> None: + """Generate udev rules, desktop entry, and launcher script.""" + app_dir = dist_dir / "AmebaControlPanel" + app_dir.mkdir(parents=True, exist_ok=True) + + # Udev rules + rules_path = app_dir / "99-ameba-serial.rules" + rules_path.write_text(_UDEV_RULES, encoding="utf-8") + print(f" udev rules: {rules_path}") + + # Launcher script + launcher = app_dir / "ameba-control-panel.sh" + launcher.write_text(_LAUNCH_SCRIPT, encoding="utf-8") + launcher.chmod(0o755) + print(f" launcher: {launcher}") + + # Desktop entry + desktop = app_dir / "ameba-control-panel.desktop" + desktop.write_text( + _DESKTOP_ENTRY.format( + exec_path=str(app_dir / "ameba-control-panel.sh"), + icon_path=str(app_dir / "icon.png") if (app_dir / "icon.png").exists() else "", + ), + encoding="utf-8", + ) + print(f" desktop: {desktop}") + + # Install helper + install_script = app_dir / "install.sh" + install_script.write_text( + "#!/bin/bash\n" + "set -e\n" + 'SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"\n' + "\n" + "echo 'Installing udev rules for serial port access...'\n" + 'sudo cp "$SCRIPT_DIR/99-ameba-serial.rules" /etc/udev/rules.d/\n' + "sudo udevadm control --reload-rules\n" + "sudo udevadm trigger\n" + "echo 'Udev rules installed.'\n" + "\n" + "echo 'Installing desktop entry...'\n" + 'DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"\n' + 'mkdir -p "$DESKTOP_DIR"\n' + 'sed "s|Exec=.*|Exec=$SCRIPT_DIR/ameba-control-panel.sh|" ' + '"$SCRIPT_DIR/ameba-control-panel.desktop" > "$DESKTOP_DIR/ameba-control-panel.desktop"\n' + "echo 'Desktop entry installed.'\n" + "\n" + "echo 'Done. You may need to log out and back in for serial port permissions to take effect.'\n", + encoding="utf-8", + ) + install_script.chmod(0o755) + print(f" installer: {install_script}") + + +def build(*, icon: str | None = None) -> None: + root = Path(__file__).resolve().parent.parent + entry = root / "script" / "auto_run.py" + flash_dir = root / "Flash" + + if not entry.exists(): + sys.exit(f"Entry script missing: {entry}") + if not flash_dir.exists(): + sys.exit(f"Flash folder missing: {flash_dir}") + + os.chdir(root) + + try: + import PyInstaller.__main__ as pyinstaller + except ImportError: + sys.exit("PyInstaller is not installed. Run: pip install PyInstaller") + + args = [ + "--noconfirm", + "--clean", + "--onedir", + "--name=AmebaControlPanel", + f"--distpath={root / 'dist'}", + f"--workpath={root / 'build'}", + f"--specpath={root}", + "--paths", str(root), + "--collect-all", "PySide6", + "--hidden-import=serial", + "--hidden-import=serial.tools.list_ports", + "--hidden-import=pyDes", + "--hidden-import=colorama", + "--add-data", _add_data(flash_dir, "Flash"), + ] + + for mod in _EXCLUDES: + args.extend(["--exclude-module", mod]) + + if icon: + icon_path = Path(icon) + if icon_path.exists(): + args.extend(["--icon", str(icon_path)]) + else: + print(f"Warning: icon not found: {icon_path}", file=sys.stderr) + + args.append(str(entry)) + + print("Building with PyInstaller...") + pyinstaller.run(args) + + # Copy icon if provided + dist_app = root / "dist" / "AmebaControlPanel" + if icon and Path(icon).exists() and dist_app.exists(): + shutil.copy2(icon, dist_app / "icon.png") + + print("\nGenerating support files...") + _generate_support_files(root / "dist") + + print(f"\nBuild complete: {dist_app}") + print("\nTo install on target system:") + print(f" 1. Copy {dist_app} to the target machine") + print(f" 2. Run: {dist_app}/install.sh") + print(f" 3. Launch: {dist_app}/ameba-control-panel.sh") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Build Ameba Control Panel for Linux") + parser.add_argument("--icon", help="Path to .png icon file") + opts = parser.parse_args() + build(icon=opts.icon)