Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/wk3-paired-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# 27.3 JSON / FlatBuffer 成对验证(2026-11-13 基线)

2026-11-13,macOS 27.3 (26C5035r)。杭州、天津、西雅图及西雅图复测共四组,
每组同 URL / 地点 / 请求参数,仅改变 Accept;启动差小于 2 ms。
抓取时旁路改写,恢复后继续使用 WK3 Preview。使用当时 dev 的三个产品 codec
解码,并独立读取原始 vtable 的时间整数与分钟向量长度。

## 后续进展(2026-11-14)

下表记录 v2.0.5 局部 schema 的基线结果。后续
[apple.WeatherKit #1](https://github.com/NSRingo/apple.WeatherKit/pull/1) 已定义 15 个根产品,
并根据原生访问器把分钟摘要 condition 补为 UInt8 向量;
[WeatherKit #111](https://github.com/NSRingo/WeatherKit/pull/111) 已接入三类天气替换。
这些基线缺项不再代表新 codec 的现状。成对测试同时支持局部 schema 和完整 schema:
有 condition 访问器时必须逐值对齐,不再期待 deprecated 缺项。

## 结果

| 样本 | 完整字段行数 | 已对齐 | 当前产品 codec 缺失 | 摘要 condition 缺失 | 返回值/版本差异 |
|---|---:|---:|---:|---:|---:|
| 杭州 | 2121 | 48 | 2073 | 0 | 0 |
| 天津 | 2057 | 48 | 2009 | 0 | 0 |
| 西雅图 | 2436 | 420 | 2014 | 1 | 1 |
| 西雅图复测 | 2448 | 422 | 2014 | 1 | 11 |
| 合计 | 9062 | 938 | 8110 | 2 | 12 |

“已对齐”包括逐值相同、float32 表示精度、UUID 字节/字符串等价和明确的缺省值。
空气质量污染物按名称对齐;原始数组顺序相反。时间戳保持整数精确比较。

| 字段(样本) | JSON 值 | FlatBuffer 解码值 | 是否一致 | 原因 / 原始字节复核 |
|---|---|---|---|---|
| AQ index(杭州) | 50 | 50 | 是 | short 标量一致 |
| AQ CO amount(杭州) | 0.7 | 0.699999988079071 | 是 | 同一 float32;单位枚举相同 |
| alert severity(西雅图) | MINOR | MINOR | 是 | 枚举一致 |
| alert importance(西雅图) | LOW | LOW | 是 | 枚举一致 |
| alert responses(西雅图) | MONITOR | MONITOR | 是 | 枚举一致 |
| nextHour minutes(西雅图) | 81 条 | 81 条 | 是 | 所有已声明分钟字段逐项比对 |
| nextHour.summary[0].condition[0] | CLEAR | 未输出 | 否 | 当前 schema 将摘要槽 2 标记 deprecated |
| AQ metadata.expireTime(西雅图) | 1794571344 | 1794571285 | 否 | 原始槽 1 的 uint32 确为 1794571285 |
| AQ metadata.expireTime(复测) | 1794571287 | 1794571351 | 否 | 原始槽 1 的 uint32 确为 1794571351 |
| nextHour.metadata.reportedTime(复测) | 1794569160 | 1794569280 | 否 | 原始时间值相差 120 秒 |
| nextHour.forecastEnd(复测) | 1794574560 | 1794574680 | 否 | 原始时间值也相差 120 秒 |
| nextHour.minutes(复测) | 82 条 | 84 条 | 否 | 原始向量长度 84;多出两条、共八个字段 |
| currentWeather / daily / hourly 等 | 有值 | 未输出 | 否 | dev 没有这些产品的 WK3 codec;没有套用 WK2 或占位 ubyte |

完整 9062 行由 `node scripts/compare-wk3-pair.mjs output/wk3-paired` 生成,
列为字段名、JSON 值、FlatBuffer 解码值、是否一致及原因,并带样本名。
四组成对脱敏响应和 SHA-256 已存入 `tests/fixtures/wk3-paired/`,可重复生成表格。

## 基线问题与处理状态

- cloudCover 在彩云 provider 边界修正为百分比,覆盖小时与日间/夜间预报;
0.5 → 50、0.8 → 80,回归另覆盖 0、0.005、1。
- 三个已实现产品中,样本内的枚举和单位标签逐项对齐;发现的时间差直接存在于原始
整数中。复测的 reportedTime 和 forecastEnd 同时相差 120 秒,记录为不同产品版本,
不归因于枚举或单位换算。
- 分钟摘要 condition 已在协议包 PR 补为 UInt8 向量,依据原生向量访问器的单字节寻址,
并用独立 flatc 写出的多元素向量做回归。
- 当前/逐日/逐小时等其余产品已在协议包 PR 定义并完成成对样本解码和往返;
本报告的 8110 项保留为原始基线记录,完整 codec 验证见协议包 PR。
- 当前分钟雨样本的雨强为零;检验非零雨强数值换算,需要追加正在降雨地区的原始成对样本。
75 changes: 75 additions & 0 deletions docs/wk3-weather-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# WK3 主天气 JSON 适配(开发入口)

基于 `dev` 的 WK3 二进制支持,补充当前天气、逐日、逐小时天气替换。
不将 WK3 二进制交给 WK2 codec;不新增供应商、缓存、空气质量算法或平台构建分支。

## 请求与处理边界

- 仅 HTTPS `weatherkit.apple.com/api/v3/weather/` 的 GET,且 User-Agent 为
`WeatherKit_Weather_iOS_` / `WeatherKit_Weather_macOS_` 前缀。
- 仅在天气来源选择彩云或和风、国家在既有 `Weather.Replace` 范围内,且 URL
`dataSets` 与用户设置的交集包含当前、逐日或逐小时天气时协商 `Accept: application/json`。
- 小组件、后台客户端、WK2、仅请求空气质量或分钟雨等请求继续原路径;发布入口不变。
- JSON 响应调用现有六种产品注入函数,仍受 URL 与设置交集限制。未请求的产品
不发起供应商请求。单产品异常、非有限数字或“暂不可用”结果保留 Apple 产品。
- JSON 协商改变了整个响应的表示,因此同包中未替换的已知 Apple 产品也需兼容
主 App JSON reader:天气枚举向量转标量、降水统计保留分类量并补标量、预警 UUID
字节对象转 UUID 字符串。未知根产品和未知嵌套对象不作转换。
- 返回 JSON 后删除旧 `bodyBytes`、正文长度、编码及缓存验证头。响应最多 4 MiB,
遍历最多 64 层 / 100000 个节点,单数组最多 8192 项。

表示适配依据 [PR #107 的固定版本与原生观察](https://github.com/hhhoratioxu/WeatherKit/blob/355b5f5dad9d01cb13167949f8334a489b301811/docs/loon-v3-compatibility.md)。
派生代码保留作者及 Apache-2.0 声明;未引入该 PR 的缓存、备用来源、地理判定和算法重构。

## 与协议包和二进制接入的分工

三项改动组成同一条请求/响应链:

| 层次 | PR | 职责 |
| --- | --- | --- |
| 协议模型 | [apple.WeatherKit #1](https://github.com/NSRingo/apple.WeatherKit/pull/1) | 提供 WK3 Schema/Codec,不决定客户端路由 |
| JSON 协商和处理 | [WeatherKit #110](https://github.com/NSRingo/WeatherKit/pull/110) | 符合设置的主 App 请求协商 JSON,JSON 响应复用注入函数 |
| FlatBuffer 响应处理 | [WeatherKit #111](https://github.com/NSRingo/WeatherKit/pull/111) | 对实际返回的 FlatBuffer 调用 Codec,保留其他产品的原始数据 |

两条响应路径按 Content-Type 分流。JSON 分支返回后不会进入二进制分支,也不会为同一
产品重复请求供应商。回归在正常 Request → Response 调用中检查 Accept=application/json、
二进制 decode/encode 均未调用、每种供应商产品只调用一次。

#110 可独立合并;#1 发布后,#111 更新协议依赖并合并。建议继续以本 PR 的 JSON 协商
作为主 App 默认路径,由维护者确认长期策略。#111 中主 App 的二进制实测是专用模块下的
codec 兼容性验收,不改变正常路由。macOS 小组件已在 #111 完成三次二进制网络响应与显示核对,本轮保留 FlatBuffer 路径,
暂不扩展小组件 JSON 白名单。

## 验证

```sh
npm ci
npm run build
npm run build:dev
npm run build:dev-args
node --test tests/*.test.mjs
```

`weatherKit3JSON.test.mjs` 覆盖设置边界、三种天气替换、混合数据集、失败回退、
未知字段、字节正文和主 App 表示转换。`platformBundleReplay.test.mjs` 从实际构建的
request/response bundle 发起模拟供应商调用,检查 Surge `$done` 形状和天气结果。
测试夹具是合成数据,不代表本机原生解码或真实天气来源已经验收。

## macOS 验收(2026-11-13)

macOS 27.3(26C5035r)、Surge 6.12.0(12820),本地加载同一构建产物:

- 主 App 的 v3 请求实际协商 `application/json`;抓取的改写响应中,当前、逐日和
逐小时产品均保留彩云来源(逐日 11 项、逐小时 274 项),空气标准为 `EU.EAQI`。
- 苏州、无锡预览显示当前天气、逐小时、逐日、分钟雨与 EAQI;无锡逐日详情中的
当前温度、最高/最低温与主页面一致,小时图表可正常打开。
- 真机发现的 EAQI 污染物 `index=-1` 哨兵、降水分类枚举、`restOfDayForecast`
和 `forecastPeriodic.forecasts[].periods[]` 均已补回归。修正后的三个有界日志窗口
合计 30 次 WeatherData 更新成功,无 `Unable to decode` 或 CloudKit 回退记录。
- 未将原始位置、授权头或完整抓包加入仓库。未实测 iPhone、watchOS 或其他代理客户端;
这不是长时间稳定性/耗电结论。后台小组件保留既有 WK3 二进制路径。

本机 GitHub 登录缺少 Packages 读取权限,因此协议依赖由公开 `v2.0.5` 标签
(`NSRingo/apple.WeatherKit@c87c1dcd851ea9293141bcfb32fde1b2cca564b9`)源码构建;
未改依赖声明或锁文件,也未声称与发布的 npm tarball 字节一致。
Siri 路由例外属于独立配置修复,不混入此协议补丁。
96 changes: 96 additions & 0 deletions scripts/compare-wk3-pair.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { ByteBuffer } from "flatbuffers";

globalThis.$environment ??= { "surge-version": "test" };
globalThis.$persistentStore ??= { read: () => null, write: () => true };
const { Console } = await import("@nsnanocat/util");
Console.logLevel = "OFF";
const { default: WeatherKit3 } = await import("../src/class/WeatherKit3.mjs");
// Retain the original three-product baseline; full-schema coverage lives in apple.WeatherKit.
const supported = ["airQuality", "forecastNextHour", "weatherAlerts"];
const missing = "<missing>";
const floatFields = new Set(["latitude", "longitude", "amount", "precipitationIntensity", "perceivedPrecipitationIntensity"]);
// Direct vtable reads independently check the decoder's integer fields and vector lengths.
function wireFacts(bb) {
const root = bb.readUint32(0);
const child = (table, slot) => {
const offset = bb.__offset(table, 4 + slot * 2);
return offset ? bb.__indirect(table + offset) : 0;
};
const scalar = (table, slot) => {
const offset = bb.__offset(table, 4 + slot * 2);
return offset ? bb.readUint32(table + offset) : 0;
};
const aqMetadata = child(child(root, 0), 0),
nextHour = child(root, 5);
return { aqExpiry: scalar(aqMetadata, 1), minuteCount: nextHour ? bb.readUint32(child(nextHour, 5)) : 0, forecastEnd: nextHour ? scalar(nextHour, 4) : 0, reportedTime: nextHour ? scalar(child(nextHour, 0), 8) : 0 };
}

function fields(value, prefix = "", out = {}) {
if (value && typeof value === "object" && !Array.isArray(value) && !(Array.isArray(value.bytes) && value.bytes.length === 16)) {
for (const [key, child] of Object.entries(value)) fields(child, prefix ? `${prefix}.${key}` : key, out);
} else if (Array.isArray(value) && value.length) {
for (const [index, child] of value.entries()) {
// The wire and JSON services return pollutants in opposite orders; compare species, not positions.
const key = child?.pollutantType ? `[${child.pollutantType}]` : `[${index}]`;
fields(child, `${prefix}${key}`, out);
}
} else out[prefix] = value;
return out;
}

export function comparePair(directory) {
const apple = JSON.parse(fs.readFileSync(path.join(directory, "apple.json")));
const wire = new ByteBuffer(new Uint8Array(fs.readFileSync(path.join(directory, "apple.bin"))));
const { json: decoded, failures } = WeatherKit3.decodeWithReport(wire, supported);
const a = fields(apple),
b = fields(decoded),
raw = wireFacts(wire);
const rows = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort().map(field => {
const x = Object.hasOwn(a, field) ? a[field] : missing;
const y = Object.hasOwn(b, field) ? b[field] : missing;
let equal = JSON.stringify(x) === JSON.stringify(y),
reason = equal ? "一致" : "值不一致:需定位单位/枚举/偏移";
const root = field.split(/[.[]/)[0];
if (!supported.includes(root)) reason = "基线范围:该产品未纳入本脚本的三个产品比较";
else if (floatFields.has(field.split(".").at(-1)) && typeof x === "number" && typeof y === "number" && Math.fround(x) === Math.fround(y)) {
equal = true;
reason = x === y ? "一致" : "一致:JSON 十进制与 float32 表示精度";
} else if (Array.isArray(x?.bytes) && typeof y === "string") {
equal = x.bytes.map(byte => byte.toString(16).padStart(2, "0")).join("") === y.replaceAll("-", "");
reason = equal ? "一致:同一 UUID 的字节/字符串表示" : "UUID 字节不一致";
} else if (field === "airQuality.metadata.expireTime" && !equal && y === raw.aqExpiry) reason = "返回值差异:原始 Metadata 槽 1 的 uint32 与解码值一致";
else if (field === "forecastNextHour.metadata.reportedTime" && !equal && y === raw.reportedTime) reason = `数据版本差异:原始 reportedTime 与 JSON 相差 ${y - x} 秒`;
else if (field === "forecastNextHour.forecastEnd" && !equal && y === raw.forecastEnd) reason = "返回值差异:原始 forecastEnd uint32 与解码值一致";
else if (x === missing && /^forecastNextHour\.minutes\[/.test(field) && raw.minuteCount > (apple.forecastNextHour?.minutes?.length ?? 0)) {
reason = `返回值差异:JSON 分钟向量 ${apple.forecastNextHour.minutes.length} 条,原始 FlatBuffer 向量 ${raw.minuteCount} 条`;
} else if (x === missing && (y === null || y === 0 || y === false || (Array.isArray(y) && y.length === 0))) {
equal = true;
reason = "一致:JSON 省略缺省值,schema 返回缺省值";
} else if (y === missing && /forecastNextHour\.summary\[\d+\]\.condition/.test(field)) reason = "缺字段:WK3 summary 槽 2 在当前 schema 标记 deprecated";
else if (y === missing) reason = "缺字段:当前 codec 未输出此字段";
return { field, json: x, flatbuffer: y, equal, reason };
});
const counts = {};
for (const row of rows) counts[row.reason] = (counts[row.reason] ?? 0) + 1;
return { rows, failures, counts, decoded, raw };
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const output = path.resolve(process.argv[2] ?? "output/wk3-paired");
fs.mkdirSync(output, { recursive: true });
const all = [],
summaries = {};
for (const city of ["hangzhou", "tianjin", "seattle", "seattle-repeat"]) {
const result = comparePair(fileURLToPath(new URL(`../tests/fixtures/wk3-paired/${city}/`, import.meta.url)));
all.push(...result.rows.map(row => ({ sample: city, ...row })));
summaries[city] = { counts: result.counts, failures: result.failures, fields: result.rows.length, raw: result.raw };
fs.writeFileSync(path.join(output, `${city}.decoded.json`), JSON.stringify(result.decoded, null, 2));
}
const cell = value => `"${String(typeof value === "object" ? JSON.stringify(value) : value).replaceAll('"', '""')}"`;
fs.writeFileSync(path.join(output, "fields.csv"), "sample,field,JSON,FlatBuffer,equal,reason\n" + all.map(row => [row.sample, row.field, row.json, row.flatbuffer, row.equal, row.reason].map(cell).join(",")).join("\n") + "\n");
fs.writeFileSync(path.join(output, "summary.json"), JSON.stringify(summaries, null, 2));
console.log(JSON.stringify(summaries, null, 2));
}
6 changes: 3 additions & 3 deletions src/class/ColorfulClouds.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,7 @@ export default class ColorfulClouds {
daytimeForecast: {
forecastStart: timeStamp + 8 * 3600, // 8 hours
forecastEnd: timeStamp + 8 * 3600 + 12 * 3600, // 8 + 12 hours
cloudCover: body?.result?.daily?.cloudrate?.[i]?.avg,
cloudCover: Math.round(body?.result?.daily?.cloudrate?.[i]?.avg * 100),
// cloudCoverHighAltPct: 0, // Not given
// cloudCoverLowAltPct: 0, // Not given
// cloudCoverMidAltPct: 0, // Not given
Expand All @@ -543,7 +543,7 @@ export default class ColorfulClouds {
overnightForecast: {
forecastStart: timeStamp + 20 * 3600, // 20 hours
forecastEnd: timeStamp + 20 * 3600 + 12 * 3600, // 20 + 12 hours
cloudCover: body?.result?.daily?.cloudrate?.[i]?.avg,
cloudCover: Math.round(body?.result?.daily?.cloudrate?.[i]?.avg * 100),
// cloudCoverHighAltPct: 0, // Not given
// cloudCoverLowAltPct: 0, // Not given
// cloudCoverMidAltPct: 0, // Not given
Expand Down Expand Up @@ -839,7 +839,7 @@ export default class ColorfulClouds {
return {
metadata: this.#Metadata(hourly.result.server_time, hourly.location),
hours: Array.from({ length: hourly.result.hourly.skycon.length }, (_, i) => ({
cloudCover: hourly.result.hourly.cloudrate[i].value,
cloudCover: Math.round(hourly.result.hourly.cloudrate[i].value * 100),
// cloudCoverHighAltPct: 0, // Not given
// cloudCoverLowAltPct: 0, // Not given
// cloudCoverMidAltPct: 0, // Not given
Expand Down
Loading