watchface: first rendering baseline (canvas renderer, widget table, layout persist)
This commit is contained in:
27
nine-hazes-face/src/c/face_packet.c
Normal file
27
nine-hazes-face/src/c/face_packet.c
Normal file
@@ -0,0 +1,27 @@
|
||||
#include <pebble.h>
|
||||
#include <string.h>
|
||||
#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;
|
||||
}
|
||||
20
nine-hazes-face/src/c/face_packet.h
Normal file
20
nine-hazes-face/src/c/face_packet.h
Normal file
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
#include <pebble.h>
|
||||
|
||||
// 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);
|
||||
275
nine-hazes-face/src/c/nine-hazes-face.c
Normal file
275
nine-hazes-face/src/c/nine-hazes-face.c
Normal file
@@ -0,0 +1,275 @@
|
||||
// ===========================================================================
|
||||
// Nine Hazes — watchface (v0.1 clean rewrite, Muninn-lifecycle pattern)
|
||||
// Replace the WHOLE file with this. Do not splice.
|
||||
// ===========================================================================
|
||||
#include <pebble.h>
|
||||
#include <time.h>
|
||||
#include <string.h>
|
||||
|
||||
#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();
|
||||
}
|
||||
53
nine-hazes-face/src/c/widgets.h
Normal file
53
nine-hazes-face/src/c/widgets.h
Normal file
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
#include <pebble.h>
|
||||
|
||||
// ---- 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;
|
||||
8
nine-hazes-face/src/pkjs/index.js
Normal file
8
nine-hazes-face/src/pkjs/index.js
Normal file
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user