接入系统 Complication,补齐倒计时/自定义格式/28天累计/空气质量

字段 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>
This commit is contained in:
2026-09-10 09:03:21 +08:00
parent 4d96ab0912
commit d9d45d983b
14 changed files with 651 additions and 19 deletions

184
source/Comp.mc Normal file
View File

@@ -0,0 +1,184 @@
import Toybox.Lang;
import Toybox.Complications;
import Toybox.System;
// ============================================================================
// 系统 Complication 桥接
// ----------------------------------------------------------------------------
// Toybox.ComplicationsCIQ 4.2+,本项目支持的 17 款设备全都有)让表盘可以订阅
// 系统和**其它 App 发布**的复杂功能数据。
//
// 这个模块解锁了一批我原本判断「表盘做不到」的字段 —— 当时只查了
// ActivityMonitor / UserProfile / Weather漏掉了这条路
//
// 日历事件 CIQ 确实没有日历 API但系统把「下一个日程」作为
// complication 发布出来了
// 训练状态 ActivityMonitor.Info 上没有,这里有
// 完赛/配速预测 同上5k/10k/半马/全马各两套
// 本周跑步/骑行距离 这是**按运动分类**的ActivityMonitor 的按天历史做不出来
// 高尔夫成绩 无其它来源
// 第三方 App CGM、补水、行情这类 App 发布的复杂功能,
// 通过迭代器按序号取(对应目录里的 App 1..App 5
//
// 取值策略每次绘制现查getComplication 很轻量),不做订阅回调 ——
// 表盘每分钟才重绘一次,订阅的实时性没有意义,反而要维护状态。
// ============================================================================
module Comp {
// 字段编号 → complication 类型。
// 返回 null 表示这个编号不归本模块管。
function typeFor(id as Number) {
if (id == 612) { return Complications.COMPLICATION_TYPE_CALENDAR_EVENTS; }
if (id == 626) { return Complications.COMPLICATION_TYPE_TRAINING_STATUS; }
if (id == 627) { return Complications.COMPLICATION_TYPE_RACE_PREDICTOR_5K; }
if (id == 628) { return Complications.COMPLICATION_TYPE_RACE_PREDICTOR_10K; }
if (id == 629) {
return Complications.COMPLICATION_TYPE_RACE_PREDICTOR_HALF_MARATHON;
}
if (id == 630) {
return Complications.COMPLICATION_TYPE_RACE_PREDICTOR_MARATHON;
}
if (id == 631) { return Complications.COMPLICATION_TYPE_RACE_PACE_PREDICTOR_5K; }
if (id == 632) { return Complications.COMPLICATION_TYPE_RACE_PACE_PREDICTOR_10K; }
if (id == 633) {
return Complications.COMPLICATION_TYPE_RACE_PACE_PREDICTOR_HALF_MARATHON;
}
if (id == 634) {
return Complications.COMPLICATION_TYPE_RACE_PACE_PREDICTOR_MARATHON;
}
if (id == 641) {
return Complications.COMPLICATION_TYPE_LAST_GOLF_ROUND_SCORE;
}
if (id == 619) {
return Complications.COMPLICATION_TYPE_WEEKLY_RUN_DISTANCE;
}
if (id == 620) {
return Complications.COMPLICATION_TYPE_WEEKLY_BIKE_DISTANCE;
}
return null;
}
// 第三方 App 列表的每帧缓存。
//
// 枚举一次要走完整个迭代器,四个数据位如果都选了 App 槽就会走四遍。
// 表盘每分钟才画一次,本来不算大开销,但 onUpdate 有看门狗时限,
// 能省则省 —— 所以每帧只枚举一次,由 view 在绘制开始时调 beginFrame() 失效。
var mApps as Array<Complications.Complication>?;
function beginFrame() as Void {
mApps = null;
}
// 695..699 是「第三方 App 1..5」,不对应固定类型,靠迭代器按序号取。
function isAppSlot(id as Number) as Boolean {
return id >= 695 && id <= 699;
}
function owns(id as Number) as Boolean {
return isAppSlot(id) || typeFor(id) != null;
}
// 按类型取一个 complication 对象;取不到返回 null。
// 用 try 包住是因为设备不支持某个类型时 getComplication 会抛,而不是返回
// null —— 和本项目里其它 CIQ API 的老毛病一样。
function byType(type) as Complications.Complication? {
try {
return Complications.getComplication(new Complications.Id(type));
} catch (e) {
return null;
}
}
// 取第 n 个**第三方发布**的 complicationn 从 0 起)。
//
// 迭代器里既有系统自带的,也有别的 App 发布的。这里只保留「有短标签且有值」
// 的项 —— 没装对应 App 时那些是空壳,全留着的话序号会随手表上装了什么 App
// 而漂移,用户设好的第 3 个明天可能就变成别的东西了。
//
// Complication 的成员是 complicationId / value / unit / shortLabel /
// longLabel / ranges没有 label别写错
function nthApp(n as Number) as Complications.Complication? {
if (mApps == null) {
var list = [] as Array<Complications.Complication>;
try {
var it = Complications.getComplications();
if (it != null) {
var c = it.next();
while (c != null) {
if (c.value != null && c.shortLabel != null
&& c.shortLabel instanceof Lang.String) {
list.add(c);
}
c = it.next();
}
}
} catch (e) {
// 设备不支持枚举时就当作一个都没有。
}
mApps = list;
}
return (n >= 0 && n < mApps.size()) ? mApps[n] : null;
}
// 把 complication 的值转成可显示的字符串。
//
// value 的类型随 complication 而变数字、字符串都有unit 有时带单位词。
// 完赛/配速预测的 value 是**秒数**,直接印出来是一串没意义的数字,
// 所以单独格式化成 h:mm:ss / mm:ss。
function render(c as Complications.Complication?, id as Number) as String? {
if (c == null || c.value == null) { return null; }
var v = c.value;
if (isTimeSeconds(id) && !(v instanceof Lang.String)) {
return hms(v.toNumber());
}
// 浮点直接 toString 会印成 "0.000000",统一保留一位小数。
if (v instanceof Lang.Float || v instanceof Lang.Double) {
return trimUnit(v.toFloat().format("%.1f"), c);
}
var s = v.toString();
// 单位词能明显帮上忙时才拼(比如距离的 km纯数字类就不拼了。
//
// ⚠️ unit 的类型不保证是 String —— 周跑步距离那种返回的不是字符串,
// 直接调 .length() 会抛 "Could not find symbol 'length'"。
// 所以先用 instanceof 判一次再说。
if (c.unit != null && c.unit instanceof Lang.String) {
var u = c.unit as String;
if (u.length() > 0 && u.length() <= 3) { s = s + u; }
}
return s;
}
// 把单位词拼到已格式化好的数值后面(如果 unit 确实是个短字符串)。
function trimUnit(s as String, c as Complications.Complication) as String {
if (c.unit != null && c.unit instanceof Lang.String) {
var u = c.unit as String;
if (u.length() > 0 && u.length() <= 3) { return s + u; }
}
return s;
}
// 完赛预测627630与配速预测631634都是秒。
function isTimeSeconds(id as Number) as Boolean {
return id >= 627 && id <= 634;
}
// 秒 → h:mm:ss不足一小时则 m:ss。配速通常是后者。
function hms(total as Number) as String {
if (total <= 0) { return "--"; }
var h = total / 3600;
var m = (total % 3600) / 60;
var s = total % 60;
if (h > 0) {
return h.format("%d") + ":" + m.format("%02d") + ":" + s.format("%02d");
}
return m.format("%d") + ":" + s.format("%02d");
}
// 字段取值总入口。不属于本模块的编号返回 null。
function value(id as Number) as String? {
if (!owns(id)) { return null; }
var c = isAppSlot(id) ? nthApp(id - 695) : byType(typeFor(id));
var out = render(c, id);
return out == null ? "--" : out;
}
}

View File

@@ -13,9 +13,10 @@ import Toybox.Position;
// HTTP 请求。Connect IQ 的方案是**后台服务**:系统按定时器唤醒一个独立进程,
// 在里面发请求,把结果通过 Background.exit() 交回前台,前台再写进 Storage。
//
// 每次唤醒发个请求:
// 每次唤醒发个请求:
// 1. 当前天气 /data/2.5/weather
// 2. 短期预报 /data/2.5/forecast?cnt=16约 48 小时,够算明日/后日高低温)
// 3. 空气质量 /data/2.5/air_pollutionAQI / PM2.5 / PM10 / CO
//
// ⚠️ 预报接口必须带 cnt 限制条数。后台进程的内存预算非常小(约 32KB
// 完整的 5 天 / 3 小时预报解析出来能有几十 KB会直接爆掉。
@@ -49,7 +50,7 @@ class Fenix8V3Background extends System.ServiceDelegate {
return;
}
mResult = {};
mPending = 2;
mPending = 3; // 当前天气 + 短预报 + 空气质量
// 一律用 units=metric 请求,单位换算统一放到 Fields 里按用户设置做,
// 这样切换单位不用重新联网。
var common = {"lat" => lat, "lon" => lon,
@@ -66,6 +67,12 @@ class Fenix8V3Background extends System.ServiceDelegate {
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 是嵌套的,逐层取值时每层都要判空 ——
@@ -143,6 +150,25 @@ class Fenix8V3Background extends System.ServiceDelegate {
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;

View File

@@ -12,6 +12,7 @@ import Fields;
import Layout;
import Icons;
import Settings;
import Comp;
// ============================================================================
// 表盘主体
@@ -429,6 +430,7 @@ class Fenix8V3View extends WatchUi.WatchFace {
var ti = Settings.themeIndex(Themes.NAMES.size());
var ds = System.getDeviceSettings();
var ctx = new Fields.Ctx(ds, Settings.tempUnit());
Comp.beginFrame(); // 让第三方 App 列表这一帧只枚举一次
var info = ctx.info();
var stats = ctx.stats();
var level = 0.0f;

View File

@@ -11,12 +11,14 @@ module FieldTable {
// 全部字段 id顺序与 FieldLabels 资源串(逗号分隔的标签表)严格一致 ——
// Fields.label() 就是靠这个下标去取对应标签的。
const IDS = [
0,860,880,864,865,851,872,852,855,870,856,853,868,858,859,850,869,873,1,14,707,2,
603,150,152,154,151,3,4,5,6,9,10,219,220,211,212,213,214,203,204,200,618,276,12,635,
11,623,8,15,281,742,13,278,277,250,251,257,253,255,254,256,252,615,616,733,712,717,
700,731,706,732,710,709,708,711,714,715,718,719,720,721,306,639,300,301,302,305,304,
308,307,310,309,312,313,314,315,500,515,503,504,505,507,502,501,506,509,510,511,518,
512,513,532,536,537,530,534,522,535,523,516,517,703,704,705
0,860,880,864,865,851,872,852,855,870,856,853,868,858,859,850,869,873,861,862,863,
735,1,14,707,2,603,150,152,154,151,3,4,5,6,9,10,219,220,211,212,213,214,203,204,200,
828,832,836,840,618,276,12,635,11,623,8,15,281,742,13,278,277,250,251,257,253,255,
254,256,252,615,616,733,712,717,700,731,706,732,710,709,708,711,714,715,718,719,720,
721,306,639,300,301,302,305,304,308,307,310,309,312,313,314,315,612,626,627,628,629,
630,631,632,633,634,641,619,620,695,696,697,698,699,500,515,503,504,505,507,502,501,
506,509,510,511,518,512,513,532,536,537,530,534,522,535,523,538,539,540,541,542,516,
517,703,704,705
];
// 由字段 id 反查它在 IDS 里的下标;找不到返回 0对应「关闭」

View File

@@ -15,6 +15,7 @@ import FieldTable;
import Settings;
import Solar;
import Owm;
import Comp;
// ============================================================================
// 数据字段
@@ -80,6 +81,89 @@ module Fields {
return Lang.format(mDateFmt, [month(g), g.day.format("%d")]);
}
// 按用户填的格式串拼时间/日期。
//
// 用单字母占位符,因为设置项里能填的长度有限,而且这样中英文都好写:
// h 小时12/24 跟随手表) H 小时补零
// m 分钟补零 s 秒补零
// a 上午/下午24 小时制下为空)
// D 日 M 月(数字) N 月(名称)
// Y 四位年 y 两位年 w 星期
// 其它字符原样输出,所以 "D/M" → "10/9""w N D" → "周四 9月 10"。
function applyFormat(fmt as String, g as Gregorian.Info, c as Ctx) as String {
var out = "";
var chars = fmt.toCharArray();
for (var i = 0; i < chars.size(); i++) {
var ch = chars[i];
if (ch == 'h') {
var hh = g.hour;
if (!c.is24) {
if (hh == 0) { hh = 12; } else if (hh > 12) { hh = hh - 12; }
}
out += hh.format("%d");
} else if (ch == 'H') {
out += g.hour.format("%02d");
} else if (ch == 'm') {
out += g.min.format("%02d");
} else if (ch == 's') {
out += g.sec.format("%02d");
} else if (ch == 'a') {
out += c.is24 ? "" : meridiem(g);
} else if (ch == 'D') {
out += g.day.format("%d");
} else if (ch == 'M') {
out += g.month.format("%d");
} else if (ch == 'N') {
out += month(g);
} else if (ch == 'Y') {
out += g.year.format("%d");
} else if (ch == 'y') {
out += (g.year % 100).format("%02d");
} else if (ch == 'w') {
out += dow(g);
} else {
out += ch.toString();
}
}
return out;
}
// 距目标日期还有几天。目标日期是设置里填的 YYYY-MM-DD 字符串。
// 解析失败(没填/格式不对)返回 null字段显示 "--"。
function daysUntil(target as String, g as Gregorian.Info) as Number? {
if (target.length() < 8) { return null; }
var parts = splitOn(target, '-');
if (parts.size() < 3) { return null; }
var y = parts[0].toNumber();
var mo = parts[1].toNumber();
var d = parts[2].toNumber();
if (y == null || mo == null || d == null) { return null; }
// 都换算成「儒略日式」的绝对天数再相减,省得处理跨月跨年。
return absDay(y, mo, d) - absDay(g.year, g.month, g.day);
}
function absDay(y as Number, m as Number, d as Number) as Number {
var yy = y;
var mm = m;
if (mm <= 2) { yy = yy - 1; mm = mm + 12; }
var a = yy / 100;
var b = 2 - a + a / 4;
return (365.25 * (yy + 4716)).toNumber() + (30.6001 * (mm + 1)).toNumber()
+ d + b - 1524;
}
function splitOn(str as String, sep as Char) as Array<String> {
var out = [] as Array<String>;
var cur = "";
var chars = str.toCharArray();
for (var i = 0; i < chars.size(); i++) {
if (chars[i] == sep) { out.add(cur); cur = ""; }
else { cur += chars[i].toString(); }
}
out.add(cur);
return out;
}
// 英文出 "10",中文出 "10日"。
// 后缀带在本地化的日期格式串里,所以这里把月份传空串即可。
function dayOnly(g as Gregorian.Info) as String {
@@ -375,6 +459,15 @@ module Fields {
// view 只在有格子选了秒时才去请求那个回调。
if (id == 850) { return c.greg.sec.format("%d"); } // 秒
if (id == 869) { return c.greg.sec.format("%02d"); } // 秒 (前导零)
// 自定义格式:格式串来自设置,占位符说明见 applyFormat
if (id == 861) { return applyFormat(Settings.text("TimeFmt"), c.greg, c); }
if (id == 862) { return applyFormat(Settings.text("DateFmt1"), c.greg, c); }
if (id == 863) { return applyFormat(Settings.text("DateFmt2"), c.greg, c); }
if (id == 735) { // 事件倒计时
var left = daysUntil(Settings.text("CountdownDate"), c.greg);
if (left == null) { return DASH; }
return left.format("%d");
} // 秒 (前导零)
if (id == 870) { return dow(c.greg); } // 星期简写
if (id == 856) { return dow(c.greg); } // 星期全称
if (id == 853) { return month(c.greg); } // 月份
@@ -450,6 +543,14 @@ module Fields {
return (id == 213) ? dist(v, c) : rounded(v);
}
// 近 7 天步数 / 近 7 天卡路里 / 近 7 天距离 / 近 7 天楼层 / 近 7 天活动分钟
if (id == 828 || id == 832 || id == 836 || id == 840) {
// 近 28 天。getHistory 能返回多少天由固件决定sumHistory 内部会按
// 实际条数截断,所以拿不到 28 天时是「有多少算多少」而不是报错。
var w28 = (id == 828) ? 0 : ((id == 832) ? 1 : ((id == 836) ? 2 : 3));
var v28 = sumHistory(c, w28, 27);
if (v28 == null) { return DASH; }
return (id == 836) ? dist(v28, c) : rounded(v28);
}
if (id == 220 || id == 212 || id == 214 || id == 204 || id == 200) {
var which = (id == 220) ? 0 : ((id == 212) ? 1
: ((id == 214) ? 2 : ((id == 204) ? 3 : 4)));
@@ -760,6 +861,18 @@ module Fields {
if (id == 523) { // OWM 后日高低温
return temp(Owm.get("hi2"), c) + "/" + temp(Owm.get("lo2"), c);
}
if (id == 538 || id == 539) { // 空气质量指数 / 描述
var aqi = Owm.get("aqi");
if (aqi == null) { return DASH; }
if (id == 538) { return num(aqi); }
// OWM 的 AQI 是 1..5 的等级,不是欧美常见的 0..500 数值。
var names = ["", "优", "良", "中", "差", "很差"];
var k = aqi.toNumber();
return (k >= 1 && k <= 5) ? names[k] : DASH;
}
if (id == 540) { return oneDp(Owm.get("pm2_5")); } // PM2.5
if (id == 541) { return oneDp(Owm.get("pm10")); } // PM10
if (id == 542) { return oneDp(Owm.get("co")); } // 一氧化碳
if (id == 516) { return str(Owm.get("city")); } // OWM 城市
if (id == 517) { // OWM 更新时间
var dt = Owm.get("dt");
@@ -768,10 +881,21 @@ module Fields {
return DASH;
}
function oneDp(v) as String {
return v == null ? DASH : v.toFloat().format("%.1f");
}
function str(v) as String {
return v == null ? DASH : v.toString();
}
// ---- 系统 Complication ----
// 日历、训练状态、完赛/配速预测、按运动的周距离、以及第三方 App 发布的
// 复杂功能。全部由 Comp.mc 经 Toybox.Complications 取得。
function complication(id as Number, c as Ctx) as String? {
return Comp.value(id);
}
// ---- 自定义文字 ----
// 用户在设置里自己填的三段文字。
function custom(id as Number, c as Ctx) as String? {
@@ -791,6 +915,7 @@ module Fields {
if (v == null) { v = system(id, c); }
if (v == null) { v = environment(id, c); }
if (v == null) { v = weather(id, c); }
if (v == null) { v = complication(id, c); }
if (v == null) { v = owm(id, c); }
if (v == null) { v = custom(id, c); }
return v == null ? "" : v;