Pebble Smartwatch Development Guide (Pebble OS & PebbleKit JS)
This skill provides comprehensive instructions, architectural guidelines, and hard-earned troubleshooting solutions for developing native Pebble smartwatch applications across all hardware generations, with first-class support for the Pebble Time 2 (emery, 200×228 display), basalt, chalk, diorite, and aplite.
1. Environment & CLI Quick Reference
The Pebble SDK CLI tool is typically installed at ~/.local/bin/pebble. Ensure PATH="$PATH:$HOME/.local/bin" is exported before invoking commands.
Common CLI Commands
# Clean previous build artifacts
pebble clean
# Compile PBW bundle for all target platforms
pebble build
# Sideload and install to watch over phone Developer Connection (Wi-Fi)
pebble install --phone <PHONE_IP>
# Stream live watch C logs (APP_LOG), crash traces (PC/LR), and PKJS console output
pebble logs --phone <PHONE_IP>
# Sideload via CloudPebble / Rebble proxy
pebble install --cloudpebble
pebble logs --cloudpebble
# Capture live watch screenshot over network
pebble screenshot --phone <PHONE_IP>
[!NOTE] Developer Connection: Enable Developer Mode in the Pebble/Rebble mobile companion app under Settings > Developer Mode, toggle on Developer Connection, and note the displayed IP address. Keep the phone app open during install and log streaming.
2. Bootstrapping a New Project (Pebble CLI)
[!IMPORTANT] Always use the Pebble SDK CLI to bootstrap projects. Never manually scaffold directories or files by hand. The CLI ensures proper Waf build tooling, generates a valid RFC 4122 UUID, sets up
sdkVersion: "3", enablesmultiJS, and registers target platforms across all hardware revisions.
Creating Projects with pebble new-project
The pebble new-project command accepts the following project types and flags:
# Standard watchapp with PebbleKit JS phone companion (Recommended for networked apps)
pebble new-project <app-name> --javascript
# Minimal C-only watchface or simple app
pebble new-project <app-name> --simple
# Watchapp with background worker thread (runs even when watchapp is closed)
pebble new-project <app-name> --javascript --worker
# Watchapp with AI coding assistant guidelines (Claude Code / Cursor rules)
pebble new-project <app-name> --javascript --ai
Other Project Types Supported by the CLI:
--c: Standard C project (default).--rocky: Rocky.js (JavaScript-only on watch) project.--alloy: Alloy project (powered by Moddable JavaScript engine).
What pebble new-project Generates Automatically
Running pebble new-project <name> --javascript scaffolds a fully working, buildable project out of the box:
<app-name>/
├── package.json # Auto-generated UUID, SDK v3, targetPlatforms, dummy messageKeys
├── wscript # Pre-configured Waf build script loading pebble_sdk
├── README.md # Starter documentation
└── src/
├── c/
│ └── <name>.c # Starter C window and event loop
└── pkjs/
└── index.js # Starter PebbleKit JS bridge
The generated package.json automatically includes all hardware target platforms:
"targetPlatforms": [
"aplite",
"basalt",
"chalk",
"diorite",
"emery",
"flint",
"gabbro"
]
Essential Post-Creation Setup Commands
After bootstrapping with the CLI:
cd <app-name>
# 1. Generate compile_commands.json so clangd / IDEs resolve Pebble SDK C headers
pebble compile-commands
# 2. Verify clean build immediately across all platforms
pebble build
Migrating Legacy Pebble Projects
If working with an older project created under Pebble SDK 2.x or early SDK 3.x using appinfo.json:
# Converts legacy appinfo.json to modern package.json format
pebble convert-project
3. Project Architecture & File Layout
A standard Pebble application consists of a C watch app and an optional JavaScript companion (PebbleKit JS / PKJS):
pebble_project/
├── package.json # App metadata, UUID, messageKeys, resources, platforms
├── wscript # Waf build rules loading pebble_sdk
├── resources/ # Media assets (bitmaps, fonts, raw data)
│ └── images/
│ ├── menu_icon.png # 25×25 launcher icon
│ └── screenshot.png
└── src/
├── c/ # Native watch C code
│ ├── main.c # Entry point: app_event_loop, init, deinit
│ ├── ui_*.c # Windows, MenuLayers, graphics drawing
│ ├── comm.c # AppMessage inbox/outbox handlers
│ └── model.c # Data storage & flash persistence
└── pkjs/ # Phone companion JavaScript
├── index.js # PebbleKit JS: networking, token refresh, REST APIs
└── config.html # Settings webview (hosted externally or via Clay)
4. Critical Pebble OS Gotchas & Traps (Post-Mortem Lessons)
These are subtle, high-impact failure modes unique to Pebble OS and embedded ARM micro-libc that cause instant app panics:
A. Flash Storage Limit (PERSIST_DATA_MAX_LENGTH = 256 bytes) & Quota Leaks
- The Trap:
persist_write_data(key, ptr, size)has a strict hardware kernel maximum of 256 bytes per key. Attempting to write a struct array larger than 256 bytes fails or causes a fatal OS memory abort on startup. Additionally, Pebble OS has a strict 4KB total storage quota per app. If saving a dynamic list that previously had more items, failing to delete trailing keys leaves orphaned records that leak your 4KB quota permanently. - The Fix: Chunk persistent data and delete orphaned keys when the count shrinks:
#define PERSIST_KEY_COUNT 100 #define PERSIST_KEY_ITEM_BASE 1000 void model_save(void) { int prev_count = persist_exists(PERSIST_KEY_COUNT) ? persist_read_int(PERSIST_KEY_COUNT) : 0; persist_write_int(PERSIST_KEY_COUNT, s_item_count); for (int i = 0; i < s_item_count; i++) { persist_write_data(PERSIST_KEY_ITEM_BASE + i, &s_items[i], sizeof(Item)); } // Clean up orphaned keys if the list shrank to reclaim flash quota for (int i = s_item_count; i < prev_count; i++) { persist_delete(PERSIST_KEY_ITEM_BASE + i); } }
B. Standard strtok() Hardware Crash in Pebble OS
- The Trap: Pebble OS uses ARM Cortex FreeRTOS with
newlib-nano. Standard Cstrtok()relies on non-reentrant internal static pointers. Callingstrtok(NULL, ",")branches into unmapped memory (PC: 0x1ab0) and triggers an immediate CPU hardware fault (App fault!). - The Fix: Never use
strtok(). Parse delimited strings using a simple, reentrant pointer scanner:char *p = buffer; int idx = 0; while (*p && idx < MAX_ITEMS) { while (*p == ' ' || *p == ',') p++; if (*p == '\0') break; int val = 0; while (*p >= '0' && *p <= '9') { val = val * 10 + (*p - '0'); p++; } items[idx++] = val; while (*p && *p != ',') p++; }
C. MenuLayer 0-Row Sections Bug
- The Trap: In Pebble's C SDK, if
menu_get_num_sectionsreturnsNsections, and any section returns0rows inmenu_get_num_rows, the OS internal layout engine dereferences an invalid index and crashes the app. - The Fix: Dynamically calculate sections. Only allocate a section if
count > 0:typedef struct { int section_type; int count; } SectionInfo; static int get_sections(SectionInfo *sections) { int num = 0; if (daily_count > 0) { if (sections) { sections[num].section_type = TYPE_DAILY; sections[num].count = daily_count; } num++; } // Only add sections that actually contain rows! return num; }
D. Cursor Bounds on Model Reload
- The Trap: Calling
menu_layer_reload_data()while the cursor selection points to an index that no longer exists in the updated model (e.g. after deleting an item, filtering, or navigating pages) crashesMenuLayer. Counter-trap: Unconditionally resetting the selection toMenuIndex(0, 0)on every model reload destroys usability by jumping the user's scroll position back to the top whenever an item is toggled or updated inline. - The Fix: Only reset the cursor to
(0, 0)when performing major view transitions (e.g. changing dates/filters), or clamp safely if the current selection index exceeds the new dataset bounds:MenuIndex current = menu_layer_get_selected_index(s_menu_layer); if (current.section >= new_num_sections || current.row >= new_rows_in_section) { menu_layer_set_selected_index(s_menu_layer, MenuIndex(0, 0), MenuRowAlignTop, false); } menu_layer_reload_data(s_menu_layer);
E. strftime Unsupported Format Specifiers
- The Trap: In
newlib-nano, POSIX format specifier%e(space-padded day) is unsupported and returns0(empty string). - The Fix: Use standard C89
%d(e.g.%b %dor%a, %b %d), or explicitly handle "Today", "Yesterday", and "Tomorrow" before falling back tostrftime.
F. Google Firestore REST updateMask.fieldPaths Escaping
- The Trap: When patching Firestore from PKJS, field paths containing hyphens or digits (e.g.
logs.2026-09-19) returnHTTP 400: INVALID_ARGUMENTif unquoted. - The Fix: Enclose date keys in backticks inside the query parameter:
var url = FIRESTORE_BASE + userId + '/habits/' + habitId + '?updateMask.fieldPaths=' + encodeURIComponent('logs.`' + periodKey + '`');
G. Phone Settings Dismissing After 1 Second
- The Trap: Tapping "Settings" in the Pebble phone app opens the configuration webview for a split second and instantly dismisses it.
- The Root Cause: PebbleKit JS is tied directly to the watchapp process. If the watchapp crashed on start (e.g., due to storage limit or MenuLayer bug), the phone app terminates PKJS and closes the webview. Fixing the watch crash fixes the phone settings behavior.
H. Use-After-Free Crash with window_destroy in window_unload
- The Trap: Calling
window_destroy(s_window)inside the window's own.unloadhandler triggers an invalid memory access CPU fault or heap corruption when popping windows. - The Root Cause: When a window is popped (
window_stack_pop), Pebble OS triggers.unloadas part of its internal transition sequence. The OS window manager still holds the window pointer and continues executing stack cleanup logic after.unloadreturns. Destroying the window struct during unload frees memory that the OS is still actively referencing. - The Fix: Never call
window_destroy()on a window inside its ownunloadhandler. Instead:- Reuse modal/detail windows across pushes (allocate once on first push or in
init(), and destroy indeinit()), or - Destroy previously popped child windows from the parent window's
.appearhandler.
- Reuse modal/detail windows across pushes (allocate once on first push or in
I. localtime() Returning NULL in Embedded libc
- The Trap: In
newlib-nanoon ARM, callinglocaltime(×tamp)can returnNULLon edge timestamps, uninitialized values, or invalid date offsets. - The Root Cause: Dereferencing
tm->tm_wdayor passing aNULLpointer intostrftime()immediately causes an unmapped memory access CPU hardware fault (App fault!). - The Fix: Always check
if (!tm)before accessing fields or passing tostrftime(), providing a sensible fallback string.
5. PebbleKit JS (PKJS) Communication Patterns
Sequential Streaming with ACKs
To prevent Bluetooth buffer overflows (APP_MSG_BUSY / dropped packets), send items sequentially using recursion:
function sendItemsToWatch(items, index) {
if (!items || index >= items.length) return;
var item = items[index];
var dict = {
ItemCount: items.length,
ItemIndex: index,
ItemId: item.id,
ItemTitle: item.title,
ItemValue: item.value
};
Pebble.sendAppMessage(dict, function() {
// On success ACK: send next item
sendItemsToWatch(items, index + 1);
}, function(err) {
// On NACK/fail: retry after backoff
setTimeout(function() {
Pebble.sendAppMessage(dict, function() {
sendItemsToWatch(items, index + 1);
});
}, 500);
});
}
Silent Ingestion on Watch
In comm.c, do not notify or reload UI on every incoming packet. Load into memory silently and only persist/render once the last packet arrives:
if (index == 0 && total_count > 0) {
model_clear_silent();
}
model_add_silent(&item);
if (index == total_count - 1) {
model_save();
model_notify(); // Reloads UI once for the entire batch
}
Preserving Offline Cache on Network Failures
- The Pitfall: If a remote API call fails in PKJS (offline, 401, or 500 error), emitting an empty list
{ ItemCount: 0 }causes the watchapp to clear its persistent flash storage, destroying all offline usability. - The Pattern: Differentiate between a confirmed empty state and a network/server failure:
- Empty remote dataset: Send
{ ItemCount: 0 }-> watch clears persistent storage. - Network / API failure: Send a distinct status key, e.g.
{ SyncStatus: -1 }-> watch keeps its local persistent cache intact and optionally shows an offline warning.
- Empty remote dataset: Send
JavaScript Runtime Compatibility in PKJS
- The Pebble mobile companion app runs PKJS inside a mobile WebView or JavaScriptCore context that may lack modern ECMAScript features (ES2017+).
- Methods like
String.prototype.padStart(),String.prototype.padEnd(), orObject.entries()will throw an unhandledTypeError: ... is not a function, silently halting all PKJS execution. - Best Practice: Stick to standard ES5 / early ES6 features, and use simple utility helpers for padding:
function pad2(n) { return (n < 10 ? '0' : '') + n; }
Configuration Webviews & Open Redirect Security
- Packaging: The standard Waf build (
wscript) only bundles.jsand.jsonfiles into the PBW bundle. Custom HTML configuration pages must be hosted on an external HTTPS URL (e.g. GitHub Pages) and opened viaPebble.openURL(hostedUrl). - Security: Configuration pages pass data back to PKJS via URL redirect (
window.location.href = returnUrl + encodeURIComponent(JSON.stringify(config))). - Always hardcode
returnUrl = 'pebblejs://close#'or strictly validate thatreturnUrl.startsWith('pebblejs://close#'). Never accept an unvalidatedreturn_toquery parameter, as this creates an open redirect vulnerability that can leak credentials (API tokens, user IDs) stored in the config hash.
6. UI & Visual Design Best Practices
Platform Screen Dimensions
| Platform | Screen Resolution | Color Depth | Notes |
|---|---|---|---|
emery |
200 × 228 | 64 Colors | Pebble Time 2 (2026). Generous headers, larger fonts (GOTHIC_24_BOLD), taller cells (54px). |
basalt |
144 × 168 | 64 Colors | Pebble Time / Time Steel. |
chalk |
180 × 180 (Round) | 64 Colors | Pebble Time Round. Requires centered radial layouts. |
diorite |
144 × 168 | 1-bit B/W | Pebble 2 SE / HR. High contrast black & white. |
aplite |
144 × 168 | 1-bit B/W | Original Pebble Classic / Steel. Strict 24KB RAM limit. |
Responsive Conditional Layouts
#if defined(PBL_PLATFORM_EMERY)
int header_height = 24;
int cell_height = 54;
#elif defined(PBL_PLATFORM_CHALK)
int header_height = 28;
int cell_height = 48;
#else
int header_height = 20;
int cell_height = 46;
#endif
App Launcher Menu Icon
To display a custom launcher icon in the system menu:
- Place a 25×25 pixel PNG with transparent background in
resources/images/menu_icon.png. - In
package.json:
The SDK will automatically inject"resources": { "media": [ { "type": "bitmap", "name": "IMAGE_MENU_ICON", "file": "images/menu_icon.png", "menuIcon": true } ] }.icon_resource_id = RESOURCE_ID_IMAGE_MENU_ICONinto thePebbleProcessInfoapp header.
Animated Loading State
During asynchronous network requests (e.g. date switching), replace the menu with an animated progress screen so the user does not see stale or uninitialized items:
- Use an
AppTimer(e.g. 180ms interval) to increment an animation frame counter. - Draw cycling dots (
.->..->...) and a moving progress bar segment. - Add an 8-second safety fallback timer to dismiss the loading view if Bluetooth disconnects.
1-Bit Monochrome Display Considerations (aplite, diorite)
On monochrome platforms, 64-color palette constants down-sample to binary 1-bit (GColorBlack or GColorWhite):
- Colors mapping to
GColorBlack:GColorCobaltBlue,GColorOxfordBlue,GColorDarkGray,GColorRed, etc. - Colors mapping to
GColorWhite:GColorKellyGreen,GColorChromeYellow,GColorMint,GColorPictonBlue, etc.
Common Traps:
- Invisible row highlight: Setting
bg_color = is_highlighted ? GColorCobaltBlue : GColorBlack;causes the selected cell to remain black on a black screen. - Invisible icons / symbols: Drawing a white icon (
GColorWhitestroke) over a circle filled withGColorKellyGreenrenders white-on-white (completely invisible) on 1-bit displays. - Invisible text: Destructive or warning actions rendered in
GColorRedbecome black-on-black invisible text on an unhighlighted black background. - Invisible progress tracks: Progress bar background tracks drawn in
GColorDarkGraybecome black on black.
The Solution (PBL_IF_COLOR_ELSE):
Use Pebble's built-in macro to supply high-contrast monochrome fallbacks:
// Row highlight background: blue on color, white on monochrome
GColor bg_color = is_highlighted ?
PBL_IF_COLOR_ELSE(GColorCobaltBlue, GColorWhite) : GColorBlack;
// Text color: inverted to black when highlighted on monochrome
GColor text_color = is_highlighted ?
PBL_IF_COLOR_ELSE(GColorWhite, GColorBlack) : GColorWhite;
// Circular badge: green on color, white on monochrome
graphics_context_set_fill_color(ctx, PBL_IF_COLOR_ELSE(GColorKellyGreen, GColorWhite));
graphics_fill_circle(ctx, center, radius);
// Icon/check stroke: white on color, black on monochrome
graphics_context_set_stroke_color(ctx, PBL_IF_COLOR_ELSE(GColorWhite, GColorBlack));
graphics_draw_line(ctx, p1, p2);
// Progress bar track: dark gray on color, white on monochrome
graphics_context_set_fill_color(ctx, PBL_IF_COLOR_ELSE(GColorDarkGray, GColorWhite));
7. Crash Diagnosis & Log Interpretation
Run pebble logs --phone <IP> and trigger the crash. Look for the App fault! line:
fault_handling.c:105> App fault! {b229949d-c6e7-4d10-9f53-6af3341edd5f} PC: 0x1ab0 LR: 0x25d
Program Counter (PC) : 0x1ab0 b'??:?'
Link Register (LR) : 0x25d b'/build/../src/c/comm.c:78'
- Program Counter (
PC): The instruction that crashed. If it shows??:?or an address like0x0/0x1ab0, code jumped into an invalid memory location. - Link Register (
LR): The return address! This points to the exact C source file and line number that called the failing function right before the crash. Inspect that line immediately.