How to Port a React or Vue App to AsteroidOS
- Install the AsteroidOS SDK by cloning the build environment and running
prepare-build.shon a Linux host. - Flash a supported smartwatch (e.g., LG G Watch, Asus ZenWatch) with the AsteroidOS image via fastboot.
- Scaffold your project with a
.profile,.desktoplauncher entry, and root QML file. - Translate React/Vue components to QML using
propertyfor props,signalfor events, and built-in reactive bindings for state. - Design for the circular display by constraining content to a 70% safe zone and using parent-relative sizing throughout.
- Implement networking with QML's
XMLHttpRequest, adding offline caching viaQt.labs.settings. - Deploy by pushing QML/JS files over SSH to the watch and restarting the compositor.
- 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
- What Is AsteroidOS and Why Does It Matter?
- Setting Up Your AsteroidOS Development Environment
- Bridging React/Vue Concepts to QML + JavaScript
- Building a Smartwatch Component Library
- Porting a Real App: Weather Dashboard for Your Wrist
- Building, Deploying, and Debugging
- Performance Optimization for Wearable Constraints
- The Road Ahead for JS on Wearables
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.


