接入系统 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:
184
source/Comp.mc
Normal file
184
source/Comp.mc
Normal file
@@ -0,0 +1,184 @@
|
||||
import Toybox.Lang;
|
||||
import Toybox.Complications;
|
||||
import Toybox.System;
|
||||
|
||||
// ============================================================================
|
||||
// 系统 Complication 桥接
|
||||
// ----------------------------------------------------------------------------
|
||||
// Toybox.Complications(CIQ 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 个**第三方发布**的 complication(n 从 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;
|
||||
}
|
||||
|
||||
// 完赛预测(627–630)与配速预测(631–634)都是秒。
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user