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?; var mMon as Array?; var mLabels as Array?; var mMeridiem as Array?; 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 { var out = [] as Array; 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 { var out = [] as Array; 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?; 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; } function profile() as UserProfile.Profile? { if (!mGotProf) { mProf = UserProfile.getProfile(); 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 的集成。 // // which:0 步数 / 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; } }