Files
fenix8v3-watchface/source/Fields.mc
ericwyuan 22340b09fa 机型覆盖 17 → 55 款,补 LICENSE 与 CHANGELOG
机型
  新增 38 款圆屏 + CIQ ≥ 4.2 的机型:fenix 7 Pro 全系、Venu 2/3S、
  Approach S50/S70、Descent G2/Mk3、D2 系列、Instinct 3 AMOLED、
  FR255S/265S/570/955 等。其中 34 款的屏宽已有现成图标资源。

  机型列表和 monkey.jungle 改由 tools/gen_devices.py 扫描本机 SDK 生成,
  筛选条件是「支持 watchFace + 圆屏 + CIQ ≥ 4.2」(Complications 要 4.2)。
  55 条资源路径不再手写。

  新增 218px 与 360px 两档图标。218px 上图标只有 14×14,设计稿里太阳与云
  之间那道 1.7 设计像素的暗缝连一个物理像素都占不到,光栅化出来是一坨没有
  结构的橙块 —— 该尺寸改用按目标像素直接摆的简化标记。

常亮模式:结论是不改
  为了压所谓的 10% 点亮率,一度把进度环也砍掉了。换成平均亮度(AMOLED 功耗
  的合理代理)重新量之后发现:普通模式 14.1%,常亮 2.9%,本来就只有 1/5;
  砍掉环只从 3.01% 降到 2.91%。0.1 个百分点换掉一个可见特征不划算,环留着。
  两种度量的差异和这个决定都写进了代码注释和 README。

其它
  - LICENSE(MIT)。只覆盖代码 —— design/ 下的设计稿版权归第三方,
    发布前需取得原作者授权或替换视觉,文件内已注明。
  - UserProfile.getProfile() 加异常保护(这个 API 家族已经坑过三次)
  - CHANGELOG.md,版本号 1.0.0 → 1.1.0

验证:55 款全部编译通过(分三批 18+18+19);.iq 上架包可正常导出。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 11:27:54 +08:00

934 lines
43 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.Math;
import Toybox.System;
import Toybox.Time;
import Toybox.Time.Gregorian;
import Toybox.Application;
import Toybox.ActivityMonitor;
import Toybox.Activity;
import Toybox.UserProfile;
import Toybox.SensorHistory;
import Toybox.Weather;
import Toybox.Position;
import Toybox.WatchUi;
import FieldTable;
import Settings;
import Solar;
import Owm;
import Comp;
// ============================================================================
// 数据字段
// ----------------------------------------------------------------------------
// 四个数据位能显示的全部内容,共 125 项。
//
// 字段编号沿用 https://watchface.io/docs/datafields两边可以直接对照。
// 驱动设置项和标签文案的那张表在 tools/gen_fields.py —— 加字段要改那里,
// 然后在本文件对应分类的函数里补一个分支。
//
// 只收录**手表本机能算出来**的字段。没做的几类及原因见 README 第 7 节,简单说:
// - 按运动分类的周/月累计CIQ 只有按天的总计,没有分运动的历史
// - StormGlass 潮汐、第三方 App 复杂功能:需要额外 key 或用户装了对应 App
// - 日历、计时器CIQ 不开放日历,表盘也拿不到触摸输入
//
// 组织方式value() 按分类依次尝试 7 个函数,每个函数不认识编号就返回 null。
// 之所以不按编号区间派发,是因为编号是**交错**的 —— 1/2/3 是活动8/11/12 是
// 身体14 又回到活动。
// ============================================================================
module Fields {
const DASH = "--";
var mDow as Array<String>?;
var mMon as Array<String>?;
var mLabels as Array<String>?;
var mMeridiem as Array<String>?;
var mDateFmt as String = "$1$ $2$";
function loadStrings() as Void {
mDow = splitCsv(WatchUi.loadResource(Rez.Strings.DowNames) as String);
mMon = splitCsv(WatchUi.loadResource(Rez.Strings.MonNames) as String);
mLabels = splitCsv(WatchUi.loadResource(Rez.Strings.FieldLabels) as String);
mMeridiem = splitCsv(WatchUi.loadResource(Rez.Strings.Meridiem) as String);
mDateFmt = WatchUi.loadResource(Rez.Strings.DateFormat) as String;
}
function ready() as Boolean { return mDow != null; }
// Monkey C 没有 String.split只能自己按逗号切。
function splitCsv(str as String) as Array<String> {
var out = [] as Array<String>;
var rest = str;
var cut = rest.find(",");
while (cut != null) {
out.add(rest.substring(0, cut) as String);
rest = rest.substring(cut + 1, rest.length()) as String;
cut = rest.find(",");
}
out.add(rest);
return out;
}
function dow(g as Gregorian.Info) as String {
return mDow == null ? "" : mDow[g.day_of_week - 1];
}
function month(g as Gregorian.Info) as String {
return mMon == null ? "" : mMon[g.month - 1];
}
function monthDay(g as Gregorian.Info) as String {
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 {
return Lang.format(mDateFmt, ["", g.day.format("%d")]);
}
function meridiem(g as Gregorian.Info) as String {
return mMeridiem == null ? "" : mMeridiem[g.hour >= 12 ? 1 : 0];
}
// 底部格子数值下方的小标签。按字段编号查 FieldTable 的下标,
// 再到本地化的标签表里取词,所以中英文会自动跟随系统语言。
function label(id as Number) as String {
if (mLabels == null) { return ""; }
var i = FieldTable.indexOf(id);
return i < mLabels.size() ? mLabels[i] : "";
}
// 每次绘制构造一个,传给所有数据位共用。
// 目的是让开销大的查询(天气预报、传感器历史、用户档案)**每次绘制最多做
// 一次,且只在真的有格子要用时才做** —— 四个格子可能都要天气,但只查一次;
// 没有格子用到 UserProfile 时就完全不去碰它。
class Ctx {
var greg as Gregorian.Info;
var is24 as Boolean = true;
var metric as Boolean = true;
var fahrenheit as Boolean = false;
private var mInfo as ActivityMonitor.Info?;
private var mStats as System.Stats?;
private var mDs as System.DeviceSettings?;
private var mAct as Activity.Info?;
private var mWx as Weather.CurrentConditions?;
private var mFc as Array<Weather.DailyForecast>?;
private var mProf as UserProfile.Profile?;
private var mGotAct = false;
private var mGotWx = false;
private var mGotFc = false;
private var mGotProf = false;
function initialize(ds as System.DeviceSettings?, tempUnit as Number) {
mDs = ds;
greg = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
if (ds != null) {
is24 = ds.is24Hour;
metric = (ds.distanceUnits != System.UNIT_STATUTE);
fahrenheit = (tempUnit == 2)
|| (tempUnit == 0 && ds.temperatureUnits == System.UNIT_STATUTE);
}
if (tempUnit == 1) { fahrenheit = false; }
}
function ds() as System.DeviceSettings? { return mDs; }
function info() as ActivityMonitor.Info? {
if (mInfo == null) { mInfo = ActivityMonitor.getInfo(); }
return mInfo;
}
function stats() as System.Stats? {
if (mStats == null) { mStats = System.getSystemStats(); }
return mStats;
}
function act() as Activity.Info? {
if (!mGotAct) { mAct = Activity.getActivityInfo(); mGotAct = true; }
return mAct;
}
function wx() as Weather.CurrentConditions? {
if (!mGotWx) { mWx = Weather.getCurrentConditions(); mGotWx = true; }
return mWx;
}
function forecast() as Weather.DailyForecast? {
if (!mGotFc) { mFc = Weather.getDailyForecast(); mGotFc = true; }
if (mFc != null && mFc.size() > 0) { return mFc[0]; }
return null;
}
// 本项目已经被这个 API 家族坑过三次Properties.getValue、
// deleteTemporalEvent、Complication.unit一律按「声明了权限也可能抛」
// 来防。取不到就当作没有用户档案,相关字段显示 "--"。
function profile() as UserProfile.Profile? {
if (!mGotProf) {
try {
mProf = UserProfile.getProfile();
} catch (e) {
mProf = null;
}
mGotProf = true;
}
return mProf;
}
// 日出/日落/晨昏蒙影都要位置,而两个实时来源都会间歇性为空:
// - 天气在同步之前没有观测点坐标
// - currentLocation 要有东西刚定过位才有值
// 所以每次拿到位置就缓存进 Storage两个来源都空时用缓存兜底
// 免得这些字段一会儿有一会儿变 "--"。
function where() as Position.Location? {
var w = wx();
if (w != null && (w has :observationLocationPosition)
&& w.observationLocationPosition != null) {
return remember(w.observationLocationPosition);
}
var a = act();
if (a != null && a.currentLocation != null) {
return remember(a.currentLocation);
}
var lat = Application.Storage.getValue("lastLat");
var lon = Application.Storage.getValue("lastLon");
if (lat != null && lon != null) {
return new Position.Location({:latitude => lat as Double,
:longitude => lon as Double,
:format => :degrees});
}
return null;
}
function remember(loc as Position.Location) as Position.Location {
var d = loc.toDegrees();
Application.Storage.setValue("lastLat", d[0]);
Application.Storage.setValue("lastLon", d[1]);
return loc;
}
}
// ------------------------------- 格式化工具 ----------------------------
function num(v as Numeric?) as String {
return v == null ? DASH : v.format("%d");
}
function one(v as Float?) as String {
return v == null ? DASH : v.format("%.1f");
}
function clock(m as Time.Moment?, c as Ctx) as String {
if (m == null) { return DASH; }
return clockInfo(Gregorian.info(m, Time.FORMAT_SHORT), c);
}
function clockInfo(g as Gregorian.Info, c as Ctx) as String {
var h = g.hour;
if (!c.is24) {
if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; }
}
return h.format("%d") + ":" + g.min.format("%02d");
}
function offsetClock(minutes as Number, c as Ctx) as String {
var m = Time.now().add(new Time.Duration(minutes * 60));
return clock(m, c);
}
function temp(celsius as Numeric?, c as Ctx) as String {
if (celsius == null) { return DASH; }
var t = celsius.toFloat();
if (c.fahrenheit) { t = t * 9.0 / 5.0 + 32.0; }
return t.format("%d") + "°";
}
// ActivityMonitor 的距离单位是**厘米**,先换算成公里再按单位制转换。
function dist(cm as Numeric?, c as Ctx) as String {
if (cm == null) { return DASH; }
var km = cm.toFloat() / 100000.0;
return (c.metric ? km : km * 0.621371).format("%.1f");
}
function metres(m as Numeric?, c as Ctx) as String {
if (m == null) { return DASH; }
var v = m.toFloat();
return (c.metric ? v : v * 3.28084).format("%d");
}
function rounded(v as Numeric?) as String {
if (v == null) { return DASH; }
var n = v.toNumber();
if (n < 1000) { return n.format("%d"); }
return (n / 1000.0).format("%.1f") + "k";
}
// 从 SensorHistory 迭代器里取最新一条读数;没有则 null。
function latest(iter) as Numeric? {
if (iter == null) { return null; }
var s = iter.next();
if (s == null) { return null; }
return s.data;
}
// 历史累计。
// ActivityMonitor.getHistory() 返回过去若干天(最新在前),**不含今天**。
// 把它加起来是本机唯一能做出「本周累计 / 近 7 天累计」的路子;
// 按运动项目细分的那些仍然做不到,那需要手机侧与 Garmin Connect 的集成。
//
// which0 步数 / 1 卡路里 / 2 距离(厘米) / 3 楼层 / 4 活动分钟
// days :往前折算几天,今天总是算在内
function sumHistory(c as Ctx, which as Number, days as Number) as Numeric? {
var today = todayValue(c, which);
if (today == null) { return null; }
var total = today;
if (days <= 0) { return total; }
var hist = ActivityMonitor.getHistory();
if (hist == null) { return total; }
var n = days < hist.size() ? days : hist.size();
for (var k = 0; k < n; k++) {
var v = sampleValue(hist[k], which);
if (v != null) { total = total + v; }
}
return total;
}
function todayValue(c as Ctx, which as Number) as Numeric? {
var i = c.info();
if (i == null) { return null; }
return sampleValue(i, which);
}
function sampleValue(d, which as Number) as Numeric? {
if (which == 0) { return (d has :steps) ? d.steps : null; }
if (which == 1) { return (d has :calories) ? d.calories : null; }
if (which == 2) { return (d has :distance) ? d.distance : null; }
if (which == 3) { return (d has :floorsClimbed) ? d.floorsClimbed : null; }
if (which == 4) {
if (!(d has :activeMinutesDay) || d.activeMinutesDay == null) { return null; }
return d.activeMinutesDay.total;
}
return null;
}
// 距本周一过了几天,本周累计据此决定往前取多少天。
// day_of_week 是 1=周日,所以周日要特判成 6。
function daysIntoWeek(g as Gregorian.Info) as Number {
var dow = g.day_of_week; // 1 = Sunday
return dow == 1 ? 6 : dow - 2;
}
function dayOfYear(g as Gregorian.Info) as Number {
var cum = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
var d = cum[g.month - 1] + g.day;
var y = g.year;
var leap = (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
if (leap && g.month > 2) { d = d + 1; }
return d;
}
// ISO 8601 周数:周一为一周之始,含当年第一个周四的那周为第 1 周。
function isoWeek(g as Gregorian.Info) as Number {
var dow = g.day_of_week - 1; // 0 = Sunday
var iso = dow == 0 ? 7 : dow; // 1 = Monday .. 7 = Sunday
var w = (dayOfYear(g) - iso + 10) / 7;
if (w < 1) { return 52; }
if (w > 52) { return 1; }
return w;
}
// 月龄:距 2000-01-06 那次新月的天数对朔望月29.530588853 天)取余。
function moonAge(g as Gregorian.Info) as Float {
var y = g.year;
var m = g.month;
if (m <= 2) { y = y - 1; m = m + 12; }
var a = y / 100;
var b = 2 - a + a / 4;
var jd = (365.25 * (y + 4716)).toNumber() + (30.6001 * (m + 1)).toNumber()
+ g.day + b - 1524.5 + g.hour / 24.0;
// ⚠️ Monkey C 的 % 不支持浮点,只能手写取余。
var syn = 29.530588853;
var x = jd - 2451550.1;
var age = x - (x / syn).toNumber() * syn;
if (age < 0) { age = age + syn; }
return age.toFloat();
}
function windDir(bearingDeg as Numeric?) as String {
if (bearingDeg == null) { return DASH; }
var pts = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"];
var b = bearingDeg.toFloat();
while (b < 0) { b = b + 360.0; }
var i = ((b + 11.25) / 22.5).toNumber() % 16;
return pts[i];
}
// ------------------------------- 分类取值 ------------------------------
// ---- 日期时间类 ----
// 不属于本类的编号返回 null交给 value() 试下一个分类。
function dateTime(id as Number, c as Ctx) as String? {
if (id == 860) { return clockInfo(c.greg, c); } // 时间
if (id == 880) { // UTC 时间
var utc = Gregorian.utcInfo(Time.now(), Time.FORMAT_SHORT);
return clockInfo(utc, c);
}
// 第二时区 1
if (id == 864) { return offsetClock(Settings.number("AltOffset1", 0), c); }
// 第二时区 2
if (id == 865) { return offsetClock(Settings.number("AltOffset2", 0), c); }
if (id == 855) { return c.greg.day.format("%d"); } // 日
if (id == 868) { return c.greg.month.format("%d"); } // 月份数字
if (id == 859) { return isoWeek(c.greg).format("%d"); } // 周数 (ISO)
// 周数
if (id == 873) { return ((dayOfYear(c.greg) + 6) / 7).format("%d"); }
// 秒数要动起来依赖设备支持 onPartialUpdate
// 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); } // 月份
if (id == 852) { return monthDay(c.greg); } // 月 日
if (id == 872) { return dow(c.greg) + " " + dayOnly(c.greg); } // 星期 日
if (id == 851) { return dow(c.greg) + " " + monthDay(c.greg); } // 星期 月 日
if (id == 858) { return meridiem(c.greg); } // 上午/下午
return null;
}
// ---- 活动类 ----
// 步数、卡路里、距离、楼层、活动分钟,以及它们的本周/近 7 天累计。
function activity(id as Number, c as Ctx) as String? {
var i = c.info();
if (id == 1) { return i == null ? DASH : num(i.steps); } // 步数
if (id == 14) { return i == null ? DASH : rounded(i.steps); } // 步数 (取整)
if (id == 707) { // 剩余步数
if (i == null || i.steps == null || i.stepGoal == null) { return DASH; }
var left = i.stepGoal - i.steps;
return left > 0 ? left.format("%d") : "0";
}
if (id == 2) { return i == null ? DASH : num(i.calories); } // 卡路里
if (id == 603) { // 活动卡路里
if (i == null || i.calories == null) { return DASH; }
var p = c.profile();
if (p == null || p.weight == null || p.height == null) { return num(i.calories); }
return num(i.calories); // device reports total only
}
if (id == 150 || id == 152 || id == 154) { // 活动分钟 / 中强度分钟 / 高强度分钟
if (i == null || !(i has :activeMinutesDay) || i.activeMinutesDay == null) {
return DASH;
}
var am = i.activeMinutesDay;
if (id == 152) { return num(am.moderate); } // 中强度分钟
if (id == 154) { return num(am.vigorous); } // 高强度分钟
return num(am.total);
}
if (id == 151) { // 本周活动分钟
if (i == null || !(i has :activeMinutesWeek) || i.activeMinutesWeek == null) {
return DASH;
}
return num(i.activeMinutesWeek.total);
}
// 距离
if (id == 3) { return i == null ? DASH : dist(i.distance, c); }
// 上楼层数
if (id == 4) { return i == null ? DASH : num(i.floorsClimbed); }
// ⚠️ ActivityMonitor.Info 上有些成员**在所有设备上都能编译通过,但只有
// 部分设备/固件运行时才真的有**。轮椅计数器就是典型:在 fenix 8 上直接抛
// "Symbol Not Found",用户一选这个字段表盘就崩。
// 所以凡是非通用成员,用之前一律先 has 检查。
if (id == 5) { // 下楼层数
return (i == null || !(i has :floorsDescended)) ? DASH
: num(i.floorsDescended);
}
if (id == 6) { // 爬升高度
return (i == null || !(i has :metersClimbed)) ? DASH
: metres(i.metersClimbed, c);
}
if (id == 9) { // 推行距离
return (i == null || !(i has :pushDistance)) ? DASH
: dist(i.pushDistance, c);
}
if (id == 10) { // 推行次数
return (i == null || !(i has :pushes)) ? DASH : num(i.pushes);
}
if (id == 219 || id == 211 || id == 213 || id == 203) { // 本周步数 / 本周卡路里 / 本周距离 / 本周楼层
var which = (id == 219) ? 0 : ((id == 211) ? 1 : ((id == 213) ? 2 : 3));
var back = daysIntoWeek(c.greg);
var v = sumHistory(c, which, back);
if (v == null) { return DASH; }
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)));
var v = sumHistory(c, which, 6);
if (v == null) { return DASH; }
return (id == 214) ? dist(v, c) : rounded(v);
}
if (id == 8 || id == 15) { // 活动条 / 活动条 (反向)
if (i == null || i.moveBarLevel == null) { return DASH; }
var lvl = i.moveBarLevel;
// 活动条 (反向)
if (id == 15) { lvl = ActivityMonitor.MOVE_BAR_LEVEL_MAX - lvl; }
return lvl.format("%d");
}
if (id == 13) { // 恢复时间
return (i == null || !(i has :timeToRecovery)) ? DASH
: num(i.timeToRecovery);
}
if (id == 11) { // 呼吸频率
return (i == null || !(i has :respirationRate)) ? DASH
: num(i.respirationRate);
}
if (id == 12) { // 压力指数
return (i == null || !(i has :stressScore)) ? DASH : num(i.stressScore);
}
return null;
}
// ---- 身体类 ----
// 心率、压力、血氧、身体电量,以及从用户档案推出来的体重/BMI/VO2max。
function body(id as Number, c as Ctx) as String? {
if (id == 618) { // 心率
var a = c.act();
if (a != null && a.currentHeartRate != null) { return num(a.currentHeartRate); }
var h = ActivityMonitor.getHeartRateHistory(1, true);
if (h != null) {
var s = h.next();
if (s != null && s.heartRate != null
&& s.heartRate != ActivityMonitor.INVALID_HR_SAMPLE) {
return num(s.heartRate);
}
}
return DASH;
}
if (id == 276) { // 静息心率
var p = c.profile();
return (p == null || !(p has :restingHeartRate)) ? DASH
: num(p.restingHeartRate);
}
if (id == 635) { // 血氧
if (!(SensorHistory has :getOxygenSaturationHistory)) { return DASH; }
return num(latest(SensorHistory.getOxygenSaturationHistory({:period => 1})));
}
if (id == 623) { // 身体电量
if (!(SensorHistory has :getBodyBatteryHistory)) { return DASH; }
return num(latest(SensorHistory.getBodyBatteryHistory({:period => 1})));
}
if (id == 281 || id == 742) { // 体重 / BMI
var p = c.profile();
if (p == null || !(p has :weight) || p.weight == null) { return DASH; }
var kg = p.weight.toFloat() / 1000.0;
if (id == 281) { // 体重
return (c.metric ? kg : kg * 2.20462).format("%.1f");
}
if (!(p has :height) || p.height == null || p.height <= 0) { return DASH; }
var m = p.height.toFloat() / 100.0;
return (kg / (m * m)).format("%.1f");
}
if (id == 277 || id == 278) { // 最大摄氧量 (骑) / 最大摄氧量 (跑)
var p = c.profile();
if (p == null) { return DASH; }
if (id == 278) { // 最大摄氧量 (跑)
return (p has :vo2maxRunning) ? num(p.vo2maxRunning) : DASH;
}
return (p has :vo2maxCycling) ? num(p.vo2maxCycling) : DASH;
}
return null;
}
// ---- 系统类 ----
// 电量、闹钟、通知、勿扰、蓝牙、太阳能强度。
function system(id as Number, c as Ctx) as String? {
var st = c.stats();
if (id == 250) { // 电量
return st == null || st.battery == null ? DASH
: st.battery.format("%d") + "%";
}
if (id == 251) { // 剩余天数
if (st == null || !(st has :batteryInDays) || st.batteryInDays == null) {
return DASH;
}
return st.batteryInDays.format("%d") + "d";
}
if (id == 257) { // 电量 / 天数
var pct = (st == null || st.battery == null) ? DASH : st.battery.format("%d");
var d = (st == null || !(st has :batteryInDays) || st.batteryInDays == null)
? DASH : st.batteryInDays.format("%d");
return pct + "/" + d;
}
if (id == 252) { // 太阳能强度
if (st == null || !(st has :solarIntensity) || st.solarIntensity == null) {
return DASH;
}
return st.solarIntensity.format("%d") + "%";
}
var ds = c.ds();
// 闹钟
if (id == 253) { return ds == null ? DASH : num(ds.alarmCount); }
// 通知
if (id == 255) { return ds == null ? DASH : num(ds.notificationCount); }
if (id == 254) { // 勿扰模式
return ds == null ? DASH : (ds.doNotDisturb ? "ON" : "OFF");
}
if (id == 256) { // 蓝牙
return ds == null ? DASH : (ds.phoneConnected ? "ON" : "OFF");
}
return null;
}
// ---- 环境类 ----
// 海拔、气压、各类太阳事件(含晨昏蒙影与黄金/蓝调时刻)、月相。
function environment(id as Number, c as Ctx) as String? {
if (id == 615) { // 海拔
var a = c.act();
return (a == null || !(a has :altitude)) ? DASH : metres(a.altitude, c);
}
if (id == 616 || id == 733) { // 海平面气压 / 环境气压
var a = c.act();
if (a == null) { return DASH; }
var pa = null;
if (id == 616 && (a has :meanSeaLevelPressure)) { // 海平面气压
pa = a.meanSeaLevelPressure;
} else if (id == 733 && (a has :ambientPressure)) { // 环境气压
pa = a.ambientPressure;
}
if (pa == null) { return DASH; }
return (pa.toFloat() / 100.0).format("%d");
}
if (id == 712 || id == 717 || id == 700 || id == 731) { // 日出 / 日落 / 下一太阳事件 / 距太阳事件
var loc = c.where();
if (loc == null) { return DASH; }
var now = Time.now();
var rise = Weather.getSunrise(loc, now);
var set = Weather.getSunset(loc, now);
if (id == 712) { return clock(rise, c); } // 日出
if (id == 717) { return clock(set, c); } // 日落
if (rise == null || set == null) { return DASH; }
var next = (now.compare(rise) < 0) ? rise
: ((now.compare(set) < 0) ? set : rise);
if (id == 700) { return clock(next, c); } // 下一太阳事件
var secs = next.compare(now);
if (secs < 0) { secs = secs + 86400; }
return (secs / 3600).format("%d") + "h" + ((secs % 3600) / 60).format("%02d");
}
if (Solar.owns(id)) {
return clock(Solar.byId(id, c.where()), c);
}
if (id == 706) { return moonAge(c.greg).format("%.1f"); } // 月龄
if (id == 732) { // 月球照明
var age = moonAge(c.greg);
var lit = (1.0 - Math.cos(2.0 * Math.PI * age / 29.530588853)) / 2.0;
return (lit * 100.0).format("%d") + "%";
}
return null;
}
// ---- Garmin 原生天气 ----
// 手表同步下来的当前天气与今日预报。与 OWM 那套完全独立。
function weather(id as Number, c as Ctx) as String? {
var w = c.wx();
// 气温
if (id == 306) { return w == null ? DASH : temp(w.temperature, c); }
if (id == 300) { // 体感温度
return (w == null || !(w has :feelsLikeTemperature)) ? DASH
: temp(w.feelsLikeTemperature, c);
}
if (id == 305) { // 湿度
return (w == null || !(w has :relativeHumidity)) ? DASH
: num(w.relativeHumidity) + "%";
}
if (id == 304) { // 降水概率
if (w == null || !(w has :precipitationChance)) { return DASH; }
return num(w.precipitationChance) + "%";
}
if (id == 308) { // 风速
if (w == null || !(w has :windSpeed) || w.windSpeed == null) { return DASH; }
var kmh = w.windSpeed.toFloat() * 3.6;
return (c.metric ? kmh : kmh * 0.621371).format("%d");
}
if (id == 307) { // 风向角
return (w == null || !(w has :windBearing)) ? DASH : num(w.windBearing);
}
if (id == 310) { // 风向
return (w == null || !(w has :windBearing)) ? DASH : windDir(w.windBearing);
}
if (id == 309) { // 天气更新时间
if (w == null || !(w has :observationTime) || w.observationTime == null) {
return DASH;
}
return clock(w.observationTime, c);
}
if (id == 312) { // 海平面气压
if (w == null || !(w has :pressure) || w.pressure == null) { return DASH; }
return (w.pressure.toFloat() / 100.0).format("%d");
}
if (id == 313) { // 紫外线指数
return (w == null || !(w has :uvIndex)) ? DASH : num(w.uvIndex);
}
if (id == 314) { // 能见度
if (w == null || !(w has :visibility) || w.visibility == null) { return DASH; }
var km = w.visibility.toFloat() / 1000.0;
return (c.metric ? km : km * 0.621371).format("%.1f");
}
if (id == 315) { // 露点
// CIQ 没有露点字段,用 Magnus 公式从温度和湿度反推,
// 误差在零点几度以内,够用。
if (w == null || w.temperature == null
|| !(w has :relativeHumidity) || w.relativeHumidity == null) {
return DASH;
}
var t = w.temperature.toFloat();
var rh = w.relativeHumidity.toFloat();
if (rh <= 0.0) { return DASH; }
var g2 = (17.62 * t) / (243.12 + t) + Math.ln(rh / 100.0);
return temp(243.12 * g2 / (17.62 - g2), c);
}
if (id == 301 || id == 302 || id == 639) { // 最高气温 / 最低气温 / 最高/最低气温
var f = c.forecast();
if (f == null) { return DASH; }
var hi = (f has :highTemperature) ? f.highTemperature : null;
var lo = (f has :lowTemperature) ? f.lowTemperature : null;
if (id == 301) { return temp(hi, c); } // 最高气温
if (id == 302) { return temp(lo, c); } // 最低气温
return temp(hi, c) + "/" + temp(lo, c);
}
return null;
}
// ---- OpenWeatherMap ----
// 读后台服务上次存下来的数据(本函数不联网,见 Owm.mc / Fenix8V3Background.mc
// 后台一律用公制请求,单位换算和其他字段一样在这里做。
function owm(id as Number, c as Ctx) as String? {
if (!Owm.owns(id)) { return null; }
if (!Owm.enabled()) { return DASH; }
if (id == 500) { return temp(Owm.get("temp"), c); } // OWM 气温
if (id == 503) { return temp(Owm.get("feels"), c); } // OWM 体感温度
if (id == 504) { return temp(Owm.get("tmax"), c); } // OWM 最高温
if (id == 505) { return temp(Owm.get("tmin"), c); } // OWM 最低温
if (id == 515) { // OWM 最高/最低
return temp(Owm.get("tmax"), c) + "/" + temp(Owm.get("tmin"), c);
}
if (id == 507) { return str(Owm.get("short")); } // OWM 天气
if (id == 502) { return str(Owm.get("long")); } // OWM 天气详述
if (id == 501) { // OWM 湿度
var h = Owm.get("humidity");
return h == null ? DASH : num(h) + "%";
}
if (id == 506) { return num(Owm.get("pressure")); } // OWM 气压
if (id == 509) { // OWM 能见度
var v = Owm.get("visibility");
if (v == null) { return DASH; }
var km = v.toFloat() / 1000.0;
return (c.metric ? km : km * 0.621371).format("%.1f");
}
if (id == 510 || id == 512) { // OWM 风速 / OWM 阵风
var w = Owm.get(id == 510 ? "wind" : "gust");
if (w == null) { return DASH; }
var kmh = w.toFloat() * 3.6;
return (c.metric ? kmh : kmh * 0.621371).format("%d");
}
if (id == 511) { return num(Owm.get("winddeg")); } // OWM 风向角
if (id == 518) { return windDir(Owm.get("winddeg")); } // OWM 风向
if (id == 513) { // OWM 1h 降雨
var r = Owm.get("rain");
return r == null ? "0" : r.toFloat().format("%.1f");
}
if (id == 532) { // OWM 1h 降雪
var sn = Owm.get("snow");
return sn == null ? "0" : sn.toFloat().format("%.1f");
}
if (id == 536) { // OWM 云量
var cl = Owm.get("clouds");
return cl == null ? DASH : num(cl) + "%";
}
if (id == 537) { // OWM 降水概率
var pop = Owm.get("pop");
return pop == null ? DASH : (pop.toFloat() * 100.0).format("%d") + "%";
}
if (id == 530) { // OWM 露点
// OWM 免费接口不返回露点,同样用 Magnus 公式从温湿度算。
var t = Owm.get("temp");
var rh = Owm.get("humidity");
if (t == null || rh == null || rh.toFloat() <= 0.0) { return DASH; }
var tf = t.toFloat();
var gg = (17.62 * tf) / (243.12 + tf) + Math.ln(rh.toFloat() / 100.0);
return temp(243.12 * gg / (17.62 - gg), c);
}
if (id == 534) { return temp(Owm.get("hi1"), c); } // OWM 明日气温
if (id == 535) { return temp(Owm.get("hi2"), c); } // OWM 后日气温
if (id == 522) { // OWM 明日高低温
return temp(Owm.get("hi1"), c) + "/" + temp(Owm.get("lo1"), c);
}
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");
return dt == null ? DASH : clock(new Time.Moment(dt as Number), c);
}
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? {
if (id == 703) { return Settings.text("Custom1"); } // 自定义文字 1
if (id == 704) { return Settings.text("Custom2"); } // 自定义文字 2
if (id == 705) { return Settings.text("Custom3"); } // 自定义文字 3
return null;
}
// 取某个字段的显示文本。
// 按分类依次尝试,而不是按编号区间派发 —— 编号是交错的1/2/3 活动、
// 8/11/12 身体、14 又是活动),区间派发行不通。
function value(id as Number, c as Ctx) as String {
var v = dateTime(id, c);
if (v == null) { v = activity(id, c); }
if (v == null) { v = body(id, c); }
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;
}
}