feat(face): idle-task sync, weather, health widgets, battery estimator v2

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.
This commit is contained in:
Mystica Venatus
2026-09-13 22:51:42 -04:00
parent 4cab1ca438
commit 917fdfa2f5
3 changed files with 180 additions and 3 deletions

View File

@@ -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": []
}

View File

@@ -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);

View File

@@ -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) +
'&current=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;
}