Files
ericwyuan d9d45d983b 接入系统 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>
2026-09-10 09:03:21 +08:00

185 lines
8.1 KiB
MonkeyC
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}