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:
@@ -17,7 +17,8 @@
|
|||||||
},
|
},
|
||||||
"messageKeys": ["taskName", "syncTime", "clockOff", "boundSec", "used",
|
"messageKeys": ["taskName", "syncTime", "clockOff", "boundSec", "used",
|
||||||
"taskCount", "curType", "curRem", "status", "abortCode",
|
"taskCount", "curType", "curRem", "status", "abortCode",
|
||||||
"pendItems", "pendXp", "dictV", "serverDown", "requestSync"],
|
"pendItems", "pendXp", "dictV", "serverDown", "requestSync",
|
||||||
|
"weatherTemp", "weatherHi", "weatherLo", "weatherTs", "weatherCond"],
|
||||||
"resources": {
|
"resources": {
|
||||||
"media": []
|
"media": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ static bool s_health_ok = false;
|
|||||||
static int s_bat_pct = -1;
|
static int s_bat_pct = -1;
|
||||||
static bool s_server_down = false; // set by serverDown tuple, cleared on good sync
|
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 ------------------------------------------------------
|
// ---- battery estimator ------------------------------------------------------
|
||||||
#define BAT_SAMPLES_MAX 16
|
#define BAT_SAMPLES_MAX 16
|
||||||
#define BAT_KEY 102
|
#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 == 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 if (s->status == 4) snprintf(s_strip, sizeof(s_strip), "LOCKED");
|
||||||
else snprintf(s_strip, sizeof(s_strip), "No hero yet");
|
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 -------------------------------------------------------------------
|
// ---- renderer -------------------------------------------------------------------
|
||||||
@@ -206,7 +217,7 @@ static void canvas_update(Layer *layer, GContext *ctx) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case W_SLEEP: text = s_sleep; 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_OMEN: text = ""; break;
|
||||||
case W_HEART: {
|
case W_HEART: {
|
||||||
// state-indicating marker: moss = reading, oxblood = no signal
|
// 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_STEPS, 1, 4, 115, 0, PAL_TEXT, 0},
|
||||||
{W_DISTANCE, 1, 64,115, 0, PAL_TEXT, 0},
|
{W_DISTANCE, 1, 64,115, 0, PAL_TEXT, 0},
|
||||||
{W_HEART, 1, 122, 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_OMEN, 0, 4, 150, 0, PAL_POSITIVE, 0},
|
||||||
{W_SLEEP, 0, 4, 170, 1, PAL_WATER, 0},
|
{W_SLEEP, 0, 4, 170, 1, PAL_WATER, 0},
|
||||||
{W_TASK_STRIP, 1, 0, 172, 1, PAL_ACCENT, 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);
|
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
|
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",
|
APP_LOG(APP_LOG_LEVEL_DEBUG, "[MSG] snapshot ok: st=%u used=%u rem=%u",
|
||||||
s.status, s.used, s.cur_rem);
|
s.status, s.used, s.cur_rem);
|
||||||
|
|||||||
@@ -38,6 +38,139 @@ function get(path, cb) {
|
|||||||
xhr.send();
|
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
|
// dictionary: names indexed by task id, cached
|
||||||
function dictNames(cb) {
|
function dictNames(cb) {
|
||||||
var cachedV = parseInt(localStorage.getItem('dict_v') || '-1', 10);
|
var cachedV = parseInt(localStorage.getItem('dict_v') || '-1', 10);
|
||||||
@@ -141,6 +274,7 @@ function sync() {
|
|||||||
Pebble.sendAppMessage({ serverDown: 1 }, function(){}, function(){});
|
Pebble.sendAppMessage({ serverDown: 1 }, function(){}, function(){});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
sendWeather(); // fire-and-forget; separate message
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cachedV === q.dict) {
|
if (cachedV === q.dict) {
|
||||||
@@ -182,3 +316,30 @@ function statusCode(s) {
|
|||||||
var map = { 'EMPTY': 0, 'RUNNING': 1, 'FROZEN': 2, 'ABORTED': 3, 'LOCKED': 4 };
|
var map = { 'EMPTY': 0, 'RUNNING': 1, 'FROZEN': 2, 'ABORTED': 3, 'LOCKED': 4 };
|
||||||
return map[s] !== undefined ? map[s] : 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user