From 917fdfa2f5ab98957c6a3b74142b5c9581dc5a09 Mon Sep 17 00:00:00 2001 From: Mystica Venatus Date: Sun, 13 Sep 2026 22:51:42 -0400 Subject: [PATCH] feat(face): idle-task sync, weather, health widgets, battery estimator v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppMessage transport (face ↔ pkjs ↔ mock Task API) validated end-to-end: - GameSnapshot full-field inbox handler (13 protocol keys) - serverDown signaling; oxblood "!" indicator on task strip, gated by strip visibility (ASCII placeholder; PDC later) - requestSync watch→phone wake on launch (deferred 300ms send) - inbox_dropped/outbox_failed diagnostics; 256-byte inbox - pkjs: timeout-guarded single-callback XHR; dictionary cache with version compare; last-good weather resend (fail-dead-silent) Widgets: - W_HEART: 1-min avg BPM, state-colored H marker (green=reading, oxblood=no signal), HealthServiceTimeScope API per SDK 4.33 - S/D placeholders for steps/distance; WB battery with PDC-pending icon - Weather: NWS primary / Open-Meteo fallback, CONUS geo auto-switch, cond/temp/hi-lo packet, ASCII condition glyph. Wire unit °F; metric toggle deferred to settings (render-time conversion, DESIGN §5) - Battery estimator: hours-based regression, 5% power-save floor, ~Nd / ~Nh under 24h display; DEV_BATT_TEST sample injection Layout: LAYOUT_VERSION 3 (W_HEART appended; version-gated regeneration). Known: emoji in FROZEN strip replaced with plain dash; battery modes (BAT_PERCENT/DAYS/BOTH) parsed but renderer honors BAT_BOTH only. Refs: PROTOCOL.md v3.0 §9 build order items 2-4; DESIGN.md Laws 5/6. --- nine-hazes-face/package.json | 3 +- nine-hazes-face/src/c/nine-hazes-face.c | 19 ++- nine-hazes-face/src/pkjs/index.js | 161 ++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 3 deletions(-) diff --git a/nine-hazes-face/package.json b/nine-hazes-face/package.json index dc4aa0a..aa78d9a 100644 --- a/nine-hazes-face/package.json +++ b/nine-hazes-face/package.json @@ -17,7 +17,8 @@ }, "messageKeys": ["taskName", "syncTime", "clockOff", "boundSec", "used", "taskCount", "curType", "curRem", "status", "abortCode", - "pendItems", "pendXp", "dictV", "serverDown", "requestSync"], + "pendItems", "pendXp", "dictV", "serverDown", "requestSync", + "weatherTemp", "weatherHi", "weatherLo", "weatherTs", "weatherCond"], "resources": { "media": [] } diff --git a/nine-hazes-face/src/c/nine-hazes-face.c b/nine-hazes-face/src/c/nine-hazes-face.c index a88c82c..0cc21f7 100644 --- a/nine-hazes-face/src/c/nine-hazes-face.c +++ b/nine-hazes-face/src/c/nine-hazes-face.c @@ -20,6 +20,10 @@ static bool s_health_ok = false; static int s_bat_pct = -1; static bool s_server_down = false; // set by serverDown tuple, cleared on good sync +static char s_weather[16]; +static int16_t s_wx_temp = INT16_MIN, s_wx_hi = INT16_MIN, s_wx_lo = INT16_MIN; +static uint8_t s_wx_cond = 0; + // ---- battery estimator ------------------------------------------------------ #define BAT_SAMPLES_MAX 16 #define BAT_KEY 102 @@ -147,6 +151,13 @@ static void update_values(void) { else if (s->status == 3) snprintf(s_strip, sizeof(s_strip), "ABORTED (%d)", s->abort_code); else if (s->status == 4) snprintf(s_strip, sizeof(s_strip), "LOCKED"); else snprintf(s_strip, sizeof(s_strip), "No hero yet"); + if (s_wx_temp != INT16_MIN) { + static const char wx_icon[8] = { '?', 'o', '~', 'C', 'F', 'R', '*', 'T' }; + snprintf(s_weather, sizeof(s_weather), "%c %d° %d/%d", + wx_icon[s_wx_cond & 7], s_wx_temp, s_wx_hi, s_wx_lo); + } else { + snprintf(s_weather, sizeof(s_weather), "--/--"); + } } // ---- renderer ------------------------------------------------------------------- @@ -206,7 +217,7 @@ static void canvas_update(Layer *layer, GContext *ctx) { break; } case W_SLEEP: text = s_sleep; break; - case W_WEATHER: text = "--/--"; break; + case W_WEATHER: text = s_weather; break; case W_OMEN: text = ""; break; case W_HEART: { // state-indicating marker: moss = reading, oxblood = no signal @@ -261,7 +272,7 @@ static void layout_default(void) { {W_STEPS, 1, 4, 115, 0, PAL_TEXT, 0}, {W_DISTANCE, 1, 64,115, 0, PAL_TEXT, 0}, {W_HEART, 1, 122, 115, 0, PAL_TEXT, 0}, - {W_WEATHER, 0, 4, 130, 2, PAL_TEXT, 0}, + {W_WEATHER, 1, 4, 130, 2, PAL_TEXT, 0}, {W_OMEN, 0, 4, 150, 0, PAL_POSITIVE, 0}, {W_SLEEP, 0, 4, 170, 1, PAL_WATER, 0}, {W_TASK_STRIP, 1, 0, 172, 1, PAL_ACCENT, 0}, @@ -338,6 +349,10 @@ static void inbox_received(DictionaryIterator *iter, void *ctx) { taskname_store(name); } + if ((t = dict_find(iter, MESSAGE_KEY_weatherTemp))) s_wx_temp = t->value->int16; + if ((t = dict_find(iter, MESSAGE_KEY_weatherHi))) s_wx_hi = t->value->int16; + if ((t = dict_find(iter, MESSAGE_KEY_weatherLo))) s_wx_lo = t->value->int16; + s_server_down = false; // a good sync clears the flag APP_LOG(APP_LOG_LEVEL_DEBUG, "[MSG] snapshot ok: st=%u used=%u rem=%u", s.status, s.used, s.cur_rem); diff --git a/nine-hazes-face/src/pkjs/index.js b/nine-hazes-face/src/pkjs/index.js index fc38ef8..380863e 100644 --- a/nine-hazes-face/src/pkjs/index.js +++ b/nine-hazes-face/src/pkjs/index.js @@ -38,6 +38,139 @@ function get(path, cb) { xhr.send(); } +// ---- weather (NWS default, Open-Meteo fallback, geo auto-switch) ----------- +// Temperature unit on the wire: Fahrenheit. (Unit toggle arrives with the +// settings page; render-time conversion then, per DESIGN §5.) + +var WX_COORD_TTL = 30 * 60 * 1000; // refix location at most every 30 min + +function getURL(url, cb) { + var xhr = new XMLHttpRequest(); + var done = false; + var abortTimer = setTimeout(function () { + if (done) return; + done = true; + cb('timeout'); + try { xhr.abort(); } catch (e) {} + }, 7000); + xhr.open('GET', url, true); + xhr.onload = function () { + if (done) return; + done = true; + clearTimeout(abortTimer); + if (xhr.status !== 200) return cb('HTTP ' + xhr.status); + try { cb(null, JSON.parse(xhr.responseText)); } + catch (e) { cb('malformed'); } + }; + xhr.onerror = function () { + if (done) return; + done = true; + clearTimeout(abortTimer); + cb('network'); + }; + xhr.send(); +} + +function fetchWeather(cb) { + var coords = null; + try { coords = JSON.parse(localStorage.getItem('wx_coords') || 'null'); } catch (e) {} + if (coords && (Date.now() - coords.ts) < WX_COORD_TTL) { + return wxLookup(coords.lat, coords.lon, cb); + } + if (typeof navigator === 'undefined' || !navigator.geolocation) { + return coords ? wxLookup(coords.lat, coords.lon, cb) : cb(null); + } + navigator.geolocation.getCurrentPosition(function (pos) { + var lat = pos.coords.latitude, lon = pos.coords.longitude; + localStorage.setItem('wx_coords', + JSON.stringify({ ts: Date.now(), lat: lat, lon: lon })); + wxLookup(lat, lon, cb); + }, function () { + // fix failed: fall back to stale coords or nothing + if (coords) return wxLookup(coords.lat, coords.lon, cb); + cb(null); + }, { timeout: 8000, maximumAge: 600000 }); +} + +function wxLookup(lat, lon, cb) { + // rough US bbox (CONUS + AK + HI + PR): NWS has data, else Open-Meteo + var inUS = (lat >= 17 && lat <= 72 && lon >= -179 && lon <= -66); + if (inUS) return nwsFetch(lat, lon, cb); + openMeteoFetch(lat, lon, cb); +} + +function nwsFetch(lat, lon, cb) { + // step 1: resolve grid point + getURL('https://api.weather.gov/points/' + lat.toFixed(4) + ',' + lon.toFixed(4), + function (err, p) { + if (err || !p || !p.properties || !p.properties.forecast) { + return openMeteoFetch(lat, lon, cb); // fallback ladder + } + // step 2: pull forecast periods + getURL(p.properties.forecast, function (ferr, f) { + if (ferr || !f || !f.properties || !f.properties.periods) { + return openMeteoFetch(lat, lon, cb); + } + var periods = f.properties.periods; + var now = periods[0]; + var cond = nwsCond(now.shortForecast); + if (!now) return cb(null); + var unitC = (now.temperatureUnit === 'C'); + var conv = function (v, c) { return c ? Math.round(v * 9 / 5 + 32) : Math.round(v); }; + var temp = conv(now.temperature, unitC); + // hi/lo: today's daytime period and tonight's low + var hi = temp, lo = temp, seenLo = false; + for (var i = 0; i < periods.length && i < 4; i++) { + var P = periods[i]; + var pu = (P.temperatureUnit === 'C'); + if (P.isDaytime) hi = conv(P.temperature, pu); + else if (!seenLo) { lo = conv(P.temperature, pu); seenLo = true; } + } + cb({ temp: temp, hi: hi, lo: lo, cond: cond }); + }); + }); +} + +function openMeteoFetch(lat, lon, cb) { + var url = 'https://api.open-meteo.com/v1/forecast' + + '?latitude=' + lat.toFixed(4) + '&longitude=' + lon.toFixed(4) + + '¤t=temperature_2m,weather_code&daily=temperature_2m_max,temperature_2m_min' + + '&temperature_unit=fahrenheit&forecast_days=1&timezone=auto'; + getURL(url, function (err, w) { + if (err || !w || !w.current) return cb(null); + cb({ + temp: Math.round(w.current.temperature_2m), + hi: Math.round(w.daily.temperature_2m_max[0]), + lo: Math.round(w.daily.temperature_2m_min[0]), + cond: wmoCond(w.current.weather_code) + }); + }); +} + +function sendWeather() { + fetchWeather(function (wx) { + var payload; + if (wx) { + localStorage.setItem('wx_last', + JSON.stringify(wx)); // last-good cache + payload = { weatherTemp: wx.temp, weatherHi: wx.hi, + weatherLo: wx.lo, weatherCond: wx.cond, + weatherTs: Math.floor(Date.now() / 1000) }; + } else { + // fail-dead-silent: resend last-good if we have one + try { + var last = JSON.parse(localStorage.getItem('wx_last') || 'null'); + if (last) payload = { weatherTemp: last.temp, weatherHi: last.hi, + weatherLo: last.lo, + weatherTs: Math.floor(Date.now() / 1000) }; + } catch (e) {} + } + if (payload) Pebble.sendAppMessage(payload, + function () { console.log('[PKJS] weather delivered'); }, + function (e) { console.log('[PKJS] weather send failed'); }); + }); +} + // dictionary: names indexed by task id, cached function dictNames(cb) { var cachedV = parseInt(localStorage.getItem('dict_v') || '-1', 10); @@ -141,6 +274,7 @@ function sync() { Pebble.sendAppMessage({ serverDown: 1 }, function(){}, function(){}); } ); + sendWeather(); // fire-and-forget; separate message }; if (cachedV === q.dict) { @@ -182,3 +316,30 @@ function statusCode(s) { var map = { 'EMPTY': 0, 'RUNNING': 1, 'FROZEN': 2, 'ABORTED': 3, 'LOCKED': 4 }; return map[s] !== undefined ? map[s] : 0; } + +// weather condition → normalized code (0 unk,1 clear,2 pcloudy,3 cloudy, +// 4 fog,5 rain,6 snow,7 tstorm); PDC icons map 1:1 later +function nwsCond(text) { + if (!text) return 0; + var t = String(text).toLowerCase(); + if (t.indexOf('thunder') >= 0) return 7; + if (t.indexOf('snow') >= 0 || t.indexOf('sleet') >= 0) return 6; + if (t.indexOf('rain') >= 0 || t.indexOf('shower') >= 0 || + t.indexOf('drizzle') >= 0) return 5; + if (t.indexOf('fog') >= 0 || t.indexOf('haze') >= 0) return 4; + if (t.indexOf('partly') >= 0 || t.indexOf('mostly sunny') >= 0) return 2; + if (t.indexOf('cloud') >= 0 || t.indexOf('overcast') >= 0) return 3; + if (t.indexOf('sun') >= 0 || t.indexOf('clear') >= 0) return 1; + return 0; +} + +function wmoCond(code) { + if (code === 0) return 1; // clear + if (code === 1 || code === 2) return 2; // mainly/partly clear + if (code === 3) return 3; // overcast + if (code === 45 || code === 48) return 4; // fog + if ((code >= 51 && code <= 67) || (code >= 80 && code <= 82)) return 5; + if ((code >= 71 && code <= 77) || code === 85 || code === 86) return 6; + if (code >= 95) return 7; // thunder + return 0; +}