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

@@ -0,0 +1,167 @@
import Toybox.Lang;
import Toybox.Application;
import Toybox.Background;
import Toybox.Communications;
import Toybox.System;
import Toybox.Time;
import Toybox.Position;
// ============================================================================
// OpenWeatherMap —— 后台拉取端
// ----------------------------------------------------------------------------
// 表盘进程绝大多数时间没在运行onUpdate 又必须够快,不可能在绘制路径里发
// HTTP 请求。Connect IQ 的方案是**后台服务**:系统按定时器唤醒一个独立进程,
// 在里面发请求,把结果通过 Background.exit() 交回前台,前台再写进 Storage。
//
// 每次唤醒发两个请求:
// 1. 当前天气 /data/2.5/weather
// 2. 短期预报 /data/2.5/forecast?cnt=16约 48 小时,够算明日/后日高低温)
//
// ⚠️ 预报接口必须带 cnt 限制条数。后台进程的内存预算非常小(约 32KB
// 完整的 5 天 / 3 小时预报解析出来能有几十 KB会直接爆掉。
//
// 响应会被**压平成一个扁平字典**再交回去,键名是短名("temp"/"hi1"…),
// 与 Owm.get() 的取值一一对应 —— 这样表盘侧永远不用扛一棵 JSON 树。
// ============================================================================
(:background)
class Fenix8V3Background extends System.ServiceDelegate {
private var mResult as Dictionary = {};
private var mPending = 0;
function initialize() {
ServiceDelegate.initialize();
}
// 系统按定时器唤醒后台进程时调用。
// 注意这里是**独立进程**,内存预算比前台小得多,而且不能碰 UI。
function onTemporalEvent() as Void {
if (!Owm.enabled()) {
Background.exit(null);
return;
}
var lat = Application.Storage.getValue("lastLat");
var lon = Application.Storage.getValue("lastLon");
if (lat == null || lon == null) {
// 表盘一拿到位置就会存进 Storage见 Fields.Ctx.where
// 在那之前没有坐标可问,直接退出。
Background.exit(null);
return;
}
mResult = {};
mPending = 2;
// 一律用 units=metric 请求,单位换算统一放到 Fields 里按用户设置做,
// 这样切换单位不用重新联网。
var common = {"lat" => lat, "lon" => lon,
"appid" => Owm.key(), "units" => "metric"};
var opts = {
:method => Communications.HTTP_REQUEST_METHOD_GET,
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON
};
Communications.makeWebRequest(
"https://api.openweathermap.org/data/2.5/weather",
common, opts, method(:onCurrent));
var fc = {"lat" => lat, "lon" => lon, "appid" => Owm.key(),
"units" => "metric", "cnt" => 16};
Communications.makeWebRequest(
"https://api.openweathermap.org/data/2.5/forecast",
fc, opts, method(:onForecast));
}
// 当前天气的回调。OWM 的 JSON 是嵌套的,逐层取值时每层都要判空 ——
// 缺字段是常态(比如没下雨就没有 rain 节点)。
function onCurrent(code as Number, body as Dictionary?) as Void {
if (code == 200 && body != null) {
var main = body.get("main") as Dictionary?;
if (main != null) {
put("temp", main.get("temp"));
put("feels", main.get("feels_like"));
put("tmin", main.get("temp_min"));
put("tmax", main.get("temp_max"));
put("pressure", main.get("pressure"));
put("humidity", main.get("humidity"));
}
put("visibility", body.get("visibility"));
var wind = body.get("wind") as Dictionary?;
if (wind != null) {
put("wind", wind.get("speed"));
put("winddeg", wind.get("deg"));
put("gust", wind.get("gust"));
}
var clouds = body.get("clouds") as Dictionary?;
if (clouds != null) { put("clouds", clouds.get("all")); }
var rain = body.get("rain") as Dictionary?;
if (rain != null) { put("rain", rain.get("1h")); }
var snow = body.get("snow") as Dictionary?;
if (snow != null) { put("snow", snow.get("1h")); }
var wx = body.get("weather") as Array?;
if (wx != null && wx.size() > 0) {
var w0 = wx[0] as Dictionary;
put("short", w0.get("main"));
put("long", w0.get("description"));
}
put("city", body.get("name"));
put("dt", Time.now().value());
}
done();
}
// 预报返回的是 3 小时粒度的列表。这里按「距今天几天」把条目分到
// 明天 / 后天两个桶里,各自取最高最低温;降水概率取明天的最大值。
function onForecast(code as Number, body as Dictionary?) as Void {
if (code == 200 && body != null) {
var list = body.get("list") as Array?;
if (list != null) {
var today = dayNumber(Time.now().value());
var lo1 = null; var hi1 = null; var lo2 = null; var hi2 = null;
var pop1 = null;
var arr = list;
for (var i = 0; i < arr.size(); i++) {
var e = arr[i] as Dictionary;
var dt = e.get("dt");
if (dt == null) { continue; }
var day = dayNumber(dt as Number) - today;
var m = e.get("main") as Dictionary?;
if (m == null) { continue; }
var tmin = m.get("temp_min");
var tmax = m.get("temp_max");
if (day == 1) {
if (lo1 == null || tmin < lo1) { lo1 = tmin; }
if (hi1 == null || tmax > hi1) { hi1 = tmax; }
var pop = e.get("pop");
if (pop != null && (pop1 == null || pop > pop1)) { pop1 = pop; }
} else if (day == 2) {
if (lo2 == null || tmin < lo2) { lo2 = tmin; }
if (hi2 == null || tmax > hi2) { hi2 = tmax; }
}
}
put("lo1", lo1); put("hi1", hi1);
put("lo2", lo2); put("hi2", hi2);
put("pop", pop1);
}
}
done();
}
// 距 Unix 纪元的整天数,用来给预报条目分桶。
function dayNumber(unix as Number) as Number {
return unix / 86400;
}
// 只写入非空值,免得用 null 覆盖字典里已有的内容。
function put(k as String, v) as Void {
if (v != null) { mResult.put(k, v); }
}
// 两个请求都回来了才算一轮完整数据,谁最后完成谁负责退出服务。
// 一条都没拿到就传 null避免用空字典覆盖掉上一次的好数据。
function done() as Void {
mPending--;
if (mPending > 0) { return; }
if (mResult.size() == 0) {
Background.exit(null);
} else {
Background.exit(mResult);
}
}
}