字段 125 → 156 项。
Toybox.Complications(source/Comp.mc)
这条路推翻了我之前几个「表盘做不到」的判断 —— 当时只查了 ActivityMonitor /
UserProfile / Weather,漏了系统把这些作为 complication 发布出来:
- 下一个日程(CIQ 确实没有日历 API,但有 CALENDAR_EVENTS complication)
- 训练状态、完赛预测 ×4、配速预测 ×4、最近高尔夫得分
- 本周跑步 / 骑行距离(按运动分类的,按天历史做不出来)
- 第三方 App 1–5(CGM、补水、行情这类 App 发布的)
需要 ComplicationSubscriber 权限。
其余补齐
- 近 28 天累计:步数 / 卡路里 / 距离 / 楼层
- 事件倒计时:设置里填 YYYY-MM-DD,显示还剩几天
- 自定义时间格式、日期格式 1/2:单字母占位符(h/H/m/s/a/D/M/N/Y/y/w)
- OWM 空气质量:AQI 等级 + 描述 + PM2.5 + PM10 + CO,走
/data/2.5/air_pollution —— 那个接口在免费档就开放,不需要 One Call 订阅
运行时踩的坑
- Complication.unit 不保证是 String(周跑步距离返回的就不是),直接调
.length() 抛 Symbol Not Found。unit 和 shortLabel 都改成先 instanceof 判型。
- 浮点 value 直接 toString 印成 "0.000000",统一格式化成一位小数。
- 第三方 App 槽位每取一次就要走完整个迭代器,四个格子都选 App 时会走四遍。
改成每帧只枚举一次,由 view 在绘制开始时调 Comp.beginFrame() 失效。
测试
全字段验证的 harness 本身会触发看门狗(一次 onUpdate 里算 156 个字段),
改成分批每帧 12 个。实际使用一帧只算 4 个,不受影响。
156 项全部可渲染,零运行时错误;17 款设备全部编译通过。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
194 lines
8.5 KiB
MonkeyC
194 lines
8.5 KiB
MonkeyC
import Toybox.Lang;
|
||
import Toybox.Application;
|
||
import Toybox.Background;
|
||
import Toybox.Communications;
|
||
import Toybox.System;
|
||
import Toybox.Time;
|
||
import Toybox.Position;
|
||
|
||
// ============================================================================
|
||
// OpenWeatherMap —— 后台拉取端
|
||
// ----------------------------------------------------------------------------
|
||
// 表盘进程绝大多数时间没在运行,onUpdate 又必须够快,不可能在绘制路径里发
|
||
// HTTP 请求。Connect IQ 的方案是**后台服务**:系统按定时器唤醒一个独立进程,
|
||
// 在里面发请求,把结果通过 Background.exit() 交回前台,前台再写进 Storage。
|
||
//
|
||
// 每次唤醒发三个请求:
|
||
// 1. 当前天气 /data/2.5/weather
|
||
// 2. 短期预报 /data/2.5/forecast?cnt=16(约 48 小时,够算明日/后日高低温)
|
||
// 3. 空气质量 /data/2.5/air_pollution(AQI / PM2.5 / PM10 / CO)
|
||
//
|
||
// ⚠️ 预报接口必须带 cnt 限制条数。后台进程的内存预算非常小(约 32KB),
|
||
// 完整的 5 天 / 3 小时预报解析出来能有几十 KB,会直接爆掉。
|
||
//
|
||
// 响应会被**压平成一个扁平字典**再交回去,键名是短名("temp"/"hi1"…),
|
||
// 与 Owm.get() 的取值一一对应 —— 这样表盘侧永远不用扛一棵 JSON 树。
|
||
// ============================================================================
|
||
(:background)
|
||
class Fenix8V3Background extends System.ServiceDelegate {
|
||
|
||
private var mResult as Dictionary = {};
|
||
private var mPending = 0;
|
||
|
||
function initialize() {
|
||
ServiceDelegate.initialize();
|
||
}
|
||
|
||
// 系统按定时器唤醒后台进程时调用。
|
||
// 注意这里是**独立进程**,内存预算比前台小得多,而且不能碰 UI。
|
||
function onTemporalEvent() as Void {
|
||
if (!Owm.enabled()) {
|
||
Background.exit(null);
|
||
return;
|
||
}
|
||
var lat = Application.Storage.getValue("lastLat");
|
||
var lon = Application.Storage.getValue("lastLon");
|
||
if (lat == null || lon == null) {
|
||
// 表盘一拿到位置就会存进 Storage(见 Fields.Ctx.where)。
|
||
// 在那之前没有坐标可问,直接退出。
|
||
Background.exit(null);
|
||
return;
|
||
}
|
||
mResult = {};
|
||
mPending = 3; // 当前天气 + 短预报 + 空气质量
|
||
// 一律用 units=metric 请求,单位换算统一放到 Fields 里按用户设置做,
|
||
// 这样切换单位不用重新联网。
|
||
var common = {"lat" => lat, "lon" => lon,
|
||
"appid" => Owm.key(), "units" => "metric"};
|
||
var opts = {
|
||
:method => Communications.HTTP_REQUEST_METHOD_GET,
|
||
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON
|
||
};
|
||
Communications.makeWebRequest(
|
||
"https://api.openweathermap.org/data/2.5/weather",
|
||
common, opts, method(:onCurrent));
|
||
var fc = {"lat" => lat, "lon" => lon, "appid" => Owm.key(),
|
||
"units" => "metric", "cnt" => 16};
|
||
Communications.makeWebRequest(
|
||
"https://api.openweathermap.org/data/2.5/forecast",
|
||
fc, opts, method(:onForecast));
|
||
// 空气质量走独立接口。它在免费档里是开放的(One Call 才要订阅),
|
||
// 所以 AQI / PM2.5 / PM10 / CO 这几项不用付费也能有。
|
||
Communications.makeWebRequest(
|
||
"https://api.openweathermap.org/data/2.5/air_pollution",
|
||
{"lat" => lat, "lon" => lon, "appid" => Owm.key()},
|
||
opts, method(:onAir));
|
||
}
|
||
|
||
// 当前天气的回调。OWM 的 JSON 是嵌套的,逐层取值时每层都要判空 ——
|
||
// 缺字段是常态(比如没下雨就没有 rain 节点)。
|
||
function onCurrent(code as Number, body as Dictionary?) as Void {
|
||
if (code == 200 && body != null) {
|
||
var main = body.get("main") as Dictionary?;
|
||
if (main != null) {
|
||
put("temp", main.get("temp"));
|
||
put("feels", main.get("feels_like"));
|
||
put("tmin", main.get("temp_min"));
|
||
put("tmax", main.get("temp_max"));
|
||
put("pressure", main.get("pressure"));
|
||
put("humidity", main.get("humidity"));
|
||
}
|
||
put("visibility", body.get("visibility"));
|
||
var wind = body.get("wind") as Dictionary?;
|
||
if (wind != null) {
|
||
put("wind", wind.get("speed"));
|
||
put("winddeg", wind.get("deg"));
|
||
put("gust", wind.get("gust"));
|
||
}
|
||
var clouds = body.get("clouds") as Dictionary?;
|
||
if (clouds != null) { put("clouds", clouds.get("all")); }
|
||
var rain = body.get("rain") as Dictionary?;
|
||
if (rain != null) { put("rain", rain.get("1h")); }
|
||
var snow = body.get("snow") as Dictionary?;
|
||
if (snow != null) { put("snow", snow.get("1h")); }
|
||
var wx = body.get("weather") as Array?;
|
||
if (wx != null && wx.size() > 0) {
|
||
var w0 = wx[0] as Dictionary;
|
||
put("short", w0.get("main"));
|
||
put("long", w0.get("description"));
|
||
}
|
||
put("city", body.get("name"));
|
||
put("dt", Time.now().value());
|
||
}
|
||
done();
|
||
}
|
||
|
||
// 预报返回的是 3 小时粒度的列表。这里按「距今天几天」把条目分到
|
||
// 明天 / 后天两个桶里,各自取最高最低温;降水概率取明天的最大值。
|
||
function onForecast(code as Number, body as Dictionary?) as Void {
|
||
if (code == 200 && body != null) {
|
||
var list = body.get("list") as Array?;
|
||
if (list != null) {
|
||
var today = dayNumber(Time.now().value());
|
||
var lo1 = null; var hi1 = null; var lo2 = null; var hi2 = null;
|
||
var pop1 = null;
|
||
var arr = list;
|
||
for (var i = 0; i < arr.size(); i++) {
|
||
var e = arr[i] as Dictionary;
|
||
var dt = e.get("dt");
|
||
if (dt == null) { continue; }
|
||
var day = dayNumber(dt as Number) - today;
|
||
var m = e.get("main") as Dictionary?;
|
||
if (m == null) { continue; }
|
||
var tmin = m.get("temp_min");
|
||
var tmax = m.get("temp_max");
|
||
if (day == 1) {
|
||
if (lo1 == null || tmin < lo1) { lo1 = tmin; }
|
||
if (hi1 == null || tmax > hi1) { hi1 = tmax; }
|
||
var pop = e.get("pop");
|
||
if (pop != null && (pop1 == null || pop > pop1)) { pop1 = pop; }
|
||
} else if (day == 2) {
|
||
if (lo2 == null || tmin < lo2) { lo2 = tmin; }
|
||
if (hi2 == null || tmax > hi2) { hi2 = tmax; }
|
||
}
|
||
}
|
||
put("lo1", lo1); put("hi1", hi1);
|
||
put("lo2", lo2); put("hi2", hi2);
|
||
put("pop", pop1);
|
||
}
|
||
}
|
||
done();
|
||
}
|
||
|
||
// 空气质量的回调。响应形如 { list: [ { main: {aqi}, components: {...} } ] }。
|
||
function onAir(code as Number, body as Dictionary?) as Void {
|
||
if (code == 200 && body != null) {
|
||
var list = body.get("list") as Array?;
|
||
if (list != null && list.size() > 0) {
|
||
var e = list[0] as Dictionary;
|
||
var main = e.get("main") as Dictionary?;
|
||
if (main != null) { put("aqi", main.get("aqi")); }
|
||
var comp = e.get("components") as Dictionary?;
|
||
if (comp != null) {
|
||
put("pm2_5", comp.get("pm2_5"));
|
||
put("pm10", comp.get("pm10"));
|
||
put("co", comp.get("co"));
|
||
}
|
||
}
|
||
}
|
||
done();
|
||
}
|
||
|
||
// 距 Unix 纪元的整天数,用来给预报条目分桶。
|
||
function dayNumber(unix as Number) as Number {
|
||
return unix / 86400;
|
||
}
|
||
|
||
// 只写入非空值,免得用 null 覆盖字典里已有的内容。
|
||
function put(k as String, v) as Void {
|
||
if (v != null) { mResult.put(k, v); }
|
||
}
|
||
|
||
// 两个请求都回来了才算一轮完整数据,谁最后完成谁负责退出服务。
|
||
// 一条都没拿到就传 null,避免用空字典覆盖掉上一次的好数据。
|
||
function done() as Void {
|
||
mPending--;
|
||
if (mPending > 0) { return; }
|
||
if (mResult.size() == 0) {
|
||
Background.exit(null);
|
||
} else {
|
||
Background.exit(mResult);
|
||
}
|
||
}
|
||
}
|