coursera_2026_06
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

How to Port a React or Vue App to AsteroidOS

  1. Install the AsteroidOS SDK by cloning the build environment and running prepare-build.sh on a Linux host.
  2. Flash a supported smartwatch (e.g., LG G Watch, Asus ZenWatch) with the AsteroidOS image via fastboot.
  3. Scaffold your project with a .pro file, .desktop launcher entry, and root QML file.
  4. Translate React/Vue components to QML using property for props, signal for events, and built-in reactive bindings for state.
  5. Design for the circular display by constraining content to a 70% safe zone and using parent-relative sizing throughout.
  6. Implement networking with QML's XMLHttpRequest, adding offline caching via Qt.labs.settings.
  7. Deploy by pushing QML/JS files over SSH to the watch and restarting the compositor.
  8. Optimize for wearable constraints: minimize timer frequency, pre-compute binding values, and keep total footprint under 15 MB.

If you've ever wanted to build a smartwatch app but balked at learning Swift for watchOS or Kotlin for Wear OS, AsteroidOS offers a genuinely different path. This AsteroidOS development tutorial walks you through taking your existing JavaScript smartwatch knowledge from React or Vue and applying it to wearable app development on open-source Linux hardware.

Table of Contents

By the end, you'll have a working weather dashboard running on a 1.4-inch circular screen, built entirely with QML and JavaScript.

The wearable ecosystem today is a walled garden duopoly. Apple locks you into Swift and Xcode. Google pushes Kotlin and Android Studio. Both platforms gate distribution through their app stores and restrict the hardware you can target. Independent developers who want to experiment with smartwatch JavaScript concepts or ship niche apps face real barriers just to get started.

AsteroidOS breaks that model. Built on OpenEmbedded with a Qt5/QML/Wayland UI stack, it runs full Linux on real smartwatch hardware. The UI layer uses QML, which has JavaScript baked into its core. If you understand component-based architectures, reactive data binding, and event-driven programming from React or Vue, you already grasp roughly 70% of QML's mental model.

What we're building: a weather dashboard app with a radial temperature gauge, swipe navigation, a 3-day forecast list, and offline caching. Along the way, we'll put together a reusable component library you can adapt for any AsteroidOS project.

What Is AsteroidOS and Why Does It Matter?

Architecture Overview

AsteroidOS sits on top of the OpenEmbedded/Yocto build system, which generates complete Linux images for specific hardware targets. The rendering pipeline runs through Wayland, with Qt5 providing the QML engine that handles both UI layout and application logic. This is the same Qt Quick technology used in automotive infotainment systems and industrial HMIs, scaled down to wrist-sized hardware.

Supported watches include the LG G Watch, Asus ZenWatch series, Sony Smartwatch 3, and several other devices that originally shipped with Android Wear. These are older devices, so expect to find them on secondary markets. Before buying anything, check the official compatibility list at asteroidos.org/install because port maturity varies significantly per device. Some watches have full support including Wi-Fi and Bluetooth; others have partial support with known issues.

AsteroidOS doesn't follow a traditional versioned release model the way commercial operating systems do. Updates flow through the OpenEmbedded layer system, and the project publishes both nightly and stable images through its build infrastructure. Over time, the platform has picked up incremental improvements to kernel support, the Bluetooth stack, and power management. For developers, this means better hardware abstraction and more stable connectivity on supported watches.

Where JavaScript Fits In

Here's the critical point that the title hints at but requires nuance: you are not running React or Vue directly on the watch. There is no DOM, no browser engine, no npm start. What you're doing is applying React and Vue architectural patterns to QML, which ships with a built-in JavaScript engine (Qt5's V4 engine). QML files contain declarative UI markup interleaved with JavaScript for logic, event handling, and data manipulation.

If you understand component-based architectures, reactive data binding, and event-driven programming from React or Vue, you already grasp roughly 70% of QML's mental model.

This maps more closely than it sounds. QML components have properties (like React props), signal handlers (like event callbacks), and a reactive binding system (like Vue's reactivity). The translation from one mental model to the other is mechanical, not conceptual.

An alternative path exists: cross-compiling lightweight JS runtimes like QuickJS for ARM and running standalone scripts. This works for headless tasks but doesn't give you UI. For graphical apps, QML + JavaScript is the sanctioned approach on AsteroidOS.

Hardware constraints are real: most supported watches have 512MB RAM (some less), around 4GB storage, and circular displays in the 300 to 400 pixel range. Battery life is measured in hours of active use, not days. Every design decision flows from these constraints.

Setting Up Your AsteroidOS Development Environment

Prerequisites

The AsteroidOS build system runs on Linux. If you're on macOS, you'll need Docker or a Linux VM. I've found that a Ubuntu 22.04 host with at least 50GB of free disk space and 8GB RAM provides a smooth build experience. The OpenEmbedded toolchain is disk-hungry.

Here's how to get the SDK, connect to a watch, and verify your setup:

# Clone the AsteroidOS build environment
git clone https://github.com/AsteroidOS/asteroid.git
cd asteroid

# Initialize the OpenEmbedded build environment
# This sets up bitbake paths and environment variables
source ./prepare-build.sh

# If you have a supported watch, flash AsteroidOS following
# the device-specific guide at asteroidos.org/install
# Most devices use fastboot:
# fastboot flash userdata asteroid-image-dory.ext4
# fastboot flash boot zImage-dtb-dory.fastboot

# Connect to the watch over USB networking
# AsteroidOS typically assigns the watch 192.168.2.15
# with the host at 192.168.2.1
# First, set up the USB network interface on your host:
sudo ifconfig usb0 192.168.2.1

# SSH into the watch (default: no password for root)
ssh root@192.168.2.15

# On the watch, explore the filesystem
ls /usr/share/asteroid-launcher/watchfaces/
ls /usr/lib/qml/

A note on the emulator: AsteroidOS documentation references QEMU-based emulation for some configurations, but in my experience, testing on actual hardware is far more reliable for catching touch input and display rendering issues. If you don't have a physical watch, the build system can produce images you can test in QEMU, but expect to spend time configuring display output.

Project Scaffolding

AsteroidOS apps follow a standard structure. You need a qmake project file, a desktop entry for the launcher, and your QML source files.

weather-app/
├── weather-app.pro          # qmake project file
├── weather-app.desktop      # Launcher entry
├── main.qml                 # Root QML file
├── components/              # Reusable QML components
│   ├── CircularContainer.qml
│   ├── WatchButton.qml
│   └── WatchList.qml
└── js/                      # JavaScript modules
    ├── WeatherService.js
    └── GestureHandler.js

Here's the minimal "Hello Watch" setup:

// weather-app.pro
TEMPLATE = app
TARGET = weather-app
QT += qml quick
SOURCES += main.cpp
RESOURCES += qml.qrc
# weather-app.desktop
[Desktop Entry]
Name=Weather
Icon=weather-app
Exec=/usr/bin/weather-app
Type=Application
// main.qml — Hello Watch
import QtQuick 2.9

Item {
    anchors.fill: parent

    // Black background behind everything
    Rectangle {
        anchors.fill: parent
        color: "black"
    }

    // The watch face is circular; center our content
    Text {
        anchors.centerIn: parent
        text: "Hello Watch"
        color: "white"
        font.pixelSize: parent.width * 0.08
        font.family: "Source Sans Pro"
    }
}

This renders white text centered on a black background. Simple, but it confirms your entire toolchain works: build, deploy, display.

Bridging React/Vue Concepts to QML + JavaScript

Component Model Mapping

If you've built apps in React or Vue, you already think in components. QML uses the same model, just with different syntax. Here's a concrete side-by-side of a counter component:

React:

import { useState } from 'react';

function Counter({ initialCount = 0, onCountChanged }) {
  const [count, setCount] = useState(initialCount);

  const increment = () => {
    const newCount = count + 1;
    setCount(newCount);
    onCountChanged?.(newCount);
  };

  return (
    <button onClick={increment}>
      Count: {count}
    </button>
  );
}

Vue:

<template>
  <button @click="increment">Count: {{ count }}</button>
</template>

<script setup>
import { ref } from 'vue';

const props = defineProps({ initialCount: { default: 0 } });
const emit = defineEmits(['countChanged']);
const count = ref(props.initialCount);

function increment() {
  count.value++;
  emit('countChanged', count.value);
}
</script>

QML + JavaScript:

// Counter.qml
import QtQuick 2.9

Rectangle {
    id: root

    // Props (like React props or Vue defineProps)
    property int initialCount: 0
    property int count: initialCount

    // Signal (like React callback prop or Vue emit)
    signal countChanged(int newCount)

    width: 120; height: 60
    radius: 8
    color: mouseArea.pressed ? "#444" : "#333"

    Text {
        anchors.centerIn: parent
        text: "Count: " + root.count
        color: "white"
        font.pixelSize: 18
    }

    MouseArea {
        id: mouseArea
        anchors.fill: parent
        onClicked: {
            root.count++;
            root.countChanged(root.count);
        }
    }
}

The mapping is direct. property declarations are props. signal declarations are emitted events. QML's property binding system is reactive like Vue's ref(): when count changes, the Text element's content updates automatically without explicit re-rendering.

Where the mapping breaks down: QML has no virtual DOM. There's no diffing algorithm. The engine evaluates property bindings directly, which means binding loops (where A depends on B which depends on A) will generate runtime warnings and the engine will break the loop by interrupting evaluation, potentially leaving properties in an unexpected state. React developers accustomed to useEffect dependency arrays need to think carefully about circular bindings in QML.

Lifecycle mapping: Component.onCompleted fires after the component is fully instantiated (similar to useEffect(() => {}, []) in React or onMounted() in Vue). Component.onDestruction fires on teardown (similar to cleanup functions in useEffect or onUnmounted).

Handling the Circular Display Constraint

The biggest mental shift from web development is the circular viewport. On a 400x400 pixel round display, the corners are dead zones. The usable "safe zone" is roughly an inscribed circle at about 70% of the display diameter, giving you about a 280x280 pixel working area.

// CircularContainer.qml
import QtQuick 2.9

Item {
    id: container
    anchors.fill: parent

    // Safe zone: 70% of the smallest dimension
    property real safeZoneRatio: 0.70
    property real safeZoneDiameter: Math.min(width, height) * safeZoneRatio

    // Content goes inside this item
    default property alias content: innerContent.data

    // Inner safe zone where content lives
    Item {
        id: innerContent
        width: container.safeZoneDiameter
        height: container.safeZoneDiameter
        anchors.centerIn: parent
    }

    // Circular mask overlay using Canvas
    Canvas {
        anchors.fill: parent
        z: 100 // Render above content
        onPaint: {
            var ctx = getContext("2d");
            ctx.reset();
            // Fill entire canvas black
            ctx.fillStyle = "black";
            ctx.fillRect(0, 0, width, height);
            // Punch out a circle
            ctx.globalCompositeOperation = "destination-out";
            ctx.beginPath();
            ctx.arc(width / 2, height / 2,
                    Math.min(width, height) / 2, 0, Math.PI * 2);
            ctx.fill();
        }
    }
}

This component creates a circular viewport mask and provides a centered content area sized to the safe zone. Think of it like applying border-radius: 50% with overflow: hidden in CSS, except you're doing it with a Canvas compositing trick because QML's clipping options for circular shapes are limited in Qt5 without pulling in additional modules like QtGraphicalEffects.

Use parent-relative sizing everywhere. Never hardcode pixel values. parent.width * 0.08 for font sizes, parent.width * 0.15 for button dimensions. This approach breaks when you have deeply nested components where parent doesn't reference the watch face. In that case, pass the root dimensions down as properties or use a singleton Screen reference.

Building a Smartwatch Component Library

Core Components for Wearable UI

Touch targets on a wrist need to be large. I've found that anything below 15% of the display width is frustratingly hard to tap accurately while walking. For a 400px display, that's a minimum of 60px per interactive element. More generous than Android's 48dp recommendation, but necessary on a wrist.

// WatchButton.qml
import QtQuick 2.9

Rectangle {
    id: button

    property string label: "Button"
    property color baseColor: "#1a73e8"
    property color pressedColor: "#1557b0"
    property real buttonSize: parent ? parent.width * 0.35 : 100

    signal clicked()
    signal longPressed()

    width: buttonSize
    height: buttonSize * 0.4
    radius: height / 2
    color: touchArea.pressed ? pressedColor : baseColor

    Behavior on color { ColorAnimation { duration: 100 } }
    Behavior on scale { NumberAnimation { duration: 80 } }

    scale: touchArea.pressed ? 0.95 : 1.0

    Text {
        anchors.centerIn: parent
        text: button.label
        color: "white"
        font.pixelSize: button.height * 0.35
        font.weight: Font.DemiBold
    }

    MouseArea {
        id: touchArea
        anchors.fill: parent
        onClicked: button.clicked()
        onPressAndHold: button.longPressed()
    }
}
// WatchList.qml
import QtQuick 2.9

ListView {
    id: watchList

    property real itemHeight: height * 0.25
    property color separatorColor: "#333333"

    signal itemSelected(int index)

    clip: true
    spacing: 2
    snapMode: ListView.SnapToItem
    flickDeceleration: 1500
    maximumFlickVelocity: 800

    // Subtle scroll indicator
    Rectangle {
        parent: watchList
        anchors.right: parent.right
        anchors.rightMargin: 2
        width: 3
        height: watchList.height *
               (watchList.visibleArea.heightRatio > 0
                ? watchList.visibleArea.heightRatio : 1)
        y: watchList.height *
           (watchList.visibleArea.yPosition > 0
            ? watchList.visibleArea.yPosition : 0)
        radius: 1.5
        color: "#ffffff"
        opacity: watchList.moving ? 0.6 : 0.0
        Behavior on opacity { NumberAnimation { duration: 300 } }
        z: 1
    }

    delegate: Rectangle {
        width: watchList.width
        height: watchList.itemHeight
        color: delegateTouch.pressed ? "#222" : "transparent"

        MouseArea {
            id: delegateTouch
            anchors.fill: parent
            onClicked: watchList.itemSelected(index)
        }
    }
}

The WatchList uses snapMode: ListView.SnapToItem so scrolling always lands cleanly on an item rather than stopping mid-scroll. I tuned the flick deceleration higher than the default because on a tiny screen, overshooting the target item is a common frustration. I tested various deceleration values on an LG G Watch and found 1500 gave the best balance between responsiveness and control.

Touch Gestures and Input on a Tiny Screen

Swipe navigation is the primary interaction model on a screenless-bezel watch. Here's a JavaScript gesture handler module:

// js/GestureHandler.js
.pragma library

// Minimum distance (in pixels) to register a swipe
var SWIPE_THRESHOLD = 50;
// Maximum time (ms) for a swipe gesture
var SWIPE_TIMEOUT = 300;

var _startX = 0;
var _startY = 0;
var _startTime = 0;

function onPressed(mouse) {
    _startX = mouse.x;
    _startY = mouse.y;
    _startTime = Date.now();
}

function onReleased(mouse, callbacks) {
    var dx = mouse.x - _startX;
    var dy = mouse.y - _startY;
    var dt = Date.now() - _startTime;

    if (dt > SWIPE_TIMEOUT) return; // Too slow, not a swipe

    var absDx = Math.abs(dx);
    var absDy = Math.abs(dy);

    if (absDx < SWIPE_THRESHOLD && absDy < SWIPE_THRESHOLD) return;

    if (absDx > absDy) {
        // Horizontal swipe
        if (dx > 0 && callbacks.onSwipeRight) callbacks.onSwipeRight();
        else if (dx < 0 && callbacks.onSwipeLeft) callbacks.onSwipeLeft();
    } else {
        // Vertical swipe
        if (dy > 0 && callbacks.onSwipeDown) callbacks.onSwipeDown();
        else if (dy < 0 && callbacks.onSwipeUp) callbacks.onSwipeUp();
    }
}

function onPressAndHold(callbacks) {
    if (callbacks.onLongPress) callbacks.onLongPress();
}

Usage in a QML file:

import "js/GestureHandler.js" as Gesture

MouseArea {
    anchors.fill: parent
    onPressed: Gesture.onPressed(mouse)
    onReleased: Gesture.onReleased(mouse, {
        onSwipeLeft: function() { pageStack.push(settingsPage) },
        onSwipeRight: function() { pageStack.pop() },
        onSwipeDown: function() { forecastPanel.show() }
    })
    onPressAndHold: Gesture.onPressAndHold({
        onLongPress: function() { contextMenu.open() }
    })
}

This approach falls apart when the user scrolls a ListView inside the swipe area, because the MouseArea will capture the events before the Flickable can process them. In that case, set propagateComposedEvents: true on the MouseArea and check whether the swipe is primarily horizontal (for navigation) or vertical (for scrolling), passing vertical events through.

Theming and Accessibility

OLED screens on most supported watches mean true blacks cost zero power but white pixels are expensive. Default to dark themes. For sunlight readability, use high-contrast foreground colors against black.

// js/Theme.js
.pragma library

var colors = {
    background: "#000000",
    surface: "#121212",
    primary: "#4fc3f7",      // Light blue, high contrast on black
    secondary: "#81c784",     // Soft green for secondary info
    text: "#ffffff",
    textSecondary: "#b0b0b0",
    danger: "#ef5350",
    divider: "#333333"
};

var fonts = {
    // Minimum readable size on a 1.4" display at arm's length
    // is approximately 5% of display width
    tiny: 0.04,       // Ratio to parent width
    small: 0.05,
    body: 0.06,
    heading: 0.08,
    display: 0.12
};

var spacing = {
    xs: 0.02,
    sm: 0.04,
    md: 0.06,
    lg: 0.08
};

Font sizes below 4% of the display width become illegible on a 320px screen. I tested this on a Sony Smartwatch 3 in outdoor sunlight and found that the body ratio of 0.06 (about 19px on a 320px display) was the minimum comfortable reading size for a sentence of text.

Porting a Real App: Weather Dashboard for Your Wrist

App Architecture

Data flow on a smartwatch is constrained. Not all AsteroidOS-supported watches have Wi-Fi. Those that do may have intermittent connectivity. Our architecture needs to handle three scenarios: direct Wi-Fi HTTP requests when available, cached data when offline, and graceful fallback UI when no data exists at all.

QML provides a built-in XMLHttpRequest object for network calls. Same API shape you know from browser JavaScript, though the implementation is Qt's, not a browser's.

Our architecture needs to handle three scenarios: direct Wi-Fi HTTP requests when available, cached data when offline, and graceful fallback UI when no data exists at all.

// js/WeatherService.js
.pragma library

// IMPORTANT: Replace with your own OpenWeatherMap API key.
// Do not ship API keys in client-side code in production;
// consider proxying through your own server.
var API_KEY = "YOUR_OPENWEATHERMAP_API_KEY";
var BASE_URL = "https://api.openweathermap.org/data/2.5/weather";
var FORECAST_URL = "https://api.openweathermap.org/data/2.5/forecast";

// Fallback data when no cache and no network
var FALLBACK_DATA = {
    temp: "--",
    condition: "Unknown",
    icon: "01d",
    location: "No data",
    forecast: [],
    cached: false,
    timestamp: 0
};

function fetchWeather(lat, lon, callback) {
    var url = BASE_URL + "?lat=" + lat + "&lon=" + lon +
              "&units=metric&appid=" + API_KEY;

    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function() {
        if (xhr.readyState === XMLHttpRequest.DONE) {
            if (xhr.status === 200) {
                try {
                    var data = JSON.parse(xhr.responseText);
                    var result = {
                        temp: Math.round(data.main.temp),
                        condition: data.weather[0].main,
                        icon: data.weather[0].icon,
                        location: data.name,
                        forecast: [],
                        cached: false,
                        timestamp: Date.now()
                    };
                    saveToCache(result);
                    callback(result, null);
                } catch (e) {
                    var cached = loadFromCache();
                    if (cached) {
                        cached.cached = true;
                        callback(cached, null);
                    } else {
                        callback(FALLBACK_DATA, "Parse error");
                    }
                }
            } else {
                // Network failed; try cache
                var cached = loadFromCache();
                if (cached) {
                    cached.cached = true;
                    callback(cached, null);
                } else {
                    callback(FALLBACK_DATA, "No network and no cache");
                }
            }
        }
    };
    xhr.open("GET", url);
    xhr.send();
}

function fetchForecast(lat, lon, callback) {
    var url = FORECAST_URL + "?lat=" + lat + "&lon=" + lon +
              "&units=metric&cnt=24&appid=" + API_KEY;

    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function() {
        if (xhr.readyState === XMLHttpRequest.DONE) {
            if (xhr.status === 200) {
                try {
                    var data = JSON.parse(xhr.responseText);
                    var days = [];
                    // Extract one entry per day (every 8th item in 3-hour intervals)
                    for (var i = 0; i < data.list.length; i += 8) {
                        var item = data.list[i];
                        days.push({
                            temp: Math.round(item.main.temp),
                            condition: item.weather[0].main,
                            icon: item.weather[0].icon
                        });
                    }
                    callback(days, null);
                } catch (e) {
                    callback([], "Forecast parse error");
                }
            } else {
                callback([], "Forecast fetch failed");
            }
        }
    };
    xhr.open("GET", url);
    xhr.send();
}

// Cache using Qt.labs.settings via QML bridge
// The calling QML component must pass a Settings reference
var _settings = null;

function initCache(settingsObj) {
    _settings = settingsObj;
}

function saveToCache(data) {
    if (_settings) {
        _settings.setValue("weatherCache", JSON.stringify(data));
    }
}

function loadFromCache() {
    if (_settings) {
        var raw = _settings.value("weatherCache", "");
        if (raw && raw !== "") {
            try {
                return JSON.parse(raw);
            } catch (e) {
                return null;
            }
        }
    }
    return null;
}

A note on the caching approach: Qt5 QML doesn't ship a built-in FileIO type. We use Qt.labs.settings instead, which persists key-value pairs and ships in most Qt5 QML builds. For more complex storage needs, you'd write a C++ plugin exposing file or SQLite access. On AsteroidOS, check whether Qt.labs.settings is available in the image by inspecting /usr/lib/qml/Qt/labs/settings/.

Assembling the UI

// WeatherApp.qml
import QtQuick 2.9
import Qt.labs.settings 1.0
import "components"
import "js/WeatherService.js" as Weather
import "js/GestureHandler.js" as Gesture
import "js/Theme.js" as Theme

Item {
    id: app
    anchors.fill: parent

    property var weatherData: null
    property var forecastData: []
    property bool loading: true
    property bool showForecast: false
    property bool showSettings: false

    // Persistent settings for location
    Settings {
        id: appSettings
        property string latitude: "51.5074"
        property string longitude: "-0.1278"
        property string units: "metric"
    }

    // Initialize on load
    Component.onCompleted: {
        Weather.initCache(appSettings);
        refreshWeather();
    }

    function refreshWeather() {
        loading = true;
        Weather.fetchWeather(
            parseFloat(appSettings.latitude),
            parseFloat(appSettings.longitude),
            function(data, err) {
                weatherData = data;
                loading = false;
            }
        );
        Weather.fetchForecast(
            parseFloat(appSettings.latitude),
            parseFloat(appSettings.longitude),
            function(days, err) {
                forecastData = days;
            }
        );
    }

    // Background
    Rectangle {
        anchors.fill: parent
        color: Theme.colors.background
    }

    CircularContainer {
        id: container

        // Main weather display
        Item {
            id: mainScreen
            anchors.fill: parent
            visible: !app.showForecast && !app.showSettings
            opacity: visible ? 1.0 : 0.0
            Behavior on opacity { NumberAnimation { duration: 200 } }

            // Temperature display (large, centered)
            Text {
                id: tempDisplay
                anchors.centerIn: parent
                anchors.verticalCenterOffset: -parent.height * 0.05
                text: app.weatherData ? app.weatherData.temp + "°" : "--"
                color: Theme.colors.primary
                font.pixelSize: parent.width * Theme.fonts.display
                font.weight: Font.Light
            }

            // Condition text
            Text {
                anchors.top: tempDisplay.bottom
                anchors.topMargin: parent.height * 0.01
                anchors.horizontalCenter: parent.horizontalCenter
                text: app.weatherData ? app.weatherData.condition : "Loading"
                color: Theme.colors.textSecondary
                font.pixelSize: parent.width * Theme.fonts.body
            }

            // Location
            Text {
                anchors.bottom: parent.bottom
                anchors.bottomMargin: parent.height * 0.05
                anchors.horizontalCenter: parent.horizontalCenter
                text: app.weatherData ? app.weatherData.location : ""
                color: Theme.colors.textSecondary
                font.pixelSize: parent.width * Theme.fonts.small
            }

            // Cached indicator
            Text {
                anchors.top: parent.top
                anchors.topMargin: parent.height * 0.05
                anchors.horizontalCenter: parent.horizontalCenter
                text: "● cached"
                color: Theme.colors.secondary
                font.pixelSize: parent.width * Theme.fonts.tiny
                visible: app.weatherData ? app.weatherData.cached : false
            }

            // Loading spinner
            Rectangle {
                anchors.centerIn: parent
                width: parent.width * 0.1
                height: width
                radius: width / 2
                color: Theme.colors.primary
                visible: app.loading
                RotationAnimation on rotation {
                    from: 0; to: 360
                    duration: 1000
                    loops: Animation.Infinite
                    running: app.loading
                }
            }
        }

        // Forecast panel (shown on swipe down)
        Item {
            id: forecastScreen
            anchors.fill: parent
            visible: app.showForecast
            opacity: visible ? 1.0 : 0.0
            Behavior on opacity { NumberAnimation { duration: 200 } }

            Text {
                id: forecastTitle
                anchors.top: parent.top
                anchors.topMargin: parent.height * 0.02
                anchors.horizontalCenter: parent.horizontalCenter
                text: "Forecast"
                color: Theme.colors.text
                font.pixelSize: parent.width * Theme.fonts.heading
            }

            ListView {
                id: forecastList
                anchors.top: forecastTitle.bottom
                anchors.topMargin: parent.height * 0.03
                anchors.horizontalCenter: parent.horizontalCenter
                width: parent.width * 0.9
                height: parent.height * 0.7
                clip: true
                snapMode: ListView.SnapToItem
                flickDeceleration: 1500
                maximumFlickVelocity: 800
                model: app.forecastData.length

                delegate: Rectangle {
                    width: forecastList.width
                    height: 50
                    color: "transparent"
                    Row {
                        anchors.centerIn: parent
                        spacing: 15
                        Text {
                            text: app.forecastData[index]
                                  ? app.forecastData[index].temp + "°" : ""
                            color: Theme.colors.primary
                            font.pixelSize: 18
                        }
                        Text {
                            text: app.forecastData[index]
                                  ? app.forecastData[index].condition : ""
                            color: Theme.colors.textSecondary
                            font.pixelSize: 16
                        }
                    }
                }
            }
        }
    }

    // Global gesture handler
    MouseArea {
        anchors.fill: parent
        propagateComposedEvents: true
        z: app.showForecast || app.showSettings ? 10 : -1

        onPressed: {
            Gesture.onPressed(mouse);
            mouse.accepted = false;
        }
        onReleased: {
            Gesture.onReleased(mouse, {
                onSwipeDown: function() {
                    if (!app.showForecast && !app.showSettings)
                        app.showForecast = true;
                },
                onSwipeUp: function() {
                    if (app.showForecast) app.showForecast = false;
                },
                onSwipeRight: function() {
                    if (app.showSettings) app.showSettings = false;
                    else if (app.showForecast) app.showForecast = false;
                }
            });
        }
    }

    // Refresh timer — every 30 minutes
    Timer {
        interval: 1800000
        running: true
        repeat: true
        onTriggered: refreshWeather()
    }
}

This wires together the component library, weather service, gesture navigation, and theming into a single app. Swipe down reveals the forecast. Swipe right goes back. The timer refreshes data every 30 minutes, a reasonable balance between freshness and battery impact.

Building, Deploying, and Debugging

Cross-Compilation and Packaging

AsteroidOS uses OpenEmbedded's bitbake for building, and packages ship as .ipk files installable via opkg on the device. For rapid iteration during development, you can skip the full bitbake cycle and push QML files directly over SSH.

#!/bin/bash
# deploy.sh — Quick deploy script for AsteroidOS app development

WATCH_IP="192.168.2.15"
APP_NAME="weather-app"
DEPLOY_DIR="/usr/share/asteroid-launcher/watchfaces"
# For standalone apps, use:
# DEPLOY_DIR="/usr/lib/${APP_NAME}"

echo "=== Deploying ${APP_NAME} to ${WATCH_IP} ==="

# Ensure remote directory exists
ssh root@${WATCH_IP} "mkdir -p ${DEPLOY_DIR}/${APP_NAME}"

# Push QML and JS files to the watch
echo "Copying files..."
scp -r main.qml components/ js/ \
    root@${WATCH_IP}:${DEPLOY_DIR}/${APP_NAME}/

# Copy desktop entry
scp weather-app.desktop \
    root@${WATCH_IP}:/usr/share/applications/

# Restart the compositor to pick up changes
echo "Restarting UI..."
ssh root@${WATCH_IP} "systemctl restart user@1000"

echo "=== Deploy complete ==="
echo "Check the app launcher on the watch."

For a production build through the full OpenEmbedded pipeline:

# From the asteroid build directory
source ./prepare-build.sh

# Add your app recipe to the local layer, then:
bitbake weather-app

# The resulting .ipk will be in
# tmp-glibc/deploy/ipk/armv7at2hf-neon/
# Push it to the watch:
scp weather-app_1.0_armv7at2hf-neon.ipk root@192.168.2.15:/tmp/
ssh root@192.168.2.15 "opkg install /tmp/weather-app_1.0_armv7at2hf-neon.ipk"

Debugging Techniques

Remote debugging on a wrist-sized device demands different habits than browser DevTools.

# Watch real-time logs from the watch
ssh root@192.168.2.15 "journalctl -f"

# Enable QML import tracing to debug module loading issues
ssh root@192.168.2.15 "QML_IMPORT_TRACE=1 /usr/bin/weather-app"

# Enable verbose Qt logging
ssh root@192.168.2.15 "QT_LOGGING_RULES='qt.qml.*=true' /usr/bin/weather-app"

# Check memory usage
ssh root@192.168.2.15 "free -m && ps aux | grep weather"

AsteroidOS runs systemd, so journalctl is your primary log interface. If your app silently fails to launch, check QML import paths first. Missing modules produce cryptic errors or simply blank screens.

Performance Optimization for Wearable Constraints

Battery and memory are your two hard constraints. Here's what actually matters, ranked by impact:

Timer discipline. Every Timer that fires wakes the CPU. A 1-second update interval will destroy battery life. Use 30-second minimum intervals for visible updates and 30-minute intervals for background data fetches. When the app isn't visible, stop all timers.

Minimize JavaScript in bindings. QML property bindings re-evaluate whenever their dependencies change. A binding like text: calculateExpensiveValue(model.data) will re-run that JS function every time model.data changes. Pre-compute values in signal handlers and assign them to properties instead.

Image assets. Use SVG where possible since the Qt SVG renderer scales cleanly to any watch resolution without shipping multiple asset sizes. For raster images, never exceed the display resolution (400x400 max). PNG files larger than the screen waste memory on decode.

On a 512MB device where the OS, compositor, and system services eat roughly 200-300MB, your app gets maybe 100-150MB of headroom. Keep total footprint under 15MB including assets.

Memory budget. On a 512MB device where the OS, compositor, and system services eat roughly 200-300MB, your app gets maybe 100-150MB of headroom. Keep total footprint under 15MB including assets. Use Loader components to instantiate QML only when needed and destroy it when off-screen.

Avoid creating and destroying components rapidly. Unlike React's virtual DOM which efficiently handles component churn, QML object creation has real cost. Use visible: false to hide components rather than destroying them, and pre-allocate list delegates.

The Road Ahead for JS on Wearables

AsteroidOS occupies a unique niche: it's the only actively maintained open-source Linux distribution targeting consumer smartwatch hardware. The community is small but committed, with development coordinated through the project's GitHub organization (github.com/AsteroidOS) and a Matrix channel for real-time discussion.

The broader ecosystem is converging in interesting ways. PineTime runs InfiniTime (a C++ firmware), Bangle.js runs a custom JavaScript runtime (Espruino) on nRF52 hardware, and AsteroidOS runs Qt/QML on repurposed Android Wear devices. Each takes a different approach to the same problem: letting independent developers build for their wrists. A unified JavaScript wearable framework spanning these platforms would be compelling but remains speculative given how different the underlying runtimes are.

For web developers willing to learn a new UI language that speaks a very familiar dialect of JavaScript, the barrier to entry for wearable development has never been lower.

What's more immediately realistic: WebAssembly support in QML could eventually allow compiling React or Vue component logic to Wasm modules and running them within QML applications. Qt has active Wasm efforts, though currently focused on running Qt apps inside browsers rather than embedding Wasm in native QML.

The component library and weather app from this tutorial are available as a starter repo you can fork and extend. Flash a supported watch, clone the repo, run deploy.sh, and you'll have a working app on your wrist in under an hour. For web developers willing to learn a new UI language that speaks a very familiar dialect of JavaScript, the barrier to entry for wearable development has never been lower.

SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.