Files
fenix8v3-watchface/source/Fields.mc
ericwyuan 4d96ab0912 Update watch face: modular refactor + OWM/Solar/background + codegen tools
- Split render into FieldTable/Fields/Icons/Layout/Settings modules
- Add Fenix8V3Background, Owm (weather), Solar modules
- Add tools/ generators (gen_fields/gen_icons/gen_themes/gen_preview_svg)
- Tune README/manifest/strings/settings
2026-09-10 07:10:55 +08:00

799 lines
37 KiB
MonkeyC
Raw 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;
// ============================================================================
// 数据字段
// ----------------------------------------------------------------------------
// 四个数据位能显示的全部内容,共 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")]);
}
// 英文出 "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;
}
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 的集成。
//
// 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"); } // 秒 (前导零)
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 == 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 == 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 str(v) as String {
return v == null ? DASH : v.toString();
}
// ---- 自定义文字 ----
// 用户在设置里自己填的三段文字。
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 = owm(id, c); }
if (v == null) { v = custom(id, c); }
return v == null ? "" : v;
}
}