diff --git a/nine-hazes-face/.gitignore b/nine-hazes-face/.gitignore new file mode 100644 index 0000000..ebd955c --- /dev/null +++ b/nine-hazes-face/.gitignore @@ -0,0 +1,16 @@ +# Build output +/build/ + +# Waf artifacts +.sconsign.dblite +.lock-wafbuild + +# Editor / OS noise +.vscode/ +.idea/ +*.swp +*~ +.DS_Store + +# Pebble tool local state +pebble.log diff --git a/nine-hazes-face/.lock-waf_linux_build b/nine-hazes-face/.lock-waf_linux_build new file mode 100644 index 0000000..82fb8a3 --- /dev/null +++ b/nine-hazes-face/.lock-waf_linux_build @@ -0,0 +1,10 @@ +argv = ['/home/joe/.local/share/pebble-sdk/SDKs/current/sdk-core/pebble/waf', 'configure'] +config_cmd = 'configure' +environ = {'SHELL': '/usr/bin/bash', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/6123,unix/unix:/tmp/.ICE-unix/6123', 'WINDOWID': '94724984244640', 'COLORTERM': 'truecolor', 'XDG_CONFIG_DIRS': '/home/joe/.config/kdedefaults:/etc/xdg', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'HISTCONTROL': 'ignoredups', 'XDG_MENU_PREFIX': 'plasma-', 'HISTSIZE': '1000', 'HOSTNAME': 'bazzite', 'ICEAUTHORITY': '/run/user/1000/iceauth_zsBrhO', 'LANGUAGE': '', 'XDG_DATA_HOME': '/home/joe/.local/share', 'XDG_CONFIG_HOME': '/home/joe/.config', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'LIBBDPLUS_PATH': '/usr/lib64/libmmbd.so.0', 'DISTROBOX_ENTER_PATH': '/usr/bin/distrobox-enter', 'DESKTOP_SESSION': 'plasma', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/joe/.gtkrc:/home/joe/.config/gtkrc', 'GPG_TTY': '/dev/pts/0', 'EDITOR': '/usr/bin/vim', 'PWD': '/var/home/joe/Documents/Pebble/clockwork-hero/nine-hazes-face', 'LOGNAME': 'joe', 'XDG_SESSION_DESKTOP': 'KDE', 'XDG_SESSION_TYPE': 'wayland', 'SYSTEMD_EXEC_PID': '6506', 'XAUTHORITY': '/run/user/1000/xauth_yNkkMF', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'container': 'podman', 'XKB_DEFAULT_MODEL': 'pc105', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/joe/.gtkrc-2.0:/home/joe/.config/gtkrc-2.0', 'HOME': '/home/joe', 'SSH_ASKPASS': '/usr/bin/ksshaskpass', 'LANG': 'en_US.UTF-8', 'LS_COLORS': 'rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=01;37;41:su=37;41:sg=30;43:ca=00:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.7z=01;31:*.ace=01;31:*.alz=01;31:*.apk=01;31:*.arc=01;31:*.arj=01;31:*.bz=01;31:*.bz2=01;31:*.cab=01;31:*.cpio=01;31:*.crate=01;31:*.deb=01;31:*.drpm=01;31:*.dwm=01;31:*.dz=01;31:*.ear=01;31:*.egg=01;31:*.esd=01;31:*.gz=01;31:*.jar=01;31:*.lha=01;31:*.lrz=01;31:*.lz=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.lzo=01;31:*.pyz=01;31:*.rar=01;31:*.rpm=01;31:*.rz=01;31:*.sar=01;31:*.swm=01;31:*.t7z=01;31:*.tar=01;31:*.taz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tgz=01;31:*.tlz=01;31:*.txz=01;31:*.tz=01;31:*.tzo=01;31:*.tzst=01;31:*.udeb=01;31:*.war=01;31:*.whl=01;31:*.wim=01;31:*.xz=01;31:*.z=01;31:*.zip=01;31:*.zoo=01;31:*.zst=01;31:*.avif=01;35:*.jpg=01;35:*.jpeg=01;35:*.jxl=01;35:*.mjpg=01;35:*.mjpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.webp=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=01;36:*.au=01;36:*.flac=01;36:*.m4a=01;36:*.mid=01;36:*.midi=01;36:*.mka=01;36:*.mp3=01;36:*.mpc=01;36:*.ogg=01;36:*.ra=01;36:*.wav=01;36:*.oga=01;36:*.opus=01;36:*.spx=01;36:*.xspf=01;36:*~=00;90:*#=00;90:*.bak=00;90:*.crdownload=00;90:*.dpkg-dist=00;90:*.dpkg-new=00;90:*.dpkg-old=00;90:*.dpkg-tmp=00;90:*.old=00;90:*.orig=00;90:*.part=00;90:*.rej=00;90:*.rpmnew=00;90:*.rpmorig=00;90:*.rpmsave=00;90:*.swp=00;90:*.tmp=00;90:*.ucf-dist=00;90:*.ucf-new=00;90:*.ucf-old=00;90:', 'XDG_CURRENT_DESKTOP': 'KDE', 'KONSOLE_DBUS_SERVICE': ':1.211', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-org.kde.plasma\\x2dfallback\\x2dsession\\x2drestore@autostart.service/memory.pressure', 'WAYLAND_DISPLAY': 'wayland-0', 'KONSOLE_DBUS_SESSION': '/Sessions/1', 'PROFILEHOME': '', 'INVOCATION_ID': 'dc2cedc8b21e41cd80abd7d30fac5b9f', 'KONSOLE_VERSION': '260800', 'MANAGERPID': '2853', 'CONTAINER_ID': 'pebble-dev', 'STEAM_FRAME_FORCE_CLOSE': '1', 'KDE_SESSION_UID': '1000', 'XDG_CACHE_HOME': '/home/joe/.cache', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'MOZ_GMP_PATH': '/usr/lib64/mozilla/plugins/gmp-gmpopenh264/system-installed', 'XKB_DEFAULT_LAYOUT': 'us', 'XDG_SESSION_CLASS': 'user', 'TERM': 'vt100', 'KONSOLE_DBUS_ACTIVATION_COOKIE': 'LAbljhQQ6d8T44OyCClJddWYI3HrvQwsVYKuh88mKQw=', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'USER': 'joe', 'COLORFGBG': '15;0', 'QT_WAYLAND_RECONNECT': '1', 'KDE_SESSION_VERSION': '6', 'SUDO_ASKPASS': '/usr/bin/ksshaskpass', 'DISPLAY': ':0', 'SHLVL': '5', 'LIBAACS_PATH': '/usr/lib64/libmmbd.so.0', 'XDG_SESSION_ID': '3', 'MANAGERPIDFDID': '16504', 'TERMINFO_DIRS': '/usr/share/terminfo:/run/host/usr/share/terminfo', 'XDG_STATE_HOME': '/home/joe/.local/state', 'XDG_RUNTIME_DIR': '/run/user/1000', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'KDEDIRS': '/usr', 'JOURNAL_STREAM': '10:71750', 'XDG_DATA_DIRS': '/home/joe/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'KDE_FULL_SESSION': 'true', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/joe/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/joe/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/joe/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/joe/.local/share/../bin:/home/joe/.local/bin:/home/joe/bin:/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/usr/local/bin:/usr/bin:/sbin:/bin', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'MAIL': '/var/spool/mail/joe', 'BREW_BASH_COMPLETION': '1', 'FLATPAK_TTY_PROGRESS': '1', 'OLDPWD': '/var/home/joe/Documents/Pebble/clockwork-hero/nine-hazes-face', 'KONSOLE_DBUS_WINDOW': '/Windows/1', '_': '/home/joe/.local/share/../bin/pebble', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/joe/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/joe/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/joe/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/joe/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/joe/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'} +files = ['/var/home/joe/Documents/Pebble/clockwork-hero/nine-hazes-face/wscript'] +hash = b'\xc6\xb0\xec\xd9\xadu[\xb0m\xd2\xe0\x84N\xf01%' +launch_dir = '/var/home/joe/Documents/Pebble/clockwork-hero/nine-hazes-face' +options = {'colors': 'auto', 'jobs': 16, 'keep': 0, 'verbose': 0, 'zones': '', 'profile': 0, 'pdb': 0, 'whelp': 0, 'version': False, 'out': '', 'top': '', 'no_lock_in_run': '', 'no_lock_in_out': '', 'no_lock_in_top': '', 'prefix': '/usr/local', 'bindir': None, 'libdir': None, 'progress_bar': 0, 'targets': '', 'files': '', 'destdir': '', 'force': False, 'distcheck_args': None, 'debug': False, 'no_groups': False, 'sandbox': False, 'timestamp': None} +out_dir = '/var/home/joe/Documents/Pebble/clockwork-hero/nine-hazes-face/build' +run_dir = '/var/home/joe/Documents/Pebble/clockwork-hero/nine-hazes-face' +top_dir = '/var/home/joe/Documents/Pebble/clockwork-hero/nine-hazes-face' diff --git a/nine-hazes-face/README.md b/nine-hazes-face/README.md new file mode 100644 index 0000000..f0517e1 --- /dev/null +++ b/nine-hazes-face/README.md @@ -0,0 +1,36 @@ +# nine-hazes-face + +A Pebble watchapp/watchface written in C using the Pebble SDK. + +## Building & running + +```sh +pebble build # build for all targetPlatforms +pebble install --emulator emery # install on the emery emulator +pebble install --phone # install to a paired phone +``` + +## Target platforms + +`targetPlatforms` in `package.json` controls which watches you build for. The +modern Pebble hardware is **emery** (Pebble Time 2), **gabbro** (Pebble Round +2), and **flint** (Pebble 2 Duo); the original Pebble platforms (aplite, +basalt, chalk, diorite) are included by default for backwards compatibility. + +## Project layout + +``` +src/c/ C source for the watchapp +src/pkjs/ PebbleKit JS (phone-side) source, if any +worker_src/c/ Background worker source, if any +resources/ Images, fonts, and other bundled resources +package.json Project metadata (UUID, platforms, resources, message keys) +wscript Build rules — usually no need to edit +``` + +By default this project is configured as a watchapp. To make it a watchface, +set `pebble.watchapp.watchface` to `true` in `package.json`. + +## Documentation + +Full SDK docs, tutorials, and API reference: diff --git a/nine-hazes-face/dev b/nine-hazes-face/dev new file mode 100755 index 0000000..93ba7a1 --- /dev/null +++ b/nine-hazes-face/dev @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# nine-hazes-face dev helpers +# Usage: +# ./dev.sh build + install on emulator +# ./dev.sh run same, plus stream watch logs (Ctrl-C detaches, app keeps running) +# ./dev.sh log attach logs only (emulator must be open) +# ./dev.sh kill stop the emulator and free the websocket +# ./dev.sh clean full rebuild (removes ./build) +set -euo pipefail + +cd "$(dirname "$0")" + +EMU="emery" + +build_install() { + echo "==> Building ($EMU)..." + pebble build + echo "==> Installing to emulator..." + pebble install --emulator "$EMU" + echo "==> Done." +} + +case "${1:-}" in + "") + build_install + ;; + run) + build_install + echo "==> Streaming logs (Ctrl-C to detach; app keeps running)..." + pebble logs --emulator "$EMU" + ;; + log) + pebble logs --emulator "$EMU" + ;; + kill) + pebble kill-pebble + ;; + clean) + rm -rf build + echo "==> ./build removed; next run is a full rebuild." + ;; + *) + echo "Unknown command: $1" >&2 + sed -n '2,8p' "$0" | sed 's/^# \{0,1\}//' + exit 1 + ;; +esac diff --git a/nine-hazes-face/package.json b/nine-hazes-face/package.json new file mode 100644 index 0000000..497c503 --- /dev/null +++ b/nine-hazes-face/package.json @@ -0,0 +1,23 @@ +{ + "name": "nine-hazes-face", + "author": "Artimidorus", + "version": "1.0.1", + "license": "Apache-2.0", + "keywords": ["pebble-app"], + "private": true, + "dependencies": {}, + "pebble": { + "displayName": "nine-hazes-face", + "uuid": "cc8b7604-d956-4c6c-a0be-f33bec671181", + "sdkVersion": "3", + "enableMultiJS": true, + "targetPlatforms": ["emery"], + "watchapp": { + "watchface": true + }, + "messageKeys": ["taskName", "taskEnd", "queueFill"], + "resources": { + "media": [] + } + } +} diff --git a/nine-hazes-face/src/c/face_packet.c b/nine-hazes-face/src/c/face_packet.c new file mode 100644 index 0000000..f15fe42 --- /dev/null +++ b/nine-hazes-face/src/c/face_packet.c @@ -0,0 +1,27 @@ +#include +#include +#include "face_packet.h" + +static FacePacket s_packet; + +void face_packet_init(void) { + if (persist_exists(PACKET_KEY) && + persist_read_data(PACKET_KEY, &s_packet, sizeof(s_packet)) == + (int)sizeof(s_packet) && + s_packet.version == PACKET_VERSION) { + return; // cached packet restored — countdown resumes from end_epoch + } + memset(&s_packet, 0, sizeof(s_packet)); + s_packet.version = PACKET_VERSION; + persist_write_data(PACKET_KEY, &s_packet, sizeof(s_packet)); +} + +void face_packet_store(const FacePacket *p) { + if (!p || p->version != PACKET_VERSION) return; + s_packet = *p; + persist_write_data(PACKET_KEY, &s_packet, sizeof(s_packet)); +} + +const FacePacket *face_packet_get(void) { + return &s_packet; +} diff --git a/nine-hazes-face/src/c/face_packet.h b/nine-hazes-face/src/c/face_packet.h new file mode 100644 index 0000000..1591fe6 --- /dev/null +++ b/nine-hazes-face/src/c/face_packet.h @@ -0,0 +1,20 @@ +#pragma once +#include + +// The face's ONLY view of game truth (DESIGN.md §4.1). Small, cacheable, +// stale-detectable. End timestamp is authoritative: countdown is local math. +#define PACKET_VERSION 1 +#define PACKET_KEY 101 +#define TASK_NAME_MAX 24 + +typedef struct __attribute__((packed)) { + uint8_t version; // PACKET_VERSION + char task_name[TASK_NAME_MAX]; // "" = no hero / not linked + uint32_t end_epoch; // UTC, chunk end of current task (0 = none) + uint8_t queue_fill; // chunks used, 0..36 + uint32_t fetched_epoch; // when JS delivered this (staleness signal) +} FacePacket; + +void face_packet_init(void); // load-or-default from persist +void face_packet_store(const FacePacket *p); // JS delivered a fresh one +const FacePacket *face_packet_get(void); diff --git a/nine-hazes-face/src/c/nine-hazes-face.c b/nine-hazes-face/src/c/nine-hazes-face.c new file mode 100644 index 0000000..da45ccd --- /dev/null +++ b/nine-hazes-face/src/c/nine-hazes-face.c @@ -0,0 +1,275 @@ +// =========================================================================== +// Nine Hazes — watchface (v0.1 clean rewrite, Muninn-lifecycle pattern) +// Replace the WHOLE file with this. Do not splice. +// =========================================================================== +#include +#include +#include + +#include "widgets.h" +#include "face_packet.h" + +static Window *s_window; +static Layer *s_canvas; +static LayoutState s_layout; +static char s_rendered[WIDGET_SLOTS][64]; +static char s_clock[8], s_date[16], s_strip[64]; +static int s_bat_pct = -1; + +// ---- battery estimator (unchanged design) --------------------------------- +#define BAT_SAMPLES_MAX 16 +#define BAT_KEY 102 +typedef struct __attribute__((packed)) { + uint8_t version; + uint8_t count; + uint32_t ts[BAT_SAMPLES_MAX]; + uint8_t pct[BAT_SAMPLES_MAX]; +} BatLog; +static BatLog s_batlog; + +static void bat_sample(uint8_t pct) { + if (pct > 100) return; + if (s_batlog.count > 0 && s_batlog.pct[s_batlog.count - 1] == pct) return; + if (s_batlog.count == BAT_SAMPLES_MAX) { + memmove(&s_batlog.ts[0], &s_batlog.ts[1], (BAT_SAMPLES_MAX - 1) * sizeof(uint32_t)); + memmove(&s_batlog.pct[0], &s_batlog.pct[1], (BAT_SAMPLES_MAX - 1) * sizeof(uint8_t)); + s_batlog.count--; + } + s_batlog.ts[s_batlog.count] = (uint32_t)(time(NULL) / 60); + s_batlog.pct[s_batlog.count] = pct; + s_batlog.count++; + persist_write_data(BAT_KEY, &s_batlog, sizeof(s_batlog)); +} + +static int bat_days_remaining(void) { + if (s_batlog.count < 3) return -1; + int n = s_batlog.count; + double sx = 0, sy = 0, sxy = 0, sxx = 0; + for (int i = 0; i < n; i++) { + double x = (double)s_batlog.ts[i] / 1440.0; + double y = (double)s_batlog.pct[i]; + sx += x; sy += y; sxy += x * y; sxx += x * x; + } + double denom = n * sxx - sx * sx; + if (denom <= 0) return -1; + double slope = (n * sxy - sx * sy) / denom; + if (slope >= -0.05) return -1; + return (int)((double)s_batlog.pct[n - 1] / -slope + 0.5); +} + +// ---- palette & fonts -------------------------------------------------------- +static GColor pal_color(PalIndex i) { + switch (i) { + case PAL_BG: return GColorWhite; + case PAL_TEXT: return GColorBlack; + case PAL_ACCENT: return GColorFromHEX(0xB08D3F); + case PAL_POSITIVE: return GColorFromHEX(0x5A7A4A); + case PAL_WARNING: return GColorFromHEX(0x6E1F24); + case PAL_WATER: return GColorFromHEX(0x4A5D73); + default: return GColorBlack; + } +} + +static GFont s_fonts[5]; +static void fonts_load(void) { + s_fonts[0] = fonts_get_system_font(FONT_KEY_GOTHIC_14); + s_fonts[1] = fonts_get_system_font(FONT_KEY_GOTHIC_18); + s_fonts[2] = fonts_get_system_font(FONT_KEY_GOTHIC_24); + s_fonts[3] = fonts_get_system_font(FONT_KEY_GOTHIC_28); + s_fonts[4] = fonts_get_system_font(FONT_KEY_BITHAM_42_BOLD); +} + +// ---- values ----------------------------------------------------------------- +static void update_values(void) { + time_t now = time(NULL); + struct tm *t = localtime(&now); + strftime(s_clock, sizeof(s_clock), + clock_is_24h_style() ? "%H:%M" : "%l:%M", t); + strftime(s_date, sizeof(s_date), "%a %d %b", t); + + const FacePacket *p = face_packet_get(); + if (p->end_epoch > 0 && p->task_name[0]) { + int32_t secs = (int32_t)(p->end_epoch - (uint32_t)now); + if (secs > 0) { + int32_t m = secs / 60 + ((secs % 60) ? 1 : 0); + snprintf(s_strip, sizeof(s_strip), "%s %ld:%02ld %u/36", + p->task_name, (long)(m / 60), (long)(m % 60), p->queue_fill); + } else { + snprintf(s_strip, sizeof(s_strip), "%s - return for reward", p->task_name); + } + } else { + snprintf(s_strip, sizeof(s_strip), "No hero yet"); + } +} + +// ---- renderer ----------------------------------------------------------------- +static void canvas_update(Layer *layer, GContext *ctx) { + static int n = 0; + if (n < 5) { APP_LOG(APP_LOG_LEVEL_DEBUG, "[RENDER] pass %d", n); n++; } + + GRect b = layer_get_bounds(layer); + graphics_context_set_fill_color(ctx, pal_color(PAL_BG)); + graphics_fill_rect(ctx, b, 0, GCornerNone); + + update_values(); // ALWAYS fresh values before drawing + + for (uint8_t i = 0; i < s_layout.count; i++) { + const WidgetCfg *w = &s_layout.widgets[i]; + if (!w->visible || w->type >= WIDGET_TYPE_COUNT) continue; + + const char *text; + char buf[64]; + switch ((WidgetType)w->type) { + case W_TIME: text = s_clock; break; + case W_DATE: text = s_date; break; + case W_BATTERY: { + int days = bat_days_remaining(); + if (days < 0) snprintf(buf, sizeof(buf), "%d%%", s_bat_pct); + else snprintf(buf, sizeof(buf), "%d%% (~%dd)", s_bat_pct, days); + text = buf; break; + } + case W_PHONE_BATTERY: text = "phone?"; break; + case W_STEPS: text = "steps?"; break; + case W_DISTANCE: text = "dist?"; break; + case W_WEATHER: text = "--/--"; break; + case W_OMEN: text = ""; break; + case W_SLEEP: text = "sleep?"; break; + case W_TASK_STRIP: text = s_strip; break; + default: continue; + } + + GFont f = (w->font_idx < 5) ? s_fonts[w->font_idx] : s_fonts[1]; + graphics_context_set_text_color(ctx, pal_color((PalIndex)w->color_idx)); + GRect box = GRect(w->x, w->y, b.size.w - w->x, 40); + graphics_draw_text(ctx, text, f, box, GTextOverflowModeTrailingEllipsis, + GTextAlignmentLeft, NULL); + } +} + +static void force_full_redraw(void) { + for (int i = 0; i < WIDGET_SLOTS; i++) s_rendered[i][0] = '\0'; + if (s_canvas) layer_mark_dirty(s_canvas); +} + +// ---- layout (persist-or-default) --------------------------------------------- +static void layout_default(void) { + memset(&s_layout, 0, sizeof(s_layout)); + s_layout.version = LAYOUT_VERSION; + s_layout.count = WIDGET_TYPE_COUNT; + const WidgetCfg defs[WIDGET_TYPE_COUNT] = { + {W_TIME, 1, 0, 10, 3, PAL_TEXT, 0}, + {W_DATE, 1, 4, 70, 0, PAL_ACCENT, 0}, + {W_BATTERY, 1, 4, 90, 0, PAL_TEXT, BAT_BOTH}, + {W_STEPS, 1, 4, 110, 0, PAL_TEXT, 0}, + {W_TASK_STRIP, 1, 0, 200, 1, PAL_ACCENT, 0}, + {W_PHONE_BATTERY, 0, 4, 130, 0, PAL_TEXT, 0}, + {W_DISTANCE, 0, 4, 150, 0, PAL_TEXT, 0}, + {W_WEATHER, 0, 4, 130, 2, PAL_TEXT, 0}, + {W_OMEN, 0, 4, 150, 0, PAL_POSITIVE,0}, + {W_SLEEP, 0, 4, 170, 1, PAL_WATER, 0}, + }; + memcpy(s_layout.widgets, defs, sizeof(defs)); + persist_write_data(LAYOUT_KEY, &s_layout, sizeof(s_layout)); + APP_LOG(APP_LOG_LEVEL_DEBUG, "[LAYOUT] defaults written"); +} + +static void layout_init(void) { + if (persist_exists(LAYOUT_KEY)) { + int read = persist_read_data(LAYOUT_KEY, &s_layout, sizeof(s_layout)); + bool ok = (read == (int)sizeof(s_layout)) && + (s_layout.version == LAYOUT_VERSION) && + (s_layout.count > 0 && s_layout.count <= WIDGET_SLOTS); + APP_LOG(APP_LOG_LEVEL_DEBUG, "[LAYOUT] read=%d ok=%d count=%u", read, ok, s_layout.count); + if (ok) return; + } + layout_default(); +} + +// ---- services ------------------------------------------------------------------ +static void tick_handler(struct tm *t, TimeUnits u) { + static int n = 0; + if (n < 3) { APP_LOG(APP_LOG_LEVEL_DEBUG, "[TICK] %02d:%02d", t->tm_hour, t->tm_min); n++; } + if (s_canvas) layer_mark_dirty(s_canvas); +} + +static void battery_handler(BatteryChargeState state) { + s_bat_pct = state.charge_percent; + APP_LOG(APP_LOG_LEVEL_DEBUG, "[BAT] %d%%", s_bat_pct); + bat_sample((uint8_t)state.charge_percent); + if (s_canvas) layer_mark_dirty(s_canvas); +} + +// ---- AppMessage ------------------------------------------------------------- +static void inbox_received(DictionaryIterator *iter, void *ctx) { + APP_LOG(APP_LOG_LEVEL_DEBUG, "[MSG] inbox received"); + FacePacket p = *face_packet_get(); + Tuple *t = dict_find(iter, MESSAGE_KEY_taskName); + if (t) { strncpy(p.task_name, t->value->cstring, TASK_NAME_MAX - 1); + p.task_name[TASK_NAME_MAX - 1] = '\0'; } + if ((t = dict_find(iter, MESSAGE_KEY_taskEnd))) p.end_epoch = t->value->uint32; + if ((t = dict_find(iter, MESSAGE_KEY_queueFill))) p.queue_fill = t->value->uint8; + p.fetched_epoch = (uint32_t)time(NULL); + face_packet_store(&p); + force_full_redraw(); +} + +// ---- window lifecycle --------------------------------------------------------- +static void window_load(Window *w) { + Layer *root = window_get_root_layer(w); + GRect b = layer_get_bounds(root); + s_canvas = layer_create(b); + layer_set_update_proc(s_canvas, canvas_update); + layer_add_child(root, s_canvas); + APP_LOG(APP_LOG_LEVEL_DEBUG, "[LOAD] canvas up, count=%u w0=%u", s_layout.count, s_layout.widgets[0].type); + update_values(); + force_full_redraw(); +} + +static void window_unload(Window *w) { + APP_LOG(APP_LOG_LEVEL_DEBUG, "[UNLOAD]"); + layer_destroy(s_canvas); + s_canvas = NULL; +} + +// ---- app lifecycle (Muninn pattern: all state first, then window) ------------- +static void init(void) { + APP_LOG(APP_LOG_LEVEL_DEBUG, "[INIT] start"); + fonts_load(); + layout_init(); + face_packet_init(); + + if (persist_exists(BAT_KEY) && + persist_read_data(BAT_KEY, &s_batlog, sizeof(s_batlog)) == (int)sizeof(s_batlog) && + s_batlog.version == 1) { + // restored + } else { + memset(&s_batlog, 0, sizeof(s_batlog)); + s_batlog.version = 1; + } + + s_window = window_create(); + window_set_window_handlers(s_window, (WindowHandlers){ + .load = window_load, .unload = window_unload }); + window_stack_push(s_window, true); + APP_LOG(APP_LOG_LEVEL_DEBUG, "[INIT] window pushed"); + + tick_timer_service_subscribe(MINUTE_UNIT, tick_handler); + battery_state_service_subscribe(battery_handler); + battery_handler(battery_state_service_peek()); + + app_message_register_inbox_received(inbox_received); + app_message_open(64, 64); + APP_LOG(APP_LOG_LEVEL_DEBUG, "[INIT] done"); +} + +static void deinit(void) { + tick_timer_service_unsubscribe(); + battery_state_service_unsubscribe(); + window_destroy(s_window); +} + +int main(void) { + init(); + app_event_loop(); + deinit(); +} diff --git a/nine-hazes-face/src/c/widgets.h b/nine-hazes-face/src/c/widgets.h new file mode 100644 index 0000000..2ef83f0 --- /dev/null +++ b/nine-hazes-face/src/c/widgets.h @@ -0,0 +1,53 @@ +#pragma once +#include + +// ---- Widget types (10, locked in DESIGN.md §5) --------------------------- +typedef enum { + W_TIME = 0, + W_DATE, + W_BATTERY, // watch battery; percent | days | both modes + W_PHONE_BATTERY, + W_STEPS, + W_DISTANCE, + W_WEATHER, // composite: current temp + hi/lo (sub-elements later) + W_OMEN, // weather advisory flavor strip + W_SLEEP, + W_TASK_STRIP, // game presence — the one game element on the face + WIDGET_TYPE_COUNT +} WidgetType; + +// ---- Celtic palette (DESIGN.md §9) — indexed, never raw --------------- +typedef enum { + PAL_BG = 0, // peat black + PAL_TEXT, // parchment white + PAL_ACCENT, // aged brass + PAL_POSITIVE, // moss green + PAL_WARNING, // oxblood + PAL_WATER, // river slate + PAL_COUNT +} PalIndex; + +// Battery widget display mode +typedef enum { BAT_PERCENT = 0, BAT_DAYS, BAT_BOTH } BatMode; + +// ---- Packed, persisted widget config ------------------------------------- +// Layout is first-class: OWN persist key, OWN version byte (DESIGN.md §4). +typedef struct __attribute__((packed)) { + uint8_t type; // WidgetType + uint8_t visible; // bool + int16_t x; // top-left, screen coords (200x228) + int16_t y; + uint8_t font_idx; // index into font table + uint8_t color_idx; // PalIndex + uint8_t aux; // per-type: BatMode for battery, sub-flags later +} WidgetCfg; + +#define LAYOUT_VERSION 1 +#define LAYOUT_KEY 100 +#define WIDGET_SLOTS 12 // 10 types + 2 spare, matching "12" scaffold note + +typedef struct __attribute__((packed)) { + uint8_t version; + uint8_t count; + WidgetCfg widgets[WIDGET_SLOTS]; +} LayoutState; diff --git a/nine-hazes-face/src/pkjs/index.js b/nine-hazes-face/src/pkjs/index.js new file mode 100644 index 0000000..94c4fe0 --- /dev/null +++ b/nine-hazes-face/src/pkjs/index.js @@ -0,0 +1,8 @@ +// Nine Hazes watchface pkjs. Phase 1: hello only. +// Later (per DESIGN.md §4.1): on 'ready', fetch the face packet from the +// configured server (default instance, self-hostable override) respecting the +// staleness timestamp, then Pebble.sendAppMessage({taskName, taskEnd, +// queueFill}). All fetches wrapped: server down -> render cached, silently. +Pebble.addEventListener('ready', () => { + console.log('Nine Hazes pkjs ready'); +}); diff --git a/nine-hazes-face/wscript b/nine-hazes-face/wscript new file mode 100644 index 0000000..5238bc8 --- /dev/null +++ b/nine-hazes-face/wscript @@ -0,0 +1,54 @@ +# +# This file is the default set of rules to compile a Pebble application. +# +# Feel free to customize this to your needs. +# +import os.path + +top = '.' +out = 'build' + + +def options(ctx): + ctx.load('pebble_sdk') + + +def configure(ctx): + """ + This method is used to configure your build. ctx.load(`pebble_sdk`) automatically configures + a build for each valid platform in `targetPlatforms`. Platform-specific configuration: add your + change after calling ctx.load('pebble_sdk') and make sure to set the correct environment first. + Universal configuration: add your change prior to calling ctx.load('pebble_sdk'). + """ + 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', + 'src/common/**/*.js']), + js_entry_file='src/pkjs/index.js')