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",
|
||||
"taskCount", "curType", "curRem", "status", "abortCode",
|
||||
"pendItems", "pendXp", "dictV", "serverDown", "requestSync"],
|
||||
"pendItems", "pendXp", "dictV", "serverDown", "requestSync",
|
||||
"weatherTemp", "weatherHi", "weatherLo", "weatherTs", "weatherCond"],
|
||||
"resources": {
|
||||
"media": []
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user