.clang-format 🔗
@@ -0,0 +1,6 @@
+BasedOnStyle: LLVM
+ColumnLimit: 120
+IndentWidth: 2
+ContinuationIndentWidth: 4
+PointerAlignment: Right
+SortIncludes: false
Amolith created
.clang-format | 6
.gitignore | 7
AGENTS.md | 23 ++
PRODUCT.md | 57 +++++
README.md | 51 ++++
mise.toml | 113 ++++++++++
package.json | 65 +++++
src/c/main.c | 547 +++++++++++++++++++++++++++++++++++++++++++++++++
src/pkjs/index.js | 455 ++++++++++++++++++++++++++++++++++++++++
wscript | 43 +++
10 files changed, 1,367 insertions(+)
@@ -0,0 +1,6 @@
+BasedOnStyle: LLVM
+ColumnLimit: 120
+IndentWidth: 2
+ContinuationIndentWidth: 4
+PointerAlignment: Right
+SortIncludes: false
@@ -0,0 +1,7 @@
+build/
+node_modules/
+.lock-waf_*_build
+*.pbw
+*.png
+*.gif
+*.log
@@ -0,0 +1,23 @@
+# pebblexus
+
+Pebble app for Plexus quotas.
+
+## Commands
+
+```sh
+mise run build # build the PBW
+mise run check # same as build; no emulator, tests, lint, or formatter
+mise run install # install on the Emery emulator
+mise run screenshot # writes screenshot_emery.png
+mise run logs # stream Emery emulator logs
+```
+
+Use `mise run emu:up`, `emu:select`, `emu:down`, and `emu:back` for button input.
+
+## Landmines
+
+- `mise.toml` and `package.json` both target `emery`; keep target/platform changes in sync.
+- Adding or renaming AppMessage keys requires updating `package.json` `pebble.messageKeys`, the C `MESSAGE_KEY_*` usage, and PebbleKit JS payload keys together.
+- The Plexus admin key belongs in phone-side PebbleKit JS storage only; never send it to the watch.
+- `mise run build` leaves `build/` and `.lock-waf_*_build`; they are ignored build artifacts.
+- PebbleKit JS here is written in ES5 style for Pebble compatibility: use `var` and functions, not modern syntax.
@@ -0,0 +1,57 @@
+# Product
+
+## Register
+
+product
+
+## Users
+
+Pebblexus is for Plexus operators who want to glance at provider quotas
+and prepaid balances from a Pebble watch. They can be managing their
+agents away from a computer and need enough signal to understand each
+quota group without opening the Plexus management UI.
+
+## Product Purpose
+
+Pebblexus presents Plexus quota checker data as small, readable watch
+pages grouped by provider or checker ID. Success means a user can browse
+groups sorted by most to least urgent according to meters where there's
+a total and a remaining amount. Groups with only numerical meters
+without a max, like an OpenRouter or Hyper balance, should sort after
+groups with meters that do have a max because we can't infer the
+former's criticality while we can the latter's.
+
+Users must see each display name and group ID on each page, and avoid
+mistaking a prepaid meter for a percentage-based meter.
+
+## Brand Personality
+
+Light, Pebble-native, charming. The app should feel like a polished
+native Pebble utility: glanceable, tactile, and a little delightful
+without making the data harder to read.
+
+## Anti-references
+
+Do not copy Plexus' management UI directly. Avoid generic AI-dashboard
+dark mode, ornamental gradients, and heavy shadows.
+
+## Design Principles
+
+- Data semantics come first: balances are numbers, quotas are bars, and
+ unknowns should be honest.
+- Group related quota meters together so one provider or checker feels
+ like one page, then sort those pages so more urgent checkers surface
+ first. Wraparound lets them predictably go back to checkers without an
+ urgency signal.
+- Use calm, tactile, playful motion as feedback for state changes, not
+ as constant decoration.
+- Prefer Pebble-native restraint: strong contrast, simple shapes, clear
+ typography, small moments of charm.
+- Keep the app usable in a quick glance under imperfect ambient light.
+
+## Accessibility & Inclusion
+
+Use high-contrast light mode as the default. Do not rely on color alone
+for quota state; pair color with numbers, labels, and page grouping.
+Keep animation brief and avoid continuous motion except during an active
+refresh. Dark mode will come later.
@@ -0,0 +1,51 @@
+# Pebblexus
+
+Pebblexus is a Pebble watchapp for checking Plexus quota status from
+your wrist.
+
+Pebblexus groups quota meters from Plexus' management quota endpoint by
+provider/checker ID. Up/down move between groups, select refreshes the
+current list, and the footer shows the current page count. The
+phone-side PebbleKit JS stores the user's Plexus base URL and admin key;
+the key is never sent to the watch.
+
+## Requirements
+
+- `mise`
+- A Plexus instance reachable from the phone
+- A Plexus admin key for that instance
+
+The development environment is described in `mise.toml`. It installs the
+`pebble` CLI through mise's `pipx:pebble-tool` backend, pins the Python
+runtime used by that tool, and adds the Pebble SDK toolchain to `PATH`.
+
+## Build
+
+```sh
+mise run build
+```
+
+`mise run check` runs the same build without requiring an emulator.
+
+## Test on Emery
+
+```sh
+mise run install
+mise run screenshot
+```
+
+Interact with the app from the CLI:
+
+```sh
+mise run emu:up # previous group
+mise run emu:select # refresh quotas
+mise run emu:down # next group
+mise run emu:back # long-press back
+```
+
+Optional app-icon generation uses the local Pebble skill helper and the
+Pebble tool Python environment, which includes Pillow via mise:
+
+```sh
+mise run icons
+```
@@ -0,0 +1,113 @@
+min_version = "2026.2.4"
+
+[vars]
+platform = "emery"
+screenshot = "screenshot_emery.png"
+python_version = "3.13"
+pebble_tool_version = "5.0.37"
+pebble_tool_python = "{{env.HOME}}/.local/share/mise/installs/pipx-pebble-tool/{{vars.pebble_tool_version}}/pebble-tool/bin/python"
+app_icons_script = "{{env.HOME}}/.agents/skills/pebble-watchface/scripts/create_app_icons.py"
+preview_gif_script = "{{env.HOME}}/.agents/skills/pebble-watchface/scripts/create_preview_gif.py"
+
+[tools]
+node = "22"
+"aqua:tamasfe/taplo" = "0.10.0"
+"npm:prettier" = "3.8.1"
+"pipx:pebble-tool" = { version = "{{vars.pebble_tool_version}}", uvx_args = "--python {{vars.python_version}}" }
+
+[tasks.fmt]
+description = "Format C, PebbleKit JS, JSON, Markdown, and TOML"
+run = ["mise run c:fmt", "mise run prettier:fmt", "mise run toml:fmt"]
+
+[tasks.lint]
+description = "Check formatting"
+run = ["mise run c:lint", "mise run prettier:lint", "mise run toml:lint"]
+
+[tasks.fix]
+description = "Format and run lightweight checks"
+run = ["mise run fmt", "mise run lint"]
+
+[tasks.build]
+description = "Build the Pebble PBW"
+run = "pebble build"
+
+[tasks.check]
+description = "Format, lint, and build without requiring an emulator"
+run = ["mise run fix", "mise run build"]
+
+[tasks.clean]
+description = "Remove Pebble build artifacts"
+run = "pebble clean"
+
+[tasks."c:fmt"]
+description = "Format Pebble C code"
+run = "clang-format -i src/c/main.c"
+
+[tasks."c:lint"]
+description = "Check Pebble C formatting"
+run = "clang-format --dry-run --Werror src/c/main.c"
+
+[tasks."prettier:fmt"]
+description = "Format JS, JSON, and Markdown"
+run = "prettier --trailing-comma none --write README.md PRODUCT.md package.json src/pkjs/index.js"
+
+[tasks."prettier:lint"]
+description = "Check JS, JSON, and Markdown formatting"
+run = "prettier --trailing-comma none --check README.md PRODUCT.md package.json src/pkjs/index.js"
+
+[tasks."toml:fmt"]
+description = "Format TOML"
+run = "taplo fmt mise.toml"
+
+[tasks."toml:lint"]
+description = "Check TOML formatting"
+run = "taplo fmt --check mise.toml"
+
+[tasks.size]
+description = "Analyze Pebble app ELF size"
+depends = ["build"]
+run = "pebble analyze-size build/{{vars.platform}}/pebble-app.elf"
+
+[tasks.install]
+description = "Install the app in the Pebble emulator"
+run = "pebble install --emulator {{vars.platform}}"
+
+[tasks."emu:config"]
+description = "Open the app-provided config page in the Pebble emulator"
+run = "pebble emu-app-config --emulator {{vars.platform}}"
+
+[tasks."install:cloudpebble"]
+description = "Install the app on a paired device through CloudPebble"
+run = "pebble install --cloudpebble"
+
+[tasks.screenshot]
+description = "Capture an emulator screenshot"
+run = "pebble screenshot --no-open --emulator {{vars.platform}} {{vars.screenshot}}"
+
+[tasks.logs]
+description = "Stream emulator logs"
+run = "pebble logs --emulator {{vars.platform}}"
+
+[tasks."emu:up"]
+description = "Press the emulator up button"
+run = "pebble emu-button click up --emulator {{vars.platform}}"
+
+[tasks."emu:select"]
+description = "Press the emulator select button"
+run = "pebble emu-button click select --emulator {{vars.platform}}"
+
+[tasks."emu:down"]
+description = "Press the emulator down button"
+run = "pebble emu-button click down --emulator {{vars.platform}}"
+
+[tasks."emu:back"]
+description = "Long-press the emulator back button"
+run = "pebble emu-button click back --duration 2000 --emulator {{vars.platform}}"
+
+[tasks.icons]
+description = "Generate app icons from the current emulator screenshot"
+run = "{{vars.pebble_tool_python}} {{vars.app_icons_script}} ."
+
+[tasks.gif]
+description = "Generate an emulator preview GIF"
+run = "{{vars.pebble_tool_python}} {{vars.preview_gif_script}} . --frames 8 --delay 400"
@@ -0,0 +1,65 @@
+{
+ "name": "pebblexus",
+ "author": "Amolith",
+ "version": "0.1.0",
+ "keywords": [
+ "pebble-app",
+ "plexus",
+ "quota"
+ ],
+ "private": true,
+ "dependencies": {},
+ "pebble": {
+ "displayName": "Pebblexus",
+ "uuid": "00317a49-ec0e-4af4-a842-b4f76d3265c8",
+ "sdkVersion": "3",
+ "enableMultiJS": true,
+ "targetPlatforms": [
+ "emery"
+ ],
+ "capabilities": [
+ "configurable"
+ ],
+ "watchapp": {
+ "watchface": false
+ },
+ "messageKeys": [
+ "REQUEST_QUOTA",
+ "REQUEST_PAGE",
+ "QUOTA_TITLE",
+ "QUOTA_LABEL",
+ "QUOTA_DETAIL",
+ "QUOTA_PERCENT",
+ "QUOTA_STATUS",
+ "QUOTA_RESET",
+ "ROW_COUNT",
+ "ROW_0_LABEL",
+ "ROW_0_DETAIL",
+ "ROW_0_PERCENT",
+ "ROW_0_STATUS",
+ "ROW_0_HAS_BAR",
+ "ROW_1_LABEL",
+ "ROW_1_DETAIL",
+ "ROW_1_PERCENT",
+ "ROW_1_STATUS",
+ "ROW_1_HAS_BAR",
+ "ROW_2_LABEL",
+ "ROW_2_DETAIL",
+ "ROW_2_PERCENT",
+ "ROW_2_STATUS",
+ "ROW_2_HAS_BAR",
+ "ROW_3_LABEL",
+ "ROW_3_DETAIL",
+ "ROW_3_PERCENT",
+ "ROW_3_STATUS",
+ "ROW_3_HAS_BAR",
+ "PAGE_INDEX",
+ "PAGE_COUNT",
+ "UPDATED_AT",
+ "ERROR"
+ ],
+ "resources": {
+ "media": []
+ }
+ }
+}
@@ -0,0 +1,547 @@
+#include <pebble.h>
+
+#define MAX_ROWS 4
+#define ANIMATION_INTERVAL_MS 33
+#define ANIMATION_FRAMES 12
+
+typedef struct {
+ char label[24];
+ char detail[28];
+ int percent;
+ int status_rank;
+ bool has_bar;
+} QuotaRow;
+
+static Window *s_main_window;
+static Layer *s_card_layer;
+static AppTimer *s_animation_timer;
+
+static int s_page_index = 0;
+static int s_page_count = 0;
+static int s_row_count = 0;
+static int s_animation_frame = ANIMATION_FRAMES;
+static int s_refresh_frame = 0;
+static int s_animation_direction = 1;
+static bool s_is_refreshing = false;
+
+static char s_title_text[40] = "Pebblexus";
+static char s_group_text[40] = "";
+static char s_reset_text[40] = "Set URL and admin key";
+static char s_status_text[16] = "Waiting";
+static char s_updated_text[16] = "";
+static QuotaRow s_rows[MAX_ROWS];
+
+static uint32_t row_label_key(int row_index) {
+ switch (row_index) {
+ case 0:
+ return MESSAGE_KEY_ROW_0_LABEL;
+ case 1:
+ return MESSAGE_KEY_ROW_1_LABEL;
+ case 2:
+ return MESSAGE_KEY_ROW_2_LABEL;
+ default:
+ return MESSAGE_KEY_ROW_3_LABEL;
+ }
+}
+
+static uint32_t row_detail_key(int row_index) {
+ switch (row_index) {
+ case 0:
+ return MESSAGE_KEY_ROW_0_DETAIL;
+ case 1:
+ return MESSAGE_KEY_ROW_1_DETAIL;
+ case 2:
+ return MESSAGE_KEY_ROW_2_DETAIL;
+ default:
+ return MESSAGE_KEY_ROW_3_DETAIL;
+ }
+}
+
+static uint32_t row_percent_key(int row_index) {
+ switch (row_index) {
+ case 0:
+ return MESSAGE_KEY_ROW_0_PERCENT;
+ case 1:
+ return MESSAGE_KEY_ROW_1_PERCENT;
+ case 2:
+ return MESSAGE_KEY_ROW_2_PERCENT;
+ default:
+ return MESSAGE_KEY_ROW_3_PERCENT;
+ }
+}
+
+static uint32_t row_status_key(int row_index) {
+ switch (row_index) {
+ case 0:
+ return MESSAGE_KEY_ROW_0_STATUS;
+ case 1:
+ return MESSAGE_KEY_ROW_1_STATUS;
+ case 2:
+ return MESSAGE_KEY_ROW_2_STATUS;
+ default:
+ return MESSAGE_KEY_ROW_3_STATUS;
+ }
+}
+
+static uint32_t row_has_bar_key(int row_index) {
+ switch (row_index) {
+ case 0:
+ return MESSAGE_KEY_ROW_0_HAS_BAR;
+ case 1:
+ return MESSAGE_KEY_ROW_1_HAS_BAR;
+ case 2:
+ return MESSAGE_KEY_ROW_2_HAS_BAR;
+ default:
+ return MESSAGE_KEY_ROW_3_HAS_BAR;
+ }
+}
+
+static int clamp_percent(int percent) {
+ if (percent < 0)
+ return 0;
+ if (percent > 100)
+ return 100;
+ return percent;
+}
+
+static GColor color_background(void) { return PBL_IF_COLOR_ELSE(GColorWhite, GColorWhite); }
+
+static GColor color_card_outline(void) { return PBL_IF_COLOR_ELSE(GColorFromHEX(0xAAFFFF), GColorBlack); }
+
+static GColor color_text_primary(void) { return GColorBlack; }
+
+static GColor color_text_secondary(void) { return PBL_IF_COLOR_ELSE(GColorCobaltBlue, GColorBlack); }
+
+static GColor color_text_muted(void) { return PBL_IF_COLOR_ELSE(GColorDarkGray, GColorBlack); }
+
+static GColor color_text_soft(void) { return PBL_IF_COLOR_ELSE(GColorDukeBlue, GColorBlack); }
+
+static GColor color_track(void) { return PBL_IF_COLOR_ELSE(GColorLightGray, GColorBlack); }
+
+static GColor color_card_highlight(void) { return PBL_IF_COLOR_ELSE(GColorVividCerulean, GColorBlack); }
+
+static GColor color_warning_soft(void) { return PBL_IF_COLOR_ELSE(GColorIcterine, GColorWhite); }
+
+static GColor color_ok_soft(void) { return PBL_IF_COLOR_ELSE(GColorMintGreen, GColorWhite); }
+
+static GColor color_for_status(int status_rank) {
+#ifdef PBL_COLOR
+ if (status_rank >= 4)
+ return GColorRed;
+ if (status_rank >= 3)
+ return GColorOrange;
+ return GColorJaegerGreen;
+#else
+ return GColorBlack;
+#endif
+}
+
+static GColor color_status_fill(void) {
+ if (strcmp(s_status_text, "CRITICAL") == 0)
+ return PBL_IF_COLOR_ELSE(GColorMelon, GColorWhite);
+ if (strcmp(s_status_text, "WARNING") == 0)
+ return color_warning_soft();
+ return color_ok_soft();
+}
+
+static GColor color_for_status_text(void) {
+ if (strcmp(s_status_text, "CRITICAL") == 0)
+ return color_for_status(4);
+ if (strcmp(s_status_text, "WARNING") == 0)
+ return color_for_status(3);
+ return color_for_status(1);
+}
+
+static int current_slide_offset(void) { return 0; }
+
+static int current_bar_percent(int percent) {
+ int clamped = clamp_percent(percent);
+ if (s_animation_frame >= ANIMATION_FRAMES)
+ return clamped;
+ int frame = s_animation_frame;
+ int eased = ANIMATION_FRAMES - (((ANIMATION_FRAMES - frame) * (ANIMATION_FRAMES - frame)) / ANIMATION_FRAMES);
+ return (clamped * eased) / ANIMATION_FRAMES;
+}
+
+static void mark_card_dirty(void) {
+ if (s_card_layer != NULL)
+ layer_mark_dirty(s_card_layer);
+}
+
+static void animation_timer_callback(void *data) {
+ s_animation_timer = NULL;
+ if (s_animation_frame < ANIMATION_FRAMES || s_is_refreshing) {
+ if (s_animation_frame < ANIMATION_FRAMES) {
+ s_animation_frame += 1;
+ }
+ s_refresh_frame = (s_refresh_frame + 1) % ANIMATION_FRAMES;
+ mark_card_dirty();
+ s_animation_timer = app_timer_register(ANIMATION_INTERVAL_MS, animation_timer_callback, NULL);
+ }
+}
+
+static void start_card_animation(int direction) {
+ if (s_animation_timer != NULL) {
+ app_timer_cancel(s_animation_timer);
+ s_animation_timer = NULL;
+ }
+
+ s_animation_direction = direction < 0 ? -1 : 1;
+ s_animation_frame = 0;
+ s_refresh_frame = 0;
+ mark_card_dirty();
+ s_animation_timer = app_timer_register(ANIMATION_INTERVAL_MS, animation_timer_callback, NULL);
+}
+
+static void request_quota(void) {
+ DictionaryIterator *iter;
+ AppMessageResult result = app_message_outbox_begin(&iter);
+ if (result != APP_MSG_OK || iter == NULL) {
+ snprintf(s_status_text, sizeof(s_status_text), "Send fail");
+ s_is_refreshing = false;
+ mark_card_dirty();
+ return;
+ }
+
+ dict_write_uint8(iter, MESSAGE_KEY_REQUEST_QUOTA, 1);
+ dict_write_int32(iter, MESSAGE_KEY_PAGE_INDEX, s_page_index);
+ result = app_message_outbox_send();
+ if (result == APP_MSG_OK) {
+ snprintf(s_status_text, sizeof(s_status_text), "Refreshing");
+ s_updated_text[0] = '\0';
+ s_is_refreshing = true;
+ start_card_animation(1);
+ } else {
+ snprintf(s_status_text, sizeof(s_status_text), "Send fail");
+ s_is_refreshing = false;
+ mark_card_dirty();
+ }
+}
+
+static void request_page(int page_index) {
+ if (s_page_count <= 0)
+ return;
+ int direction = page_index >= s_page_index ? 1 : -1;
+ if (page_index < 0) {
+ page_index = s_page_count - 1;
+ direction = -1;
+ }
+ if (page_index >= s_page_count) {
+ page_index = 0;
+ direction = 1;
+ }
+
+ DictionaryIterator *iter;
+ AppMessageResult result = app_message_outbox_begin(&iter);
+ if (result != APP_MSG_OK || iter == NULL) {
+ snprintf(s_status_text, sizeof(s_status_text), "Page fail");
+ mark_card_dirty();
+ return;
+ }
+
+ s_page_index = page_index;
+ dict_write_int32(iter, MESSAGE_KEY_REQUEST_PAGE, page_index);
+ result = app_message_outbox_send();
+ if (result == APP_MSG_OK) {
+ start_card_animation(direction);
+ } else {
+ snprintf(s_status_text, sizeof(s_status_text), "Send fail");
+ mark_card_dirty();
+ }
+}
+
+static void up_click_handler(ClickRecognizerRef recognizer, void *context) { request_page(s_page_index - 1); }
+
+static void down_click_handler(ClickRecognizerRef recognizer, void *context) { request_page(s_page_index + 1); }
+
+static void select_click_handler(ClickRecognizerRef recognizer, void *context) { request_quota(); }
+
+static void click_config_provider(void *context) {
+ window_single_repeating_click_subscribe(BUTTON_ID_UP, 180, up_click_handler);
+ window_single_click_subscribe(BUTTON_ID_SELECT, select_click_handler);
+ window_single_repeating_click_subscribe(BUTTON_ID_DOWN, 180, down_click_handler);
+}
+
+static void draw_text(GContext *ctx, const char *text, GRect box, GFont font, GColor color, GTextAlignment alignment) {
+ graphics_context_set_text_color(ctx, color);
+ graphics_draw_text(ctx, text, font, box, GTextOverflowModeTrailingEllipsis, alignment, NULL);
+}
+
+static void draw_progress_bar(GContext *ctx, GRect frame, int percent, GColor color) {
+ graphics_context_set_fill_color(ctx, color_track());
+ graphics_fill_rect(ctx, frame, 3, GCornersAll);
+
+ int fill_width = (frame.size.w * current_bar_percent(percent)) / 100;
+ if (fill_width < 2 && percent > 0)
+ fill_width = 2;
+ graphics_context_set_fill_color(ctx, color);
+ graphics_fill_rect(ctx, GRect(frame.origin.x, frame.origin.y, fill_width, frame.size.h), 3, GCornersAll);
+}
+
+static void draw_status_pill(GContext *ctx, GRect frame) {
+ graphics_context_set_fill_color(ctx, color_status_fill());
+ graphics_fill_rect(ctx, frame, 6, GCornersAll);
+ graphics_context_set_stroke_color(ctx, color_for_status_text());
+ graphics_draw_rect(ctx, frame);
+ draw_text(ctx, s_status_text, GRect(frame.origin.x, frame.origin.y - 4, frame.size.w, frame.size.h + 4),
+ fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), color_text_primary(), GTextAlignmentCenter);
+}
+
+static void draw_refresh_dots(GContext *ctx, GRect bounds) {
+ if (!s_is_refreshing)
+ return;
+ int phase = s_refresh_frame;
+ int center_x = bounds.size.w - 23;
+ int center_y = 18;
+
+ graphics_context_set_fill_color(ctx, color_text_muted());
+ graphics_fill_circle(ctx, GPoint(center_x - 7, center_y), phase < 4 ? 3 : 2);
+ graphics_fill_circle(ctx, GPoint(center_x, center_y), phase >= 3 && phase < 7 ? 3 : 2);
+ graphics_fill_circle(ctx, GPoint(center_x + 7, center_y), phase >= 6 ? 3 : 2);
+}
+
+static void draw_page_dots(GContext *ctx, GRect frame) {
+ if (s_page_count <= 1)
+ return;
+
+ int dots = s_page_count > 7 ? 7 : s_page_count;
+ int total_width = dots * 7 - 3;
+ int x = frame.origin.x + (frame.size.w - total_width) / 2;
+ int y = frame.origin.y + frame.size.h - 10;
+
+ for (int i = 0; i < dots; i += 1) {
+ graphics_context_set_fill_color(ctx, i == s_page_index || (s_page_count > 7 && i == 6 && s_page_index >= 6)
+ ? color_card_highlight()
+ : color_text_muted());
+ int active = i == s_page_index || (s_page_count > 7 && i == 6 && s_page_index >= 6);
+ int radius = active ? 2 : 1;
+ if (active && s_animation_frame < ANIMATION_FRAMES / 2)
+ radius = 3;
+ graphics_fill_circle(ctx, GPoint(x + (i * 7), y), radius);
+ }
+}
+
+static void card_update_proc(Layer *layer, GContext *ctx) {
+ GRect bounds = layer_get_bounds(layer);
+ graphics_context_set_fill_color(ctx, color_background());
+ graphics_fill_rect(ctx, bounds, 0, GCornerNone);
+
+ const int margin = 10;
+ int offset = current_slide_offset();
+ int content_x = margin;
+ int content_w = bounds.size.w - (margin * 2);
+ int y = 8 + offset;
+
+ draw_text(ctx, s_title_text, GRect(content_x, y, content_w - 50, 29), fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD),
+ color_text_primary(), GTextAlignmentLeft);
+
+ char page_text[16];
+ if (s_page_count > 0) {
+ int display_page_index = s_page_index + 1;
+ if (display_page_index < 1)
+ display_page_index = 1;
+ if (display_page_index > 99)
+ display_page_index = 99;
+ int display_page_count = s_page_count;
+ if (display_page_count < 1)
+ display_page_count = 1;
+ if (display_page_count > 99)
+ display_page_count = 99;
+ snprintf(page_text, sizeof(page_text), "%d/%d", display_page_index, display_page_count);
+ } else {
+ snprintf(page_text, sizeof(page_text), "--");
+ }
+ draw_text(ctx, page_text, GRect(content_x + content_w - 46, y + 2, 46, 24),
+ fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), color_text_secondary(), GTextAlignmentRight);
+ draw_refresh_dots(ctx, bounds);
+
+ y += 26;
+ if (s_status_text[0] != '\0') {
+ draw_status_pill(ctx, GRect(content_x + content_w - 60, y + 1, 60, 18));
+ }
+
+ y += s_status_text[0] == '\0' ? 8 : 26;
+ if (s_row_count <= 0) {
+ graphics_context_set_fill_color(ctx, color_card_highlight());
+ graphics_fill_circle(ctx, GPoint(content_x + (content_w / 2), y + 26), 15);
+ graphics_context_set_fill_color(ctx, color_background());
+ graphics_fill_circle(ctx, GPoint(content_x + (content_w / 2), y + 26), 9);
+ draw_text(ctx, "Quota unavailable", GRect(content_x, y + 48, content_w, 34),
+ fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD), color_text_primary(), GTextAlignmentCenter);
+ draw_text(ctx, s_reset_text, GRect(content_x, y + 86, content_w, 46), fonts_get_system_font(FONT_KEY_GOTHIC_24),
+ color_text_secondary(), GTextAlignmentCenter);
+ } else {
+ for (int i = 0; i < s_row_count && i < MAX_ROWS; i += 1) {
+ QuotaRow *row = &s_rows[i];
+ GColor accent = color_for_status(row->status_rank);
+ int detail_width = 62;
+ draw_text(ctx, row->label, GRect(content_x, y, content_w - detail_width - 8, 22),
+ fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), color_text_primary(), GTextAlignmentLeft);
+ draw_text(ctx, row->detail, GRect(content_x + content_w - detail_width, y, detail_width, 22),
+ fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), row->has_bar ? color_text_soft() : color_text_primary(),
+ GTextAlignmentRight);
+ y += 22;
+ if (row->has_bar) {
+ draw_progress_bar(ctx, GRect(content_x, y, content_w, 6), row->percent, accent);
+ y += 16;
+ } else {
+ y += 6;
+ }
+ if (i + 1 < s_row_count && i + 1 < MAX_ROWS) {
+ graphics_context_set_stroke_color(ctx, color_card_outline());
+ graphics_draw_line(ctx, GPoint(content_x, y), GPoint(content_x + content_w, y));
+ y += 3;
+ }
+ }
+ if (s_row_count > MAX_ROWS) {
+ draw_text(ctx, "+ more", GRect(content_x, y, content_w, 22), fonts_get_system_font(FONT_KEY_GOTHIC_18),
+ color_text_muted(), GTextAlignmentCenter);
+ }
+ }
+
+ draw_page_dots(ctx, bounds);
+
+ draw_text(ctx, s_reset_text, GRect(content_x, bounds.size.h - 55, content_w, 24),
+ fonts_get_system_font(FONT_KEY_GOTHIC_18), color_text_muted(), GTextAlignmentCenter);
+
+ char footer[32];
+ if (s_page_count > 0 && s_status_text[0] != '\0') {
+ snprintf(footer, sizeof(footer), "%.10s %.12s", s_status_text, s_updated_text);
+ } else if (s_page_count > 0) {
+ snprintf(footer, sizeof(footer), "%.31s", s_updated_text);
+ } else {
+ snprintf(footer, sizeof(footer), "%.31s", s_status_text);
+ }
+ draw_text(ctx, footer, GRect(content_x, bounds.size.h - 33, content_w, 24), fonts_get_system_font(FONT_KEY_GOTHIC_18),
+ color_text_secondary(), GTextAlignmentCenter);
+}
+
+static void copy_string_tuple(DictionaryIterator *iterator, uint32_t key, char *buffer, size_t buffer_size) {
+ Tuple *tuple = dict_find(iterator, key);
+ if (tuple == NULL)
+ return;
+ snprintf(buffer, buffer_size, "%s", tuple->value->cstring);
+}
+
+static void read_row(DictionaryIterator *iterator, int row_index) {
+ QuotaRow *row = &s_rows[row_index];
+ copy_string_tuple(iterator, row_label_key(row_index), row->label, sizeof(row->label));
+ copy_string_tuple(iterator, row_detail_key(row_index), row->detail, sizeof(row->detail));
+
+ Tuple *percent_tuple = dict_find(iterator, row_percent_key(row_index));
+ row->percent = percent_tuple != NULL ? clamp_percent((int)percent_tuple->value->int32) : 0;
+
+ Tuple *status_tuple = dict_find(iterator, row_status_key(row_index));
+ row->status_rank = status_tuple != NULL ? (int)status_tuple->value->int32 : 1;
+
+ Tuple *has_bar_tuple = dict_find(iterator, row_has_bar_key(row_index));
+ row->has_bar = has_bar_tuple != NULL && has_bar_tuple->value->int32 != 0;
+}
+
+static void inbox_received_callback(DictionaryIterator *iterator, void *context) {
+ Tuple *error_tuple = dict_find(iterator, MESSAGE_KEY_ERROR);
+ if (error_tuple != NULL) {
+ s_is_refreshing = false;
+ s_row_count = 0;
+ s_page_index = 0;
+ s_page_count = 0;
+ snprintf(s_title_text, sizeof(s_title_text), "Pebblexus");
+ snprintf(s_reset_text, sizeof(s_reset_text), "%s", error_tuple->value->cstring);
+ snprintf(s_status_text, sizeof(s_status_text), "Select retries");
+ s_updated_text[0] = '\0';
+ s_animation_frame = ANIMATION_FRAMES;
+ mark_card_dirty();
+ return;
+ }
+
+ s_is_refreshing = false;
+ copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_TITLE, s_title_text, sizeof(s_title_text));
+ copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_LABEL, s_group_text, sizeof(s_group_text));
+ copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_RESET, s_reset_text, sizeof(s_reset_text));
+ copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_STATUS, s_status_text, sizeof(s_status_text));
+ copy_string_tuple(iterator, MESSAGE_KEY_UPDATED_AT, s_updated_text, sizeof(s_updated_text));
+
+ Tuple *page_index_tuple = dict_find(iterator, MESSAGE_KEY_PAGE_INDEX);
+ if (page_index_tuple != NULL)
+ s_page_index = (int)page_index_tuple->value->int32;
+
+ Tuple *page_count_tuple = dict_find(iterator, MESSAGE_KEY_PAGE_COUNT);
+ if (page_count_tuple != NULL)
+ s_page_count = (int)page_count_tuple->value->int32;
+ if (s_page_count < 0)
+ s_page_count = 0;
+ if (s_page_index >= s_page_count)
+ s_page_index = s_page_count - 1;
+ if (s_page_index < 0)
+ s_page_index = 0;
+
+ Tuple *row_count_tuple = dict_find(iterator, MESSAGE_KEY_ROW_COUNT);
+ int row_count = row_count_tuple != NULL ? (int)row_count_tuple->value->int32 : 0;
+ if (row_count < 0)
+ row_count = 0;
+ s_row_count = row_count;
+
+ int rows_to_read = row_count > MAX_ROWS ? MAX_ROWS : row_count;
+ for (int i = 0; i < rows_to_read; i += 1) {
+ s_rows[i].label[0] = '\0';
+ s_rows[i].detail[0] = '\0';
+ s_rows[i].percent = 0;
+ s_rows[i].status_rank = 1;
+ s_rows[i].has_bar = false;
+ read_row(iterator, i);
+ }
+
+ start_card_animation(s_animation_direction);
+}
+
+static void inbox_dropped_callback(AppMessageResult reason, void *context) {
+ s_is_refreshing = false;
+ snprintf(s_status_text, sizeof(s_status_text), "Dropped");
+ mark_card_dirty();
+}
+
+static void outbox_failed_callback(DictionaryIterator *iterator, AppMessageResult reason, void *context) {
+ s_is_refreshing = false;
+ snprintf(s_status_text, sizeof(s_status_text), "No phone");
+ mark_card_dirty();
+}
+
+static void main_window_load(Window *window) {
+ Layer *window_layer = window_get_root_layer(window);
+ GRect bounds = layer_get_bounds(window_layer);
+
+ s_card_layer = layer_create(bounds);
+ layer_set_update_proc(s_card_layer, card_update_proc);
+ layer_add_child(window_layer, s_card_layer);
+}
+
+static void main_window_unload(Window *window) {
+ if (s_animation_timer != NULL) {
+ app_timer_cancel(s_animation_timer);
+ s_animation_timer = NULL;
+ }
+ layer_destroy(s_card_layer);
+}
+
+static void init(void) {
+ s_main_window = window_create();
+ window_set_background_color(s_main_window, color_background());
+ window_set_click_config_provider(s_main_window, click_config_provider);
+ window_set_window_handlers(s_main_window, (WindowHandlers){.load = main_window_load, .unload = main_window_unload});
+
+ app_message_register_inbox_received(inbox_received_callback);
+ app_message_register_inbox_dropped(inbox_dropped_callback);
+ app_message_register_outbox_failed(outbox_failed_callback);
+ app_message_open(2048, 256);
+
+ window_stack_push(s_main_window, true);
+ request_quota();
+}
+
+static void deinit(void) { window_destroy(s_main_window); }
+
+int main(void) {
+ init();
+ app_event_loop();
+ deinit();
+}
@@ -0,0 +1,455 @@
+var CONFIG_BASE_URL = "plexusBaseUrl";
+var CONFIG_ADMIN_KEY = "plexusAdminKey";
+var MAX_ROWS_PER_GROUP = 4;
+var quotaGroups = [];
+var currentPageIndex = 0;
+var providerDisplayNames = {};
+
+var ROW_KEYS = [
+ {
+ label: "ROW_0_LABEL",
+ detail: "ROW_0_DETAIL",
+ percent: "ROW_0_PERCENT",
+ status: "ROW_0_STATUS",
+ hasBar: "ROW_0_HAS_BAR"
+ },
+ {
+ label: "ROW_1_LABEL",
+ detail: "ROW_1_DETAIL",
+ percent: "ROW_1_PERCENT",
+ status: "ROW_1_STATUS",
+ hasBar: "ROW_1_HAS_BAR"
+ },
+ {
+ label: "ROW_2_LABEL",
+ detail: "ROW_2_DETAIL",
+ percent: "ROW_2_PERCENT",
+ status: "ROW_2_STATUS",
+ hasBar: "ROW_2_HAS_BAR"
+ },
+ {
+ label: "ROW_3_LABEL",
+ detail: "ROW_3_DETAIL",
+ percent: "ROW_3_PERCENT",
+ status: "ROW_3_STATUS",
+ hasBar: "ROW_3_HAS_BAR"
+ }
+];
+
+function trimSlash(value) {
+ return String(value || "").replace(/\/+$/, "");
+}
+
+function sendError(message) {
+ Pebble.sendAppMessage({ ERROR: String(message).slice(0, 38) });
+}
+
+function xhrRequest(url, adminKey, callback) {
+ var xhr = new XMLHttpRequest();
+ xhr.onload = function () {
+ if (xhr.status < 200 || xhr.status >= 300) {
+ callback(new Error("HTTP " + xhr.status), null);
+ return;
+ }
+ callback(null, xhr.responseText);
+ };
+ xhr.onerror = function () {
+ callback(new Error("Network error"), null);
+ };
+ xhr.open("GET", url);
+ xhr.setRequestHeader("x-admin-key", adminKey);
+ xhr.send();
+}
+
+function asNumber(value) {
+ if (typeof value === "number" && isFinite(value)) return value;
+ if (typeof value === "string" && value !== "" && isFinite(Number(value)))
+ return Number(value);
+ return null;
+}
+
+function statusRank(status) {
+ if (status === "critical" || status === "exhausted") return 4;
+ if (status === "warning") return 3;
+ if (status === "unknown") return 2;
+ if (status === "ok") return 1;
+ return 0;
+}
+
+function meterUrgency(meter) {
+ var rank = statusRank(meter.status);
+ var percent = asNumber(meter.utilizationPercent);
+ if (percent === null) percent = 0;
+ return rank * 1000 + percent;
+}
+
+function groupHasComparableMeter(group) {
+ for (var i = 0; i < group.meters.length; i += 1) {
+ if (group.meters[i].hasBar) return true;
+ }
+ return false;
+}
+
+function groupUrgency(group) {
+ var urgency = 0;
+ for (var i = 0; i < group.meters.length; i += 1) {
+ if (!group.meters[i].hasBar) continue;
+ urgency = Math.max(urgency, meterUrgency(group.meters[i]));
+ }
+ return urgency;
+}
+
+function clampPageIndex(pageIndex, pageCount) {
+ var index = asNumber(pageIndex);
+ if (index === null) index = currentPageIndex;
+ index = Math.round(index);
+ if (pageCount <= 0) return 0;
+ if (index < 0) return pageCount - 1;
+ if (index >= pageCount) return 0;
+ return index;
+}
+
+function formatNumber(value) {
+ var number = asNumber(value);
+ if (number === null) return "?";
+ if (Math.abs(number) >= 1000)
+ return String(Math.round(number).toLocaleString());
+ if (Math.abs(number) >= 100) return String(Math.round(number));
+ if (Math.abs(number) >= 10) return String(Math.round(number * 10) / 10);
+ return String(Math.round(number * 100) / 100);
+}
+
+function formatCompactNumber(value) {
+ var number = asNumber(value);
+ if (number === null) return "?";
+ var absolute = Math.abs(number);
+ if (absolute >= 1000000)
+ return String(Math.round(number / 100000) / 10) + "m";
+ if (absolute >= 1000) return String(Math.round(number / 100) / 10) + "k";
+ return formatNumber(number);
+}
+
+function formatValue(value, unit) {
+ var number = asNumber(value);
+ if (number === null) return "?";
+ if (unit === "usd") return "$" + number.toFixed(2);
+ if (unit === "percent" || unit === "percentage")
+ return formatNumber(number) + "%";
+ if (unit === "hypercredits") return formatCompactNumber(number) + " hc";
+ if (unit === "requests") return formatCompactNumber(number) + " req";
+ if (unit === "kwh") return formatCompactNumber(number) + " kWh";
+ if (!unit) return formatNumber(number);
+ return formatCompactNumber(number) + " " + String(unit).slice(0, 4);
+}
+
+function formatMeterDetail(meter) {
+ var percent = asNumber(meter.utilizationPercent);
+ if (meter.hasBar && percent !== null)
+ return String(Math.round(percent)) + "%";
+
+ var remaining = asNumber(meter.remaining);
+ if (remaining !== null) return formatValue(remaining, meter.unit);
+
+ var limit = asNumber(meter.limit);
+ if (limit !== null) return formatValue(limit, meter.unit);
+
+ return "—";
+}
+
+function shortResetForGroup(group) {
+ var soonest = null;
+ for (var i = 0; i < group.meters.length; i += 1) {
+ if (!group.meters[i].resetsAt) continue;
+ var reset = new Date(group.meters[i].resetsAt);
+ if (!isFinite(reset.getTime())) continue;
+ if (soonest === null || reset.getTime() < soonest.getTime())
+ soonest = reset;
+ }
+ if (soonest === null) return "No reset time";
+
+ var diffMs = soonest.getTime() - Date.now();
+ var prefix = diffMs >= 0 ? "Next reset " : "Reset ";
+ var absMinutes = Math.round(Math.abs(diffMs) / 60000);
+ if (absMinutes < 90) return prefix + absMinutes + "m";
+ var absHours = Math.round(absMinutes / 60);
+ if (absHours < 48) return prefix + absHours + "h";
+ return prefix + Math.round(absHours / 24) + "d";
+}
+
+function hasComparableLimit(meter) {
+ var percent = asNumber(meter.utilizationPercent);
+ var limit = asNumber(meter.limit);
+ if (percent === null) return false;
+ if (meter.kind === "balance") return false;
+ if (meter.unit === "percent" || meter.unit === "percentage") return true;
+ if (limit === null || limit <= 0) return false;
+ return true;
+}
+
+function normalizeMeter(quota, meter) {
+ var percent = asNumber(meter.utilizationPercent);
+ return {
+ checkerId: quota.checkerId || quota.provider || "unknown",
+ provider: quota.provider || quota.checkerId || "unknown",
+ label: meter.label || meter.key || "Quota",
+ kind: meter.kind || "unknown",
+ status: meter.status || "unknown",
+ unit: meter.unit || "",
+ limit: meter.limit,
+ remaining: meter.remaining,
+ utilizationPercent: percent,
+ resetsAt: meter.resetsAt,
+ hasBar: hasComparableLimit(meter)
+ };
+}
+
+function displayNameForQuota(quota, id, displayNames) {
+ return (
+ displayNames[id] ||
+ quota.display_name ||
+ quota.displayName ||
+ quota.name ||
+ quota.providerName ||
+ quota.provider ||
+ id
+ );
+}
+
+function compareText(left, right) {
+ var normalizedLeft = String(left || "").toLowerCase();
+ var normalizedRight = String(right || "").toLowerCase();
+ if (normalizedLeft < normalizedRight) return -1;
+ if (normalizedLeft > normalizedRight) return 1;
+ return 0;
+}
+
+function compareMeters(left, right) {
+ if (left.hasBar !== right.hasBar) return left.hasBar ? -1 : 1;
+ var urgencyDelta = meterUrgency(right) - meterUrgency(left);
+ if (urgencyDelta !== 0) return urgencyDelta;
+ return compareText(left.label, right.label);
+}
+
+function flattenGroups(response, displayNames) {
+ var quotas = JSON.parse(response);
+ var groupsById = {};
+ var groups = [];
+
+ for (var i = 0; i < quotas.length; i += 1) {
+ var quota = quotas[i];
+ if (!quota || quota.success !== true || !quota.meters) continue;
+ var id = quota.checkerId || quota.provider || "unknown";
+ if (!groupsById[id]) {
+ groupsById[id] = {
+ id: id,
+ title: displayNameForQuota(quota, id, displayNames || {}),
+ meters: []
+ };
+ groups.push(groupsById[id]);
+ }
+ for (var j = 0; j < quota.meters.length; j += 1) {
+ if (quota.meters[j])
+ groupsById[id].meters.push(normalizeMeter(quota, quota.meters[j]));
+ }
+ }
+
+ for (var groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
+ groups[groupIndex].meters.sort(compareMeters);
+ }
+ groups.sort(function (left, right) {
+ var leftHasComparableMeter = groupHasComparableMeter(left);
+ var rightHasComparableMeter = groupHasComparableMeter(right);
+ if (leftHasComparableMeter !== rightHasComparableMeter)
+ return leftHasComparableMeter ? -1 : 1;
+ var urgencyDelta = groupUrgency(right) - groupUrgency(left);
+ if (urgencyDelta !== 0) return urgencyDelta;
+ return compareText(left.id, right.id);
+ });
+
+ return groups;
+}
+
+function sendGroupPage(pageIndex) {
+ if (quotaGroups.length === 0) {
+ sendError("No quota groups found");
+ return;
+ }
+
+ currentPageIndex = clampPageIndex(pageIndex, quotaGroups.length);
+ var group = quotaGroups[currentPageIndex];
+ var dictionary = {
+ QUOTA_TITLE: String(group.title).slice(0, 38),
+ QUOTA_STATUS: "",
+ QUOTA_RESET: shortResetForGroup(group).slice(0, 38),
+ QUOTA_LABEL: "",
+ ROW_COUNT: group.meters.length,
+ PAGE_INDEX: currentPageIndex,
+ PAGE_COUNT: quotaGroups.length,
+ UPDATED_AT: currentTimeText()
+ };
+
+ var worstStatusRank = 1;
+ var pageHasBar = false;
+ var rowsToSend = Math.min(group.meters.length, MAX_ROWS_PER_GROUP);
+ for (var i = 0; i < rowsToSend; i += 1) {
+ var meter = group.meters[i];
+ var keys = ROW_KEYS[i];
+ var rank = statusRank(meter.status);
+ if (meter.hasBar) pageHasBar = true;
+ worstStatusRank = Math.max(worstStatusRank, rank);
+ dictionary[keys.label] = String(meter.label).slice(0, 22);
+ dictionary[keys.detail] = formatMeterDetail(meter).slice(0, 26);
+ dictionary[keys.percent] = Math.max(
+ 0,
+ Math.min(100, Math.round(asNumber(meter.utilizationPercent) || 0))
+ );
+ dictionary[keys.status] = rank;
+ dictionary[keys.hasBar] = meter.hasBar ? 1 : 0;
+ }
+
+ if (pageHasBar) {
+ if (worstStatusRank >= 4) dictionary.QUOTA_STATUS = "CRITICAL";
+ else if (worstStatusRank >= 3) dictionary.QUOTA_STATUS = "WARNING";
+ else dictionary.QUOTA_STATUS = "OK";
+ }
+
+ Pebble.sendAppMessage(dictionary);
+}
+
+function currentTimeText() {
+ var now = new Date();
+ var minutes = now.getMinutes();
+ return (
+ "at " + now.getHours() + ":" + (minutes < 10 ? "0" + minutes : minutes)
+ );
+}
+
+function parseProviderDisplayNames(responseText) {
+ var providers = JSON.parse(responseText);
+ var displayNames = {};
+ if (!providers || typeof providers !== "object") return displayNames;
+
+ for (var id in providers) {
+ if (!Object.prototype.hasOwnProperty.call(providers, id)) continue;
+ var provider = providers[id];
+ if (!provider || typeof provider !== "object") continue;
+ var displayName =
+ provider.display_name || provider.displayName || provider.name;
+ if (displayName) displayNames[id] = String(displayName);
+ }
+
+ return displayNames;
+}
+
+function refreshQuotaWithDisplayNames(
+ baseUrl,
+ adminKey,
+ pageIndex,
+ displayNames
+) {
+ xhrRequest(
+ baseUrl + "/v0/management/quotas",
+ adminKey,
+ function (error, responseText) {
+ if (error) {
+ sendError(error.message);
+ return;
+ }
+ try {
+ quotaGroups = flattenGroups(responseText, displayNames);
+ sendGroupPage(pageIndex);
+ } catch (e) {
+ sendError("Bad quota response");
+ }
+ }
+ );
+}
+
+function refreshQuota(pageIndex) {
+ var baseUrl = trimSlash(localStorage.getItem(CONFIG_BASE_URL));
+ var adminKey = localStorage.getItem(CONFIG_ADMIN_KEY);
+ if (!baseUrl || !adminKey) {
+ sendError("Set URL and admin key");
+ return;
+ }
+
+ xhrRequest(
+ baseUrl + "/v0/management/providers",
+ adminKey,
+ function (providerError, providerResponseText) {
+ var displayNames = providerDisplayNames;
+ try {
+ if (!providerError) {
+ displayNames = parseProviderDisplayNames(providerResponseText);
+ providerDisplayNames = displayNames;
+ }
+ } catch (e) {
+ displayNames = providerDisplayNames;
+ }
+ refreshQuotaWithDisplayNames(baseUrl, adminKey, pageIndex, displayNames);
+ }
+ );
+}
+
+function htmlEscape(value) {
+ return String(value || "")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """);
+}
+
+function configurationHtml() {
+ var baseUrl = htmlEscape(localStorage.getItem(CONFIG_BASE_URL));
+ var adminKey = htmlEscape(localStorage.getItem(CONFIG_ADMIN_KEY));
+ return (
+ '<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1">' +
+ "<title>Pebblexus</title><style>body{font:16px sans-serif;margin:1rem;line-height:1.4}" +
+ "label{display:block;margin:1rem 0 .25rem}input{box-sizing:border-box;width:100%;font:inherit;padding:.5rem}" +
+ "button{font:inherit;margin-top:1rem;padding:.7rem 1rem;width:100%}.hint{color:#555;font-size:.9rem}</style></head>" +
+ '<body><h1>Pebblexus</h1><p class="hint">Your admin key stays in PebbleKit JS storage on this phone and is not sent to the watch.</p>' +
+ '<form id="form"><label>Plexus base URL</label><input id="baseUrl" type="url" required value="' +
+ baseUrl +
+ '" placeholder="https://plexus.example">' +
+ '<label>Admin key</label><input id="adminKey" type="password" required value="' +
+ adminKey +
+ '">' +
+ '<button type="submit">Save</button></form><script>document.getElementById("form").addEventListener("submit",function(e){e.preventDefault();' +
+ 'var data={plexusBaseUrl:document.getElementById("baseUrl").value,plexusAdminKey:document.getElementById("adminKey").value};' +
+ "var encoded=encodeURIComponent(JSON.stringify(data));var match=location.href.match(/[?&]return_to=([^&#]+)/);" +
+ 'if(match){location.href=decodeURIComponent(match[1])+encoded;}else{location.href="pebblejs://close#"+encoded;}});</script></body></html>'
+ );
+}
+
+Pebble.addEventListener("ready", function () {
+ refreshQuota();
+});
+
+Pebble.addEventListener("appmessage", function (e) {
+ if (!e.payload) return;
+ if (e.payload.REQUEST_PAGE !== undefined) {
+ if (quotaGroups.length === 0) {
+ refreshQuota(e.payload.REQUEST_PAGE);
+ return;
+ }
+ sendGroupPage(e.payload.REQUEST_PAGE);
+ return;
+ }
+ if (e.payload.REQUEST_QUOTA) refreshQuota(e.payload.PAGE_INDEX);
+});
+
+Pebble.addEventListener("showConfiguration", function () {
+ Pebble.openURL("data:text/html," + encodeURIComponent(configurationHtml()));
+});
+
+Pebble.addEventListener("webviewclosed", function (e) {
+ if (!e || !e.response) return;
+ try {
+ var response = JSON.parse(decodeURIComponent(e.response));
+ localStorage.setItem(CONFIG_BASE_URL, trimSlash(response.plexusBaseUrl));
+ localStorage.setItem(CONFIG_ADMIN_KEY, response.plexusAdminKey || "");
+ refreshQuota();
+ } catch (error) {
+ sendError("Config save failed");
+ }
+});
@@ -0,0 +1,43 @@
+import os.path
+
+top = '.'
+out = 'build'
+
+
+def options(ctx):
+ ctx.load('pebble_sdk')
+
+
+def configure(ctx):
+ ctx.load('pebble_sdk')
+
+
+def build(ctx):
+ ctx.load('pebble_sdk')
+
+ build_worker = os.path.exists('worker_src')
+ binaries = []
+
+ cached_env = ctx.env
+ for platform in ctx.env.TARGET_PLATFORMS:
+ ctx.env = ctx.all_envs[platform]
+ ctx.set_group(ctx.env.PLATFORM_NAME)
+ app_elf = '{}/pebble-app.elf'.format(ctx.env.BUILD_DIR)
+ ctx.pbl_build(source=ctx.path.ant_glob('src/c/**/*.c'),
+ target=app_elf, bin_type='app')
+
+ if build_worker:
+ worker_elf = '{}/pebble-worker.elf'.format(ctx.env.BUILD_DIR)
+ binaries.append({'platform': platform, 'app_elf': app_elf,
+ 'worker_elf': worker_elf})
+ ctx.pbl_build(source=ctx.path.ant_glob('worker_src/c/**/*.c'),
+ target=worker_elf, bin_type='worker')
+ else:
+ binaries.append({'platform': platform, 'app_elf': app_elf})
+ ctx.env = cached_env
+
+ ctx.set_group('bundle')
+ ctx.pbl_bundle(binaries=binaries,
+ js=ctx.path.ant_glob(['src/pkjs/**/*.js',
+ 'src/pkjs/**/*.json']),
+ js_entry_file='src/pkjs/index.js')