Chapter 3 - Application Development

Chapter 3: Application Development

Modeled on the existing apps, write your first Luwu-OS app. This chapter gives you a minimal copy-paste template and explains theming, internationalization and registration, so you can get a new app onto the main UI quickly.

In this chapter:

SectionWhat it coversDifficulty
3.1 PySide6 App TemplateA minimal runnable app (template code)★★☆
3.2 Theme SystemColor/typography/asset tokens and QSS helpers★★☆
3.3 InternationalizationChinese/English switching (Translator / t / get_lang)★☆☆
3.4 Creating a New AppFrom creating the folder to registering it on the UI★★☆
3.5 Existing App ReferenceTech-stack overview of the official apps (dev examples)★☆☆

🧭 Reading path: For your first app, follow 3.1 → 3.4; to write it well, review the theming/i18n conventions in 3.2 / 3.3.

Prerequisites:

  • Done Chapter 1, you can SSH into the robot;
  • Read Chapter 2’s directory layout and IPC (know apps/ and /tmp/luwu_run.fifo);
  • Basic Python 3 and PySide6 (you can follow the template even if not fluent).

3.1 PySide6 App Template

Bottom line: writing an app in Luwu-OS means “a class inheriting AppFrame + a main()”. Why this shape? Because the launcher (Qt C++) owns the app lifecycle; your app only renders its own UI, and by inheriting AppFrame it gets the title bar, corner hints and key-to-exit common capabilities for free — you don't have to write the window shell yourself.

#!/usr/bin/env python3
import os, sys, time, signal

# Luwu-OS root
LUWU_ROOT = os.environ.get("LUWU_ROOT", "/opt/luwu-os")
if LUWU_ROOT not in sys.path:
    sys.path.insert(0, LUWU_ROOT)

from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QKeyEvent
from PySide6.QtWidgets import QApplication, QWidget, QLabel

from libs.theme import apply_app_palette, Asset
from libs.ui import AppFrame
from libs.i18n import Translator

_T = Translator({
    "cn": {"title": "我的应用", "corner_exit": "退出"},
    "en": {"title": "My App", "corner_exit": "Exit"},
})

class MyPage(AppFrame):
    def __init__(self):
        super().__init__()
        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
        self.setTitle(_T("title"))
        self.setCornerHints(
            bl=(_T("corner_exit"), Asset.icon_back),
        )
        QTimer.singleShot(600000, self.close)  # auto exit after 10 minutes

    def keyPressEvent(self, ev: QKeyEvent):
        if ev.key() == Qt.Key.Key_Back:
            self.close()

def main():
    signal.signal(signal.SIGINT, lambda *_: QApplication.instance().quit())
    signal.signal(signal.SIGTERM, lambda *_: QApplication.instance().quit())
    app = QApplication(sys.argv)
    apply_app_palette(app)
    w = MyPage()
    w.showFullScreen()
    sys.exit(app.exec())

if __name__ == "__main__":
    main()

3.1.1 Run This Template

Save the template above as apps/my_new_app/main.py, then:

cd /opt/luwu-os/apps/my_new_app && python3 main.py   # bare run: check logic (no theme/key routing)
# or let the launcher start it (recommended, verifies the real effect):
echo "apps/my_new_app/main.py" > /tmp/luwu_run.fifo

Expected result: the physical screen shows “My App” fullscreen with an “Exit” corner hint in the title bar; press C key (Back) or the top-left back icon to exit; auto closes after 10 minutes of inactivity (QTimer.singleShot(600000, ...)).

⚠️ If a bare run fails with ModuleNotFoundError: PySide6 or No module named 'libs', you're not running inside the Luwu-OS environment (must be on the robot, with LUWU_ROOT=/opt/luwu-os); if it fails with Failed to open /dev/fb-spi, it's usually a missing launcher context — start it via the fifo instead.

3.2 Theme System

Luwu-OS uses a light theme that is visually consistent with the launcher. Color / font / spacing / radius / asset constants are defined in libs/theme/tokens.py:

from libs.theme import Color, Font, Spacing, Radius, Asset, qss

# Color tokens (light theme)
Color.bg_solid        # solid background #eaf0fb
Color.text_primary    # primary text #1a3a6e
Color.text_secondary  # secondary text #5d7299
Color.text_muted      # muted text #8aa1c7
Color.text_invert     # inverted text (on dark backgrounds) #ffffff
Color.accent          # accent color #3a8dff
Color.success         # success #18a957
Color.warning         # warning #e69900
Color.danger          # danger #d6453d
Color.card_bg         # card background rgba(255,255,255,200)

# Font tokens (point sizes)
Font.family = "Noto Sans CJK SC"
Font.title / Font.subtitle / Font.body / Font.hint / Font.caption
#   18       15              14         12         11

# Asset tokens (located in launcher/assets/)
Asset.bg_image       # main background bg_macos.png
Asset.icon_back      # back icon
Asset.icon_enter     # confirm/enter icon
Asset.icon_left      # left arrow
Asset.icon_right     # right arrow

# QSS helpers (libs/theme/qss.py, real function signatures)
qss.app_palette()                    # global base QSS for the app (font, scrollbar, ...)
qss.app_root()                       # app root style
qss.text(role="body", color=None)    # text style (role: title/subtitle/body/hint/caption)
qss.card(selected=False)             # card style
qss.chip(state="info")               # status chip (info/success/warning/danger/muted)
qss.transparent()                    # transparent background
qss.overlay_pill(role="body", color=None, strong=False)  # overlay pill (e.g. toast bar)
qss.corner_pill(color=None)          # rounded pill (e.g. corner badge)

💡 Child apps should use these components and tokens directly and must not pick their own colors / font sizes or write raw setStyleSheet (see the convention in libs/ui/__init__.py).

Use a theme token for real (a styled label):

from PySide6.QtWidgets import QLabel
from libs.theme import qss, Color

label = QLabel("Notice")
label.setStyleSheet(qss.text(role="body", color=Color.warning))

This makes the text follow the theme's warning color instead of a hard-coded one; for rounded cards / status chips use qss.card() / qss.chip().

3.3 Internationalization

The single source of language config is /opt/luwu-os/configs/language.ini (a single line of plain text: cn or en).

from libs.i18n import Translator, t, get_lang, FONT_PATH

# Method 1: Translator dictionary object
_T = Translator({
    "cn": {"hello": "你好"},
    "en": {"hello": "Hello"},
})
label.setText(_T("hello"))

# Method 2: t() shortcut (single translation)
label.setText(t({"cn": "你好", "en": "Hello"}))

# Current language
lang = get_lang()      # "cn" or "en"

Chinese font path FONT_PATH: prefers model/msyh.ttc, falls back to DroidSansFallbackFull.ttf / DejaVuSans.ttf (see _FONT_CANDIDATES in libs/i18n.py).

When does the language take effect? get_lang() reads configs/language.ini on every call, so edit that file and restart the app to switch language — no system reboot needed:

echo "en" > /opt/luwu-os/configs/language.ini   # switch to English
echo "cn" > /opt/luwu-os/configs/language.ini   # switch back to Chinese

💡 Official apps read the language once at startup in main.py and don't hot-switch at runtime; your custom apps can follow the same approach.

3.4 Creating a New App

  1. mkdir -p /opt/luwu-os/apps/my_new_app
  2. Create main.py (use the template above)
  3. Register it on the main screen: edit the CARDS array in launcher/galleryview.cpp and add one card entry (Chinese name, English name, card image, script path):
// launcher/galleryview.cpp
const CardData CARDS[CARD_COUNT] = {
    {"无线网络",   "WiFi",     "card_network.png",  "apps/network/main.py"},
    {"图形化编程", "Coding",   "card_coding.png",   "apps/coding/main.py"},
    {"AI交互",    "AI Chat",  "card_ai.png",       "apps/ai_chat_pro/main.py"},
    {"示例程序",   "Demos",    "card_more.png",     "apps/demo_page/main.py"},
    {"系统设置",   "Settings", "card_settings.png", "apps/settings/main.py"},
    // New: {"我的应用", "My App", "card_mine.png", "apps/my_new_app/main.py"},
};

If you want it to appear in the "Demos" grid instead, edit the demos array in launcher/demogridview.cpp (it supports model filter tags such as "@dog" / "!xgorider").

⚠️ launcher/devicetable.h is the robot-model registry (used when adding a new XGO model); it has nothing to do with registering apps — do not modify it.

  1. Test it:
cd /opt/luwu-os/apps/my_new_app && python3 main.py     # run directly
# or let the launcher start it (applies theme/key routing automatically):
echo "apps/my_new_app/main.py" > /tmp/luwu_run.fifo

⚠️ Common issues: - App doesn't appear on the main UI → check whether CARDS in galleryview.cpp added a card (not replaced one); - Icon does not show → wrong card image path, or the image isn't in launcher/assets/; - Tapping the card does nothing → the script path in CARDS should be apps/xxx/main.py (relative to LUWU_ROOT), not an absolute path; - Want it in the “Demos” grid instead → edit demogridview.cpp, and note the model filter tags (@dog / !xgorider).

3.5 Reference: Existing Apps (Core Tech Stacks)

The apps under the official apps/ directory are good examples for secondary development. Their core technologies:

AppDescriptionCore Technology
ai_chat_pro / voice_chatAI Chat Pro (new voice chat; reuses the wake-word / emotion modules in apps/ai)Voice chat, wake-word, MQTT, cloud binding, expression animation
aiOld AI Chat Dev (dev AI; config page at http://<robot-ip>:5000)LLM, TTS, ASR, ONNX emotion recognition
codingBlockly visual programmingBlockly, Python code generation
face_followFace detection & trackingMediaPipe Face Detection, Picamera2
ball_trackColor ball detection & trackingOpenCV color filtering, PID control
gestureGesture command controlMediaPipe Hands, ONNX
gamepadBluetooth/USB gamepad controlevdev, Bluetooth HID, joystick calibration
rc_modeWeb remote controlFlask web server, MJPEG streaming
radar360° environment scanningYDLiDAR SDK, real-time rendering
group_performMulti-robot synchronizationMQTT pub/sub, time sync
settingsSystem config & device infoLanguage switching, volume control

📦 See the official application list in the luwu_os README for the full gallery view. Actual app processes render to /dev/fb-spi as needed and consistently use the libs/ui and libs/theme components, instead of writing their own rendering logic.