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
This commit is contained in:
2026-09-10 07:10:55 +08:00
parent 52346bdd09
commit 4d96ab0912
23 changed files with 1549 additions and 344 deletions

View File

@@ -13,16 +13,27 @@ import Toybox.Position;
import Toybox.WatchUi;
import FieldTable;
import Settings;
import Solar;
import Owm;
// Everything a complication slot can show. Ids match
// https://watchface.io/docs/datafields so the two catalogues line up; see
// tools/gen_fields.py for the table that drives the settings and the labels.
// ============================================================================
// 数据字段
// ----------------------------------------------------------------------------
// 四个数据位能显示的全部内容,共 125 项。
//
// Only fields Connect IQ can answer from the watch itself are here. The
// week-to-date and 7/28-day per-sport aggregates in that catalogue come from a
// phone-side Garmin Connect integration, and the OpenWeatherMap / StormGlass /
// third-party-app fields need network calls and API keys; neither is something
// a self-contained watch face can produce.
// 字段编号沿用 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 = "--";
@@ -43,7 +54,7 @@ module Fields {
function ready() as Boolean { return mDow != null; }
// Monkey C has no String.split.
// Monkey C 没有 String.split,只能自己按逗号切。
function splitCsv(str as String) as Array<String> {
var out = [] as Array<String>;
var rest = str;
@@ -69,8 +80,8 @@ module Fields {
return Lang.format(mDateFmt, [month(g), g.day.format("%d")]);
}
// "10" in English, "10日" in Chinese -- the format string carries the
// suffix, so feed it an empty month.
// 英文出 "10",中文出 "10日"。
// 后缀带在本地化的日期格式串里,所以这里把月份传空串即可。
function dayOnly(g as Gregorian.Info) as String {
return Lang.format(mDateFmt, ["", g.day.format("%d")]);
}
@@ -79,16 +90,18 @@ module Fields {
return mMeridiem == null ? "" : mMeridiem[g.hour >= 12 ? 1 : 0];
}
// Short caption printed under a bottom-row value.
// 底部格子数值下方的小标签。按字段编号查 FieldTable 的下标,
// 再到本地化的标签表里取词,所以中英文会自动跟随系统语言。
function label(id as Number) as String {
if (mLabels == null) { return ""; }
var i = FieldTable.indexOf(id);
return i < mLabels.size() ? mLabels[i] : "";
}
// One of these is built per onUpdate and handed to every slot, so the
// expensive lookups (forecast, sensor history, user profile) happen at most
// once per draw and only if some slot actually asks for them.
// 每次绘制构造一个,传给所有数据位共用。
// 目的是让开销大的查询(天气预报、传感器历史、用户档案)**每次绘制最多做
// 一次,且只在真的有格子要用时才做** —— 四个格子可能都要天气,但只查一次;
// 没有格子用到 UserProfile 时就完全不去碰它。
class Ctx {
var greg as Gregorian.Info;
var is24 as Boolean = true;
@@ -152,18 +165,40 @@ module Fields {
return mProf;
}
// 日出/日落/晨昏蒙影都要位置,而两个实时来源都会间歇性为空:
// - 天气在同步之前没有观测点坐标
// - currentLocation 要有东西刚定过位才有值
// 所以每次拿到位置就缓存进 Storage两个来源都空时用缓存兜底
// 免得这些字段一会儿有一会儿变 "--"。
function where() as Position.Location? {
var w = wx();
if (w != null && w.observationLocationPosition != null) {
return w.observationLocationPosition;
if (w != null && (w has :observationLocationPosition)
&& w.observationLocationPosition != null) {
return remember(w.observationLocationPosition);
}
var a = act();
if (a != null && a.currentLocation != null) { return a.currentLocation; }
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;
}
}
// ------------------------------------------------------------- helpers
// ------------------------------- 格式化工具 ----------------------------
function num(v as Numeric?) as String {
return v == null ? DASH : v.format("%d");
}
@@ -197,7 +232,7 @@ module Fields {
return t.format("%d") + "°";
}
// ActivityMonitor distances are centimetres.
// ActivityMonitor 的距离单位是**厘米**,先换算成公里再按单位制转换。
function dist(cm as Numeric?, c as Ctx) as String {
if (cm == null) { return DASH; }
var km = cm.toFloat() / 100000.0;
@@ -217,7 +252,7 @@ module Fields {
return (n / 1000.0).format("%.1f") + "k";
}
// Newest reading out of a SensorHistory iterator, or null.
// 从 SensorHistory 迭代器里取最新一条读数;没有则 null
function latest(iter) as Numeric? {
if (iter == null) { return null; }
var s = iter.next();
@@ -225,6 +260,53 @@ module Fields {
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;
@@ -234,7 +316,7 @@ module Fields {
return d;
}
// ISO 8601 week: weeks start Monday, week 1 holds the first Thursday.
// 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
@@ -244,7 +326,7 @@ module Fields {
return w;
}
// Days since the new moon of 2000-01-06, folded into one synodic month.
// 月龄:距 2000-01-06 那次新月的天数对朔望月29.530588853 天)取余。
function moonAge(g as Gregorian.Info) as Float {
var y = g.year;
var m = g.month;
@@ -253,7 +335,7 @@ module Fields {
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 has no float modulo.
// ⚠️ Monkey C 的 % 不支持浮点,只能手写取余。
var syn = 29.530588853;
var x = jd - 2451550.1;
var age = x - (x / syn).toNumber() * syn;
@@ -271,113 +353,136 @@ module Fields {
return pts[i];
}
// --------------------------------------------------------------- value
// Clock, calendar and week-number fields.
// Returns null when the id is not one of ours, so value()
// can try the next category.
// ------------------------------- 分类取值 ------------------------------
// ---- 日期时间类 ----
// 不属于本类的编号返回 null交给 value() 试下一个分类。
function dateTime(id as Number, c as Ctx) as String? {
if (id == 860) { return clockInfo(c.greg, c); }
if (id == 880) {
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"); }
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"); }
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); }
// 秒数要动起来依赖设备支持 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;
}
// Steps, calories, distance, floors and active minutes.
// Returns null when the id is not one of ours, so value()
// can try the next category.
// ---- 活动类 ----
// 步数、卡路里、距离、楼层、活动分钟,以及它们的本周/近 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 (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 (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 (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); }
if (id == 152) { return num(am.moderate); } // 中强度分钟
if (id == 154) { return num(am.vigorous); } // 高强度分钟
return num(am.total);
}
if (id == 151) {
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); }
// Several ActivityMonitor.Info members compile everywhere but only
// exist on some devices and firmware -- the wheelchair counters in
// particular threw "Symbol Not Found" on a fenix 8. Check before use.
if (id == 5) {
// ⚠️ ActivityMonitor.Info 上有些成员**在所有设备上都能编译通过,但只有
// 部分设备/固件运行时才真的有**。轮椅计数器就是典型:在 fenix 8 上直接抛
// "Symbol Not Found",用户一选这个字段表盘就崩。
// 所以凡是非通用成员,用之前一律先 has 检查。
if (id == 5) { // 下楼层数
return (i == null || !(i has :floorsDescended)) ? DASH
: num(i.floorsDescended);
}
if (id == 6) {
if (id == 6) { // 爬升高度
return (i == null || !(i has :metersClimbed)) ? DASH
: metres(i.metersClimbed, c);
}
if (id == 9) {
if (id == 9) { // 推行距离
return (i == null || !(i has :pushDistance)) ? DASH
: dist(i.pushDistance, c);
}
if (id == 10) {
if (id == 10) { // 推行次数
return (i == null || !(i has :pushes)) ? DASH : num(i.pushes);
}
if (id == 8 || id == 15) {
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) {
if (id == 13) { // 恢复时间
return (i == null || !(i has :timeToRecovery)) ? DASH
: num(i.timeToRecovery);
}
if (id == 11) {
if (id == 11) { // 呼吸频率
return (i == null || !(i has :respirationRate)) ? DASH
: num(i.respirationRate);
}
if (id == 12) {
if (id == 12) { // 压力指数
return (i == null || !(i has :stressScore)) ? DASH : num(i.stressScore);
}
return null;
}
// Heart, stress, oxygen, body battery and profile-derived values.
// Returns null when the id is not one of ours, so value()
// can try the next category.
// ---- 身体类 ----
// 心率、压力、血氧、身体电量,以及从用户档案推出来的体重/BMI/VO2max。
function body(id as Number, c as Ctx) as String? {
if (id == 618) {
if (id == 618) { // 心率
var a = c.act();
if (a != null && a.currentHeartRate != null) { return num(a.currentHeartRate); }
var h = ActivityMonitor.getHeartRateHistory(1, true);
@@ -390,34 +495,34 @@ module Fields {
}
return DASH;
}
if (id == 276) {
if (id == 276) { // 静息心率
var p = c.profile();
return (p == null || !(p has :restingHeartRate)) ? DASH
: num(p.restingHeartRate);
}
if (id == 635) {
if (id == 635) { // 血氧
if (!(SensorHistory has :getOxygenSaturationHistory)) { return DASH; }
return num(latest(SensorHistory.getOxygenSaturationHistory({:period => 1})));
}
if (id == 623) {
if (id == 623) { // 身体电量
if (!(SensorHistory has :getBodyBatteryHistory)) { return DASH; }
return num(latest(SensorHistory.getBodyBatteryHistory({:period => 1})));
}
if (id == 281 || id == 742) {
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) {
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) {
if (id == 277 || id == 278) { // 最大摄氧量 (骑) / 最大摄氧量 (跑)
var p = c.profile();
if (p == null) { return DASH; }
if (id == 278) {
if (id == 278) { // 最大摄氧量 (跑)
return (p has :vo2maxRunning) ? num(p.vo2maxRunning) : DASH;
}
return (p has :vo2maxCycling) ? num(p.vo2maxCycling) : DASH;
@@ -426,84 +531,87 @@ module Fields {
return null;
}
// Battery, alarms, notifications and radio state.
// Returns null when the id is not one of ours, so value()
// can try the next category.
// ---- 系统类 ----
// 电量、闹钟、通知、勿扰、蓝牙、太阳能强度。
function system(id as Number, c as Ctx) as String? {
var st = c.stats();
if (id == 250) {
if (id == 250) { // 电量
return st == null || st.battery == null ? DASH
: st.battery.format("%d") + "%";
}
if (id == 251) {
if (id == 251) { // 剩余天数
if (st == null || !(st has :batteryInDays) || st.batteryInDays == null) {
return DASH;
}
return st.batteryInDays.format("%d") + "d";
}
if (id == 257) {
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 (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) {
if (id == 254) { // 勿扰模式
return ds == null ? DASH : (ds.doNotDisturb ? "ON" : "OFF");
}
if (id == 256) {
if (id == 256) { // 蓝牙
return ds == null ? DASH : (ds.phoneConnected ? "ON" : "OFF");
}
return null;
}
// Altitude, pressure, sun events and moon phase.
// Returns null when the id is not one of ours, so value()
// can try the next category.
// ---- 环境类 ----
// 海拔、气压、各类太阳事件(含晨昏蒙影与黄金/蓝调时刻)、月相。
function environment(id as Number, c as Ctx) as String? {
if (id == 615) {
if (id == 615) { // 海拔
var a = c.act();
return (a == null || !(a has :altitude)) ? DASH : metres(a.altitude, c);
}
if (id == 616 || id == 733) {
if (id == 616 || id == 733) { // 海平面气压 / 环境气压
var a = c.act();
if (a == null) { return DASH; }
var pa = null;
if (id == 616 && (a has :meanSeaLevelPressure)) {
if (id == 616 && (a has :meanSeaLevelPressure)) { // 海平面气压
pa = a.meanSeaLevelPressure;
} else if (id == 733 && (a has :ambientPressure)) {
} 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) {
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 (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); }
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 (id == 706) { return moonAge(c.greg).format("%.1f"); }
if (id == 732) {
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") + "%";
@@ -512,66 +620,170 @@ module Fields {
return null;
}
// Garmin's cached conditions and today's forecast.
// Returns null when the id is not one of ours, so value()
// can try the next category.
// ---- 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) {
if (id == 300) { // 体感温度
return (w == null || !(w has :feelsLikeTemperature)) ? DASH
: temp(w.feelsLikeTemperature, c);
}
if (id == 305) {
if (id == 305) { // 湿度
return (w == null || !(w has :relativeHumidity)) ? DASH
: num(w.relativeHumidity) + "%";
}
if (id == 304) {
if (id == 304) { // 降水概率
if (w == null || !(w has :precipitationChance)) { return DASH; }
return num(w.precipitationChance) + "%";
}
if (id == 308) {
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) {
if (id == 307) { // 风向角
return (w == null || !(w has :windBearing)) ? DASH : num(w.windBearing);
}
if (id == 310) {
if (id == 310) { // 风向
return (w == null || !(w has :windBearing)) ? DASH : windDir(w.windBearing);
}
if (id == 309) {
if (id == 309) { // 天气更新时间
if (w == null || !(w has :observationTime) || w.observationTime == null) {
return DASH;
}
return clock(w.observationTime, c);
}
if (id == 301 || id == 302 || id == 639) {
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); }
if (id == 301) { return temp(hi, c); } // 最高气温
if (id == 302) { return temp(lo, c); } // 最低气温
return temp(hi, c) + "/" + temp(lo, c);
}
return null;
}
// Free text the wearer typed into the settings.
// Returns null when the id is not one of ours, so value()
// can try the next category.
// ---- 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"); }
if (id == 704) { return Settings.text("Custom2"); }
if (id == 705) { return Settings.text("Custom3"); }
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;
}
// Text for one data slot. Categories are tried in turn rather
// than dispatched on an id range, because the ids interleave.
// 取某个字段的显示文本。
// 按分类依次尝试,而不是按编号区间派发 —— 编号是交错的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); }
@@ -579,6 +791,7 @@ module Fields {
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;
}